From 95f08d3e6498354dfcabc27184d2b959726314d9 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 09:20:37 -0600 Subject: [PATCH 01/13] chore: stop pre-commit eslint failing on ignored staged files The staged-eslint step runs with `--max-warnings 0`, so staging a file the flat config intentionally ignores (`vitest.config.ts`, `vite.config.ts`, `*.config.*`) emitted a "File ignored because of a matching ignore pattern" warning and failed the hook. `--no-warn-ignored` suppresses only that warning and leaves real warnings blocking. --- .githooks/pre-commit | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 9d27d9ce3..51b61fa25 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -105,11 +105,11 @@ if [ -s "$ESLINT_LIST" ]; then while IFS= read -r f || [ -n "$f" ]; do set -- "$@" "$f" if [ "$#" -eq 50 ]; then - pnpm exec eslint --cache --max-warnings 0 -- "$@" || exit 1 + pnpm exec eslint --cache --max-warnings 0 --no-warn-ignored -- "$@" || exit 1 set -- fi done < "$ESLINT_LIST" - [ "$#" -eq 0 ] || pnpm exec eslint --cache --max-warnings 0 -- "$@" + [ "$#" -eq 0 ] || pnpm exec eslint --cache --max-warnings 0 --no-warn-ignored -- "$@" ) || fail 'eslint (staged)' else printf 'pre-commit: skip eslint (no staged JS/TS)\n' >&2 From ec343a6d9a604e6bd4cffac1d4110c70fc4d17ad Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 09:23:16 -0600 Subject: [PATCH 02/13] test: assert pre-commit eslint invariant instead of exact flags The guard pinned the eslint invocation to one literal flag string, so adding `--no-warn-ignored` failed it even though the property it protects was intact. It now checks the actual invariant: only options precede `--`, staged paths follow as quoted `"$@"`, `--max-warnings` is present, and xargs is never used. --- scripts/pre-commit.eslint-batch.test.mjs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/pre-commit.eslint-batch.test.mjs b/scripts/pre-commit.eslint-batch.test.mjs index 982e97f2f..7b1f81f84 100644 --- a/scripts/pre-commit.eslint-batch.test.mjs +++ b/scripts/pre-commit.eslint-batch.test.mjs @@ -16,15 +16,30 @@ function isEslintInvocation(line) { return trimmed.startsWith('pnpm exec eslint') || trimmed.startsWith('pnpm exec eslint'); } +/** Options that consume the following argument as their value. */ +const OPTIONS_TAKING_VALUE = new Set(['--max-warnings']); + /** - * Safe form: `pnpm exec eslint --cache --max-warnings 0 -- "$@"` (+ optional `|| exit 1`). + * Safe form: `pnpm exec eslint -- "$@"` (+ optional `|| exit 1`), where every + * argument before `--` is an option so staged paths reach eslint literally. * @param {string} line * @returns {boolean} */ function isSafeEslintInvocation(line) { - const trimmed = line.trim(); - const base = 'pnpm exec eslint --cache --max-warnings 0 -- "$@"'; - return trimmed === base || trimmed === `${base} || exit 1`; + const PREFIX = 'pnpm exec eslint '; + const SUFFIX = ' -- "$@"'; + const trimmed = line.trim().replace(/ \|\| exit 1$/, ''); + if (!trimmed.startsWith(PREFIX) || !trimmed.endsWith(SUFFIX)) return false; + + const args = trimmed.slice(PREFIX.length, -SUFFIX.length).split(' ').filter(Boolean); + let sawMaxWarnings = false; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg.startsWith('-')) return false; + if (arg === '--max-warnings' || arg.startsWith('--max-warnings=')) sawMaxWarnings = true; + if (OPTIONS_TAKING_VALUE.has(arg)) i += 1; + } + return sawMaxWarnings; } describe('pre-commit eslint batching', () => { From 0e85081b7f79a3fa4e6952f1fd3bdc7f9d2086ea Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 09:23:47 -0600 Subject: [PATCH 03/13] refactor: make PacketRouter the only packet ingress (#484) Dual ingress meant every Meshtastic and MeshCore packet was decoded twice: once by Protocol.subscribe into DomainEvents, and again by a parallel bag of SDK subscriptions that wrote to hook-local node/message mirrors. The two paths drifted, and MeshCore CLI responses were dropped outright. Packet decode now happens only in Protocol.subscribe; post-router behavior lives in PacketRouter listeners, and Zustand stores are the single ingress destination. Runtime hooks keep only true lifecycle work. - MeshCore: restore CLI (txtType 1); move DM ack 130, waiting drain 131, RF RX 136, and disconnect into meshcoreConnSideEffects; drop the protocolOwnedEvents gate and legacyOwnsRoomPosts - Meshtastic: add node/raw-packet/trace/module-port/store-forward router side effects and route MQTT uplink plus notifications through them; wire effects keep six lifecycle SDK subscriptions - Fix decodeTraceRoute for the MeshPacket path and enrich node_info, telemetry, waypoint, and raw_packet payloads so listeners reach parity - Delete runtime nodesRef/messagesRef; send, dedup, and RPC read stores via identityStoreReads - Rename registerLegacyTransport to registerExternalTransport and the bind*Ingress helpers to attach*ProtocolIngress - Read routing errors from messageStore only; centralize the self-hybrid path badge in meshtasticSourceIcons Closes #484 --- AGENTS.md | 10 +- docs/diagnostics.md | 2 +- scripts/check-log-panel-filter.mjs | 8 +- src/renderer/components/ChatComposer.tsx | 2 +- .../components/LogPanel.filtering.test.ts | 4 +- src/renderer/components/LogPanel.tsx | 4 +- src/renderer/components/NodeInfoBody.tsx | 3 +- src/renderer/components/NodeListPanel.tsx | 7 +- .../meshcore/meshcoreConnSideEffects.test.ts | 322 +++ ...nnEvents.ts => meshcoreConnSideEffects.ts} | 864 ++----- ...tsCtx.ts => meshcoreConnSideEffectsCtx.ts} | 32 +- ...seMeshcoreRuntime.import-contacts.test.tsx | 2 +- ...eMeshcoreRuntime.initconn-db-hops.test.tsx | 14 +- .../lib/drivers/ConnectionDriver.test.ts | 6 +- src/renderer/lib/drivers/ConnectionDriver.ts | 10 +- .../lib/hydrateIdentityStoresFromDb.ts | 3 +- src/renderer/lib/identityStoreReads.test.ts | 102 + src/renderer/lib/identityStoreReads.ts | 50 + src/renderer/lib/meshIdentityBridge.test.ts | 6 +- src/renderer/lib/meshIdentityBridge.ts | 16 +- .../meshcoreAdvertEventApply.test.ts} | 29 +- .../lib/meshcore/meshcoreDmAckRuntime.test.ts | 69 + .../lib/meshcore/meshcoreDmAckRuntime.ts | 104 + .../meshcore/meshcorePathUpdatedRuntime.ts | 67 +- .../lib/meshcoreDirectMessageDecode.test.ts | 31 +- .../lib/meshcoreDirectMessageDecode.ts | 33 +- .../meshcoreProcessWaitingMessageItem.test.ts | 35 +- .../lib/meshcoreProcessWaitingMessageItem.ts | 81 +- .../meshtastic/meshtasticChatSenderNode.ts | 2 +- ...Subscriptions.diagnostics.contract.test.ts | 32 - .../meshtasticLegacyWireSubscriptions.ts | 2269 ----------------- .../meshtasticModulePortSideEffects.test.ts | 47 + .../meshtasticModulePortSideEffects.ts | 149 ++ .../meshtasticNodeAwaitingNodeInfo.ts | 2 +- .../meshtastic/meshtasticNodeSideEffects.ts | 541 ++++ .../meshtasticRawPacketSideEffects.ts | 172 ++ .../meshtasticRouterSideEffects.test.ts | 107 + .../meshtastic/meshtasticRouterSideEffects.ts | 212 ++ ...meWireEffects.diagnostics.contract.test.ts | 33 + ...ticRuntimeWireEffects.post-reboot.test.ts} | 10 +- ...imeWireEffects.telemetry-nodeinfo.test.ts} | 72 +- .../meshtasticRuntimeWireEffects.ts | 856 +++++++ .../meshtasticSdkRoutingErrorLog.test.ts | 387 +-- .../meshtasticSdkRoutingErrorLog.ts | 62 +- .../meshtasticStoreForwardSideEffects.ts | 115 + .../meshtasticTraceSideEffects.test.ts | 41 + .../meshtastic/meshtasticTraceSideEffects.ts | 109 + ...=> meshtasticTransportSideEffects.test.ts} | 2 +- ...s.ts => meshtasticTransportSideEffects.ts} | 2 +- .../lib/meshtasticSourceIcons.test.ts | 10 + src/renderer/lib/meshtasticSourceIcons.tsx | 11 +- .../lib/protocols/MeshCoreProtocol.ts | 51 +- .../lib/protocols/MeshtasticProtocol.test.ts | 26 +- .../lib/protocols/MeshtasticProtocol.ts | 293 ++- src/renderer/lib/protocols/Protocol.ts | 93 +- src/renderer/lib/storeRecordAdapters.ts | 21 + .../useMeshcoreRuntime.reconnect.test.ts | 7 +- src/renderer/runtime/useMeshcoreRuntime.ts | 361 +-- src/renderer/runtime/useMeshtasticRuntime.ts | 207 +- src/renderer/stores/nodeStore.ts | 2 + vitest.config.ts | 7 +- 61 files changed, 4388 insertions(+), 3839 deletions(-) create mode 100644 src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts rename src/renderer/hooks/meshcore/{meshcoreLegacyConnEvents.ts => meshcoreConnSideEffects.ts} (51%) rename src/renderer/hooks/meshcore/{meshcoreLegacyConnEventsCtx.ts => meshcoreConnSideEffectsCtx.ts} (77%) create mode 100644 src/renderer/lib/identityStoreReads.test.ts create mode 100644 src/renderer/lib/identityStoreReads.ts rename src/renderer/{hooks/meshcore/meshcoreLegacyConnEvents.test.ts => lib/meshcore/meshcoreAdvertEventApply.test.ts} (77%) create mode 100644 src/renderer/lib/meshcore/meshcoreDmAckRuntime.test.ts create mode 100644 src/renderer/lib/meshcore/meshcoreDmAckRuntime.ts delete mode 100644 src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.diagnostics.contract.test.ts delete mode 100644 src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.test.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticRouterSideEffects.test.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticRouterSideEffects.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.diagnostics.contract.test.ts rename src/renderer/lib/meshtastic/{meshtasticLegacyWireSubscriptions.post-reboot.test.ts => meshtasticRuntimeWireEffects.post-reboot.test.ts} (94%) rename src/renderer/lib/meshtastic/{meshtasticLegacyWireSubscriptions.telemetry-nodeinfo.test.ts => meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts} (79%) create mode 100644 src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticTraceSideEffects.test.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticTraceSideEffects.ts rename src/renderer/lib/meshtastic/{meshtasticLegacyDeviceEvents.test.ts => meshtasticTransportSideEffects.test.ts} (99%) rename src/renderer/lib/meshtastic/{meshtasticLegacyDeviceEvents.ts => meshtasticTransportSideEffects.ts} (93%) diff --git a/AGENTS.md b/AGENTS.md index 5ad592f2b..5698d105b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,9 +188,9 @@ See **Renderer: hooks vs runtime vs lib** (layout map above). Legacy `useDevice` Do **not** remount protocol runtimes in child components. Do **not** compare `protocol === 'meshcore'` for feature gates; use `ProtocolCapabilities` / `useRadioProvider(protocol)`. -Protocol SDK adapters: `src/renderer/lib/protocols/`. Connection lifecycle: `ConnectionDriver`; inbound domain events: `PacketRouter` → stores. **MeshCore post-router side effects:** `lib/ingest/meshcoreIngest.ts` (chat persist, `last_heard`, path-updated), `lib/meshcore/meshcoreLiveContactPersist.ts` (SQLite contact rows), `lib/meshcore/meshcorePubKeyRegistry.ts` (DM/trace pubkeys mirrored into runtime refs). Live UI nodes/messages read `nodeStore` / `messageStore`; `useMeshcoreRuntime` still keeps hook-local `nodesRef` for send/RPC until contact rebuild syncs it. **Favorites:** `setNodeFavorited` patches `meshcoreIdentityIdRef` (fallback `getIdentityIdForProtocol('meshcore')`). **Dedup windows:** cross-transport and channel RF **5 min**; room/tapback **60 s**. Path-updated (129) for existing contacts does not bump SQLite `last_advert` until the next advert (128). +Protocol SDK adapters: `src/renderer/lib/protocols/`. Connection lifecycle: `ConnectionDriver`; inbound domain events: `PacketRouter` → stores. **Meshtastic post-router side effects:** `lib/ingest/meshtasticIngest.ts`, `meshtasticRouterSideEffects.ts` (MQTT uplink / notifications / device_log), `meshtasticNodeSideEffects.ts`, `meshtasticRawPacketSideEffects.ts`, `meshtasticTraceSideEffects.ts`, `meshtasticModulePortSideEffects.ts`, `meshtasticStoreForwardSideEffects.ts`; lifecycle-only SDK attach remains in `meshtasticRuntimeWireEffects.ts` (DeviceStatus / MyNodeInfo / FromRadio / heartbeat / config / remote-admin). **MeshCore post-router side effects:** `lib/ingest/meshcoreIngest.ts` (chat persist, `last_heard`, path-updated), `hooks/meshcore/meshcoreConnSideEffects.ts` (DM ack 130, waiting drain 131, RF RX 136, CLI, disconnect), `lib/meshcore/meshcoreLiveContactPersist.ts` (SQLite contact rows), `lib/meshcore/meshcorePubKeyRegistry.ts` (DM/trace pubkeys). Live UI nodes/messages read `nodeStore` / `messageStore` via `identityStoreReads` (`getIdentityNode` / `getIdentityChatMessages`); runtimes do not keep hook-local node/message wire mirrors. **Favorites:** `setNodeFavorited` patches `meshcoreIdentityIdRef` (fallback `getIdentityIdForProtocol('meshcore')`). **Dedup windows:** cross-transport and channel RF **5 min**; room/tapback **60 s**. Path-updated (129) for existing contacts does not bump SQLite `last_advert` until the next advert (128). -**Identity-scoped UI stores:** `identityStore`, `nodeStore`, `messageStore`, `connectionStore` — nodes/messages keyed by `identityId`. **MQTT status bridge:** `mirrorMqttStatusToConnection` copies main-process `mqtt.onStatus` IPC into `connectionStore.mqttStatus` from legacy runtime handlers until MQTT moves fully into `ConnectionDriver`. **SQLite → UI:** `lib/hydrateIdentityStoresFromDb.ts` (coordinator: `identityHydrationCoordinator.ts`; Meshtastic node map: `meshtasticDbCacheHydration.ts`; message cap: `meshtasticMessageLoadLimit.ts`); manual refresh via `hooks/useDbRefresh.ts`. Runtimes may still merge DB rows into hook-local refs on connect; identity-scoped Zustand hydration is the canonical UI path ([#375]). **MeshCore contacts DB:** `meshcore_contacts.last_advert` is Unix **seconds**; age prune uses `src/shared/meshcoreContactAgeCutoff.ts` (do not compare in ms). +**Identity-scoped UI stores:** `identityStore`, `nodeStore`, `messageStore`, `connectionStore` — nodes/messages keyed by `identityId`. **MQTT status bridge:** `mirrorMqttStatusToConnection` copies main-process `mqtt.onStatus` IPC into `connectionStore.mqttStatus` from runtime handlers until MQTT moves fully into `ConnectionDriver`. **SQLite → UI:** `lib/hydrateIdentityStoresFromDb.ts` (coordinator: `identityHydrationCoordinator.ts`; Meshtastic node map: `meshtasticDbCacheHydration.ts`; message cap: `meshtasticMessageLoadLimit.ts`); manual refresh via `hooks/useDbRefresh.ts`. Identity-scoped Zustand hydration is the canonical UI path ([#375]). **MeshCore contacts DB:** `meshcore_contacts.last_advert` is Unix **seconds**; age prune uses `src/shared/meshcoreContactAgeCutoff.ts` (do not compare in ms). ### Protocol entry points @@ -229,7 +229,7 @@ Do **not** reintroduce Meshtastic-only startup gates, child-before-parent init, ### Meshtastic channel URLs & Store & Forward -- **Config apply (Radio / Modules / Security):** Firmware `setConfig` / `setModuleConfig` replace full protobuf structs. UI must merge cached device slices with form edits via `meshtasticConfigApply.ts` (`mergeMeshtasticConfigApplyValue`, `buildMeshtasticModuleApplyValue`); slices live in `deviceStore.meshtasticConfigSlices` and `moduleConfigs` (PacketRouter + `meshtasticLegacyWireSubscriptions`). Module-specific validation: `meshtasticMqttModuleApply.ts`, `meshtasticSerialModuleApply.ts`. Apply failures surface `clientNotification` text within 8s (`meshtasticClientNotification.ts` → `formatMeshtasticModuleApplyError`); inline status via `ConfigApplyNotice.tsx`. Forms re-sync after reboot via `useSyncFormFromConfig`. +- **Config apply (Radio / Modules / Security):** Firmware `setConfig` / `setModuleConfig` replace full protobuf structs. UI must merge cached device slices with form edits via `meshtasticConfigApply.ts` (`mergeMeshtasticConfigApplyValue`, `buildMeshtasticModuleApplyValue`); slices live in `deviceStore.meshtasticConfigSlices` and `moduleConfigs` (PacketRouter + runtime lifecycle wire effects). Module-specific validation: `meshtasticMqttModuleApply.ts`, `meshtasticSerialModuleApply.ts`. Apply failures surface `clientNotification` text within 8s (`meshtasticClientNotification.ts` → `formatMeshtasticModuleApplyError`); inline status via `ConfigApplyNotice.tsx`. Forms re-sync after reboot via `useSyncFormFromConfig`. - **Administration tab:** `AdminPanel.tsx` — device commands and Danger Zone (reboot, shutdown, factory reset, NodeDB reset, OTA/DFU); shared `ConfirmModal.tsx` with Radio/Modules destructive flows. Local-only OTA/DFU disabled when **Configure node** targets a remote node. - **Remote admin module snapshot:** `meshtasticRemoteAdminModuleFetches.ts` — canonical list/count of `ModuleConfig` reads during remote snapshot (`REMOTE_ADMIN_MODULE_CONFIG_FETCHES`). - **Channel URLs:** `src/shared/meshtasticUrlEncoder.ts` (parse/generate), `src/shared/meshtasticChannelApply.ts` (replace vs add-only apply); Radio panel UI; Meshtastic-only. @@ -264,9 +264,9 @@ Panels: `src/renderer/components/`. New tabs: `lazyTabPanels.ts` / `lazyAppPanel - **Components:** `ChatPanel.tsx` (channel/DM UI) + shared `ChatComposer.tsx` (drafts, mentions, chunking, spellcheck, emoji; also used by `RoomsPanel.tsx`). Scroll-at-bottom helper: `chatScrollUtils.ts` (`getDistFromChatBottom`). - **Payload / links:** `ChatPayloadText.tsx` — mention highlighting, search marks, URL linkification; link previews via `chat:fetchLinkPreview` (`src/main/fetchLinkPreview.ts`, blocks hostname and **resolved** private/loopback addresses via DNS pin + undici `Agent`, 10s fetch timeout, 3s DNS lookup timeout, 64 KiB HTML cap, LRU preview/image caches, single-flight dedup). Reply quotes: `replyPreview.ts`. - **Storage helpers:** `src/renderer/lib/chatPanelProtocolStorage.ts` — drafts (`mesh-client:drafts:`), open DM tabs, last-read, per-view mute (`mesh-client:mutedViews:`), starred (`mesh-client:starred:`, cap 200). -- **Notifications:** `src/renderer/lib/chatNotifications.ts` — `playMessageNotification(type)` via Web Audio: `channel` = single 880 Hz pulse (150 ms); `dm` / `reply` = dual pulse (587.33 Hz then 783.99 Hz, 50 ms each, 35 ms gap). Resumes suspended `AudioContext` when the window is hidden/minimized. Type selection in `chatUnreadCounts.ts` (`resolveChatNotificationType`, `pickAudibleNotificationType`; batch priority reply > dm > channel). **ChatPanel** plays when the user is on Chat but reading another view; **App** plays for other panels / backgrounded window (avoids double beep). Meshtastic hidden-window desktop notifications are visual-only (`silent: true` in `meshtasticLegacyWireSubscriptions.ts`); typed Web Audio from App owns sound. Global mute `mesh-client:notifMuted`; per-view mute in `mutedViews`. Main-process **tray** icon shows unread when chat or MeshCore Rooms traffic arrives while backgrounded (`src/main/index.ts` `buildTrayIcon`). +- **Notifications:** `src/renderer/lib/chatNotifications.ts` — `playMessageNotification(type)` via Web Audio: `channel` = single 880 Hz pulse (150 ms); `dm` / `reply` = dual pulse (587.33 Hz then 783.99 Hz, 50 ms each, 35 ms gap). Resumes suspended `AudioContext` when the window is hidden/minimized. Type selection in `chatUnreadCounts.ts` (`resolveChatNotificationType`, `pickAudibleNotificationType`; batch priority reply > dm > channel). **ChatPanel** plays when the user is on Chat but reading another view; **App** plays for other panels / backgrounded window (avoids double beep). Meshtastic hidden-window desktop notifications are visual-only (`silent: true` in `meshtasticRouterSideEffects.ts`); typed Web Audio from App owns sound. Global mute `mesh-client:notifMuted`; per-view mute in `mutedViews`. Main-process **tray** icon shows unread when chat or MeshCore Rooms traffic arrives while backgrounded (`src/main/index.ts` `buildTrayIcon`). - **Meshtastic dedup:** `meshtasticMessageDedup.ts` — merges delayed RF/MQTT duplicates (10-minute content window) in `useMeshtasticRuntime` ingest. -- **Hop badges:** `MessageRecord.rxHops` / `viaStoreForward` round-trip via `storeRecordAdapters.ts` and `meshtasticDbCacheHydration.ts` (`hopCount` on `MessageRecord` bridges to `rxHops` for Meshtastic PacketRouter rows); Chat hop pills read `ChatMessage.rxHops`. **MeshCore (primary):** companion `pathLen` on events 7/8 → `meshcoreCompanionRxPathLenToHopCount` → `DomainEvent.payload.hopCount` (`MeshCoreProtocol`, `meshcoreDirectMessageDecode`); waiting-message drain also sets `rxHops` from `pathLen`. **MeshCore (fallback):** raw-log correlation via `resolveMeshcoreIngestRxHops` / `MESHCORE_CHAT_CORRELATE_WINDOW_MS` (3000ms); `rawPacketsRef` synced inside event-136 `setRawPackets` updater for same-tick ingest (`meshcoreLegacyConnEvents`). **Meshtastic:** `MeshtasticProtocol` uses `meshtasticComputedRfHopsAway` — omit hops for `viaMqtt` and `hopStart === 0`; else `hopStart - hopLimit` when `hopStart > 0 && hopLimit <= hopStart`. +- **Hop badges:** `MessageRecord.rxHops` / `viaStoreForward` round-trip via `storeRecordAdapters.ts` and `meshtasticDbCacheHydration.ts` (`hopCount` on `MessageRecord` bridges to `rxHops` for Meshtastic PacketRouter rows); Chat hop pills read `ChatMessage.rxHops`. **MeshCore (primary):** companion `pathLen` on events 7/8 → `meshcoreCompanionRxPathLenToHopCount` → `DomainEvent.payload.hopCount` (`MeshCoreProtocol`, `meshcoreDirectMessageDecode`); waiting-message drain also sets `rxHops` from `pathLen`. **MeshCore (fallback):** raw-log correlation via `resolveMeshcoreIngestRxHops` / `MESHCORE_CHAT_CORRELATE_WINDOW_MS` (3000ms); `rawPacketsRef` synced inside event-136 `setRawPackets` updater for same-tick ingest (`meshcoreConnSideEffects`). **Meshtastic:** `MeshtasticProtocol` uses `meshtasticComputedRfHopsAway` — omit hops for `viaMqtt` and `hopStart === 0`; else `hopStart - hopLimit` when `hopStart > 0 && hopLimit <= hopStart`. - **MeshCore dedup:** `meshcoreStoreDedup.ts` — RF/MQTT merge, companion TX echo, tapback self-echo, room BBS paths in `useMeshcoreRuntime` ingest. - **Reactions / tapbacks:** `reactions.ts` (Meshtastic protobuf) + `meshcoreChannelText.ts` (MeshCore default outbound keyless `@[Name] emoji` / `@[Name] body`; opt-in **MeshCore Open compatibility** in App enables keyed replies, `r:` reactions, and `g:` GIF send via `meshcoreOpenWireCompatEnabled` in `appSettingsStorage.ts`; inbound keyed/sec↔ms parent match via `meshcoreMessageMatchesReplyKey`; inbound emoji-only replies promoted via `meshcorePromoteEmojiOnlyReplyToTapback`; inbound Open `r:HASH:INDEX` in `meshcoreOpenReaction.ts`); `ChatPanel.tsx` attaches tapbacks via `replyId` + runtime/panel `sendReaction`. - **Mention segments:** `src/renderer/lib/chatMentionSegments.ts` — parse/build `@[Name]` tokens; `MentionAutocomplete.tsx` renders the dropdown. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 4b894e1c5..970e5ca60 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -683,7 +683,7 @@ Meshtastic short/full header rules (unchanged): #### Connected-node telemetry ingest (Meshtastic) -`meshtasticLegacyWireSubscriptions` calls `processNodeUpdate` on **LocalStats** telemetry and on RF mesh packets that update `hops_away`/SNR so `cuHistory`, hop history, and incremental RF analysis stay live (previously only device-metrics / NodeInfo paths fed the store). +`meshtasticRuntimeWireEffects` calls `processNodeUpdate` on **LocalStats** telemetry and on RF mesh packets that update `hops_away`/SNR so `cuHistory`, hop history, and incremental RF analysis stay live (previously only device-metrics / NodeInfo paths fed the store). #### Proximity classification (`classifyProximity()`) diff --git a/scripts/check-log-panel-filter.mjs b/scripts/check-log-panel-filter.mjs index fea522f6e..ae3f9abae 100644 --- a/scripts/check-log-panel-filter.mjs +++ b/scripts/check-log-panel-filter.mjs @@ -9,9 +9,9 @@ * * Files checked: * Meshtastic main: noble-ble-manager.ts, mqtt-manager.ts - * Meshtastic renderer: useMeshtasticRuntime.ts, meshtasticLegacyWireSubscriptions.ts + * Meshtastic renderer: useMeshtasticRuntime.ts, meshtasticRuntimeWireEffects.ts * MeshCore main: meshcore-mqtt-adapter.ts - * MeshCore renderer: useMeshcoreRuntime.ts, meshcoreLegacyConnEvents.ts + * MeshCore renderer: useMeshcoreRuntime.ts, meshcoreConnSideEffects.ts * * Filter source: src/renderer/components/LogPanel.tsx * @@ -36,12 +36,12 @@ const DEVICE_FILES = { path.join(ROOT, 'src', 'main', 'noble-ble-manager.ts'), path.join(ROOT, 'src', 'main', 'mqtt-manager.ts'), path.join(ROOT, 'src', 'renderer', 'runtime', 'useMeshtasticRuntime.ts'), - path.join(ROOT, 'src', 'renderer', 'lib', 'meshtastic', 'meshtasticLegacyWireSubscriptions.ts'), + path.join(ROOT, 'src', 'renderer', 'lib', 'meshtastic', 'meshtasticRuntimeWireEffects.ts'), ], meshcore: [ path.join(ROOT, 'src', 'main', 'meshcore-mqtt-adapter.ts'), path.join(ROOT, 'src', 'renderer', 'runtime', 'useMeshcoreRuntime.ts'), - path.join(ROOT, 'src', 'renderer', 'hooks', 'meshcore', 'meshcoreLegacyConnEvents.ts'), + path.join(ROOT, 'src', 'renderer', 'hooks', 'meshcore', 'meshcoreConnSideEffects.ts'), ], reticulum: [ path.join(ROOT, 'src', 'main', 'reticulum-sidecar-manager.ts'), diff --git a/src/renderer/components/ChatComposer.tsx b/src/renderer/components/ChatComposer.tsx index 54d5fb6d7..ba5efa987 100644 --- a/src/renderer/components/ChatComposer.tsx +++ b/src/renderer/components/ChatComposer.tsx @@ -103,7 +103,7 @@ export interface ChatComposerProps { onRememberFloodScopePreset?: (hashtag: string) => void; /** * Resolve GPS/static position for one-click location share. - * Sourced from runtime `refreshOurPosition` (Composer has no nodesRef access). + * Sourced from runtime `refreshOurPosition` (Composer reads position via runtime, not nodeStore). */ resolveShareLocation?: () => Promise<{ lat: number; lon: number } | null>; /** diff --git a/src/renderer/components/LogPanel.filtering.test.ts b/src/renderer/components/LogPanel.filtering.test.ts index b200c06d0..a3d3cd79d 100644 --- a/src/renderer/components/LogPanel.filtering.test.ts +++ b/src/renderer/components/LogPanel.filtering.test.ts @@ -109,10 +109,10 @@ describe('isDeviceEntry — Meshtastic protocol', () => { }); describe('isDeviceEntry — MeshCore protocol', () => { - it('classifies [meshcoreLegacyConnEvents] message as MeshCore device entry', () => { + it('classifies [meshcoreConnSideEffects] message as MeshCore device entry', () => { expect( isDeviceEntry( - entry('main', '[meshcoreLegacyConnEvents] stale conn close timeout'), + entry('main', '[meshcoreConnSideEffects] stale conn close timeout'), 'meshcore', ), ).toBe(true); diff --git a/src/renderer/components/LogPanel.tsx b/src/renderer/components/LogPanel.tsx index cf9a2c235..9d5ef2586 100644 --- a/src/renderer/components/LogPanel.tsx +++ b/src/renderer/components/LogPanel.tsx @@ -97,7 +97,7 @@ export function isDeviceEntry(entry: LogEntry, protocol?: MeshProtocol): boolean return ( entry.source.includes('meshcore') || entry.message.includes('[useMeshcoreRuntime]') || - entry.message.includes('[meshcoreLegacyConnEvents]') || + entry.message.includes('[meshcoreConnSideEffects]') || entry.message.includes('[MeshCore MQTT]') || entry.message.includes('[BLE:meshcore]') || entry.message.includes('[IpcNobleConnection:meshcore]') @@ -126,7 +126,7 @@ export function isDeviceEntry(entry: LogEntry, protocol?: MeshProtocol): boolean entry.message.includes('[useMeshtasticRuntime]') || entry.message.includes('[iMeshDevice]') || entry.message.includes('[useMeshcoreRuntime]') || - entry.message.includes('[meshcoreLegacyConnEvents]') || + entry.message.includes('[meshcoreConnSideEffects]') || entry.message.includes('[useReticulumRuntime]') || entry.message.includes('[TransportNobleIpc]') || entry.message.includes('[MeshCore MQTT]') || diff --git a/src/renderer/components/NodeInfoBody.tsx b/src/renderer/components/NodeInfoBody.tsx index 9ec31ca77..b4eb7444c 100644 --- a/src/renderer/components/NodeInfoBody.tsx +++ b/src/renderer/components/NodeInfoBody.tsx @@ -38,6 +38,7 @@ import { formatRelativeOrIsoDateTime } from '../lib/formatRelativeOrIsoDate'; import { meshtasticHwModelDisplay } from '../lib/hardwareModels'; import { meshcoreTracePathLenToHops } from '../lib/meshcoreUtils'; import { + isMeshtasticSelfHybridPath, MeshtasticHybridPathIcons, MeshtasticMqttOnlyPathIcons, MeshtasticRfPathIcon, @@ -121,7 +122,7 @@ function NodeSourceBadge({ const displayBadge = pathBadge === 'none' ? 'rfOnly' : pathBadge; if (displayBadge === 'hybrid') { - const isSelfHybrid = isSelf && mqttConnected && radioConnected; + const isSelfHybrid = isMeshtasticSelfHybridPath(isSelf, mqttConnected, radioConnected); return ( (current: T) { + return { current }; +} + +interface Harness { + ctx: MeshcoreConnSideEffectsCtx; + conn: MeshCoreConnection; + deviceLogs: DeviceLogEntry[]; + rawPackets: RxPacketEntry[]; + signal: TelemetryPoint[]; + state: DeviceState; + cliHistory: { nodeId: number; text: string }[]; + handleResponse: ReturnType; + teardownConn: ReturnType; + handleConnectionLost: ReturnType; + syncNextMessage: ReturnType; + pendingAcks: Map; + messages: ChatMessage[]; +} + +function makeHarness(overrides?: { handleResponseResult?: boolean }): Harness { + const deviceLogs: DeviceLogEntry[] = []; + const rawPackets: RxPacketEntry[] = []; + const signal: TelemetryPoint[] = []; + const cliHistory: { nodeId: number; text: string }[] = []; + const pendingAcks = new Map(); + const messages: ChatMessage[] = []; + let state: DeviceState = { + status: 'configured', + myNodeNum: 1, + connectionType: 'ble', + }; + + const syncNextMessage = vi.fn().mockResolvedValue(null); + const conn = { + getWaitingMessages: vi.fn().mockResolvedValue([]), + syncNextMessage, + close: vi.fn().mockResolvedValue(undefined), + } as unknown as MeshCoreConnection; + + const handleResponse = vi.fn().mockReturnValue(overrides?.handleResponseResult ?? false); + const teardownConn = vi.fn(); + const handleConnectionLost = vi.fn(); + + const ctx: MeshcoreConnSideEffectsCtx = { + resolveIdentityId: () => ID, + meshcoreIdentityIdRef: ref(ID), + meshcoreDriverConnectedRef: ref(true), + connRef: ref(conn), + lastPacketLogAtRef: ref(0), + lastPacketLogPublishFailureLogAtRef: ref(0), + meshcoreContactsRefreshTimerRef: ref | null>(null), + meshcoreHookMountedRef: ref(true), + meshcoreSessionPathUpdatedNodeIdsRef: ref(new Set()), + meshcoreWaitingMessagesPollRef: ref | null>(null), + meshcoreConnectTypeRef: ref<'ble' | 'serial' | 'tcp'>('ble'), + mqttStatusRef: ref('disconnected' as const), + myNodeNumRef: ref(1), + nicknameMapRef: ref(new Map()), + readNodes: () => getIdentityNodeMap(ID), + pendingAcksRef: ref(pendingAcks), + processWaitingMessagesRef: ref< + ((options?: { showSyncBanner?: boolean }) => Promise) | null + >(null), + pubKeyMapRef: ref(new Map()), + pubKeyPrefixMapRef: ref(new Map()), + rawPacketsRef: ref(rawPackets), + repeaterCommandServiceRef: ref({ + handleResponse, + parseResponseToken: (text: string) => ({ token: null, body: text }), + } as never), + selfInfoRef: ref(null), + setDeviceLogs: (updater) => { + const next = typeof updater === 'function' ? updater(deviceLogs) : updater; + deviceLogs.splice(0, deviceLogs.length, ...next); + }, + setMeshcorePingRouteReadyEpoch: vi.fn(), + setMessages: vi.fn(), + setNodes: vi.fn(), + setQueueStatus: vi.fn(), + setRawPackets: (updater) => { + const next = typeof updater === 'function' ? updater(rawPackets) : updater; + rawPackets.splice(0, rawPackets.length, ...next); + }, + setSignalTelemetry: (updater) => { + const next = typeof updater === 'function' ? updater(signal) : updater; + signal.splice(0, signal.length, ...next); + }, + setState: (updater) => { + state = typeof updater === 'function' ? updater(state) : updater; + }, + setWaitingMessagesCount: vi.fn(), + setWaitingMessagesSyncActive: vi.fn(), + setWaitingMessagesSyncProgress: vi.fn(), + setWaitingMessagesSilentDrainActive: vi.fn(), + setWaitingMessagesDrainDeferred: vi.fn(), + addMessagesBatch: vi.fn(), + addCliHistoryEntry: (nodeId, entry) => { + cliHistory.push({ nodeId, text: entry.text }); + }, + teardownMeshcoreConnEventListeners: teardownConn, + handleConnectionLostRef: ref(handleConnectionLost), + meshcoreExplicitDisconnectRef: ref(false), + }; + + return { + ctx, + conn, + deviceLogs, + rawPackets, + signal, + get state() { + return state; + }, + cliHistory, + handleResponse, + teardownConn, + handleConnectionLost, + syncNextMessage, + pendingAcks, + messages, + }; +} + +function dispatch(event: DomainEvent, identityId = ID): void { + packetRouter.dispatch(event, identityId); +} + +describe('attachMeshcoreConnSideEffects', () => { + let detach: (() => void) | null = null; + + beforeEach(() => { + resetMeshcoreWaitingMessagesDrainState(0); + useNodeStore.setState({ nodes: {} }); + useMessageStore.setState({ messages: {} }); + }); + + afterEach(() => { + detach?.(); + detach = null; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('routes CLI data responses to the repeater command service', () => { + const h = makeHarness({ handleResponseResult: true }); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + dispatch({ + type: 'meshcore_cli_response', + payload: { text: 'A1|uptime 42', senderNodeId: 0x1234, pubKeyPrefixHex: 'aabbcc' }, + }); + + expect(h.handleResponse).toHaveBeenCalledWith('A1|uptime 42', 0x1234); + expect(h.cliHistory).toHaveLength(0); + }); + + it('appends unmatched CLI responses to panel history', () => { + const h = makeHarness({ handleResponseResult: false }); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + dispatch({ + type: 'meshcore_cli_response', + payload: { text: 'stray output', senderNodeId: 0x99, pubKeyPrefixHex: 'aabbcc' }, + }); + + expect(h.cliHistory).toEqual([{ nodeId: 0x99, text: 'stray output' }]); + }); + + it('ignores events routed to a different identity', () => { + const h = makeHarness({ handleResponseResult: true }); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + dispatch( + { + type: 'meshcore_cli_response', + payload: { text: 'other', senderNodeId: 1, pubKeyPrefixHex: 'aabbcc' }, + }, + 'some-other-identity', + ); + + expect(h.handleResponse).not.toHaveBeenCalled(); + }); + + it('records RF RX into device logs, signal telemetry, and the raw packet log', () => { + const h = makeHarness(); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + dispatch({ + type: 'meshcore_rf_rx', + payload: { lastSnr: 7.5, lastRssi: -60, raw: Uint8Array.from([0x11, 0x22, 0x33, 0x44]) }, + }); + + expect(h.deviceLogs.at(-1)?.message).toContain('SNR=7.50dB RSSI=-60dBm'); + expect(h.signal.at(-1)).toMatchObject({ snr: 7.5, rssi: -60 }); + expect(h.rawPackets).toHaveLength(1); + expect(h.ctx.rawPacketsRef.current).toHaveLength(1); + }); + + it('logs RF RX without raw bytes and skips the raw packet log', () => { + const h = makeHarness(); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + dispatch({ type: 'meshcore_rf_rx', payload: { lastSnr: 0, lastRssi: 0, raw: null } }); + + expect(h.deviceLogs).toHaveLength(1); + expect(h.rawPackets).toHaveLength(0); + }); + + it('resolves a pending DM ack and persists the new status', () => { + const h = makeHarness(); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + const timeoutId = setTimeout(() => {}, 60_000); + h.pendingAcks.set(0x80, { + timeoutId, + mapKeys: [0x80], + canonicalPacketIdU32: 0x80, + }); + + dispatch({ type: 'meshcore_dm_ack', payload: { ackCode: 0x80 } }); + + expect(h.pendingAcks.size).toBe(0); + expect(window.electronAPI.db.updateMeshcoreMessageStatus).toHaveBeenCalledWith(0x80, 'acked'); + clearTimeout(timeoutId); + }); + + it('resolves a late DM ack against messageStore rows instead of a runtime mirror', () => { + const h = makeHarness(); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + useMessageStore.setState({ + messages: { + [ID]: { + '128': { + id: '128', + from: 1, + to: 42, + payload: 'late dm', + channelIndex: -1, + timestamp: 1_700_000_000_000, + status: 'sending', + }, + }, + }, + }); + + dispatch({ type: 'meshcore_dm_ack', payload: { ackCode: 0x80 } }); + + expect(h.ctx.setMessages).toHaveBeenCalled(); + expect(window.electronAPI.db.updateMeshcoreMessageStatus).toHaveBeenCalledWith(0x80, 'acked'); + }); + + it('marks a NACK ack code as failed', () => { + const h = makeHarness(); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + dispatch({ type: 'meshcore_dm_ack', payload: { ackCode: 0x81 } }); + + expect(window.electronAPI.db.updateMeshcoreMessageStatus).toHaveBeenCalledWith(0x81, 'failed'); + }); + + it('schedules a silent drain on the message-waiting signal', async () => { + vi.useFakeTimers(); + const h = makeHarness(); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + expect(h.ctx.processWaitingMessagesRef.current).toBeTypeOf('function'); + + dispatch({ type: 'meshcore_waiting_messages', payload: {} }); + await vi.advanceTimersByTimeAsync(MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS + 50); + + expect(h.syncNextMessage).toHaveBeenCalled(); + expect(h.conn.getWaitingMessages).not.toHaveBeenCalled(); + }); + + it('tears down the session and requests reconnect on disconnect', async () => { + const h = makeHarness(); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + dispatch({ type: 'device_status', payload: { status: 'disconnected' } }); + + expect(h.state.status).toBe('disconnected'); + expect(h.teardownConn).toHaveBeenCalledWith({ driverDisconnect: true }); + expect(h.ctx.connRef.current).toBeNull(); + await Promise.resolve(); + expect(h.handleConnectionLost).toHaveBeenCalled(); + }); + + it('stops handling events after detach', () => { + const h = makeHarness({ handleResponseResult: true }); + const stop = attachMeshcoreConnSideEffects(h.conn, h.ctx); + stop(); + + dispatch({ + type: 'meshcore_cli_response', + payload: { text: 'after detach', senderNodeId: 5, pubKeyPrefixHex: 'aabbcc' }, + }); + + expect(h.handleResponse).not.toHaveBeenCalled(); + expect(h.ctx.processWaitingMessagesRef.current).toBeNull(); + }); +}); diff --git a/src/renderer/hooks/meshcore/meshcoreLegacyConnEvents.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts similarity index 51% rename from src/renderer/hooks/meshcore/meshcoreLegacyConnEvents.ts rename to src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index f29405b35..0a86d57ad 100644 --- a/src/renderer/hooks/meshcore/meshcoreLegacyConnEvents.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -1,42 +1,35 @@ +/** + * Runtime side effects for MeshCore pushes that `MeshCoreProtocol` decodes into `DomainEvent`s + * (hop ACK 130, message-waiting 131, RF RX 136, CLI data responses, disconnect). + * + * Every handler runs off `PacketRouter` — this module never subscribes to the SDK event bus. + * + * Failure point: DB / MQTT IPC rejections are logged; Zustand stores and hook state stay + * authoritative for the UI. + */ import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; -import { isMeshtasticBroadcastNodeNum } from '@/shared/nodeNameUtils'; +import { getIdentityChatMessages } from '@/renderer/lib/identityStoreReads'; import { withTimeout } from '@/shared/withTimeout'; import { parseMeshCoreRfPacket } from '../../../shared/meshcoreRfPacketParse'; +import { packetRouter } from '../../lib/drivers/PacketRouter'; import { classifyPayload, classifyProximity, extractMeshtasticSenderId, meshtasticSenderIdForRawLogFallback, } from '../../lib/foreignLoraDetection'; +import { syncNodesMapToIdentityStore } from '../../lib/hydrateIdentityStoresFromDb'; import { - applyMeshcoreAdvertEvent128, - applyMeshcorePathUpdated129, - updateMeshcorePubKeyPrefixMaps, -} from '../../lib/meshcore/meshcoreAdvertEventApply'; + applyMeshcoreDmAckToPending, + syncMeshcoreDmAckToMessageStore, +} from '../../lib/meshcore/meshcoreDmAckRuntime'; import type { DeviceLogEntry, MeshCoreConnection, - MeshCoreContactRaw, RxPacketEntry, } from '../../lib/meshcore/meshcoreHookTypes'; -import { - buildMeshcoreRoomIncomingMessage, - parseMeshcoreChannelIncomingFromThread, - parseMeshcoreDmIncomingFromThread, - resolveMeshcoreChannelMessageSender, -} from '../../lib/meshcoreChannelText'; -import { - meshcoreCoerceRadioRxFrame, - parseAutoaddConfigResponse, -} from '../../lib/meshcoreContactAutoAdd'; import { processMeshcoreWaitingMessageItem } from '../../lib/meshcoreProcessWaitingMessageItem'; -import { meshcoreContactOutPathBytesForTrace } from '../../lib/meshcoreRadioContactPath'; -import { - MESHCORE_CHAT_CORRELATE_WINDOW_MS, - meshcoreCorrelateOrSynthesizeChatEntry, - meshcoreFindRecentGrpTxtRawPacket, -} from '../../lib/meshcoreRawPacketCorrelate'; import { meshcoreRawPacketLogFromBytesFallback, meshcoreRawPacketResolveFromParsed, @@ -45,22 +38,8 @@ import { meshcoreRfResolvePathSender, } from '../../lib/meshcoreRawPacketSender'; import { shouldCoalesceSelfFloodAdvert } from '../../lib/meshcoreRawSelfFloodAdvertCoalesce'; -import { meshcoreRoomPostBodyFromWire } from '../../lib/meshcoreRoomMessageRouting'; -import { setMeshcoreRoomLastPostAt } from '../../lib/meshcoreRoomSyncStorage'; import { meshcoreSortedStorePrior } from '../../lib/meshcoreStoreDedup'; -import { - isMeshcoreTransportStatusChatLine, - mergeHwModelOnContactUpdate, - mergeMeshcoreChatStubNodes, - MESHCORE_COORD_SCALE, - meshcoreContactToMeshNode, - meshcoreInferHopsFromOutPath, - meshcoreMergeChannelDisplayNameOntoNode, - meshcoreMergeContactHopsAwayFromPrevious, - meshcoreSyntheticPlaceholderPubKeyHex, - minimalMeshcoreChatNode, - pubkeyToNodeId, -} from '../../lib/meshcoreUtils'; +import { meshcoreMergeContactHopsAwayFromPrevious, pubkeyToNodeId } from '../../lib/meshcoreUtils'; import { normalizeMeshcoreWaitingMessageBatch, normalizeMeshcoreWaitingMessageItem, @@ -76,10 +55,7 @@ import { waitingMessagesDrainTimeoutMs, } from '../../lib/meshcoreWaitingMessagesDrain'; import { getMeshtasticConnectedMyNodeNum } from '../../lib/meshtasticConnectedNodeRef'; -import { - effectiveMessageTimestampMs, - mergeMeshcoreLastHeardFromAdvert, -} from '../../lib/nodeStatus'; +import type { DomainEvent } from '../../lib/protocols/Protocol'; import { MAX_RAW_PACKET_LOG_ENTRIES } from '../../lib/rawPacketLogConstants'; import { getStoredMeshProtocol } from '../../lib/storedMeshProtocol'; import { @@ -88,24 +64,14 @@ import { MESHCORE_SYNC_NEXT_MESSAGE_TIMEOUT_MS, MESHCORE_WAITING_MESSAGES_BATCH_YIELD, } from '../../lib/timeConstants'; -import type { ChatMessage, IdentityId, MeshNode, TelemetryPoint } from '../../lib/types'; +import type { ChatMessage, MeshNode, TelemetryPoint } from '../../lib/types'; import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; -import { renameMessageId, updateMessageStatus, useMessageStore } from '../../stores/messageStore'; import { usePathHistoryStore } from '../../stores/pathHistoryStore'; -import { usePositionHistoryStore } from '../../stores/positionHistoryStore'; -import { - contactToDbRow, - MAX_DEVICE_LOGS, - MAX_TELEMETRY_POINTS, - meshcoreContactRawFromDevice, - meshcoreDeviceAckLookupKeys, - meshcoreDmAckKeyU32, - type PendingDmAckEntry, -} from './meshcoreHookPreamble'; import type { - MeshcoreLegacyConnEventsCtx, + MeshcoreConnSideEffectsCtx, ProcessWaitingMessagesOptions, -} from './meshcoreLegacyConnEventsCtx'; +} from './meshcoreConnSideEffectsCtx'; +import { MAX_DEVICE_LOGS, MAX_TELEMETRY_POINTS, meshcoreDmAckKeyU32 } from './meshcoreHookPreamble'; import { getMeshcoreProcessWaitingMessagesInFlight, requestMeshcoreWaitingMessagesFollowUp, @@ -116,60 +82,18 @@ import { takeMeshcoreWaitingMessagesManualFollowUp, } from './meshcoreWaitingMessagesSyncState'; -export type { ProcessWaitingMessagesOptions } from './meshcoreLegacyConnEventsCtx'; -export { resetMeshcoreProcessWaitingMessagesSync } from './meshcoreWaitingMessagesSyncState'; - function yieldToEventLoop(): Promise { return new Promise((resolve) => { setTimeout(resolve, 0); }); } -function isOutboundMeshcoreDmRecord(rec: { from: number; to: number; status?: string }): boolean { - return rec.to !== 0xffffffff && !isMeshtasticBroadcastNodeNum(rec.to) && rec.to > 0; -} - -/** Identity-scoped chat rows from {@link useSendMessage} use numeric ids after rename. */ -export function syncMeshcoreDmAckToMessageStore( - identityId: IdentityId, - ackKeyU32: number, - selfId: number, - newStatus: 'acked' | 'failed', -): boolean { - const byId = useMessageStore.getState().messages[identityId] ?? {}; - let matched = false; - for (const [id, rec] of Object.entries(byId)) { - if (rec.from !== selfId) continue; - if (rec.status !== 'sending' && rec.status !== 'failed') continue; - if (!/^\d+$/.test(id)) continue; - if (meshcoreDmAckKeyU32(Number(id)) !== ackKeyU32) continue; - updateMessageStatus(identityId, id, newStatus); - matched = true; - } - if (matched) return true; - - const inflight = Object.entries(byId).filter( - ([, rec]) => rec.status === 'sending' && rec.from === selfId && isOutboundMeshcoreDmRecord(rec), - ); - if (inflight.length !== 1) return false; - - const [id] = inflight[0]; - const ackId = String(ackKeyU32); - if (id !== ackId) { - renameMessageId(identityId, id, ackId); - } - updateMessageStatus(identityId, ackId, newStatus); - return true; -} - -export function attachMeshcoreLegacyConnEvents( +export function attachMeshcoreConnSideEffects( conn: MeshCoreConnection, - ctx: MeshcoreLegacyConnEventsCtx, + ctx: MeshcoreConnSideEffectsCtx, ): () => void { - // Driver + MeshCoreProtocol own 128/7/8/129/138/rx when identity ingest is active; handlers - // below for those codes are legacy fallbacks (e.g. pre-driver connect). Event 130 stays here. - const protocolOwnedEvents = new Set([128, 7, 8, 129, 138, 'rx']); const { + resolveIdentityId, meshcoreIdentityIdRef, meshcoreDriverConnectedRef, connRef, @@ -177,16 +101,13 @@ export function attachMeshcoreLegacyConnEvents( lastPacketLogPublishFailureLogAtRef, meshcoreContactsRefreshTimerRef, meshcoreHookMountedRef, - meshcorePathUpdatePendingRef, meshcoreSessionPathUpdatedNodeIdsRef, meshcoreWaitingMessagesPollRef, meshcoreConnectTypeRef, - messagesRef, mqttStatusRef, myNodeNumRef, nicknameMapRef, - nodesRef, - outPathMapRef, + readNodes, pendingAcksRef, processWaitingMessagesRef, pubKeyMapRef, @@ -194,10 +115,7 @@ export function attachMeshcoreLegacyConnEvents( rawPacketsRef, repeaterCommandServiceRef, selfInfoRef, - buildNodesFromContactsRef, setDeviceLogs, - setMeshcoreAutoadd, - setMeshcoreContactsForTelemetry, setMeshcorePingRouteReadyEpoch, setMessages, setNodes, @@ -210,37 +128,17 @@ export function attachMeshcoreLegacyConnEvents( setWaitingMessagesSyncProgress, setWaitingMessagesSilentDrainActive, setWaitingMessagesDrainDeferred, - addMessage, addMessagesBatch, addCliHistoryEntry, teardownMeshcoreConnEventListeners, - meshcorePreviousNodesBaselineForBuild, handleConnectionLostRef, meshcoreExplicitDisconnectRef, bumpLastDataReceived, } = ctx; - /** PacketRouter + meshcoreIngest own parsed room posts when identity is bound. */ - const legacyOwnsRoomPosts = (): boolean => meshcoreIdentityIdRef.current == null; - const storePriorForIngest = (): ChatMessage[] => { const storeId = meshcoreIdentityIdRef.current; - if (storeId) return meshcoreSortedStorePrior(storeId); - return [...messagesRef.current].sort((a, b) => a.timestamp - b.timestamp); - }; - - const meshcorePersistentListenerRegs: { - event: string | number; - handler: (...args: unknown[]) => void; - }[] = []; - const onMeshcoreConn = (event: string | number, handler: (...args: unknown[]) => void) => { - if (protocolOwnedEvents.has(event)) return; - const wrapped = (...args: unknown[]) => { - bumpLastDataReceived?.(); - handler(...args); - }; - conn.on(event, wrapped); - meshcorePersistentListenerRegs.push({ event, handler: wrapped }); + return storeId ? meshcoreSortedStorePrior(storeId) : []; }; const logTransportLineAsDevice = (line: string) => { @@ -257,251 +155,23 @@ export function attachMeshcoreLegacyConnEvents( }); }; - // Push: periodic advert — event 0x80 = 128 (meshcore.js emits publicKey only; lat/lastAdvert may be absent) - onMeshcoreConn(128, (data: unknown) => { - const d = data as { - publicKey: Uint8Array; - advLat?: number; - advLon?: number; - lastAdvert?: number; - type?: number; - advName?: string; - }; - if (d.publicKey?.length !== 32) { - return; - } - const nodeId = pubkeyToNodeId(d.publicKey); - if (nodeId === 0) return; - const nowSec = Math.floor(Date.now() / 1000); - const nick = nicknameMapRef.current.get(nodeId); - let persistOut!: ReturnType['persist']; - setNodes((prev) => { - const result = applyMeshcoreAdvertEvent128(prev, d, { - nodeId, - nowSec, - nick, - myNodeNum: myNodeNumRef.current, - }); - persistOut = result.persist; - return result.next; - }); - if (persistOut.updatePubKeyMaps) { - updateMeshcorePubKeyPrefixMaps( - nodeId, - d.publicKey, - pubKeyMapRef.current, - pubKeyPrefixMapRef.current, - ); - } - if (persistOut.contactTypeUpdate) { - void window.electronAPI.db - .updateMeshcoreContactType( - persistOut.contactTypeUpdate.nodeId, - persistOut.contactTypeUpdate.contactType, - ) - .catch((e: unknown) => { - console.warn( - '[useMeshcoreRuntime] updateMeshcoreContactType error ' + errLikeToLogString(e), - ); - }); - } - if ( - typeof d.advLat === 'number' && - Number.isFinite(d.advLat) && - d.advLat !== 0 && - typeof d.advLon === 'number' && - Number.isFinite(d.advLon) && - d.advLon !== 0 - ) { - usePositionHistoryStore - .getState() - .recordPosition(nodeId, d.advLat / MESHCORE_COORD_SCALE, d.advLon / MESHCORE_COORD_SCALE); - } - if (persistOut.kind === 'insert') { - void window.electronAPI.db - .saveMeshcoreContact({ - node_id: nodeId, - public_key: Array.from(d.publicKey) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''), - adv_name: persistOut.insertAdvName, - contact_type: persistOut.insertContactType, - last_advert: persistOut.persistLastAdvert, - adv_lat: persistOut.persistLat, - adv_lon: persistOut.persistLon, - nickname: null, - on_radio: 1, - }) - .catch((e: unknown) => { - console.warn( - '[useMeshcoreRuntime] saveMeshcoreContact (event 128 new) error ' + - errLikeToLogString(e), - ); - }); - } else if (persistOut.kind === 'update') { - void window.electronAPI.db - .updateMeshcoreContactAdvert( - nodeId, - persistOut.persistLastAdvert, - persistOut.persistLat, - persistOut.persistLon, - persistOut.persistAdvName, - ) - .catch((e: unknown) => { - console.warn( - '[useMeshcoreRuntime] updateMeshcoreContactAdvert error ' + errLikeToLogString(e), - ); - }); - } - // Foreign LoRa for MeshCore overhear is recorded from RF RX (event 136), not advert sync (128). - }); + // --- Hop ACK (event 130) --- - // Push: path updated — event 0x81 = 129; update last_heard for that contact - onMeshcoreConn(129, (data: unknown) => { - const d = data as { publicKey: Uint8Array }; - if (d.publicKey?.length !== 32) { - return; - } - const nodeId = pubkeyToNodeId(d.publicKey); - if (nodeId === 0) return; - if (!meshcoreSessionPathUpdatedNodeIdsRef.current.has(nodeId)) { - meshcoreSessionPathUpdatedNodeIdsRef.current.add(nodeId); - setMeshcorePingRouteReadyEpoch((e) => e + 1); - } - useDiagnosticsStore.getState().recordPathUpdated(nodeId); - const nowSec = Math.floor(Date.now() / 1000); - const nick129 = nicknameMapRef.current.get(nodeId); - let persist129!: ReturnType['persist']; - setNodes((prev) => { - const result = applyMeshcorePathUpdated129(prev, d.publicKey, { - nodeId, - nowSec, - nick: nick129, - }); - persist129 = result.persist; - return result.next; - }); - if (persist129.updatePubKeyMaps) { - updateMeshcorePubKeyPrefixMaps( - nodeId, - d.publicKey, - pubKeyMapRef.current, - pubKeyPrefixMapRef.current, - ); - } - if (persist129.kind === 'insert') { - void window.electronAPI.db - .saveMeshcoreContact({ - node_id: nodeId, - public_key: Array.from(d.publicKey) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''), - adv_name: null, - contact_type: 0, - last_advert: persist129.persistLastAdvert, - adv_lat: null, - adv_lon: null, - nickname: null, - on_radio: 1, - }) - .catch((e: unknown) => { - console.warn( - '[useMeshcoreRuntime] saveMeshcoreContact (event 129 new) error ' + - errLikeToLogString(e), - ); - }); - } - // Accumulate nodeIds for path history recording after the debounced refresh - meshcorePathUpdatePendingRef.current.add(nodeId); - // Refresh route bytes quickly so trace/ping can use outPath before the debounced full rebuild. - void (async () => { - if (!connRef.current) return; - try { - const contactsRaw = await connRef.current.getContacts(); - const contacts = contactsRaw.map(meshcoreContactRawFromDevice); - for (const contact of contacts) { - const cNodeId = pubkeyToNodeId(contact.publicKey); - if (cNodeId !== nodeId) continue; - const sliced = meshcoreContactOutPathBytesForTrace(contact); - if (sliced.length > 0) { - outPathMapRef.current.set(cNodeId, sliced); - const pathBytes = Array.from(sliced); - const hops = meshcoreInferHopsFromOutPath(contact) ?? Math.max(0, pathBytes.length - 1); - usePathHistoryStore.getState().recordPathUpdated(cNodeId, pathBytes, hops, false); - meshcorePathUpdatePendingRef.current.delete(cNodeId); - } - break; - } - } catch (e: unknown) { - console.warn( - '[useMeshcoreRuntime] immediate path refresh after 129 error ' + errLikeToLogString(e), - ); - } - })(); - // Path updates may change hop counts; debounced contacts refresh to fetch updated outPathLen - if (meshcoreContactsRefreshTimerRef.current) { - clearTimeout(meshcoreContactsRefreshTimerRef.current); - } - meshcoreContactsRefreshTimerRef.current = setTimeout(() => { - void (async () => { - if (!connRef.current) return; - const buildFn = buildNodesFromContactsRef.current; - if (!buildFn) return; - try { - const contactsRaw = await connRef.current.getContacts(); - const contacts = contactsRaw.map(meshcoreContactRawFromDevice); - setMeshcoreContactsForTelemetry(contacts); - const newNodes = await buildFn(contacts, { - self: selfInfoRef.current, - myNodeId: myNodeNumRef.current, - previousNodes: meshcorePreviousNodesBaselineForBuild(), - }); - setNodes((prev) => mergeMeshcoreChatStubNodes(prev, newNodes)); - // Record path history for any nodeIds that triggered event 129 - const pendingIds = meshcorePathUpdatePendingRef.current; - meshcorePathUpdatePendingRef.current = new Set(); - for (const contact of contacts) { - const cNodeId = pubkeyToNodeId(contact.publicKey); - if (!pendingIds.has(cNodeId)) continue; - const sliced = meshcoreContactOutPathBytesForTrace(contact); - const pathBytes = sliced.length > 0 ? Array.from(sliced) : []; - if (pathBytes.length > 0) { - const hops = newNodes.get(cNodeId)?.hops_away ?? 0; - usePathHistoryStore.getState().recordPathUpdated(cNodeId, pathBytes, hops, false); - } - } - } catch (e) { - console.warn( - '[useMeshcoreRuntime] debounced contacts refresh error ' + errLikeToLogString(e), - ); - } - })(); - }, 2000); - }); + const handleDmAck = ( + identityId: string, + payload: Extract['payload'], + ) => { + const { pending, isNack, newStatus, ackKeyU32 } = applyMeshcoreDmAckToPending( + payload.ackCode, + pendingAcksRef.current, + ); + const selfId = myNodeNumRef.current; - // Push: send confirmed — event 0x82 = 130; resolve pending DM delivery - // ackCode: 0x80 = RESP_CODE_ACK (success), 0x81 = RESP_CODE_NACK (failure) - onMeshcoreConn(130, (data: unknown) => { - const d = data as { ackCode: number; roundTrip?: number }; - if (typeof d.ackCode !== 'number' || !Number.isFinite(d.ackCode)) { - console.warn('[useMeshcoreRuntime] event 130: non-numeric ackCode', d.ackCode); - return; - } - const isNack = d.ackCode === 0x81 || d.ackCode === 129; // 0x81 or signed representation - let pending: PendingDmAckEntry | undefined; - for (const lk of meshcoreDeviceAckLookupKeys(d.ackCode)) { - pending = pendingAcksRef.current.get(lk); - if (pending) break; - } if (!pending) { - const lateKey = meshcoreDmAckKeyU32(d.ackCode); - const selfId = myNodeNumRef.current; - const newStatus = isNack ? 'failed' : 'acked'; - const storeId = meshcoreIdentityIdRef.current; - const hadLateOutbound = messagesRef.current.some( + const hadLateOutbound = getIdentityChatMessages(identityId).some( (m) => m.packetId != null && - meshcoreDmAckKeyU32(m.packetId) === lateKey && + meshcoreDmAckKeyU32(m.packetId) === ackKeyU32 && m.sender_id === selfId && m.to != null && (m.status === 'sending' || m.status === 'failed'), @@ -510,7 +180,7 @@ export function attachMeshcoreLegacyConnEvents( setMessages((prev) => prev.map((m) => m.packetId != null && - meshcoreDmAckKeyU32(m.packetId) === lateKey && + meshcoreDmAckKeyU32(m.packetId) === ackKeyU32 && m.sender_id === selfId && m.to != null && (m.status === 'sending' || m.status === 'failed') @@ -519,23 +189,18 @@ export function attachMeshcoreLegacyConnEvents( ), ); } - if (storeId) { - syncMeshcoreDmAckToMessageStore(storeId, lateKey, selfId, newStatus); - } + syncMeshcoreDmAckToMessageStore(identityId, ackKeyU32, selfId, newStatus); void window.electronAPI.db - .updateMeshcoreMessageStatus(lateKey, newStatus) + .updateMeshcoreMessageStatus(ackKeyU32, newStatus) .catch((e: unknown) => { console.warn( - '[useMeshcoreRuntime] updateMeshcoreMessageStatus (late 130) error ' + + '[meshcoreConnSideEffects] updateMeshcoreMessageStatus (late 130) error ' + errLikeToLogString(e), ); }); return; } - clearTimeout(pending.timeoutId); - for (const k of pending.mapKeys) { - pendingAcksRef.current.delete(k); - } + if (pending.destNodeId != null && pending.pathHash != null) { usePathHistoryStore .getState() @@ -543,70 +208,27 @@ export function attachMeshcoreLegacyConnEvents( pending.destNodeId, pending.pathHash, !isNack, - !isNack && typeof d.roundTrip === 'number' ? d.roundTrip : undefined, + !isNack && typeof payload.roundTrip === 'number' ? payload.roundTrip : undefined, ); } - const canon = pending.canonicalPacketIdU32; - const newStatus = isNack ? 'failed' : 'acked'; setMessages((prev) => prev.map((m) => - m.packetId != null && meshcoreDmAckKeyU32(m.packetId) === canon + m.packetId != null && meshcoreDmAckKeyU32(m.packetId) === ackKeyU32 ? { ...m, status: newStatus } : m, ), ); - const storeId = meshcoreIdentityIdRef.current; - if (storeId) { - syncMeshcoreDmAckToMessageStore(storeId, canon, myNodeNumRef.current, newStatus); - } - void window.electronAPI.db.updateMeshcoreMessageStatus(canon, newStatus).catch((e: unknown) => { - console.warn( - '[useMeshcoreRuntime] updateMeshcoreMessageStatus error ' + errLikeToLogString(e), - ); - }); - }); - - // Push: new contact discovered — event 0x8A = 138 - onMeshcoreConn(138, (data: unknown) => { - const d = meshcoreContactRawFromDevice(data as MeshCoreContactRaw); - const node = meshcoreContactToMeshNode(d); - pubKeyMapRef.current.set(node.node_id, d.publicKey); - outPathMapRef.current.set(node.node_id, meshcoreContactOutPathBytesForTrace(d)); - const prefix = Array.from(d.publicKey.slice(0, 6)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - pubKeyPrefixMapRef.current.set(prefix, node.node_id); - const nick = nicknameMapRef.current.get(node.node_id); - const nodeWithNick = nick ? { ...node, long_name: nick, short_name: '' } : node; - const prevHopsForDb = nodesRef.current.get(nodeWithNick.node_id)?.hops_away; - const mergedHopsForDb = meshcoreMergeContactHopsAwayFromPrevious( - nodeWithNick.hops_away, - prevHopsForDb, - 0, - ); - setNodes((prev) => { - const next = new Map(prev); - const existing = prev.get(nodeWithNick.node_id); - next.set(nodeWithNick.node_id, { - ...existing, - ...nodeWithNick, - hw_model: mergeHwModelOnContactUpdate(existing?.hw_model, nodeWithNick.hw_model), - hops_away: meshcoreMergeContactHopsAwayFromPrevious( - nodeWithNick.hops_away, - existing?.hops_away, - 0, - ), - }); - return next; - }); + syncMeshcoreDmAckToMessageStore(identityId, ackKeyU32, selfId, newStatus); void window.electronAPI.db - .saveMeshcoreContact(contactToDbRow(d, nick ?? null, 1, undefined, mergedHopsForDb)) + .updateMeshcoreMessageStatus(ackKeyU32, newStatus) .catch((e: unknown) => { console.warn( - '[useMeshcoreRuntime] saveMeshcoreContact (event 138) error ' + errLikeToLogString(e), + '[meshcoreConnSideEffects] updateMeshcoreMessageStatus error ' + errLikeToLogString(e), ); }); - }); + }; + + // --- Waiting messages (event 131) --- const waitingMessageDrainScheduleOptions = { isMounted: () => meshcoreHookMountedRef.current, @@ -637,12 +259,10 @@ export function attachMeshcoreLegacyConnEvents( pubKeyPrefixMap: pubKeyPrefixMapRef.current, myNodeNum: myNodeNumRef.current || 0, meshcoreIdentityId: meshcoreIdentityIdRef.current, - legacyOwnsRoomPosts, storePriorForBatch: storePriorForIngest, logTransportLineAsDevice, }); - // Push: message waiting — event 0x83 = 131; fetch all queued messages const processWaitingMessages = async (options?: ProcessWaitingMessagesOptions) => { if (getMeshcoreProcessWaitingMessagesInFlight()) { if (options?.showSyncBanner !== false) { @@ -650,7 +270,7 @@ export function attachMeshcoreLegacyConnEvents( } else { requestMeshcoreWaitingMessagesFollowUp(); } - console.debug('[useMeshcoreRuntime] processWaitingMessages skipped (in flight)'); + console.debug('[meshcoreConnSideEffects] processWaitingMessages skipped (in flight)'); return getMeshcoreProcessWaitingMessagesInFlight()!; } const showSyncBanner = options?.showSyncBanner !== false; @@ -668,7 +288,7 @@ export function attachMeshcoreLegacyConnEvents( let bannerActive = false; let processed = 0; let pendingMessages: ChatMessage[] = []; - const workingNodes = new Map(nodesRef.current); + const workingNodes = new Map(readNodes()); let nodesDirty = false; const flushBatch = () => { @@ -677,8 +297,10 @@ export function attachMeshcoreLegacyConnEvents( pendingMessages = []; } if (nodesDirty) { - nodesRef.current = workingNodes; setNodes(workingNodes); + // Publish before React commits so the next drain iteration reads merged rows. + const storeId = meshcoreIdentityIdRef.current; + if (storeId) syncNodesMapToIdentityStore(storeId, workingNodes); nodesDirty = false; } }; @@ -704,7 +326,8 @@ export function attachMeshcoreLegacyConnEvents( } } catch (e: unknown) { console.warn( - '[useMeshcoreRuntime] processWaitingMessages ingest error ' + errLikeToLogString(e), + '[meshcoreConnSideEffects] processWaitingMessages ingest error ' + + errLikeToLogString(e), ); } await yieldToEventLoop(); @@ -732,10 +355,12 @@ export function attachMeshcoreLegacyConnEvents( setWaitingMessagesCount(total); setWaitingMessagesSyncProgress({ processed: 0, total }); } else if (total === 0) { - console.debug('[useMeshcoreRuntime] processWaitingMessages empty queue (manual sync)'); + console.debug( + '[meshcoreConnSideEffects] processWaitingMessages empty queue (manual sync)', + ); return; } - console.debug('[useMeshcoreRuntime] processWaitingMessages start', { + console.debug('[meshcoreConnSideEffects] processWaitingMessages start', { count: total, showSyncBanner, }); @@ -751,7 +376,7 @@ export function attachMeshcoreLegacyConnEvents( } flushBatch(); } else { - console.debug('[useMeshcoreRuntime] processWaitingMessages start (incremental)', { + console.debug('[meshcoreConnSideEffects] processWaitingMessages start (incremental)', { showSyncBanner, }); let silentDrainExhaustedCap = false; @@ -782,7 +407,7 @@ export function attachMeshcoreLegacyConnEvents( } flushBatch(); } - console.debug('[useMeshcoreRuntime] processWaitingMessages done', { + console.debug('[meshcoreConnSideEffects] processWaitingMessages done', { count: processed, durationMs: Date.now() - startedAt, showSyncBanner, @@ -804,7 +429,8 @@ export function attachMeshcoreLegacyConnEvents( return inFlight; }; processWaitingMessagesRef.current = processWaitingMessages; - onMeshcoreConn(131, () => { + + const handleWaitingMessages = () => { markMeshcoreMsgWaitingEvent(); scheduleMeshcoreWaitingMessagesDrain( async () => { @@ -835,269 +461,42 @@ export function attachMeshcoreLegacyConnEvents( onDeferredChange: setWaitingMessagesDrainDeferred, }, ); - }); + }; - // Incoming DM — event 7 - onMeshcoreConn(7, (data: unknown) => { - const now = Date.now(); - const d = data as { - pubKeyPrefix: Uint8Array; - text: string; - senderTimestamp: number; - txtType?: number; - }; - const prefix = Array.from(d.pubKeyPrefix) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - const senderId = pubKeyPrefixMapRef.current.get(prefix) ?? 0; - if (senderId === 0) { - console.warn('[useMeshcoreRuntime] event 7: unknown pubKeyPrefix, sender will be 0', prefix); - } - const sender = nodesRef.current.get(senderId); - // CLI data response (txtType === 1) - if (d.txtType === 1) { - const service = repeaterCommandServiceRef.current; - if (service) { - const handled = service.handleResponse(d.text, senderId); - if (handled) { - return; - } - } else { - console.warn( - '[useMeshcoreRuntime] event 7: CLI response received but no command service active (sender:', - senderId.toString(16).toUpperCase(), - ')', - ); - } - // CLI response without matching pending command - add to history - if (senderId !== 0) { - const { body } = service ? service.parseResponseToken(d.text) : { body: d.text }; - addCliHistoryEntry(senderId, { - type: 'received', - text: body, - timestamp: Date.now(), - }); - } - return; - } + // --- CLI data response (direct message, txtType 1) --- - // Room server BBS post (SignedPlain or PLAIN system lines) — not a personal DM. - if (sender?.hw_model === 'Room') { - const postTs = effectiveMessageTimestampMs(d.senderTimestamp * 1000); - if (!legacyOwnsRoomPosts()) { - void setMeshcoreRoomLastPostAt(senderId, postTs); - return; - } - const { authorId, payload } = meshcoreRoomPostBodyFromWire( - d.text, - d.txtType, - pubKeyPrefixMapRef.current, - { isKnownRoomNode: true }, - ); - const authorNode = authorId !== 0 ? nodesRef.current.get(authorId) : undefined; - const authorName = - authorNode?.long_name ?? - (authorId !== 0 ? `Node-${authorId.toString(16).toUpperCase()}` : 'Unknown'); - addMessage( - buildMeshcoreRoomIncomingMessage({ - rawText: payload, - roomServerId: senderId, - authorId: authorId !== 0 ? authorId : myNodeNumRef.current || 0, - authorName, - timestamp: postTs, - receivedVia: 'rf', - }), + const handleCliResponse = ( + payload: Extract['payload'], + ) => { + const senderId = payload.senderNodeId; + const service = repeaterCommandServiceRef.current; + if (service) { + if (service.handleResponse(payload.text, senderId)) return; + } else { + console.warn( + '[meshcoreConnSideEffects] CLI response received but no command service active (sender:', + senderId.toString(16).toUpperCase(), + ')', ); - void setMeshcoreRoomLastPostAt(senderId, postTs); - return; } - + // CLI response without matching pending command — surface it in the panel history. if (senderId !== 0) { - setNodes((prev) => { - const node = prev.get(senderId); - if (!node) return prev; - const next = new Map(prev); - next.set(senderId, { - ...node, - last_heard: Math.max(node.last_heard ?? 0, d.senderTimestamp), - }); - return next; + const { body } = service ? service.parseResponseToken(payload.text) : { body: payload.text }; + addCliHistoryEntry(senderId, { + type: 'received', + text: body, + timestamp: Date.now(), }); } - if (isMeshcoreTransportStatusChatLine(d.text)) { - logTransportLineAsDevice(d.text); - return; - } - const dmRfMatch = rawPacketsRef.current - .slice() - .reverse() - .find( - (e) => - e.payloadTypeString === 'TXT_MSG' && - e.fromNodeId === null && - now - e.ts <= MESHCORE_CHAT_CORRELATE_WINDOW_MS, - ); - addMessage( - parseMeshcoreDmIncomingFromThread(storePriorForIngest(), { - rawText: d.text, - senderId, - displayName: sender?.long_name ?? `Node-${senderId.toString(16).toUpperCase()}`, - timestamp: effectiveMessageTimestampMs(d.senderTimestamp * 1000), - receivedVia: 'rf', - rxHops: dmRfMatch != null ? dmRfMatch.hopCount : undefined, - peerNodeId: senderId, - myNodeId: myNodeNumRef.current || 0, - to: myNodeNumRef.current || undefined, - }), - ); - const resolvedSenderId = senderId !== 0 ? senderId : null; - setRawPackets((prev) => - meshcoreCorrelateOrSynthesizeChatEntry(prev, 'TXT_MSG', resolvedSenderId, { - ts: now, - snr: 0, - rssi: 0, - raw: new Uint8Array(0), - routeTypeString: null, - payloadTypeString: 'TXT_MSG', - hopCount: 0, - pathBytes: [], - pathHashSizeBytes: 1, - fromNodeId: resolvedSenderId, - messageFingerprintHex: null, - transportScopeCode: null, - transportReturnCode: null, - advertName: null, - advertLat: null, - advertLon: null, - advertTimestampSec: null, - parseOk: false, - }), - ); - }); + }; - // Incoming channel message — event 8 - onMeshcoreConn(8, (data: unknown) => { - const now = Date.now(); - const d = data as { channelIdx: number; text: string; senderTimestamp: number }; - if (isMeshcoreTransportStatusChatLine(d.text)) { - logTransportLineAsDevice(d.text); - return; - } - const rfMatch = meshcoreFindRecentGrpTxtRawPacket(rawPacketsRef.current, now); - const hopsAway = rfMatch?.hopCount; - const resolved = resolveMeshcoreChannelMessageSender({ - rawText: d.text, - rfFromNodeId: rfMatch?.fromNodeId ?? null, - rfAdvertName: rfMatch?.advertName ?? null, - nodes: nodesRef.current, - }); - if (resolved.senderId !== 0) { - setNodes((prev) => { - const next = new Map(prev); - const existing = next.get(resolved.senderId); - const updated: MeshNode = existing - ? meshcoreMergeChannelDisplayNameOntoNode( - { - ...existing, - last_heard: Math.max(existing.last_heard ?? 0, d.senderTimestamp), - ...(hopsAway != null ? { hops_away: hopsAway } : {}), - source: 'rf', - heard_via_mqtt_only: false, - via_mqtt: false, - }, - resolved.displayName, - ) - : { - ...minimalMeshcoreChatNode( - resolved.senderId, - resolved.displayName, - d.senderTimestamp, - 'rf', - ), - ...(hopsAway != null ? { hops_away: hopsAway } : {}), - source: 'rf', - heard_via_mqtt_only: false, - via_mqtt: false, - }; - next.set(resolved.senderId, updated); - if (hopsAway != null) { - const nowSec = Math.floor(Date.now() / 1000); - const lastAdvert = mergeMeshcoreLastHeardFromAdvert( - d.senderTimestamp, - next.get(resolved.senderId)?.last_heard, - nowSec, - ); - void window.electronAPI.db.saveMeshcoreContact({ - node_id: resolved.senderId, - public_key: meshcoreSyntheticPlaceholderPubKeyHex(resolved.senderId), - adv_name: resolved.displayName, - contact_type: 1, - last_advert: lastAdvert > 0 ? lastAdvert : nowSec, - nickname: null, - hops_away: hopsAway, - on_radio: 1, - }); - } - return next; - }); - } - addMessage( - parseMeshcoreChannelIncomingFromThread(storePriorForIngest(), { - rawText: d.text, - senderId: resolved.senderId, - displayName: resolved.displayName, - channel: d.channelIdx, - timestamp: effectiveMessageTimestampMs(d.senderTimestamp * 1000), - receivedVia: 'rf', - rxHops: rfMatch != null ? rfMatch.hopCount : undefined, - }), - ); - if (mqttStatusRef.current === 'connected') { - void window.electronAPI.mqtt - .publishMeshcorePacketLog({ - origin: selfInfoRef.current?.name ?? 'mesh-client', - snr: rfMatch?.snr ?? 0, - rssi: rfMatch?.rssi ?? 0, - }) - .catch((e: unknown) => { - console.warn( - '[useMeshcoreRuntime] publishMeshcorePacketLog (heard RF) error ' + - errLikeToLogString(e), - ); - }); - } - setRawPackets((prev) => - meshcoreCorrelateOrSynthesizeChatEntry(prev, 'GRP_TXT', resolved.senderId || null, { - ts: now, - snr: 0, - rssi: 0, - raw: new Uint8Array(0), - routeTypeString: null, - payloadTypeString: 'GRP_TXT', - hopCount: 0, - pathBytes: [], - pathHashSizeBytes: 1, - fromNodeId: resolved.senderId || null, - messageFingerprintHex: null, - transportScopeCode: null, - transportReturnCode: null, - advertName: null, - advertLat: null, - advertLon: null, - advertTimestampSec: null, - parseOk: false, - }), - ); - }); + // --- RF RX (event 136) --- - // Push: RF packet received — event 0x88 = 136; feed into device logs + signal telemetry. - // Foreign LoRa fingerprinting requires d.raw (Uint8Array) from meshcore.js/device. - onMeshcoreConn(136, (data: unknown) => { - const d = data as { lastSnr?: number; lastRssi?: number; raw?: unknown }; - const snr = d.lastSnr ?? 0; - const rssi = d.lastRssi ?? 0; + const handleRfRx = (payload: Extract['payload']) => { + const snr = payload.lastSnr; + const rssi = payload.lastRssi; const now = Date.now(); - const rawU8 = d.raw instanceof Uint8Array && d.raw.length > 0 ? d.raw : null; + const rawU8 = payload.raw; const loraPacketClass = rawU8 ? classifyPayload(rawU8) : null; // Extract sender ID and update known node's last_heard + signal metrics @@ -1108,7 +507,7 @@ export function attachMeshcoreLegacyConnEvents( if (senderId !== null) { senderInfo = ` from=0x${senderId.toString(16)}`; // If we know this node (and it's not ourselves), update last_heard + SNR/RSSI - if (senderId !== myNodeNumRef.current && nodesRef.current.has(senderId)) { + if (senderId !== myNodeNumRef.current && readNodes().has(senderId)) { const nowSec = Math.floor(now / 1000); setNodes((prev) => { const existing = prev.get(senderId); @@ -1199,7 +598,7 @@ export function attachMeshcoreLegacyConnEvents( ) .catch((e: unknown) => { console.warn( - '[useMeshcoreRuntime] updateMeshcoreContactRfTransport error ' + + '[meshcoreConnSideEffects] updateMeshcoreContactRfTransport error ' + errLikeToLogString(e), ); }); @@ -1220,9 +619,10 @@ export function attachMeshcoreLegacyConnEvents( // Update hops_away on known MeshCore nodes from RF packet hop count. // Only use fromNodeId resolved by MeshCore parsing (before the Meshtastic fallback). if (fromNodeId !== null && fromNodeId !== myNodeNumRef.current) { + const resolvedFromNodeId = fromNodeId; const nowSec = Math.floor(now / 1000); setNodes((prev) => { - const existing = prev.get(fromNodeId!); + const existing = prev.get(resolvedFromNodeId); if (!existing) return prev; const mergedHopsAway = meshcoreMergeContactHopsAwayFromPrevious( hopCount, @@ -1251,21 +651,28 @@ export function attachMeshcoreLegacyConnEvents( } const next = new Map(prev); - next.set(fromNodeId!, updated); + next.set(resolvedFromNodeId, updated); void window.electronAPI.db - .updateMeshcoreContactLastRf(fromNodeId!, snr, rssi, mergedHopsAway ?? hopCount, nowSec) + .updateMeshcoreContactLastRf( + resolvedFromNodeId, + snr, + rssi, + mergedHopsAway ?? hopCount, + nowSec, + ) .catch((e: unknown) => { console.warn( - '[useMeshcoreRuntime] updateMeshcoreContactLastRf error ' + errLikeToLogString(e), + '[meshcoreConnSideEffects] updateMeshcoreContactLastRf error ' + + errLikeToLogString(e), ); }); void useDiagnosticsStore .getState() - .saveMeshcoreHopHistory(fromNodeId!, now, mergedHopsAway ?? hopCount, snr, rssi) + .saveMeshcoreHopHistory(resolvedFromNodeId, now, mergedHopsAway ?? hopCount, snr, rssi) .catch((e: unknown) => { console.warn( - '[useMeshcoreRuntime] saveMeshcoreHopHistory error ' + errLikeToLogString(e), + '[meshcoreConnSideEffects] saveMeshcoreHopHistory error ' + errLikeToLogString(e), ); }); @@ -1314,7 +721,7 @@ export function attachMeshcoreLegacyConnEvents( next.length > MAX_RAW_PACKET_LOG_ENTRIES ? next.slice(next.length - MAX_RAW_PACKET_LOG_ENTRIES) : next; - // Sync before React commit so same-tick event 7/8 ingest sees this row. + // Sync before React commit so same-tick chat ingest sees this row. rawPacketsRef.current = trimmed; return trimmed; } @@ -1323,7 +730,7 @@ export function attachMeshcoreLegacyConnEvents( next.length > MAX_RAW_PACKET_LOG_ENTRIES ? next.slice(next.length - MAX_RAW_PACKET_LOG_ENTRIES) : next; - // Sync before React commit so same-tick event 7/8 ingest sees this row. + // Sync before React commit so same-tick chat ingest sees this row. rawPacketsRef.current = trimmed; return trimmed; }); @@ -1362,7 +769,7 @@ export function attachMeshcoreLegacyConnEvents( if (mtNode > 0) { let rfSenderId = fromNodeId ?? undefined; let rfDisplayName: string | undefined; - const meshcoreNodes = nodesRef.current; + const meshcoreNodes = readNodes(); if (parsed.ok) { if (rfSenderId == null && parsed.advert) { const advertId = pubkeyToNodeId(parsed.advert.publicKey); @@ -1421,7 +828,7 @@ export function attachMeshcoreLegacyConnEvents( rssi || undefined, snr || undefined, rfSenderId, - () => nodesRef.current, + readNodes, 'meshcore-radio-rf', rfFingerprint, rfDisplayName, @@ -1447,7 +854,7 @@ export function attachMeshcoreLegacyConnEvents( rssi || undefined, snr || undefined, senderId ?? undefined, - () => nodesRef.current, + readNodes, ); } } @@ -1473,16 +880,17 @@ export function attachMeshcoreLegacyConnEvents( if (t - lastPacketLogPublishFailureLogAtRef.current >= 30_000) { lastPacketLogPublishFailureLogAtRef.current = t; console.warn( - '[meshcoreLegacyConnEvents] MQTT packet-log publish failed ' + - errLikeToLogString(e), + '[meshcoreConnSideEffects] MQTT packet-log publish failed ' + errLikeToLogString(e), ); } }); } } - }); + }; + + // --- Disconnect --- - onMeshcoreConn('disconnected', () => { + const handleDisconnected = () => { let shouldReconnect = false; setState((prev) => { const wasOperational = @@ -1520,25 +928,43 @@ export function attachMeshcoreLegacyConnEvents( resetMeshcoreWaitingMessagesDrainSchedule(); if (staleConn && !usedDriverConnect) { void staleConn.close().catch((e: unknown) => { - console.debug('[meshcoreLegacyConnEvents] stale conn close ' + errLikeToLogString(e)); + console.debug('[meshcoreConnSideEffects] stale conn close ' + errLikeToLogString(e)); }); } if (shouldReconnect && !meshcoreExplicitDisconnectRef.current) { handleConnectionLostRef.current(); } }); - }); + }; - onMeshcoreConn('rx', (data: unknown) => { - const frame = meshcoreCoerceRadioRxFrame(data); - const parsed = frame && parseAutoaddConfigResponse(frame); - if (parsed) setMeshcoreAutoadd(parsed); + const detachListener = packetRouter.addListener((event, routedIdentityId) => { + if (routedIdentityId !== resolveIdentityId()) return; + bumpLastDataReceived?.(); + switch (event.type) { + case 'meshcore_dm_ack': + handleDmAck(routedIdentityId, event.payload); + break; + case 'meshcore_waiting_messages': + handleWaitingMessages(); + break; + case 'meshcore_cli_response': + handleCliResponse(event.payload); + break; + case 'meshcore_rf_rx': + handleRfRx(event.payload); + break; + case 'device_status': + if (event.payload.status === 'disconnected') { + handleDisconnected(); + } + break; + default: + break; + } }); return () => { - for (const { event, handler } of meshcorePersistentListenerRegs) { - conn.off(event, handler); - } + detachListener(); resetMeshcoreProcessWaitingMessagesSync( setWaitingMessagesCount, setWaitingMessagesSyncActive, diff --git a/src/renderer/hooks/meshcore/meshcoreLegacyConnEventsCtx.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts similarity index 77% rename from src/renderer/hooks/meshcore/meshcoreLegacyConnEventsCtx.ts rename to src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts index 676c8eaee..abf3a821d 100644 --- a/src/renderer/hooks/meshcore/meshcoreLegacyConnEventsCtx.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts @@ -3,11 +3,9 @@ import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { DeviceLogEntry, MeshCoreConnection, - MeshCoreContactRaw, MeshCoreSelfInfo, RxPacketEntry, } from '../../lib/meshcore/meshcoreHookTypes'; -import type { MeshcoreAutoaddWireState } from '../../lib/meshcoreContactAutoAdd'; import type { CliHistoryEntry, RepeaterCommandService } from '../../lib/repeaterCommandService'; import type { ChatMessage, @@ -23,7 +21,14 @@ export interface ProcessWaitingMessagesOptions { showSyncBanner?: boolean; } -export interface MeshcoreLegacyConnEventsCtx { +/** + * Runtime state needed by the MeshCore `DomainEvent` side-effect listener + * ({@link attachMeshcoreConnSideEffects}). Everything here is owned by + * `useMeshcoreRuntime`; the listener only reads refs and calls setters. + */ +export interface MeshcoreConnSideEffectsCtx { + /** Identity the listener accepts events for (pending driver identity before configure). */ + resolveIdentityId: () => string | null; meshcoreIdentityIdRef: RefObject; meshcoreDriverConnectedRef: RefObject; connRef: RefObject; @@ -31,16 +36,14 @@ export interface MeshcoreLegacyConnEventsCtx { lastPacketLogPublishFailureLogAtRef: RefObject; meshcoreContactsRefreshTimerRef: RefObject | null>; meshcoreHookMountedRef: RefObject; - meshcorePathUpdatePendingRef: RefObject>; meshcoreSessionPathUpdatedNodeIdsRef: RefObject>; meshcoreWaitingMessagesPollRef: RefObject | null>; meshcoreConnectTypeRef: RefObject<'ble' | 'serial' | 'tcp'>; - messagesRef: RefObject; mqttStatusRef: RefObject; myNodeNumRef: RefObject; nicknameMapRef: RefObject>; - nodesRef: RefObject>; - outPathMapRef: RefObject>; + /** Identity-scoped `nodeStore` snapshot — the runtime no longer keeps a node mirror. */ + readNodes: () => Map; pendingAcksRef: RefObject>; processWaitingMessagesRef: RefObject< ((options?: ProcessWaitingMessagesOptions) => Promise) | null @@ -50,20 +53,7 @@ export interface MeshcoreLegacyConnEventsCtx { rawPacketsRef: RefObject; repeaterCommandServiceRef: RefObject; selfInfoRef: RefObject; - buildNodesFromContactsRef: RefObject< - | (( - contacts: MeshCoreContactRaw[], - opts?: { - self?: MeshCoreSelfInfo | null; - myNodeId?: number; - previousNodes?: Map; - }, - ) => Promise>) - | null - >; setDeviceLogs: Dispatch>; - setMeshcoreAutoadd: Dispatch>; - setMeshcoreContactsForTelemetry: Dispatch>; setMeshcorePingRouteReadyEpoch: Dispatch>; setMessages: Dispatch>; setNodes: Dispatch>>; @@ -79,10 +69,8 @@ export interface MeshcoreLegacyConnEventsCtx { setWaitingMessagesSilentDrainActive: Dispatch>; setWaitingMessagesDrainDeferred: Dispatch>; addMessagesBatch: (msgs: ChatMessage[]) => void; - addMessage: (msg: ChatMessage) => void; addCliHistoryEntry: (nodeId: number, entry: CliHistoryEntry) => void; teardownMeshcoreConnEventListeners: (opts?: { driverDisconnect?: boolean }) => void; - meshcorePreviousNodesBaselineForBuild: () => Map; handleConnectionLostRef: RefObject<() => void>; meshcoreExplicitDisconnectRef: RefObject; bumpLastDataReceived?: () => void; diff --git a/src/renderer/hooks/useMeshcoreRuntime.import-contacts.test.tsx b/src/renderer/hooks/useMeshcoreRuntime.import-contacts.test.tsx index 027126fa0..fe95e3f97 100644 --- a/src/renderer/hooks/useMeshcoreRuntime.import-contacts.test.tsx +++ b/src/renderer/hooks/useMeshcoreRuntime.import-contacts.test.tsx @@ -92,7 +92,7 @@ describe('useMeshcoreRuntime importContacts', () => { expect(result.current.nodes.get(IMPORT_NODE_ID)?.hops_away).toBe(3); }); - it('preserves last_heard on re-import when the node already exists (no stale nodesRef)', async () => { + it('preserves last_heard on re-import when the node already exists (no stale store row)', async () => { const importMs = new Date('2026-05-20T12:00:00Z').getTime(); const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(importMs); try { diff --git a/src/renderer/hooks/useMeshcoreRuntime.initconn-db-hops.test.tsx b/src/renderer/hooks/useMeshcoreRuntime.initconn-db-hops.test.tsx index f0978a329..dcd95f5b3 100644 --- a/src/renderer/hooks/useMeshcoreRuntime.initconn-db-hops.test.tsx +++ b/src/renderer/hooks/useMeshcoreRuntime.initconn-db-hops.test.tsx @@ -1,11 +1,19 @@ /** - * Regression: first connect after app start must merge hops from SQLite even when - * nodesRef has not flushed yet (mount hydration vs initConn race). + * Regression: first connect after app start must merge hops from SQLite even when the + * identity-scoped node store has not been populated yet (mount hydration vs initConn race). */ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { pubkeyToNodeId } from '../lib/meshcoreUtils'; +import { useMessageStore } from '../stores/messageStore'; +import { useNodeStore } from '../stores/nodeStore'; + +/** Node/message stores are module-global; a fresh app start is an empty store. */ +function resetIdentityStores(): void { + useNodeStore.setState({ nodes: {} }); + useMessageStore.setState({ messages: {} }); +} const getSelfInfoMock = vi.fn(); @@ -210,6 +218,7 @@ function makeMockSerialPort() { describe('useMeshcoreRuntime initConn merges DB hops at first connect', () => { beforeEach(() => { capturedConn = null; + resetIdentityStores(); vi.mocked(window.electronAPI.db.getMeshcoreMessages).mockResolvedValue([]); vi.mocked(window.electronAPI.db.getMeshcoreContacts).mockResolvedValue([ { @@ -316,6 +325,7 @@ describe('useMeshcoreRuntime initConn merges DB hops at first connect', () => { describe('useMeshcoreRuntime buildNodesFromContacts merges nodes-table hops when meshcore_contacts is empty', () => { beforeEach(() => { capturedConn = null; + resetIdentityStores(); vi.mocked(window.electronAPI.db.getMeshcoreMessages).mockResolvedValue([]); vi.mocked(window.electronAPI.db.getMeshcoreContacts).mockResolvedValue([]); vi.mocked(window.electronAPI.db.getNodes).mockResolvedValue([ diff --git a/src/renderer/lib/drivers/ConnectionDriver.test.ts b/src/renderer/lib/drivers/ConnectionDriver.test.ts index 08ce35c46..8f1c40a6b 100644 --- a/src/renderer/lib/drivers/ConnectionDriver.test.ts +++ b/src/renderer/lib/drivers/ConnectionDriver.test.ts @@ -20,18 +20,18 @@ describe('ConnectionDriver', () => { useConnectionStore.setState({ connections: {} }); }); - it('registerLegacyTransport exposes handle to getHandle', () => { + it('registerExternalTransport exposes handle to getHandle', () => { const identityId = `test-${Date.now()}`; addIdentity({ id: identityId, protocol: meshtasticProtocol, - signature: 'meshtastic:test:legacy', + signature: 'meshtastic:test:external', transports: [], createdAt: Date.now(), lastSeenAt: Date.now(), }); const fakeHandle = { kind: 'test-device' }; - const detach = connectionDriver.registerLegacyTransport( + const detach = connectionDriver.registerExternalTransport( identityId, meshtasticProtocol, fakeHandle, diff --git a/src/renderer/lib/drivers/ConnectionDriver.ts b/src/renderer/lib/drivers/ConnectionDriver.ts index 699bb8952..c0a78bb49 100644 --- a/src/renderer/lib/drivers/ConnectionDriver.ts +++ b/src/renderer/lib/drivers/ConnectionDriver.ts @@ -253,11 +253,11 @@ export class ConnectionDriver { } /** - * Attach a transport opened by legacy hooks (`useMeshtasticRuntime` / `useMeshcoreRuntime`) so - * action hooks can resolve the live SDK handle. Caller supplies teardown from - * `protocol.subscribe` (ingress is already wired before this call). + * Attach a transport opened outside {@link ConnectionDriver.connect} so action hooks can + * resolve the live SDK handle. Caller supplies teardown from `protocol.subscribe` + * (ingress is already wired before this call). */ - registerLegacyTransport( + registerExternalTransport( identityId: IdentityId, protocol: Protocol, handle: unknown, @@ -289,7 +289,7 @@ export class ConnectionDriver { try { teardown(); } catch (e) { - console.debug('[ConnectionDriver] legacy teardown error ' + errLikeToLogString(e)); + console.debug('[ConnectionDriver] external teardown error ' + errLikeToLogString(e)); } this.slots.delete(transportId); removeTransport(identityId, transportId); diff --git a/src/renderer/lib/hydrateIdentityStoresFromDb.ts b/src/renderer/lib/hydrateIdentityStoresFromDb.ts index efc15b00d..195f1776e 100644 --- a/src/renderer/lib/hydrateIdentityStoresFromDb.ts +++ b/src/renderer/lib/hydrateIdentityStoresFromDb.ts @@ -72,7 +72,8 @@ export async function loadMeshcoreMessagesForHydration(): Promise, ) { return messages.map((msg) => { diff --git a/src/renderer/lib/identityStoreReads.test.ts b/src/renderer/lib/identityStoreReads.test.ts new file mode 100644 index 000000000..07a713ee3 --- /dev/null +++ b/src/renderer/lib/identityStoreReads.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useMessageStore } from '../stores/messageStore'; +import { useNodeStore } from '../stores/nodeStore'; +import { getIdentityChatMessages, getIdentityNode, getIdentityNodeMap } from './identityStoreReads'; +import { resetNodeRecordsToMeshNodeMapCacheForTests } from './storeRecordAdapters'; + +const IDENTITY = 'identity-1'; + +describe('identityStoreReads', () => { + beforeEach(() => { + resetNodeRecordsToMeshNodeMapCacheForTests(); + useNodeStore.setState({ nodes: {} }); + useMessageStore.setState({ messages: {} }); + }); + + it('returns an empty node map for a missing identity or bucket', () => { + expect(getIdentityNodeMap(null).size).toBe(0); + expect(getIdentityNodeMap(undefined).size).toBe(0); + expect(getIdentityNodeMap(IDENTITY).size).toBe(0); + }); + + it('converts node records for an identity into MeshNode entries', () => { + useNodeStore.setState({ + nodes: { + [IDENTITY]: { + 7: { nodeId: 7, longName: 'Repeater', shortName: 'RPT', snr: 3 }, + }, + }, + }); + + const map = getIdentityNodeMap(IDENTITY); + expect(map.size).toBe(1); + expect(map.get(7)?.long_name).toBe('Repeater'); + expect(map.get(7)?.short_name).toBe('RPT'); + expect(map.get(7)?.snr).toBe(3); + }); + + it('reads a single node without converting the whole bucket', () => { + useNodeStore.setState({ + nodes: { + [IDENTITY]: { + 7: { nodeId: 7, longName: 'Repeater' }, + 9: { nodeId: 9, longName: 'Router' }, + }, + }, + }); + + expect(getIdentityNode(IDENTITY, 9)?.long_name).toBe('Router'); + expect(getIdentityNode(IDENTITY, 11)).toBeUndefined(); + expect(getIdentityNode(null, 9)).toBeUndefined(); + expect(getIdentityNode(IDENTITY, 0)).toBeUndefined(); + }); + + it('keeps fields that send and RPC paths read off a node (public key, position, counters)', () => { + useNodeStore.setState({ + nodes: { + [IDENTITY]: { + 7: { + nodeId: 7, + longName: 'Repeater', + publicKeyHex: 'aa'.repeat(32), + altitude: 1600, + numPacketsRx: 12, + numPacketsTx: 3, + }, + }, + }, + }); + + const node = getIdentityNode(IDENTITY, 7); + expect(node?.public_key_hex).toBe('aa'.repeat(32)); + expect(node?.altitude).toBe(1600); + expect(node?.num_packets_rx).toBe(12); + expect(node?.num_packets_tx).toBe(3); + expect(getIdentityNodeMap(IDENTITY).get(7)?.public_key_hex).toBe('aa'.repeat(32)); + }); + + it('converts message records for an identity into ChatMessage rows', () => { + useMessageStore.setState({ + messages: { + [IDENTITY]: { + '42': { + id: '42', + from: 7, + to: 0, + payload: 'hello', + channelIndex: 0, + timestamp: 1_700_000_000_000, + }, + }, + }, + }); + + const messages = getIdentityChatMessages(IDENTITY); + expect(messages).toHaveLength(1); + expect(messages[0]?.payload).toBe('hello'); + expect(messages[0]?.sender_id).toBe(7); + expect(getIdentityChatMessages(null)).toEqual([]); + expect(getIdentityChatMessages('other')).toEqual([]); + }); +}); diff --git a/src/renderer/lib/identityStoreReads.ts b/src/renderer/lib/identityStoreReads.ts new file mode 100644 index 000000000..9d0e4b449 --- /dev/null +++ b/src/renderer/lib/identityStoreReads.ts @@ -0,0 +1,50 @@ +/** + * Synchronous, identity-scoped reads of the canonical Zustand stores. + * + * `PacketRouter` / ingest write every decoded packet into `nodeStore` / + * `messageStore`. Runtime send, dedup, and RPC paths read through these helpers + * so there is a single ingress destination (no hook-local node/message mirrors). + * + * Failure point: none — a missing identity or bucket yields an empty result and + * callers fall back to their own defaults. + */ +import { useMessageStore } from '../stores/messageStore'; +import { useNodeStore } from '../stores/nodeStore'; +import { + messageRecordsToChatMessages, + nodeRecordsToMeshNodeMap, + nodeRecordToMeshNode, +} from './storeRecordAdapters'; +import type { ChatMessage, MeshNode } from './types'; + +const EMPTY_NODES: ReadonlyMap = new Map(); +const EMPTY_MESSAGES: readonly ChatMessage[] = []; + +/** + * Full node map for an identity. `nodeRecordsToMeshNodeMap` memoizes the export, + * so repeated calls on unchanged store state reuse the previous map. + */ +export function getIdentityNodeMap(identityId: string | null | undefined): Map { + if (!identityId) return new Map(EMPTY_NODES); + const byId = useNodeStore.getState().nodes[identityId]; + if (!byId) return new Map(EMPTY_NODES); + return nodeRecordsToMeshNodeMap(Object.values(byId)); +} + +/** Single node lookup — avoids converting the whole bucket on hot packet paths. */ +export function getIdentityNode( + identityId: string | null | undefined, + nodeId: number, +): MeshNode | undefined { + if (!identityId || !nodeId) return undefined; + const record = useNodeStore.getState().nodes[identityId]?.[nodeId]; + return record ? nodeRecordToMeshNode(record) : undefined; +} + +/** Chat rows for an identity, in the shape UI and dedup helpers expect. */ +export function getIdentityChatMessages(identityId: string | null | undefined): ChatMessage[] { + if (!identityId) return [...EMPTY_MESSAGES]; + const byId = useMessageStore.getState().messages[identityId]; + if (!byId) return [...EMPTY_MESSAGES]; + return messageRecordsToChatMessages(Object.values(byId)); +} diff --git a/src/renderer/lib/meshIdentityBridge.test.ts b/src/renderer/lib/meshIdentityBridge.test.ts index d58fa8672..ad0925dfd 100644 --- a/src/renderer/lib/meshIdentityBridge.test.ts +++ b/src/renderer/lib/meshIdentityBridge.test.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getIdentity } from '../stores/identityStore'; import { addMessage } from '../stores/messageStore'; import { connectionDriver } from './drivers/ConnectionDriver'; -import { bindMeshtasticIngress, meshtasticTransportParams } from './meshIdentityBridge'; +import { attachMeshtasticProtocolIngress, meshtasticTransportParams } from './meshIdentityBridge'; import { meshtasticProtocol } from './protocols/MeshtasticProtocol'; function mockMeshDevice(): MeshDevice { @@ -20,7 +20,7 @@ describe('meshIdentityBridge', () => { const peripheralId = `ble-${Date.now()}`; const device = mockMeshDevice(); - const first = bindMeshtasticIngress(device, 'ble', { peripheralId }); + const first = attachMeshtasticProtocolIngress(device, 'ble', { peripheralId }); addMessage(first.identityId, { id: 'msg-1', from: 1, @@ -37,7 +37,7 @@ describe('meshIdentityBridge', () => { ); first.detach(); - const second = bindMeshtasticIngress(device, 'ble', { peripheralId }); + const second = attachMeshtasticProtocolIngress(device, 'ble', { peripheralId }); expect(second.identityId).toBe(first.identityId); expect(getIdentity(second.identityId)?.signature).toBe('meshtastic:node:424242'); second.detach(); diff --git a/src/renderer/lib/meshIdentityBridge.ts b/src/renderer/lib/meshIdentityBridge.ts index 7aac807da..af46cdc8b 100644 --- a/src/renderer/lib/meshIdentityBridge.ts +++ b/src/renderer/lib/meshIdentityBridge.ts @@ -76,7 +76,7 @@ function resolveOrCreateIdentity( return identityId; } -export interface MeshtasticIngressBind { +export interface MeshtasticProtocolIngressAttach { identityId: IdentityId; detach: () => void; } @@ -107,12 +107,12 @@ export function meshtasticTransportParams( * Registers a Meshtastic identity, wires protocol ingress into PacketRouter, * and exposes the SDK handle to ConnectionDriver action hooks. */ -export function bindMeshtasticIngress( +export function attachMeshtasticProtocolIngress( device: MeshDevice, type: ConnectionType, opts: { peripheralId?: string; portSignature?: string; host?: string }, discovery?: DiscoveryInfo, -): MeshtasticIngressBind { +): MeshtasticProtocolIngressAttach { const params = meshtasticTransportParams(type, opts); const identityId = resolveOrCreateIdentity(meshtasticProtocol, params, discovery); if (discovery?.myNodeNum != null && discovery.myNodeNum > 0) { @@ -134,7 +134,7 @@ export function bindMeshtasticIngress( ); } }); - const detachDriver = connectionDriver.registerLegacyTransport( + const detachDriver = connectionDriver.registerExternalTransport( identityId, meshtasticProtocol, device, @@ -158,7 +158,7 @@ export function finalizeMeshcoreDriverIdentity( connectionDriver.registerTransportKeys(identityId, provisionalKey, resolvedKey); } -export interface MeshcoreIngressBind { +export interface MeshcoreProtocolIngressAttach { identityId: IdentityId; detach: () => void; } @@ -181,12 +181,12 @@ export function meshcoreTransportParams( } } -export function bindMeshcoreIngress( +export function attachMeshcoreProtocolIngress( conn: Connection, type: 'ble' | 'serial' | 'tcp', opts: { peripheralId?: string; portSignature?: string; host?: string }, discovery?: DiscoveryInfo, -): MeshcoreIngressBind { +): MeshcoreProtocolIngressAttach { const params = meshcoreTransportParams(type, opts); const identityId = resolveOrCreateIdentity(meshcoreProtocol, params, discovery); const connectionType = type === 'tcp' ? 'http' : type; @@ -203,7 +203,7 @@ export function bindMeshcoreIngress( ); } }); - const detachDriver = connectionDriver.registerLegacyTransport( + const detachDriver = connectionDriver.registerExternalTransport( identityId, meshcoreProtocol, conn, diff --git a/src/renderer/hooks/meshcore/meshcoreLegacyConnEvents.test.ts b/src/renderer/lib/meshcore/meshcoreAdvertEventApply.test.ts similarity index 77% rename from src/renderer/hooks/meshcore/meshcoreLegacyConnEvents.test.ts rename to src/renderer/lib/meshcore/meshcoreAdvertEventApply.test.ts index f1a9eb928..a2d9d95cd 100644 --- a/src/renderer/hooks/meshcore/meshcoreLegacyConnEvents.test.ts +++ b/src/renderer/lib/meshcore/meshcoreAdvertEventApply.test.ts @@ -1,13 +1,12 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; -import { - applyMeshcoreAdvertEvent128, - applyMeshcorePathUpdated129, -} from '@/renderer/lib/meshcore/meshcoreAdvertEventApply'; import { pubkeyToNodeId } from '@/renderer/lib/meshcoreUtils'; import type { MeshNode } from '@/renderer/lib/types'; -import { resetMeshcoreProcessWaitingMessagesSync } from './meshcoreWaitingMessagesSyncState'; +import { + applyMeshcoreAdvertEvent128, + applyMeshcorePathUpdated129, +} from './meshcoreAdvertEventApply'; function makePubKey(seed: number): Uint8Array { const key = new Uint8Array(32); @@ -32,24 +31,6 @@ function minimalNode( }; } -describe('resetMeshcoreProcessWaitingMessagesSync', () => { - it('clears waiting-message sync UI state', () => { - const setWaitingMessagesCount = vi.fn(); - const setWaitingMessagesSyncActive = vi.fn(); - const setWaitingMessagesSyncProgress = vi.fn(); - - resetMeshcoreProcessWaitingMessagesSync( - setWaitingMessagesCount, - setWaitingMessagesSyncActive, - setWaitingMessagesSyncProgress, - ); - - expect(setWaitingMessagesCount).toHaveBeenCalledWith(0); - expect(setWaitingMessagesSyncActive).toHaveBeenCalledWith(false); - expect(setWaitingMessagesSyncProgress).toHaveBeenCalledWith(null); - }); -}); - describe('applyMeshcoreAdvertEvent128', () => { it('inserts a new contact and returns insert persist metadata', () => { const publicKey = makePubKey(7); diff --git a/src/renderer/lib/meshcore/meshcoreDmAckRuntime.test.ts b/src/renderer/lib/meshcore/meshcoreDmAckRuntime.test.ts new file mode 100644 index 000000000..80c0d8abc --- /dev/null +++ b/src/renderer/lib/meshcore/meshcoreDmAckRuntime.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { PendingDmAckEntry } from '../../hooks/meshcore/meshcoreHookPreamble'; +import { addIdentity } from '../../stores/identityStore'; +import { upsertMessage, useMessageStore } from '../../stores/messageStore'; +import { meshcoreProtocol } from '../protocols/MeshCoreProtocol'; +import { + applyMeshcoreDmAckToPending, + resolveMeshcoreDmAck, + syncMeshcoreDmAckToMessageStore, +} from './meshcoreDmAckRuntime'; + +const ID = 'meshcore-dm-ack-runtime-test'; + +describe('meshcoreDmAckRuntime', () => { + afterEach(() => { + useMessageStore.setState({ messages: {} }); + vi.restoreAllMocks(); + }); + + it('resolveMeshcoreDmAck marks NACK codes as failed', () => { + const pending = new Map(); + expect(resolveMeshcoreDmAck(0x81, pending).newStatus).toBe('failed'); + expect(resolveMeshcoreDmAck(129, pending).newStatus).toBe('failed'); + expect(resolveMeshcoreDmAck(0x80, pending).newStatus).toBe('acked'); + }); + + it('applyMeshcoreDmAckToPending clears matching pending entry', () => { + const timeoutId = setTimeout(() => {}, 60_000); + const entry: PendingDmAckEntry = { + timeoutId, + mapKeys: [42, 42 >>> 0], + canonicalPacketIdU32: 42, + destNodeId: 7, + pathHash: 'ab', + }; + const pending = new Map([ + [42, entry], + [42 >>> 0, entry], + ]); + const resolution = applyMeshcoreDmAckToPending(42, pending); + expect(resolution.pending).toBe(entry); + expect(resolution.newStatus).toBe('acked'); + expect(pending.size).toBe(0); + clearTimeout(timeoutId); + }); + + it('syncMeshcoreDmAckToMessageStore updates sending outbound by ack key', () => { + addIdentity({ + id: ID, + protocol: meshcoreProtocol, + signature: 'meshcore:test:dm-ack', + transports: [], + createdAt: Date.now(), + lastSeenAt: Date.now(), + }); + upsertMessage(ID, { + id: '42', + from: 1, + to: 99, + payload: 'hi', + channelIndex: -1, + timestamp: Date.now(), + status: 'sending', + }); + expect(syncMeshcoreDmAckToMessageStore(ID, 42, 1, 'acked')).toBe(true); + expect(useMessageStore.getState().messages[ID]?.['42']?.status).toBe('acked'); + }); +}); diff --git a/src/renderer/lib/meshcore/meshcoreDmAckRuntime.ts b/src/renderer/lib/meshcore/meshcoreDmAckRuntime.ts new file mode 100644 index 000000000..b3f3313b7 --- /dev/null +++ b/src/renderer/lib/meshcore/meshcoreDmAckRuntime.ts @@ -0,0 +1,104 @@ +/** + * MeshCore hop-ACK (event 130) resolution shared by the runtime side-effect listener. + * + * Failure point: SQLite status write — logged by the caller; Zustand + in-memory chat rows + * stay authoritative for UI. + */ +import { isMeshtasticBroadcastNodeNum } from '@/shared/nodeNameUtils'; + +import { + meshcoreDeviceAckLookupKeys, + meshcoreDmAckKeyU32, + type PendingDmAckEntry, +} from '../../hooks/meshcore/meshcoreHookPreamble'; +import { renameMessageId, updateMessageStatus, useMessageStore } from '../../stores/messageStore'; +import type { IdentityId } from '../types'; + +/** MeshCore firmware RESP codes: 0x80 = ACK, 0x81 = NACK (may arrive signed). */ +const MESHCORE_RESP_CODE_NACK = 0x81; + +export type MeshcoreDmAckStatus = 'acked' | 'failed'; + +export interface MeshcoreDmAckResolution { + isNack: boolean; + newStatus: MeshcoreDmAckStatus; + /** Pending send this ACK belongs to, when the device code still matches a live entry. */ + pending: PendingDmAckEntry | null; + /** Canonical uint32 packet id to apply the status to. */ + ackKeyU32: number; +} + +/** Pure: match a device ACK code against pending sends without mutating the map. */ +export function resolveMeshcoreDmAck( + ackCode: number, + pendingAcks: ReadonlyMap, +): MeshcoreDmAckResolution { + const isNack = ackCode === MESHCORE_RESP_CODE_NACK || ackCode === 129; + const newStatus: MeshcoreDmAckStatus = isNack ? 'failed' : 'acked'; + let pending: PendingDmAckEntry | null = null; + for (const lookupKey of meshcoreDeviceAckLookupKeys(ackCode)) { + pending = pendingAcks.get(lookupKey) ?? null; + if (pending) break; + } + return { + isNack, + newStatus, + pending, + ackKeyU32: pending?.canonicalPacketIdU32 ?? meshcoreDmAckKeyU32(ackCode), + }; +} + +/** + * Resolve the ACK and consume the matching pending entry (clears its timeout and every + * alias key). Returns the same shape as {@link resolveMeshcoreDmAck}. + */ +export function applyMeshcoreDmAckToPending( + ackCode: number, + pendingAcks: Map, +): MeshcoreDmAckResolution { + const resolution = resolveMeshcoreDmAck(ackCode, pendingAcks); + if (resolution.pending) { + clearTimeout(resolution.pending.timeoutId); + for (const key of resolution.pending.mapKeys) { + pendingAcks.delete(key); + } + } + return resolution; +} + +function isOutboundMeshcoreDmRecord(rec: { from: number; to: number; status?: string }): boolean { + return rec.to !== 0xffffffff && !isMeshtasticBroadcastNodeNum(rec.to) && rec.to > 0; +} + +/** Identity-scoped chat rows from {@link useSendMessage} use numeric ids after rename. */ +export function syncMeshcoreDmAckToMessageStore( + identityId: IdentityId, + ackKeyU32: number, + selfId: number, + newStatus: MeshcoreDmAckStatus, +): boolean { + const byId = useMessageStore.getState().messages[identityId] ?? {}; + let matched = false; + for (const [id, rec] of Object.entries(byId)) { + if (rec.from !== selfId) continue; + if (rec.status !== 'sending' && rec.status !== 'failed') continue; + if (!/^\d+$/.test(id)) continue; + if (meshcoreDmAckKeyU32(Number(id)) !== ackKeyU32) continue; + updateMessageStatus(identityId, id, newStatus); + matched = true; + } + if (matched) return true; + + const inflight = Object.entries(byId).filter( + ([, rec]) => rec.status === 'sending' && rec.from === selfId && isOutboundMeshcoreDmRecord(rec), + ); + if (inflight.length !== 1) return false; + + const [id] = inflight[0]; + const ackId = String(ackKeyU32); + if (id !== ackId) { + renameMessageId(identityId, id, ackId); + } + updateMessageStatus(identityId, ackId, newStatus); + return true; +} diff --git a/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts b/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts index 65138b7f6..3e4225952 100644 --- a/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts +++ b/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts @@ -1,15 +1,23 @@ import { meshcoreContactRawFromDevice } from '../../hooks/meshcore/meshcoreHookPreamble'; import { usePathHistoryStore } from '../../stores/pathHistoryStore'; import { errLikeToLogString } from '../errLikeToLogString'; -import type { MeshCoreConnection } from '../meshcore/meshcoreHookTypes'; +import type { + MeshCoreConnection, + MeshCoreContactRaw, + MeshCoreSelfInfo, +} from '../meshcore/meshcoreHookTypes'; import { meshcoreContactOutPathBytesForTrace } from '../meshcoreRadioContactPath'; import { meshcoreInferHopsFromOutPath, pubkeyToNodeId } from '../meshcoreUtils'; +import type { MeshNode } from '../types'; + +/** Path updates change hop counts; coalesce bursts before the full contact rebuild. */ +export const MESHCORE_PATH_UPDATED_CONTACTS_REBUILD_DEBOUNCE_MS = 2000; /** * Refresh outPath bytes for one contact after path-updated (129) so trace/ping can proceed. * * Failure point: `getContacts` timeout — logged; pending path update retried on debounced refresh. - * Fallback: debounced full contact rebuild in legacy conn events. + * Fallback: {@link rebuildMeshcoreContactsAfterPathUpdated}. */ export async function refreshMeshcoreOutPathAfterPathUpdated( conn: MeshCoreConnection, @@ -39,3 +47,58 @@ export async function refreshMeshcoreOutPathAfterPathUpdated( ); } } + +export interface MeshcoreContactsRebuildDeps { + conn: MeshCoreConnection; + buildNodesFromContacts: ( + contacts: MeshCoreContactRaw[], + opts?: { + self?: MeshCoreSelfInfo | null; + myNodeId?: number; + previousNodes?: Map; + }, + ) => Promise>; + self: MeshCoreSelfInfo | null; + myNodeId: number; + previousNodes: Map; + /** Node ids that saw path-updated (129) since the last rebuild; consumed by this call. */ + pendingPathUpdateNodeIds: Set; + onContacts: (contacts: MeshCoreContactRaw[]) => void; + onNodes: (nodes: Map) => void; +} + +/** + * Full contact rebuild after a burst of path-updated (129) pushes, so hop counts and + * `outPathLen` reflect the new routes and path history gets one row per updated node. + * + * Failure point: `getContacts` / rebuild rejection — logged; the previous node map stays in + * place and the next path update schedules another rebuild. + */ +export async function rebuildMeshcoreContactsAfterPathUpdated( + deps: MeshcoreContactsRebuildDeps, +): Promise { + try { + const contactsRaw = await deps.conn.getContacts(); + const contacts = contactsRaw.map(meshcoreContactRawFromDevice); + deps.onContacts(contacts); + const newNodes = await deps.buildNodesFromContacts(contacts, { + self: deps.self, + myNodeId: deps.myNodeId, + previousNodes: deps.previousNodes, + }); + deps.onNodes(newNodes); + for (const contact of contacts) { + const nodeId = pubkeyToNodeId(contact.publicKey); + if (!deps.pendingPathUpdateNodeIds.has(nodeId)) continue; + const sliced = meshcoreContactOutPathBytesForTrace(contact); + if (sliced.length === 0) continue; + usePathHistoryStore + .getState() + .recordPathUpdated(nodeId, Array.from(sliced), newNodes.get(nodeId)?.hops_away ?? 0, false); + } + } catch (e) { + console.warn( + '[meshcorePathUpdatedRuntime] debounced contacts refresh error ' + errLikeToLogString(e), + ); + } +} diff --git a/src/renderer/lib/meshcoreDirectMessageDecode.test.ts b/src/renderer/lib/meshcoreDirectMessageDecode.test.ts index 331722fa4..bb45b3c80 100644 --- a/src/renderer/lib/meshcoreDirectMessageDecode.test.ts +++ b/src/renderer/lib/meshcoreDirectMessageDecode.test.ts @@ -6,7 +6,7 @@ import { useNodeStore } from '../stores/nodeStore'; import { packetRouter } from './drivers/PacketRouter'; import { attachMeshcoreIngest } from './ingest/meshcoreIngest'; import { registerMeshcorePubKey } from './meshcore/meshcorePubKeyRegistry'; -import { MESHCORE_TXT_TYPE_SIGNED_PLAIN } from './meshcoreChannelText'; +import { MESHCORE_TXT_TYPE_CLI_DATA, MESHCORE_TXT_TYPE_SIGNED_PLAIN } from './meshcoreChannelText'; import { decodeMeshcoreDirectMessageEvents, dispatchMeshcoreWaitingContactMessage, @@ -18,6 +18,35 @@ import { meshcoreProtocol } from './protocols/MeshCoreProtocol'; const ID = 'meshcore-waiting-decode-test'; describe('decodeMeshcoreDirectMessageEvents', () => { + it('emits meshcore_cli_response for txtType CLI data', () => { + const pubKey = Uint8Array.from({ length: 32 }, (_, i) => i + 7); + const nodeId = pubkeyToNodeId(pubKey); + registerMeshcorePubKey(nodeId, pubKey); + const prefixHex = Array.from(pubKey.slice(0, 6)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + const prefixMap = new Map([[prefixHex, nodeId]]); + const events = decodeMeshcoreDirectMessageEvents( + { + pubKeyPrefix: pubKey.slice(0, 6), + text: 'clock sync ok', + senderTimestamp: 1_700_000_050, + txtType: MESHCORE_TXT_TYPE_CLI_DATA, + }, + prefixMap, + new Set(), + ); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: 'meshcore_cli_response', + payload: { + text: 'clock sync ok', + senderNodeId: nodeId, + pubKeyPrefixHex: prefixHex, + }, + }); + }); + it('includes author in room message id for SignedPlain posts', () => { const roomPubKey = Uint8Array.from({ length: 32 }, (_, i) => i + 10); const authorPubKey = Uint8Array.from({ length: 32 }, (_, i) => i + 20); diff --git a/src/renderer/lib/meshcoreDirectMessageDecode.ts b/src/renderer/lib/meshcoreDirectMessageDecode.ts index 250517a17..02a008b06 100644 --- a/src/renderer/lib/meshcoreDirectMessageDecode.ts +++ b/src/renderer/lib/meshcoreDirectMessageDecode.ts @@ -2,6 +2,7 @@ import { MESHCORE_ROOM_MESSAGE_CHANNEL } from '@/renderer/hooks/meshcore/meshcor import { resolveMeshcoreNodeIdFromPubKeyPrefix } from './meshcore/meshcorePubKeyRegistry'; import { + MESHCORE_TXT_TYPE_CLI_DATA, MESHCORE_TXT_TYPE_SIGNED_PLAIN, parseMeshcoreRoomPostPayload, } from './meshcoreChannelText'; @@ -51,24 +52,40 @@ function resolveRoomAuthorIdForMessageId( return authorId !== 0 ? authorId : undefined; } +function resolveSenderIdFromPrefix(prefix: string, nodeIdByPrefix: Map): number { + const known = nodeIdByPrefix.get(prefix) ?? 0; + if (known !== 0) return known; + const resolved = resolveMeshcoreNodeIdFromPubKeyPrefix(prefix) ?? 0; + if (resolved !== 0) { + nodeIdByPrefix.set(prefix, resolved); + } + return resolved; +} + /** Shared DM/room decode for MeshCoreProtocol.subscribe and event-131 waiting-message ingest. */ export function decodeMeshcoreDirectMessageEvents( raw: DecodeMeshcoreDirectMessageInput, nodeIdByPrefix: Map, roomNodeIds: ReadonlySet, ): DomainEvent[] { - if (raw.txtType === 1) return []; + if (raw.txtType === MESHCORE_TXT_TYPE_CLI_DATA) { + const cliPrefix = pubKeyPrefixHex(raw.pubKeyPrefix); + return [ + { + type: 'meshcore_cli_response', + payload: { + text: raw.text, + senderNodeId: resolveSenderIdFromPrefix(cliPrefix, nodeIdByPrefix), + pubKeyPrefixHex: cliPrefix, + }, + }, + ]; + } if (isMeshcoreTransportStatusChatLine(raw.text)) { return decodeTransportStatusDeviceLog(raw.text); } const prefix = pubKeyPrefixHex(raw.pubKeyPrefix); - let senderId = nodeIdByPrefix.get(prefix) ?? 0; - if (senderId === 0) { - senderId = resolveMeshcoreNodeIdFromPubKeyPrefix(prefix) ?? 0; - if (senderId !== 0) { - nodeIdByPrefix.set(prefix, senderId); - } - } + const senderId = resolveSenderIdFromPrefix(prefix, nodeIdByPrefix); const isSignedPlain = raw.txtType === MESHCORE_TXT_TYPE_SIGNED_PLAIN; if (isSignedPlain && senderId !== 0) { (roomNodeIds as Set).add(senderId); diff --git a/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts b/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts index 8a2f2549d..c3f082606 100644 --- a/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts +++ b/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts @@ -26,7 +26,6 @@ function baseDeps(overrides?: Partial(), myNodeNum: 0x42, meshcoreIdentityId: 'meshcore-test-id', - legacyOwnsRoomPosts: () => false, storePriorForBatch: () => [] as ChatMessage[], logTransportLineAsDevice: vi.fn(), ...overrides, @@ -152,7 +151,6 @@ describe('processMeshcoreWaitingMessageItem', () => { [prefixHexFromBytes(roomPrefixBytes), roomId], [prefixHexFromBytes(authorPubKey.slice(0, 6)), authorId], ]), - legacyOwnsRoomPosts: () => false, }); deps.workingNodes.set(roomId, { node_id: roomId, @@ -194,7 +192,6 @@ describe('processMeshcoreWaitingMessageItem', () => { const deps = baseDeps({ meshcoreIdentityId: null, pubKeyPrefixMap: new Map([[prefixHexFromBytes(roomPrefixBytes), roomId]]), - legacyOwnsRoomPosts: () => false, }); deps.workingNodes.set(roomId, { node_id: roomId, @@ -227,13 +224,13 @@ describe('processMeshcoreWaitingMessageItem', () => { dispatchSpy.mockRestore(); }); - it('queues legacy room posts in pendingMessages', () => { + it('dispatches room posts through PacketRouter when identity is bound', () => { + const dispatchSpy = vi.spyOn(packetRouter, 'dispatch').mockImplementation(() => {}); const roomPubKey = makePubKey(32); const roomId = pubkeyToNodeId(roomPubKey); const roomPrefixBytes = roomPubKey.slice(0, 6); const deps = baseDeps({ pubKeyPrefixMap: new Map([[prefixHexFromBytes(roomPrefixBytes), roomId]]), - legacyOwnsRoomPosts: () => true, }); deps.workingNodes.set(roomId, { node_id: roomId, @@ -252,15 +249,17 @@ describe('processMeshcoreWaitingMessageItem', () => { { contactMessage: { pubKeyPrefix: roomPrefixBytes, - text: 'legacy room post', + text: 'room post via router', senderTimestamp: 1_700_000_500, }, }, deps, ); - expect(result.pendingMessages).toHaveLength(1); - expect(result.pendingMessages[0]?.payload).toContain('legacy room post'); + expect(result.roomDispatched).toBe(true); + expect(result.pendingMessages).toHaveLength(0); + expect(dispatchSpy).toHaveBeenCalled(); + dispatchSpy.mockRestore(); }); it('queues channel messages with isHistory', () => { @@ -304,12 +303,11 @@ describe('processMeshcoreWaitingMessageItem', () => { return { deps, prefixBytes }; } - function roomDeps(roomPubKey: Uint8Array, legacyOwnsRoomPosts: boolean) { + function roomDeps(roomPubKey: Uint8Array) { const roomPrefixBytes = roomPubKey.slice(0, 6); const roomId = pubkeyToNodeId(roomPubKey); const deps = baseDeps({ pubKeyPrefixMap: new Map([[prefixHexFromBytes(roomPrefixBytes), roomId]]), - legacyOwnsRoomPosts: () => legacyOwnsRoomPosts, }); deps.workingNodes.set(roomId, { node_id: roomId, @@ -401,25 +399,32 @@ describe('processMeshcoreWaitingMessageItem', () => { expect(result.pendingMessages[0]?.rxHops).toBeUndefined(); }); - it('sets legacy room rxHops from pathLen', () => { - const { deps, roomPrefixBytes } = roomDeps(makePubKey(52), true); + it('forwards pathLen into PacketRouter room dispatch as hopCount (room pathLen)', () => { + const dispatchSpy = vi.spyOn(packetRouter, 'dispatch').mockImplementation(() => {}); + const { deps, roomPrefixBytes } = roomDeps(makePubKey(52)); const result = processMeshcoreWaitingMessageItem( { contactMessage: { pubKeyPrefix: roomPrefixBytes, - text: 'legacy room hops', + text: 'room hops', senderTimestamp: 1_700_000_730, pathLen: 4, }, }, deps, ); - expect(result.pendingMessages[0]?.rxHops).toBe(4); + expect(result.roomDispatched).toBe(true); + expect(result.pendingMessages).toHaveLength(0); + const textEvent = dispatchSpy.mock.calls + .map(([event]) => event) + .find((e) => e.type === 'text_message'); + expect(textEvent?.type === 'text_message' && textEvent.payload.hopCount).toBe(4); + dispatchSpy.mockRestore(); }); it('forwards pathLen into PacketRouter room dispatch as hopCount', () => { const dispatchSpy = vi.spyOn(packetRouter, 'dispatch').mockImplementation(() => {}); - const { deps, roomPrefixBytes } = roomDeps(makePubKey(53), false); + const { deps, roomPrefixBytes } = roomDeps(makePubKey(53)); const result = processMeshcoreWaitingMessageItem( { contactMessage: { diff --git a/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts b/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts index 7559b89b4..220836644 100644 --- a/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts +++ b/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts @@ -1,12 +1,10 @@ import { packetRouter } from '@/renderer/lib/drivers/PacketRouter'; import { - buildMeshcoreRoomIncomingMessage, parseMeshcoreChannelIncomingFromThread, parseMeshcoreDmIncomingFromThread, resolveMeshcoreChannelMessageSender, } from '@/renderer/lib/meshcoreChannelText'; import { dispatchMeshcoreWaitingContactMessage } from '@/renderer/lib/meshcoreDirectMessageDecode'; -import { meshcoreRoomPostBodyFromWire } from '@/renderer/lib/meshcoreRoomMessageRouting'; import { setMeshcoreRoomLastPostAt } from '@/renderer/lib/meshcoreRoomSyncStorage'; import { isMeshcoreTransportStatusChatLine, @@ -30,7 +28,6 @@ export interface ProcessWaitingMessageItemDeps { pubKeyPrefixMap: Map; myNodeNum: number; meshcoreIdentityId: string | null; - legacyOwnsRoomPosts: () => boolean; storePriorForBatch: () => ChatMessage[]; logTransportLineAsDevice: (text: string) => void; } @@ -51,7 +48,7 @@ export function processMeshcoreWaitingMessageItem( const senderId = deps.pubKeyPrefixMap.get(prefix) ?? 0; if (senderId === 0) { console.warn( - '[useMeshcoreRuntime] event 131: unknown pubKeyPrefix in queued DM, sender will be 0', + '[meshcoreProcessWaitingMessageItem] unknown pubKeyPrefix in queued DM, sender will be 0', prefix, ); } @@ -69,60 +66,36 @@ export function processMeshcoreWaitingMessageItem( if (isMeshcoreTransportStatusChatLine(d.text)) { deps.logTransportLineAsDevice(d.text); } else if (sender?.hw_model === 'Room') { + // Room posts route through PacketRouter → meshcoreIngest, matching live event-7 posts. const postTs = effectiveMessageTimestampMs(d.senderTimestamp * 1000); - if (!deps.legacyOwnsRoomPosts()) { - const identityId = deps.meshcoreIdentityId; - if (identityId) { - const roomNodeIds = new Set(); - for (const [nodeId, node] of deps.workingNodes) { - if (node.hw_model === 'Room') roomNodeIds.add(nodeId); - } - dispatchMeshcoreWaitingContactMessage( - identityId, - { - pubKeyPrefix: d.pubKeyPrefix, - text: d.text, - senderTimestamp: d.senderTimestamp, - ...(d.txtType != null ? { txtType: d.txtType } : {}), - ...(d.pathLen != null ? { pathLen: d.pathLen } : {}), - }, - deps.pubKeyPrefixMap, - roomNodeIds, - (event, id) => { - packetRouter.dispatch(event, id); - }, - deps.logTransportLineAsDevice, - ); - roomDispatched = true; - void setMeshcoreRoomLastPostAt(senderId, postTs); - } else { - console.warn( - '[useMeshcoreRuntime] event 131: room post skipped (no identityId)', - senderId, - ); + const identityId = deps.meshcoreIdentityId; + if (identityId) { + const roomNodeIds = new Set(); + for (const [nodeId, node] of deps.workingNodes) { + if (node.hw_model === 'Room') roomNodeIds.add(nodeId); } - } else { - const { authorId, payload } = meshcoreRoomPostBodyFromWire( - d.text, - d.txtType, + dispatchMeshcoreWaitingContactMessage( + identityId, + { + pubKeyPrefix: d.pubKeyPrefix, + text: d.text, + senderTimestamp: d.senderTimestamp, + ...(d.txtType != null ? { txtType: d.txtType } : {}), + ...(d.pathLen != null ? { pathLen: d.pathLen } : {}), + }, deps.pubKeyPrefixMap, - { isKnownRoomNode: true }, + roomNodeIds, + (event, id) => { + packetRouter.dispatch(event, id); + }, + deps.logTransportLineAsDevice, ); - const authorNode = authorId !== 0 ? deps.workingNodes.get(authorId) : undefined; - const authorName = - authorNode?.long_name ?? - (authorId !== 0 ? `Node-${authorId.toString(16).toUpperCase()}` : 'Unknown'); - const roomRxHops = meshcoreCompanionRxPathLenToHopCount(d.pathLen); - pendingMessages.push( - buildMeshcoreRoomIncomingMessage({ - rawText: payload, - roomServerId: senderId, - authorId: authorId !== 0 ? authorId : deps.myNodeNum || 0, - authorName, - timestamp: effectiveMessageTimestampMs(d.senderTimestamp * 1000), - receivedVia: 'rf', - ...(roomRxHops != null ? { rxHops: roomRxHops } : {}), - }), + roomDispatched = true; + void setMeshcoreRoomLastPostAt(senderId, postTs); + } else { + console.warn( + '[meshcoreProcessWaitingMessageItem] room post skipped (no identityId)', + senderId, ); } } else { diff --git a/src/renderer/lib/meshtastic/meshtasticChatSenderNode.ts b/src/renderer/lib/meshtastic/meshtasticChatSenderNode.ts index 2a43e7524..e928c1838 100644 --- a/src/renderer/lib/meshtastic/meshtasticChatSenderNode.ts +++ b/src/renderer/lib/meshtastic/meshtasticChatSenderNode.ts @@ -1,7 +1,7 @@ import { upsertNodeRecord, useNodeStore } from '../../stores/nodeStore'; import type { IdentityId } from '../types'; -/** Ensure a chat sender exists in identity-scoped node store (mirrors legacy ensureNodeExists). */ +/** Ensure a chat sender exists in the identity-scoped node store (store-side `ensureNodeExists`). */ export function ensureMeshtasticChatSenderInNodeStore( identityId: IdentityId, nodeId: number, diff --git a/src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.diagnostics.contract.test.ts b/src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.diagnostics.contract.test.ts deleted file mode 100644 index 2ed5da9de..000000000 --- a/src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.diagnostics.contract.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Regression guard: LocalStats and RF hop/signal ingest must feed diagnosticsStore - * so connected-node CU history, RF analysis, and hop-flapping detection stay live. - */ -import { readFileSync } from 'fs'; -import { join } from 'path'; -import { describe, expect, it } from 'vitest'; - -const SOURCE = readFileSync(join(__dirname, 'meshtasticLegacyWireSubscriptions.ts'), 'utf-8'); - -function sliceFromMarker(marker: string, length = 1400): string { - const index = SOURCE.indexOf(marker); - if (index === -1) return ''; - return SOURCE.slice(index, index + length); -} - -describe('meshtasticLegacyWireSubscriptions diagnostics ingest contract', () => { - it('localStats handler calls processNodeUpdate when protocol is meshtastic', () => { - const body = sliceFromMarker('// Handle localStats variant'); - expect(body.length).toBeGreaterThan(0); - expect(body).toContain("tel.variant?.case === 'localStats'"); - expect(body).toContain('processNodeUpdate'); - expect(body).toContain("getStoredMeshProtocol() === 'meshtastic'"); - }); - - it('RF mesh packet hop/signal handler calls processNodeUpdate when protocol is meshtastic', () => { - const body = sliceFromMarker('if (hasSignal || hasHopUpdate)'); - expect(body.length).toBeGreaterThan(0); - expect(body).toContain('processNodeUpdate'); - expect(body).toContain("getStoredMeshProtocol() === 'meshtastic'"); - }); -}); diff --git a/src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.ts b/src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.ts deleted file mode 100644 index 9811b8837..000000000 --- a/src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.ts +++ /dev/null @@ -1,2269 +0,0 @@ -import { fromBinary, toBinary } from '@bufbuild/protobuf'; -import { type MeshDevice, Types } from '@meshtastic/core'; -import { Admin, Mesh, Portnums } from '@meshtastic/protobufs'; -import type { Dispatch, RefObject, SetStateAction } from 'react'; - -import { dedupeChannelPillsByIndex } from '@/renderer/lib/channelListDedupe'; -import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; -import { - decodeStoreForwardTextPayload, - isDuplicateHistoryMessage, - parseStoreForwardHeartbeat, -} from '@/renderer/lib/meshtasticBacklogUtils'; -import { persistLastRfSelfNodeId } from '@/renderer/lib/meshtasticMqttIdentity'; -import { - loadMeshtasticMqttManualChannelPsks, - resolveMeshtasticMqttPublishFieldsForChannel, - type ResolveMeshtasticMqttPublishOptions, -} from '@/renderer/lib/meshtasticMqttPublish'; -import { stripControlCharacters } from '@/renderer/lib/stripControlCharacters'; -import { resolveMeshtasticTextMessagePayload } from '@/shared/meshtasticTextMessagePayload'; -import type { MeshtasticLoraConfig } from '@/shared/meshtasticUrlEncoder'; - -import { - meshtasticNodeLacksDisplayIdentity, - meshtasticShortNameAfterClearingDefault, - preferNonEmptyTrimmedString, -} from '../../../shared/nodeNameUtils'; -import { - meshtasticWireUint32AllowZero, - meshtasticWireUint32NonZero, -} from '../../../shared/reactionEmoji'; -import { setConnection } from '../../stores/connectionStore'; -import { setMeshtasticConfigSlice } from '../../stores/deviceStore'; -import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; -import { updateIdentity } from '../../stores/identityStore'; -import { useMessageStore } from '../../stores/messageStore'; -import { usePositionHistoryStore } from '../../stores/positionHistoryStore'; -import { MAX_IN_MEMORY_CHAT_MESSAGES, trimChatMessagesToMax } from '../chatInMemoryBuffer'; -import { safeDisconnect } from '../connection'; -import { validateCoords } from '../coordUtils'; -import { connectionDriver } from '../drivers/ConnectionDriver'; -import { isForeignLoraLogCandidate } from '../foreignLoraDetection'; -import type { OurPosition } from '../gpsSource'; -import { shouldPreserveStaticGpsForSelfNode } from '../gpsSource'; -import { meshtasticHwModelName } from '../hardwareModels'; -import { attachMeshtasticIngest, type MeshtasticIngestSession } from '../ingest/meshtasticIngest'; -import { bindMeshtasticIngress, meshtasticTransportParams } from '../meshIdentityBridge'; -import { setMeshtasticConnectedMyNodeNum } from '../meshtasticConnectedNodeRef'; -import { - computeNodeInfoLastHeardMs, - mergeMeshtasticLivePacketLastHeard, - mergeMeshtasticUserPacketLastHeard, - meshtasticPacketRxTimeMs, - meshtasticTracerouteLastHeardNodeIds, -} from '../meshtasticLastHeard'; -import { - findMeshtasticCrossTransportDuplicate, - mapMeshtasticCrossTransportUpgrade, - meshtasticPacketIdsEqual, - normalizeMeshtasticPacketId, -} from '../meshtasticMessageDedup'; -import type { MeshtasticRemoteAdminClient } from '../meshtasticRemoteAdmin'; -import { meshtasticComputedRfHopsAway } from '../meshtasticRfHops'; -import { - mergeMeshtasticTraceRouteIntoResultsMap, - meshtasticTraceRouteLookupKeys, -} from '../meshtasticTraceRouteLookupKeys'; -import { parseStoredJson } from '../parseStoredJson'; -import { MESHTASTIC_CAPABILITIES } from '../radio/BaseRadioProvider'; -import { - MAX_RAW_PACKET_LOG_ENTRIES, - type MeshtasticRawPacketEntry, -} from '../rawPacketLogConstants'; -import { normalizeReactionEmoji } from '../reactions'; -import { enrichMeshtasticReplyPreviews } from '../replyPreview'; -import { LAST_SERIAL_PORT_KEY } from '../serialPortSignature'; -import { getStoredMeshProtocol } from '../storedMeshProtocol'; -import { messageRecordsToChatMessages } from '../storeRecordAdapters'; -import { - MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS, - MESHTASTIC_LOCAL_LORA_CONFIG_DELAY_MS, -} from '../timeConstants'; -import type { - ChatMessage, - ConnectionType, - DeviceState, - EnvironmentTelemetryPoint, - MeshNode, - MeshtasticRemoteConfigSnapshot, - MeshWaypoint, - MQTTStatus, - NeighborInfoRecord, - RemoteAdminStatus, - TelemetryPoint, -} from '../types'; -import { recordMeshtasticClientNotification } from './meshtasticClientNotification'; -import { pushMeshtasticTransportSideEffectUnsubs } from './meshtasticLegacyDeviceEvents'; -import { shouldFetchLocalLoraConfigAfterConfigure } from './meshtasticLocalLoraConfig'; -import { - appendModulePortEvent, - appendPaxHistory, - type ModulePortEvent, - type PaxCounterPoint, -} from './meshtasticModuleEvents'; -import type { MeshtasticMqttClientProxyBridge } from './meshtasticMqttClientProxy'; -import { parseMeshtasticRawPacketExpand } from './meshtasticRawPacketExpand'; -import { - installMeshtasticSdkRoutingErrorConsoleHook, - installMeshtasticSdkRoutingErrorUnhandledRejectionHandler, -} from './meshtasticSdkRoutingErrorConsoleHook'; -import { - applyMeshtasticOutboundRoutingErrorFromLog, - applyMeshtasticOutboundRoutingErrorFromRejection, -} from './meshtasticSdkRoutingErrorLog'; - -const MAX_TELEMETRY_POINTS = 50; -const BROADCAST_ADDR = 0xffffffff; -const REQUEST_NODEINFO_MIN_INTERVAL_MS = 120_000; -const { PortNum } = Portnums; -const { DeviceStatusEnum } = Types; - -function isMeshtasticTraceroutePortnum(portnum: unknown): boolean { - return Number(portnum) === Portnums.PortNum.TRACEROUTE_APP; -} - -function meshtasticPublicKeyHex(bytes: Uint8Array | undefined): string | undefined { - if (bytes?.length !== 32) return undefined; - return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); -} - -function meshtasticRawPacketPortLabel(packet: unknown): string { - const p = packet as { - payloadVariant?: { case?: string; value?: { portnum?: number } }; - }; - const variant = p.payloadVariant?.case; - if (variant === 'decoded') { - const portnum = p.payloadVariant?.value?.portnum; - if (typeof portnum === 'number') { - const found = Object.entries(Portnums.PortNum).find(([, v]) => v === portnum); - return found ? found[0] : `PORT_${portnum}`; - } - return 'decoded'; - } - if (variant === 'encrypted') return 'encrypted'; - return variant ?? '?'; -} - -function meshtasticMqttPublishOpts( - mqttOnly: boolean, -): ResolveMeshtasticMqttPublishOptions | undefined { - return mqttOnly ? { preferManualOverRadio: true } : undefined; -} - -export type RequestStoreForwardHistoryResult = - | { ok: true } - | { - ok: false; - code: - | 'no_server' - | 'not_configured' - | 'local_is_server' - | 'send_failed' - | 'cooldown' - | 'offline_gate' - | 'already_requested'; - }; - -const ROLE_CLIENT_MUTE = 1; - -export interface MeshtasticLegacyWireSubscriptionDeps { - channelConfigsRef: RefObject< - { - index: number; - name: string; - role: number; - psk: Uint8Array; - uplinkEnabled: boolean; - downlinkEnabled: boolean; - positionPrecision: number; - }[] - >; - configureTargetNodeNumRef: RefObject; - configureTargetPersistRestoredRef: RefObject; - configureTimeoutRef: RefObject | null>; - connectionParamsRef: RefObject<{ - type: ConnectionType; - httpAddress?: string; - blePeripheralId?: string; - lastSerialPortId?: string | null; - } | null>; - deviceConfiguredRef: RefObject; - deviceGpsModeRef: RefObject; - deviceRef: RefObject; - handleConnectionLostRef: RefObject<() => void>; - schedulePostCommitRebootRecoveryRef: RefObject<(source?: string) => void>; - clearPostCommitRebootRecoveryRef: RefObject<() => void>; - isConfiguringRef: RefObject; - lastDataReceivedRef: RefObject; - lastNodeInfoRequestAtRef: RefObject>; - lastRfDisconnectAtRef: RefObject; - lastRfSelfNodeIdRef: RefObject; - lastSfHeartbeatChannelRef: RefObject; - lastSfHeartbeatPeriodRef: RefObject; - lastSfHeartbeatServerRef: RefObject; - localLoraConfigTimerRef: RefObject | undefined>; - meshtasticIdentityIdRef: RefObject; - meshtasticIngestSessionRef: RefObject; - meshtasticIngressDetachRef: RefObject<(() => void) | null>; - messagesRef: RefObject; - mqttClientProxyBridgeRef: RefObject; - mqttStatusRef: RefObject; - myNodeNumRef: RefObject; - nodesRef: RefObject>; - pendingTempIdRef: RefObject; - ackMeshPacketIdByTempIdRef: RefObject>; - pendingTracePacketIdToTargetRef: RefObject>; - pendingTraceRequestsRef: RefObject>; - refreshOurPositionRef: RefObject<() => Promise>; - remoteAdminClientRef: RefObject; - remoteAdminStatusRef: RefObject; - requestStoreForwardHistoryRef: RefObject< - (options?: { - serverNodeId?: number; - manual?: boolean; - }) => Promise - >; - rfHeardNodeIds: RefObject>; - sfHistoryRequestedServersRef: RefObject>; - skipLocalLoraConfigRef: RefObject; - loraConfigRef: RefObject; - unsubscribesRef: RefObject<(() => void)[]>; - virtualNodeIdRef: RefObject; - touchLastData: () => void; - applyOwnNodeBatteryFromDeviceMetrics: (batteryLevel: number) => void; - getNodeName: (nodeNum: number) => string; - updateNodes: (updater: (prev: Map) => Map) => void; - startWatchdog: () => void; - stopWatchdog: () => void; - cleanupSubscriptions: () => void; - startGpsInterval: () => void; - stopGpsInterval: () => void; - isDuplicate: (senderId: number, packetId: number) => boolean; - ensureNodeExists: (nodeNum: number, source: 'rf' | 'mqtt') => void; - clearConfigureTimeout: () => void; - applyMeshtasticForeignLoraFromLog: (message: string) => void; - emptyNode: (nodeId: number) => MeshNode; - setMeshtasticIdentityId: Dispatch>; - setState: Dispatch>; - setQueueStatus: Dispatch>; - setDeviceLogs: Dispatch< - SetStateAction<{ message: string; time: number; source: string; level: number }[]> - >; - setTraceRouteResults: Dispatch< - SetStateAction> - >; - setNeighborInfo: Dispatch>>; - setWaypoints: Dispatch>>; - setModuleConfigs: Dispatch>>; - setSecurityConfig: Dispatch< - SetStateAction<{ - publicKey: Uint8Array; - privateKey: Uint8Array; - adminKey: Uint8Array[]; - isManaged: boolean; - serialEnabled: boolean; - debugLogApiEnabled: boolean; - adminChannelEnabled: boolean; - } | null> - >; - setLoraConfig: Dispatch>; - setConfigureTargetNodeNumState: Dispatch>; - setRemoteConfigSnapshot: Dispatch>; - setRemoteAdminStatus: Dispatch>; - setRemoteAdminError: Dispatch>; - setMessages: Dispatch>; - setTelemetry: Dispatch>; - setSignalTelemetry: Dispatch>; - setEnvironmentTelemetry: Dispatch>; - setDeviceOwner: Dispatch< - SetStateAction<{ longName: string; shortName: string; isLicensed: boolean } | null> - >; - setChannels: Dispatch>; - setChannelConfigs: Dispatch< - SetStateAction< - { - index: number; - name: string; - role: number; - psk: Uint8Array; - uplinkEnabled: boolean; - downlinkEnabled: boolean; - positionPrecision: number; - }[] - > - >; - setDeviceGpsMode: Dispatch>; - setDeviceFixedPosition: Dispatch>; - setTelemetryDeviceUpdateInterval: Dispatch>; - setRawPackets: Dispatch>; - setRemoteHardwareMessages: Dispatch< - SetStateAction> - >; - setAudioMessages: Dispatch< - SetStateAction> - >; - setDetectionSensorEvents: Dispatch>>; - setPingResponses: Dispatch< - SetStateAction> - >; - setIpTunnelMessages: Dispatch< - SetStateAction> - >; - setPaxCounterData: Dispatch>>; - setSerialMessages: Dispatch< - SetStateAction> - >; - setStoreForwardMessages: Dispatch< - SetStateAction> - >; - setRangeTestPackets: Dispatch>>; - setZpsMessages: Dispatch< - SetStateAction> - >; - setSimulatorPackets: Dispatch< - SetStateAction> - >; - setAtakMessages: Dispatch< - SetStateAction> - >; - setMapReports: Dispatch< - SetStateAction> - >; - setPrivateMessages: Dispatch< - SetStateAction> - >; -} - -export function attachMeshtasticLegacyWireSubscriptions( - device: MeshDevice, - type: ConnectionType, - opts: { driverIdentityId?: string } | undefined, - deps: MeshtasticLegacyWireSubscriptionDeps, -): void { - const { - channelConfigsRef, - configureTargetNodeNumRef, - configureTargetPersistRestoredRef, - configureTimeoutRef, - connectionParamsRef, - deviceConfiguredRef, - deviceGpsModeRef, - deviceRef, - handleConnectionLostRef, - schedulePostCommitRebootRecoveryRef, - clearPostCommitRebootRecoveryRef, - isConfiguringRef, - lastDataReceivedRef, - lastNodeInfoRequestAtRef, - lastRfDisconnectAtRef, - lastRfSelfNodeIdRef, - lastSfHeartbeatChannelRef, - lastSfHeartbeatPeriodRef, - lastSfHeartbeatServerRef, - localLoraConfigTimerRef, - meshtasticIdentityIdRef, - meshtasticIngestSessionRef, - meshtasticIngressDetachRef, - messagesRef, - mqttClientProxyBridgeRef, - mqttStatusRef, - myNodeNumRef, - nodesRef, - pendingTempIdRef, - ackMeshPacketIdByTempIdRef, - pendingTracePacketIdToTargetRef, - pendingTraceRequestsRef, - refreshOurPositionRef, - remoteAdminClientRef, - remoteAdminStatusRef, - requestStoreForwardHistoryRef, - rfHeardNodeIds, - sfHistoryRequestedServersRef, - skipLocalLoraConfigRef, - loraConfigRef, - unsubscribesRef, - virtualNodeIdRef, - touchLastData, - applyOwnNodeBatteryFromDeviceMetrics, - getNodeName, - updateNodes, - startWatchdog, - stopWatchdog, - cleanupSubscriptions, - startGpsInterval, - stopGpsInterval, - isDuplicate, - ensureNodeExists, - clearConfigureTimeout, - applyMeshtasticForeignLoraFromLog, - emptyNode, - setMeshtasticIdentityId, - setState, - setQueueStatus, - setDeviceLogs, - setTraceRouteResults, - setNeighborInfo, - setWaypoints, - setModuleConfigs, - setSecurityConfig, - setLoraConfig, - setConfigureTargetNodeNumState, - setRemoteConfigSnapshot, - setRemoteAdminStatus, - setRemoteAdminError, - setMessages, - setTelemetry, - setSignalTelemetry, - setEnvironmentTelemetry, - setDeviceOwner, - setChannels, - setChannelConfigs, - setDeviceGpsMode, - setDeviceFixedPosition, - setTelemetryDeviceUpdateInterval, - setRawPackets, - setRemoteHardwareMessages, - setAudioMessages, - setDetectionSensorEvents, - setPingResponses, - setIpTunnelMessages, - setPaxCounterData, - setSerialMessages, - setStoreForwardMessages, - setRangeTestPackets, - setZpsMessages, - setSimulatorPackets, - setAtakMessages, - setMapReports, - setPrivateMessages, - } = deps; - - // Protocol ingress → identity-scoped stores (before legacy handlers so both - // receive SDK events when the transport supports multiple subscribers). - // Legacy handlers below are still required for MQTT, watchdog, etc. - // Legacy side effects until all Meshtastic listeners are protocol events (#375 / #377). - if (meshtasticIngressDetachRef.current) { - meshtasticIngressDetachRef.current(); - } - const cp = connectionParamsRef.current; - let identityId = opts?.driverIdentityId ?? null; - if (identityId) { - meshtasticIngressDetachRef.current = null; - meshtasticIdentityIdRef.current = identityId; - setMeshtasticIdentityId(identityId); - } else { - const ingress = bindMeshtasticIngress(device, type, { - peripheralId: cp?.blePeripheralId, - host: cp?.httpAddress, - }); - meshtasticIngressDetachRef.current = ingress.detach; - identityId = ingress.identityId; - meshtasticIdentityIdRef.current = identityId; - setMeshtasticIdentityId(identityId); - } - if (meshtasticIngestSessionRef.current) { - meshtasticIngestSessionRef.current.detach(); - } - if (identityId) { - meshtasticIngestSessionRef.current = attachMeshtasticIngest(identityId, { - getIsConfiguring: () => isConfiguringRef.current, - getMyNodeNum: () => myNodeNumRef.current, - }); - } - - // ─── Device status ───────────────────────────────────────── - const unsub1 = device.events.onDeviceStatus.subscribe((status) => { - if (status !== DeviceStatusEnum.DeviceRestarting) { - touchLastData(); - } - const statusMap: Record = { - [DeviceStatusEnum.DeviceRestarting]: 'connecting', - [DeviceStatusEnum.DeviceDisconnected]: 'disconnected', - [DeviceStatusEnum.DeviceConnecting]: 'connecting', - [DeviceStatusEnum.DeviceReconnecting]: 'connecting', - [DeviceStatusEnum.DeviceConnected]: 'connected', - [DeviceStatusEnum.DeviceConfiguring]: 'connecting', - [DeviceStatusEnum.DeviceConfigured]: 'configured', - }; - const mapped = statusMap[status] ?? 'connected'; - setState((s) => ({ - ...s, - status: mapped, - ...(mapped === 'configured' || mapped === 'connected' ? { connectionLoss: false } : {}), - })); - - if (status === DeviceStatusEnum.DeviceRestarting) { - deviceConfiguredRef.current = false; - isConfiguringRef.current = true; - meshtasticIngestSessionRef.current?.setConfiguring(true); - schedulePostCommitRebootRecoveryRef.current('DeviceRestarting'); - } - - // Track configuring phase so packet replays are marked as historical - if ( - status === DeviceStatusEnum.DeviceConnecting || - status === DeviceStatusEnum.DeviceConnected || - status === DeviceStatusEnum.DeviceConfiguring - ) { - isConfiguringRef.current = true; - meshtasticIngestSessionRef.current?.setConfiguring(true); - if ( - status === DeviceStatusEnum.DeviceConfiguring && - type === 'ble' && - !configureTimeoutRef.current - ) { - configureTimeoutRef.current = setTimeout(() => { - console.warn('[useMeshtasticRuntime] configure timeout (BLE 30s) — forcing disconnect'); - const activeDevice = deviceRef.current; - deviceRef.current = null; - if (activeDevice) { - void safeDisconnect(activeDevice).catch((e: unknown) => { - console.debug( - '[useMeshtasticRuntime] configure timeout safeDisconnect ' + errLikeToLogString(e), - ); - }); - } - cleanupSubscriptions(); - stopWatchdog(); - stopGpsInterval(); - setState({ - status: 'disconnected', - myNodeNum: 0, - connectionType: null, - batteryPercent: undefined, - batteryCharging: undefined, - }); - clearConfigureTimeout(); - }, 30000); - } - } - - // Start watchdog when configured - if (status === DeviceStatusEnum.DeviceConfigured) { - clearPostCommitRebootRecoveryRef.current(); - clearConfigureTimeout(); - isConfiguringRef.current = false; - meshtasticIngestSessionRef.current?.setConfiguring(false); - lastDataReceivedRef.current = Date.now(); - startWatchdog(); - void refreshOurPositionRef.current(); - startGpsInterval(); - setQueueStatus({ free: 16, maxlen: 16, res: 0 }); - deviceConfiguredRef.current = true; - mqttClientProxyBridgeRef.current?.flushPendingToDevice(); - const myNode = myNodeNumRef.current; - if (myNode > 0) { - const requestMetadataAfterConfigure = (attempt: 1 | 2): void => { - void device.getMetadata(myNode).catch((e: unknown) => { - console.debug( - '[useMeshtasticRuntime] getMetadata after configure failed ' + - errLikeToLogString(e) + - (attempt === 2 ? ' (retry)' : ''), - ); - if (attempt === 1) { - setTimeout(() => { - requestMetadataAfterConfigure(2); - }, MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS); - } - }); - }; - requestMetadataAfterConfigure(1); - } - if (localLoraConfigTimerRef.current != null) { - clearTimeout(localLoraConfigTimerRef.current); - } - localLoraConfigTimerRef.current = setTimeout(() => { - localLoraConfigTimerRef.current = undefined; - if ( - !shouldFetchLocalLoraConfigAfterConfigure({ - skipLocalLoraConfig: skipLocalLoraConfigRef.current, - configureTargetNodeNum: configureTargetNodeNumRef.current, - remoteAdminStatus: remoteAdminStatusRef.current, - loraConfig: loraConfigRef.current, - }) - ) { - return; - } - void deviceRef.current - ?.getConfig(Admin.AdminMessage_ConfigType.LORA_CONFIG) - .catch((e: unknown) => { - console.debug( - '[useMeshtasticRuntime] LoRa config request failed ' + errLikeToLogString(e), - ); - }); - }, MESHTASTIC_LOCAL_LORA_CONFIG_DELAY_MS); - } - - // Always clean up on disconnect, even if we never reached configured - if (status === DeviceStatusEnum.DeviceDisconnected) { - if (localLoraConfigTimerRef.current != null) { - clearTimeout(localLoraConfigTimerRef.current); - localLoraConfigTimerRef.current = undefined; - } - skipLocalLoraConfigRef.current = false; - lastRfDisconnectAtRef.current = Date.now(); - rfHeardNodeIds.current.clear(); - lastNodeInfoRequestAtRef.current.clear(); - clearConfigureTimeout(); - isConfiguringRef.current = false; - stopWatchdog(); - stopGpsInterval(); - cleanupSubscriptions(); - setTraceRouteResults(new Map()); - setQueueStatus(null); - setDeviceLogs([]); - usePositionHistoryStore.getState().clearHistory(); - setNeighborInfo(new Map()); - setWaypoints(new Map()); - setModuleConfigs({}); - setSecurityConfig(null); - setLoraConfig(null); - setConfigureTargetNodeNumState(null); - configureTargetNodeNumRef.current = null; - configureTargetPersistRestoredRef.current = false; - setRemoteConfigSnapshot(null); - setRemoteAdminStatus('idle'); - setRemoteAdminError(undefined); - remoteAdminClientRef.current?.resetEditState(); - remoteAdminClientRef.current?.sessionStore.clear(); - deviceRef.current = null; - deviceConfiguredRef.current = false; - sfHistoryRequestedServersRef.current = new Set(); - setState((s) => ({ - ...s, - status: 'disconnected', - connectionType: null, - firmwareVersion: undefined, - batteryPercent: undefined, - batteryCharging: undefined, - })); - } - }); - unsubscribesRef.current.push(unsub1); - - // ─── My node info ────────────────────────────────────────── - const unsub2 = device.events.onMyNodeInfo.subscribe((info) => { - console.debug(`[useMeshtasticRuntime] onMyNodeInfo: myNodeNum=${info.myNodeNum}`); - touchLastData(); - const virtualNodeId = virtualNodeIdRef.current; - if (virtualNodeId !== info.myNodeNum) { - window.electronAPI.db.deleteNode(virtualNodeId).catch((e: unknown) => { - console.debug('[useMeshtasticRuntime] deleteNode virtual ' + errLikeToLogString(e)); - }); - } - myNodeNumRef.current = info.myNodeNum; - const identityId = meshtasticIdentityIdRef.current; - if (identityId) { - const cp = connectionParamsRef.current; - if (cp) { - const transportParams = meshtasticTransportParams(cp.type, { - peripheralId: cp.blePeripheralId, - host: cp.httpAddress, - }); - connectionDriver.remapMeshtasticNodeSignature(identityId, transportParams, info.myNodeNum); - } else { - updateIdentity(identityId, { - selfNodeNum: info.myNodeNum, - signature: `meshtastic:node:${info.myNodeNum}`, - }); - } - setConnection(identityId, { myNodeNum: info.myNodeNum, status: 'configured' }); - } - setMeshtasticConnectedMyNodeNum(info.myNodeNum); - lastRfSelfNodeIdRef.current = info.myNodeNum; - persistLastRfSelfNodeId(info.myNodeNum); - if (getStoredMeshProtocol() === 'meshtastic') { - useDiagnosticsStore.getState().migrateForeignLoraFromZero(info.myNodeNum); - } - setState((s) => ({ - ...s, - myNodeNum: info.myNodeNum, - batteryPercent: undefined, - batteryCharging: undefined, - })); - updateNodes((prev) => { - const updated = new Map(prev); - if (virtualNodeId !== info.myNodeNum) updated.delete(virtualNodeId); - const existing = updated.get(info.myNodeNum); - if (!existing) { - const selfNode: MeshNode = { - ...emptyNode(info.myNodeNum), - hops_away: 0, - last_heard: Date.now(), - source: 'rf', - heard_via_mqtt_only: false, - }; - updated.set(info.myNodeNum, selfNode); - } else { - const selfNode: MeshNode = { - ...existing, - hops_away: 0, - source: 'rf', - heard_via_mqtt_only: false, - }; - updated.set(info.myNodeNum, selfNode); - void window.electronAPI.db.saveNode(selfNode); - } - return updated; - }); - }); - unsubscribesRef.current.push(unsub2); - - // ─── Device metadata (firmware version) ──────────────────── - const unsub_meta = device.events.onDeviceMetadataPacket.subscribe((packet) => { - const data = packet.data as { - firmwareVersion?: string; - hasWifi?: boolean; - hasEthernet?: boolean; - }; - if (data.firmwareVersion) { - setState((s) => ({ ...s, firmwareVersion: data.firmwareVersion })); - } - const identityId = meshtasticIdentityIdRef.current; - if (identityId && (data.hasWifi != null || data.hasEthernet != null)) { - setConnection(identityId, { - ...(data.hasWifi != null ? { deviceHasWifi: data.hasWifi } : {}), - ...(data.hasEthernet != null ? { deviceHasEthernet: data.hasEthernet } : {}), - }); - } - }); - unsubscribesRef.current.push(unsub_meta); - - const maybeRequestNodeInfoForNode = (from: number): void => { - if (from === 0 || from === myNodeNumRef.current) return; - if (isConfiguringRef.current) return; - const existing = nodesRef.current.get(from); - if (existing && !meshtasticNodeLacksDisplayIdentity(existing, from)) return; - const now = Date.now(); - const last = lastNodeInfoRequestAtRef.current.get(from) ?? 0; - if (now - last < REQUEST_NODEINFO_MIN_INTERVAL_MS) return; - lastNodeInfoRequestAtRef.current.set(from, now); - void (async () => { - try { - await device.sendPacket(new Uint8Array(), Portnums.PortNum.NODEINFO_APP, from); - console.debug(`[useMeshtasticRuntime] NODEINFO request sent for 0x${from.toString(16)}`); - } catch (e: unknown) { - console.debug( - '[useMeshtasticRuntime] NODEINFO request failed', - e instanceof Error ? e.message : e, - ); - } - })(); - }; - - // ─── Text messages ───────────────────────────────────────── - const unsub3 = device.events.onMeshPacket.subscribe((meshPacket) => { - if (meshPacket.payloadVariant.case !== 'decoded') { - return; - } - const dataPacket = meshPacket.payloadVariant.value; - if (dataPacket.portnum !== PortNum.TEXT_MESSAGE_APP) return; - - ensureNodeExists(meshPacket.from, 'rf'); - maybeRequestNodeInfoForNode(meshPacket.from); - - // Bump last_heard for the sender on live (non-replay) packets. - if (!isConfiguringRef.current && meshPacket.from) { - updateNodes((prev) => { - const existing = prev.get(meshPacket.from); - if (!existing) return prev; - const now = mergeMeshtasticLivePacketLastHeard( - existing.last_heard || 0, - meshtasticPacketRxTimeMs(meshPacket.rxTime), - false, - ); - if (now <= (existing.last_heard || 0)) return prev; - const next = new Map(prev); - const updated: MeshNode = { - ...existing, - last_heard: now, - source: 'rf', - heard_via_mqtt_only: false, - via_mqtt: meshPacket.viaMqtt ?? false, - }; - next.set(meshPacket.from, updated); - void window.electronAPI.db.saveNode(updated); - return next; - }); - } - - touchLastData(); - const isEcho = meshPacket.from === myNodeNumRef.current; - const payloadBytes = dataPacket.payload ?? new Uint8Array(); - const resolvedText = resolveMeshtasticTextMessagePayload(payloadBytes); - if (!resolvedText) { - console.debug( - `[useMeshtasticRuntime] Dropped non-readable TEXT_MESSAGE from 0x${meshPacket.from.toString(16)} len=${payloadBytes.length}`, - ); - return; - } - let payloadText = resolvedText.text; - const data = dataPacket as { replyId?: number; reply_id?: number; emoji?: number }; - - const replyId = meshtasticWireUint32NonZero(data.replyId ?? data.reply_id); - const wireEmoji = meshtasticWireUint32NonZero(data.emoji); - - if (payloadText.trim() === '0' && !replyId && !wireEmoji) { - // Strip leading "0" payloads that are just a placeholder from buggy senders - payloadText = ''; - } - - const emoji = replyId ? normalizeReactionEmoji(wireEmoji, payloadText) : undefined; - - const packetIdCoerced = meshtasticWireUint32AllowZero(meshPacket.id); - - const incomingRxHops = !isEcho ? meshtasticComputedRfHopsAway(meshPacket) : undefined; - - const msgBase: ChatMessage = { - sender_id: meshPacket.from, - sender_name: getNodeName(meshPacket.from), - payload: payloadText, - channel: meshPacket.channel ?? 0, - timestamp: meshPacket.rxTime ? meshPacket.rxTime * 1000 : Date.now(), - packetId: packetIdCoerced, - status: isEcho ? 'sending' : undefined, - emoji, - replyId, - to: meshPacket.to && meshPacket.to !== BROADCAST_ADDR ? meshPacket.to : undefined, - ...(incomingRxHops !== undefined ? { rxHops: incomingRxHops } : {}), - ...(resolvedText.viaStoreForward ? { viaStoreForward: true } : {}), - }; - const msg = enrichMeshtasticReplyPreviews(msgBase, messagesRef.current, getNodeName); - const chatInStore = Boolean(meshtasticIdentityIdRef.current); - - // Packet ID dedup: skip if already seen (e.g. via MQTT) so same message is not shown twice - if (!isEcho && !msg.emoji && msg.packetId && isDuplicate(meshPacket.from, msg.packetId)) { - if (!chatInStore) { - const rfDedupPacketId = msg.packetId; - const rfDedupHops = meshtasticComputedRfHopsAway(meshPacket); - setMessages((prev) => - prev.map((m) => - meshtasticPacketIdsEqual(m.packetId, rfDedupPacketId) && m.receivedVia === 'mqtt' - ? { - ...m, - receivedVia: 'both' as const, - rxHops: m.rxHops ?? rfDedupHops, - packetId: rfDedupPacketId, - } - : m, - ), - ); - } - return; - } - - // If we have an optimistic message in state for this send, skip the echo to avoid a duplicate - if (isEcho && pendingTempIdRef.current !== undefined) { - pendingTempIdRef.current = undefined; - return; - } - - const rfMsg: ChatMessage = isEcho - ? msg - : { - ...msg, - receivedVia: 'rf' as const, - isHistory: isConfiguringRef.current || undefined, - }; - - if (!chatInStore && !isEcho && !rfMsg.emoji) { - const storeId = meshtasticIdentityIdRef.current; - const storeMsgs = storeId - ? messageRecordsToChatMessages( - Object.values(useMessageStore.getState().messages[storeId] ?? {}), - ) - : []; - const dedupSource = storeMsgs.length > 0 ? storeMsgs : messagesRef.current; - const crossDup = findMeshtasticCrossTransportDuplicate(dedupSource, rfMsg); - if (crossDup) { - const rfDedupHops = meshtasticComputedRfHopsAway(meshPacket); - setMessages((prev) => { - const { messages: next, matched } = mapMeshtasticCrossTransportUpgrade(prev, { - ...rfMsg, - rxHops: rfMsg.rxHops ?? rfDedupHops, - }); - if (!matched) return prev; - return next; - }); - const pid = - rfMsg.packetId !== undefined && rfMsg.packetId !== 0 - ? rfMsg.packetId - : normalizeMeshtasticPacketId(crossDup.packetId); - if (pid !== undefined && pid !== 0) { - isDuplicate(rfMsg.sender_id, pid); // registers as seen to suppress future duplicates - } - return; - } - } - - if (!chatInStore) { - setMessages((prev) => { - // Dedup reaction retransmissions before the DB write completes - if (rfMsg.emoji && rfMsg.replyId) { - const isDup = prev.some( - (m) => - m.emoji === rfMsg.emoji && - m.replyId === rfMsg.replyId && - m.sender_id === rfMsg.sender_id, - ); - if (isDup) return prev; - } - // Dedup regular messages by packetId (e.g. device config-sync replay) - if (!rfMsg.emoji && rfMsg.packetId && prev.some((m) => m.packetId === rfMsg.packetId)) { - return prev; - } - return trimChatMessagesToMax([...prev, rfMsg], MAX_IN_MEMORY_CHAT_MESSAGES); - }); - } - - // Gateway uplink: forward RF messages to MQTT if uplinkEnabled for this channel - // Skip our own echoes, reactions, and DMs (privacy) - if (!isEcho && !emoji && !msg.to && mqttStatusRef.current === 'connected') { - const chCfg = channelConfigsRef.current.find((c) => c.index === msg.channel); - if (chCfg?.uplinkEnabled) { - const uplinkMqtt = resolveMeshtasticMqttPublishFieldsForChannel( - msg.channel, - channelConfigsRef.current, - loadMeshtasticMqttManualChannelPsks(), - meshtasticMqttPublishOpts(!deviceRef.current), - ); - if (uplinkMqtt.channelName) { - window.electronAPI.mqtt - .publish({ - text: msg.payload, - from: msg.sender_id, - channel: msg.channel, - destination: BROADCAST_ADDR, - channelName: uplinkMqtt.channelName, - pskBase64: uplinkMqtt.pskBase64, - publishJsonMirror: uplinkMqtt.publishJsonMirror, - }) - .then((packetId) => { - isDuplicate(msg.sender_id, packetId); - }) - .catch((e: unknown) => { - console.debug( - '[useMeshtasticRuntime] MQTT publish echo register non-fatal ' + - errLikeToLogString(e), - ); - }); - } - } - } - - // OS toast when hidden: silent so App.tsx owns typed Web Audio (chatNotifications.ts). - // Sound plays via resolveInactiveChatNotificationType + playMessageNotification on message store growth. - if (!isEcho && !emoji && document.hidden) { - try { - const safeSender = stripControlCharacters(msg.sender_name).slice(0, 120); - const title = msg.to ? `DM from ${safeSender}` : `Message from ${safeSender}`; - const body = stripControlCharacters(msg.payload).slice(0, 100); - new Notification(title, { - body, - silent: true, - }); - } catch (e) { - console.debug('[useMeshtasticRuntime] Notification not available ' + errLikeToLogString(e)); - } - } - }); - unsubscribesRef.current.push(unsub3); - - // ─── User info (node identity) ───────────────────────────── - const unsub4 = device.events.onUserPacket.subscribe((packet) => { - touchLastData(); - rfHeardNodeIds.current.add(packet.from); - const user = packet.data as { - id?: string; - longName?: string; - shortName?: string; - hwModel?: number; - role?: number; - publicKey?: Uint8Array; - }; - const packetRxMs = meshtasticPacketRxTimeMs(packet.rxTime); - updateNodes((prev) => { - const updated = new Map(prev); - const existing = updated.get(packet.from) ?? emptyNode(packet.from); - const long_name = preferNonEmptyTrimmedString(user.longName, existing.long_name, { - nodeId: packet.from, - }); - const short_name = meshtasticShortNameAfterClearingDefault( - long_name, - preferNonEmptyTrimmedString(user.shortName, existing.short_name), - packet.from, - ); - const last_heard = mergeMeshtasticUserPacketLastHeard( - existing.last_heard || 0, - packetRxMs, - isConfiguringRef.current, - ); - const public_key_hex = meshtasticPublicKeyHex(user.publicKey) ?? existing.public_key_hex; - const node: MeshNode = { - ...existing, - node_id: packet.from, - long_name, - short_name, - hw_model: user.hwModel != null ? meshtasticHwModelName(user.hwModel) : existing.hw_model, - role: user.role ?? existing.role, - public_key_hex, - // During configure, skip rxTime bumps (NodeDB replay). After configure, use mesh rxTime. - last_heard, - heard_via_mqtt_only: false, - via_mqtt: false, - source: 'rf', - }; - updated.set(packet.from, node); - void window.electronAPI.db.saveNode(node); - return updated; - }); - if (packet.from === myNodeNumRef.current) { - setDeviceOwner({ - longName: preferNonEmptyTrimmedString(user.longName, ''), - shortName: preferNonEmptyTrimmedString(user.shortName, ''), - isLicensed: (user as { isLicensed?: boolean }).isLicensed ?? false, - }); - } - }); - unsubscribesRef.current.push(unsub4); - - // ─── Node info packets ───────────────────────────────────── - const unsub5 = device.events.onNodeInfoPacket.subscribe((packet) => { - touchLastData(); - const rfPayload = packet as { num?: number; from?: number }; - const rfNodeId = rfPayload.num ?? rfPayload.from; - if (rfNodeId != null) rfHeardNodeIds.current.add(rfNodeId); - const info = packet as { - num?: number; - user?: { - longName?: string; - shortName?: string; - hwModel?: number; - role?: number; - }; - snr?: number; - position?: { latitudeI?: number; longitudeI?: number; altitude?: number }; - deviceMetrics?: { - batteryLevel?: number; - voltage?: number; - channelUtilization?: number; - airUtilTx?: number; - }; - lastHeard?: number; - hopsAway?: number; - viaMqtt?: boolean; - }; - if (!info.num) return; - const nodeNum = info.num; - const prevOwnRole = - nodeNum === myNodeNumRef.current ? nodesRef.current.get(nodeNum)?.role : undefined; - - updateNodes((prev) => { - const updated = new Map(prev); - const existing = updated.get(nodeNum) ?? emptyNode(nodeNum); - - let newLat = existing.latitude; - let newLon = existing.longitude; - let newAlt = info.position?.altitude ?? existing.altitude; - let posWarn: string | undefined = existing.lastPositionWarning; - - if ( - info.position?.latitudeI != null && - info.position?.longitudeI != null && - !shouldPreserveStaticGpsForSelfNode(nodeNum, myNodeNumRef.current) - ) { - const lat = info.position.latitudeI / 1e7; - const lon = info.position.longitudeI / 1e7; - const r = validateCoords(lat, lon); - if (r.valid) { - newLat = lat; - newLon = lon; - newAlt = info.position?.altitude ?? existing.altitude; - posWarn = undefined; - } else if ( - nodeNum !== myNodeNumRef.current || - (existing.latitude === 0 && existing.longitude === 0) - ) { - posWarn = r.warning; - } - } - - const lastHeardMs = computeNodeInfoLastHeardMs( - info.lastHeard, - existing.last_heard, - nodeNum === myNodeNumRef.current, - ); - const lastHeardStale = - lastHeardMs > 0 && Date.now() - lastHeardMs > MESHTASTIC_CAPABILITIES.nodeStaleThresholdMs; - - const long_name = preferNonEmptyTrimmedString(info.user?.longName, existing.long_name, { - nodeId: nodeNum, - }); - const short_name = meshtasticShortNameAfterClearingDefault( - long_name, - preferNonEmptyTrimmedString(info.user?.shortName, existing.short_name), - nodeNum, - ); - const node: MeshNode = { - ...existing, - node_id: nodeNum, - long_name, - short_name, - hw_model: - info.user?.hwModel != null ? meshtasticHwModelName(info.user.hwModel) : existing.hw_model, - snr: info.snr ?? existing.snr, - battery: info.deviceMetrics?.batteryLevel ?? existing.battery, - last_heard: lastHeardMs, - latitude: newLat, - longitude: newLon, - role: info.user?.role ?? existing.role, - // Stale NodeInfo still carries cached hops; don't show hop count for ghosts. - hops_away: - nodeNum === myNodeNumRef.current - ? (info.hopsAway ?? 0) - : lastHeardStale - ? undefined - : (info.hopsAway ?? existing.hops_away), - via_mqtt: info.viaMqtt ?? false, - voltage: info.deviceMetrics?.voltage ?? existing.voltage, - channel_utilization: info.deviceMetrics?.channelUtilization ?? existing.channel_utilization, - air_util_tx: info.deviceMetrics?.airUtilTx ?? existing.air_util_tx, - altitude: newAlt, - heard_via_mqtt_only: false, - source: 'rf', - lastPositionWarning: posWarn, - }; - updated.set(nodeNum, node); - void window.electronAPI.db.saveNode(node); - return updated; - }); - if (nodeNum === myNodeNumRef.current && info.deviceMetrics?.batteryLevel !== undefined) { - applyOwnNodeBatteryFromDeviceMetrics(info.deviceMetrics.batteryLevel); - } - if ( - nodeNum === myNodeNumRef.current && - nodesRef.current.get(nodeNum)?.role === ROLE_CLIENT_MUTE && - prevOwnRole !== ROLE_CLIENT_MUTE - ) { - console.info( - '[useMeshtasticRuntime] Device role is Client Mute — position reports to device suppressed', - ); - } - const updatedRfNode = nodesRef.current.get(nodeNum); - if (updatedRfNode && getStoredMeshProtocol() === 'meshtastic') { - useDiagnosticsStore - .getState() - .processNodeUpdate( - updatedRfNode, - nodesRef.current.get(myNodeNumRef.current) ?? null, - myNodeNumRef.current, - MESHTASTIC_CAPABILITIES, - ); - } - if (info.position?.latitudeI != null && info.position?.longitudeI != null) { - const lat = info.position.latitudeI / 1e7; - const lon = info.position.longitudeI / 1e7; - if (validateCoords(lat, lon).valid) { - usePositionHistoryStore.getState().recordPosition(nodeNum, lat, lon); - } - } - if (type === 'ble' && nodeNum === myNodeNumRef.current) { - const btDevice = (device.transport as { __bluetoothDevice?: { id?: string } }) - ?.__bluetoothDevice; - const shortName = preferNonEmptyTrimmedString(info.user?.shortName, '') || null; - if (btDevice?.id && shortName) { - try { - const key = 'mesh-client:bleDeviceNames'; - const cache = - parseStoredJson>( - localStorage.getItem(key), - 'useMeshtasticRuntime bleDeviceNames cache', - ) ?? {}; - cache[btDevice.id] = shortName; - localStorage.setItem(key, JSON.stringify(cache)); - } catch { - // catch-no-log-ok localStorage write for BLE device name cache — non-critical - } - } - } - if (type === 'serial' && nodeNum === myNodeNumRef.current) { - const portId = localStorage.getItem(LAST_SERIAL_PORT_KEY); - const shortName = - preferNonEmptyTrimmedString( - info.user?.shortName, - preferNonEmptyTrimmedString(info.user?.longName, ''), - ) || null; - if (portId && shortName) { - try { - const key = 'mesh-client:serialPortNodeNames'; - const cache = - parseStoredJson>( - localStorage.getItem(key), - 'useMeshtasticRuntime serialPortNodeNames cache', - ) ?? {}; - cache[portId] = shortName; - localStorage.setItem(key, JSON.stringify(cache)); - } catch { - // catch-no-log-ok localStorage write for serial port node name cache — non-critical - } - } - } - }); - unsubscribesRef.current.push(unsub5); - - // ─── Position packets ────────────────────────────────────── - const unsub6 = device.events.onPositionPacket.subscribe((packet) => { - touchLastData(); - if (packet.from !== 0) { - rfHeardNodeIds.current.add(packet.from); - } - const pos = packet.data as { - latitudeI?: number; - longitudeI?: number; - altitude?: number; - }; - - const lat = (pos.latitudeI ?? 0) / 1e7; - const lon = (pos.longitudeI ?? 0) / 1e7; - const r = validateCoords(lat, lon); - - if (!r.valid) { - updateNodes((prev) => { - const updated = new Map(prev); - const existing = updated.get(packet.from) ?? emptyNode(packet.from); - // Don't flag our own node if we have valid fallback coords - if ( - packet.from === myNodeNumRef.current && - (existing.latitude != null || existing.longitude != null) - ) { - return prev; // no change - } - updated.set(packet.from, { - ...existing, - lastPositionWarning: r.warning, - last_heard: mergeMeshtasticLivePacketLastHeard( - existing.last_heard || 0, - Date.now(), - isConfiguringRef.current, - ), - }); - return updated; - }); - return; - } - - if (shouldPreserveStaticGpsForSelfNode(packet.from, myNodeNumRef.current)) { - return; - } - - const homeNode = nodesRef.current.get(myNodeNumRef.current) ?? null; - const existing = nodesRef.current.get(packet.from) ?? emptyNode(packet.from); - const node: MeshNode = { - ...existing, - latitude: lat, - longitude: lon, - altitude: pos.altitude ?? existing.altitude, - // Position replays at connect must not bump last_heard (configure guard). - last_heard: mergeMeshtasticLivePacketLastHeard( - existing.last_heard || 0, - meshtasticPacketRxTimeMs(packet.rxTime), - isConfiguringRef.current, - ), - lastPositionWarning: undefined, - source: 'rf', - heard_via_mqtt_only: false, - via_mqtt: false, - }; - updateNodes((prev) => { - const updated = new Map(prev); - updated.set(packet.from, node); - void window.electronAPI.db.saveNode(node); - return updated; - }); - if (getStoredMeshProtocol() === 'meshtastic') { - useDiagnosticsStore - .getState() - .processNodeUpdate(node, homeNode, myNodeNumRef.current, MESHTASTIC_CAPABILITIES); - } - usePositionHistoryStore.getState().recordPosition(packet.from, lat, lon); - maybeRequestNodeInfoForNode(packet.from); - }); - unsubscribesRef.current.push(unsub6); - - // ─── Telemetry ───────────────────────────────────────────── - const unsub7 = device.events.onTelemetryPacket.subscribe((packet) => { - touchLastData(); - const tel = packet.data as { - deviceMetrics?: { batteryLevel?: number; voltage?: number }; - variant?: { - case?: string; - value?: { - batteryLevel?: number; - voltage?: number; - channelUtilization?: number; - airUtilTx?: number; - numPacketsRxBad?: number; - numRxDupe?: number; - numPacketsRx?: number; - numPacketsTx?: number; - // environmentMetrics fields - temperature?: number; - relativeHumidity?: number; - barometricPressure?: number; - gasResistance?: number; - iaq?: number; - lux?: number; - windSpeed?: number; - windDirection?: number; - windGust?: number; - windLull?: number; - weight?: number; - rainfall1h?: number; - rainfall24h?: number; - }; - }; - }; - - // Handle environmentMetrics variant - if (tel.variant?.case === 'environmentMetrics' && tel.variant.value) { - const env = tel.variant.value; - const point: EnvironmentTelemetryPoint = { - timestamp: Date.now(), - nodeNum: packet.from, - temperature: env.temperature, - relativeHumidity: env.relativeHumidity, - barometricPressure: env.barometricPressure, - gasResistance: env.gasResistance, - iaq: env.iaq, - lux: env.lux, - windSpeed: env.windSpeed, - windDirection: env.windDirection, - windGust: env.windGust, - windLull: env.windLull, - weight: env.weight, - rainfall1h: env.rainfall1h, - rainfall24h: env.rainfall24h, - }; - setEnvironmentTelemetry((prev) => [...prev, point].slice(-MAX_TELEMETRY_POINTS)); - updateNodes((prev) => { - const updated = new Map(prev); - const existing = updated.get(packet.from); - if (existing) { - updated.set(packet.from, { - ...existing, - env_temperature: env.temperature ?? existing.env_temperature, - env_humidity: env.relativeHumidity ?? existing.env_humidity, - env_pressure: env.barometricPressure ?? existing.env_pressure, - env_iaq: env.iaq ?? existing.env_iaq, - env_lux: env.lux ?? existing.env_lux, - env_wind_speed: env.windSpeed ?? existing.env_wind_speed, - env_wind_direction: env.windDirection ?? existing.env_wind_direction, - last_heard: mergeMeshtasticLivePacketLastHeard( - existing.last_heard || 0, - meshtasticPacketRxTimeMs(packet.rxTime), - isConfiguringRef.current, - ), - source: 'rf', - heard_via_mqtt_only: false, - via_mqtt: false, - }); - } - return updated; - }); - return; - } - - // Handle localStats variant (connected node's radio statistics) - if ( - tel.variant?.case === 'localStats' && - tel.variant.value && - packet.from === myNodeNumRef.current - ) { - const ls = tel.variant.value; - const existing = nodesRef.current.get(myNodeNumRef.current); - if (existing) { - const node: MeshNode = { - ...existing, - channel_utilization: ls.channelUtilization ?? existing.channel_utilization, - air_util_tx: ls.airUtilTx ?? existing.air_util_tx, - num_packets_rx_bad: ls.numPacketsRxBad ?? existing.num_packets_rx_bad, - num_rx_dupe: ls.numRxDupe ?? existing.num_rx_dupe, - num_packets_rx: ls.numPacketsRx ?? existing.num_packets_rx, - num_packets_tx: ls.numPacketsTx ?? existing.num_packets_tx, - source: 'rf', - heard_via_mqtt_only: false, - via_mqtt: false, - }; - updateNodes((prev) => { - const updated = new Map(prev); - updated.set(myNodeNumRef.current, node); - void window.electronAPI.db.saveNode(node); - return updated; - }); - if (getStoredMeshProtocol() === 'meshtastic') { - useDiagnosticsStore - .getState() - .processNodeUpdate(node, node, myNodeNumRef.current, MESHTASTIC_CAPABILITIES); - } - } - return; - } - - const metrics = tel.deviceMetrics ?? tel.variant?.value; - if (!metrics) return; - - const point: TelemetryPoint = { - timestamp: Date.now(), - batteryLevel: metrics.batteryLevel, - voltage: metrics.voltage, - }; - setTelemetry((prev) => [...prev, point].slice(-MAX_TELEMETRY_POINTS)); - - // Update node battery if from a known node - if (metrics.batteryLevel != null && packet.from) { - ensureNodeExists(packet.from, 'rf'); - const existing = nodesRef.current.get(packet.from); - if (existing) { - const node: MeshNode = { - ...existing, - battery: metrics.batteryLevel, - last_heard: mergeMeshtasticLivePacketLastHeard( - existing.last_heard || 0, - meshtasticPacketRxTimeMs(packet.rxTime), - isConfiguringRef.current, - ), - source: 'rf', - heard_via_mqtt_only: false, - via_mqtt: false, - }; - updateNodes((prev) => { - const updated = new Map(prev); - updated.set(packet.from, node); - return updated; - }); - if (getStoredMeshProtocol() === 'meshtastic') { - useDiagnosticsStore - .getState() - .processNodeUpdate( - node, - nodesRef.current.get(myNodeNumRef.current) ?? null, - myNodeNumRef.current, - MESHTASTIC_CAPABILITIES, - ); - } - } - maybeRequestNodeInfoForNode(packet.from); - if (packet.from === myNodeNumRef.current) { - applyOwnNodeBatteryFromDeviceMetrics(metrics.batteryLevel); - } - } - }); - unsubscribesRef.current.push(unsub7); - - // ─── Channel discovery ───────────────────────────────────── - const unsub8 = device.events.onChannelPacket.subscribe((channel) => { - touchLastData(); - const ch = channel as { - index?: number; - settings?: { - name?: string; - psk?: Uint8Array; - uplinkEnabled?: boolean; - downlinkEnabled?: boolean; - moduleSettings?: { positionPrecision?: number }; - }; - role?: number; - }; - if (ch.index === undefined) return; - - // Update simple channels list for chat pill selector (skip disabled) - if (ch.role !== 0) { - setChannels((prev) => { - const existing = prev.findIndex((c) => c.index === ch.index); - const entry = { - index: ch.index!, - name: ch.settings?.name || (ch.index === 0 ? 'Primary' : `Channel ${ch.index}`), - }; - if (existing >= 0) { - const updated = [...prev]; - updated[existing] = entry; - return dedupeChannelPillsByIndex(updated); - } - return dedupeChannelPillsByIndex([...prev, entry]); - }); - } - - // Update full channel configs for config panel (includes disabled) - setChannelConfigs((prev) => { - const existing = prev.findIndex((c) => c.index === ch.index); - const entry = { - index: ch.index!, - name: ch.settings?.name || '', - role: ch.role ?? 0, - psk: ch.settings?.psk ?? new Uint8Array([1]), - uplinkEnabled: ch.settings?.uplinkEnabled ?? false, - downlinkEnabled: ch.settings?.downlinkEnabled ?? false, - positionPrecision: ch.settings?.moduleSettings?.positionPrecision ?? 0, - }; - if (existing >= 0) { - const updated = [...prev]; - updated[existing] = entry; - return updated; - } - return [...prev, entry].sort((a, b) => a.index - b.index); - }); - }); - unsubscribesRef.current.push(unsub8); - - // ─── SNR/RSSI from mesh packets ──────────────────────────── - const unsub9 = device.events.onMeshPacket.subscribe((packet) => { - touchLastData(); - const mp = packet as { - id?: number; - rxSnr?: number; - rxRssi?: number; - from?: number; - hopLimit?: number; - hopStart?: number; - viaMqtt?: boolean; - }; - if (getStoredMeshProtocol() === 'meshtastic' && mp.from) { - try { - const raw = toBinary(Mesh.MeshPacketSchema, packet as never); - const portLabel = meshtasticRawPacketPortLabel(packet); - const expanded = parseMeshtasticRawPacketExpand(raw, { viaMqtt: mp.viaMqtt === true }); - const entry: MeshtasticRawPacketEntry = { - ts: Date.now(), - snr: mp.rxSnr ?? 0, - rssi: mp.rxRssi ?? 0, - raw, - fromNodeId: mp.from, - portLabel, - viaMqtt: mp.viaMqtt === true, - isLocal: mp.from === myNodeNumRef.current && !mp.viaMqtt && portLabel === 'TELEMETRY_APP', - hopsAway: expanded.ok ? expanded.hopsAway : undefined, - }; - setRawPackets((prev) => { - const next = [...prev, entry]; - return next.length > MAX_RAW_PACKET_LOG_ENTRIES - ? next.slice(next.length - MAX_RAW_PACKET_LOG_ENTRIES) - : next; - }); - } catch (e) { - console.debug( - '[useMeshtasticRuntime] raw packet log entry failed ' + errLikeToLogString(e), - ); - } - - // Record noisy portnums for diagnostics - const decoded = packet.payloadVariant; - if (decoded.case === 'decoded') { - const portnum = decoded.value.portnum; - if (typeof portnum === 'number') { - useDiagnosticsStore.getState().recordNoisePort(mp.from, portnum); - } - } - } - - if (!mp.from) return; - - const computedHopsAway = meshtasticComputedRfHopsAway(mp); - - // Record RF path for packet redundancy tracking (skip id 0 — protobuf: no unique id for no-ack/non-broadcast) - const rawId = Number(mp.id); - const packetId = rawId >>> 0; - if (getStoredMeshProtocol() === 'meshtastic' && Number.isInteger(rawId) && packetId !== 0) { - useDiagnosticsStore.getState().recordPacketPath(packetId, mp.from, { - transport: 'rf', - snr: mp.rxSnr, - rssi: mp.rxRssi, - timestamp: Date.now(), - }); - } - - const hasSignal = Boolean(mp.rxSnr || mp.rxRssi); - const hasHopUpdate = computedHopsAway !== undefined && mp.from !== myNodeNumRef.current; - - if (hasSignal || hasHopUpdate) { - const existing = nodesRef.current.get(mp.from); - if (existing) { - const node: MeshNode = { - ...existing, - ...(mp.rxSnr ? { snr: mp.rxSnr } : {}), - ...(mp.rxRssi ? { rssi: mp.rxRssi } : {}), - ...(hasSignal - ? { - last_heard: mergeMeshtasticLivePacketLastHeard( - existing.last_heard || 0, - Date.now(), - isConfiguringRef.current, - ), - } - : {}), - ...(hasHopUpdate && - !( - existing.last_heard > 0 && - Date.now() - existing.last_heard > MESHTASTIC_CAPABILITIES.nodeStaleThresholdMs - ) - ? { hops_away: computedHopsAway } - : {}), - source: 'rf', - heard_via_mqtt_only: false, - via_mqtt: mp.viaMqtt ?? false, - }; - updateNodes((prev) => { - const updated = new Map(prev); - updated.set(mp.from!, node); - void window.electronAPI.db.saveNode(node); - return updated; - }); - if (getStoredMeshProtocol() === 'meshtastic') { - useDiagnosticsStore - .getState() - .processNodeUpdate( - node, - nodesRef.current.get(myNodeNumRef.current) ?? null, - myNodeNumRef.current, - MESHTASTIC_CAPABILITIES, - ); - } - } - } - - if (mp.rxSnr || mp.rxRssi) { - setSignalTelemetry((prev) => - [ - ...prev, - { - timestamp: Date.now(), - snr: mp.rxSnr, - rssi: mp.rxRssi, - }, - ].slice(-MAX_TELEMETRY_POINTS), - ); - } - }); - unsubscribesRef.current.push(unsub9); - - // ─── Mesh heartbeat (built-in liveness signal) ───────────── - const unsub10 = device.events.onMeshHeartbeat.subscribe(() => { - touchLastData(); - }); - unsubscribesRef.current.push(unsub10); - - // ─── Device config (track GPS mode and telemetry) ─────────── - const unsubConfig = device.events.onConfigPacket.subscribe((config) => { - if (configureTargetNodeNumRef.current != null) return; - const cfg = config as { - payloadVariant?: { - case?: string; - value?: { - gpsMode?: number; - device_update_interval?: number; - deviceUpdateInterval?: number; - }; - }; - }; - if (cfg.payloadVariant?.case === 'position' && cfg.payloadVariant.value?.gpsMode != null) { - deviceGpsModeRef.current = cfg.payloadVariant.value.gpsMode; - setDeviceGpsMode(cfg.payloadVariant.value.gpsMode); - const fixedPosition = (cfg.payloadVariant.value as { fixedPosition?: boolean }).fixedPosition; - if (typeof fixedPosition === 'boolean') { - setDeviceFixedPosition(fixedPosition); - } - } - if (cfg.payloadVariant?.case === 'telemetry' && cfg.payloadVariant.value != null) { - const interval = - cfg.payloadVariant.value.device_update_interval ?? - cfg.payloadVariant.value.deviceUpdateInterval; - if (typeof interval === 'number') { - setTelemetryDeviceUpdateInterval(interval); - } - } - if (cfg.payloadVariant?.case === 'security' && cfg.payloadVariant.value != null) { - setSecurityConfig( - cfg.payloadVariant.value as { - publicKey: Uint8Array; - privateKey: Uint8Array; - adminKey: Uint8Array[]; - isManaged: boolean; - serialEnabled: boolean; - debugLogApiEnabled: boolean; - adminChannelEnabled: boolean; - }, - ); - } - if (cfg.payloadVariant?.case === 'lora' && cfg.payloadVariant.value != null) { - setLoraConfig(cfg.payloadVariant.value as MeshtasticLoraConfig); - } - const configCase = cfg.payloadVariant?.case; - const configValue = cfg.payloadVariant?.value; - const identityId = meshtasticIdentityIdRef.current; - if (configCase && configValue != null && identityId) { - setMeshtasticConfigSlice(identityId, configCase, configValue); - } - }); - unsubscribesRef.current.push(unsubConfig); - - const unsubFromRadio = device.events.onFromRadio.subscribe((packet) => { - const variant = packet.payloadVariant; - if (variant?.case === 'mqttClientProxyMessage') { - void mqttClientProxyBridgeRef.current?.handleFromRadio(packet).catch((e: unknown) => { - console.warn( - '[useMeshtasticRuntime] mqttClientProxy FromRadio failed ' + errLikeToLogString(e), - ); - }); - return; - } - if (variant?.case === 'clientNotification') { - const message = variant.value?.message; - if (typeof message === 'string' && message.trim()) { - recordMeshtasticClientNotification(message); - } - } - }); - unsubscribesRef.current.push(unsubFromRadio); - - // ─── Trace route responses (concurrent in-flight: pending per node + outbound packet id map) - // onMeshPacket reads Data; onTraceRoutePacket fallback (@meshtastic/core) - const applyMeshtasticTracerouteReply = ( - meshFrom: number, - rd: { route: readonly number[]; routeBack: readonly number[] }, - dataLayerDest: number | undefined, - correlationIds?: { - replyId?: number; - requestId?: number; - }, - dataLayerSource?: number, - packetRxTimeMs?: number, - ) => { - const baseLookupKeys = meshtasticTraceRouteLookupKeys({ - from: meshFrom, - data: { route: rd.route, routeBack: rd.routeBack }, - dataLayerDest, - dataLayerSource, - }); - let correlatedDest: number | undefined; - if (correlationIds) { - const tryIds: number[] = []; - const r = correlationIds.replyId; - const q = correlationIds.requestId; - if (typeof r === 'number' && Number.isFinite(r) && r >>> 0 !== 0) { - tryIds.push(r >>> 0); - } - if (typeof q === 'number' && Number.isFinite(q) && q >>> 0 !== 0) { - tryIds.push(q >>> 0); - } - for (const id of tryIds) { - const mapped = pendingTracePacketIdToTargetRef.current.get(id); - if (mapped !== undefined) { - correlatedDest = mapped >>> 0; - pendingTracePacketIdToTargetRef.current.delete(id); - break; - } - } - } - const correlatedAdditionalKeys = - correlatedDest !== undefined ? [correlatedDest] : ([] as number[]); - const lookupKeys = [...new Set([...baseLookupKeys, ...correlatedAdditionalKeys])]; - const mergeAdditionalKeys = correlatedAdditionalKeys; - for (const key of lookupKeys) { - pendingTraceRequestsRef.current.delete(key); - } - const cutoff = Date.now() - 2 * 60_000; - for (const [target, startedAt] of pendingTraceRequestsRef.current) { - if (startedAt < cutoff) pendingTraceRequestsRef.current.delete(target); - } - for (const [packetId, dest] of [...pendingTracePacketIdToTargetRef.current.entries()]) { - if (!pendingTraceRequestsRef.current.has(dest)) { - pendingTracePacketIdToTargetRef.current.delete(packetId); - } - } - setTraceRouteResults((prev) => - mergeMeshtasticTraceRouteIntoResultsMap( - prev, - meshFrom, - rd, - dataLayerDest, - mergeAdditionalKeys, - dataLayerSource, - ), - ); - - if (!isConfiguringRef.current) { - const bumpIds = meshtasticTracerouteLastHeardNodeIds(meshFrom, correlatedDest); - if (bumpIds.length > 0) { - updateNodes((prev) => { - const updated = new Map(prev); - let changed = false; - for (const nodeId of bumpIds) { - const existing = updated.get(nodeId); - if (!existing) continue; - const last_heard = mergeMeshtasticLivePacketLastHeard( - existing.last_heard || 0, - packetRxTimeMs ?? 0, - false, - ); - if (last_heard <= (existing.last_heard || 0)) continue; - const node: MeshNode = { - ...existing, - last_heard, - source: 'rf', - heard_via_mqtt_only: false, - via_mqtt: false, - }; - updated.set(nodeId, node); - void window.electronAPI.db.saveNode(node); - changed = true; - } - return changed ? updated : prev; - }); - } - } - }; - - const unsubTraceMesh = device.events.onMeshPacket.subscribe((meshPacket) => { - if (meshPacket.payloadVariant.case !== 'decoded') return; - const dataPacket = meshPacket.payloadVariant.value; - if (!isMeshtasticTraceroutePortnum(dataPacket.portnum)) return; - try { - const rd = fromBinary(Mesh.RouteDiscoverySchema, dataPacket.payload) as unknown as { - route: readonly number[]; - routeBack: readonly number[]; - }; - const rawDest = (dataPacket as { dest?: number }).dest; - const dataLayerDest = - typeof rawDest === 'number' && Number.isFinite(rawDest) ? rawDest : undefined; - const rawSource = (dataPacket as { source?: number }).source; - const dataLayerSource = - typeof rawSource === 'number' && Number.isFinite(rawSource) ? rawSource : undefined; - const dp = dataPacket as { requestId?: number; replyId?: number }; - const rawReply = dp.replyId; - const rawReq = dp.requestId; - applyMeshtasticTracerouteReply( - meshPacket.from, - rd, - dataLayerDest, - { - replyId: typeof rawReply === 'number' && Number.isFinite(rawReply) ? rawReply : undefined, - requestId: typeof rawReq === 'number' && Number.isFinite(rawReq) ? rawReq : undefined, - }, - dataLayerSource, - meshtasticPacketRxTimeMs(meshPacket.rxTime), - ); - } catch { - // catch-no-log-ok RouteDiscovery decode failed (non-traceroute payload on port) - } - }); - unsubscribesRef.current.push(unsubTraceMesh); - - const unsubTraceLegacy = device.events.onTraceRoutePacket.subscribe((packet) => { - const rd = packet.data as unknown as { - route: readonly number[]; - routeBack: readonly number[]; - }; - applyMeshtasticTracerouteReply( - packet.from, - rd, - undefined, - undefined, - undefined, - meshtasticPacketRxTimeMs(packet.rxTime), - ); - }); - unsubscribesRef.current.push(unsubTraceLegacy); - - // Queue status → connectionStore via MeshtasticProtocol + PacketRouter (no legacy handler). - - const applySdkRoutingErrorFromLog = (logMessage: string): boolean => { - return applyMeshtasticOutboundRoutingErrorFromLog(logMessage, { - myNodeNum: myNodeNumRef.current, - identityId: meshtasticIdentityIdRef.current, - messagesRef, - setMessages, - tempIdToWirePacketId: ackMeshPacketIdByTempIdRef.current, - }); - }; - - const applySdkRoutingErrorFromRejection = (reason: unknown): boolean => { - const uiApplied = applyMeshtasticOutboundRoutingErrorFromRejection(reason, { - myNodeNum: myNodeNumRef.current, - identityId: meshtasticIdentityIdRef.current, - messagesRef, - setMessages, - tempIdToWirePacketId: ackMeshPacketIdByTempIdRef.current, - }); - if (!uiApplied) { - const parsed = reason as { id?: number; packetId?: number; error?: number }; - const packetId = parsed.id ?? parsed.packetId; - console.debug('[meshtasticSdkRoutingErrorLog] SDK queue rejection', packetId, parsed.error); - } - return uiApplied; - }; - - // Device logs → deviceStore via protocol; legacy handler only for foreign LoRa parsing. - const unsubLog = device.events.onLogRecord.subscribe((record) => { - applyMeshtasticForeignLoraFromLog(record.message); - applySdkRoutingErrorFromLog(record.message); - }); - unsubscribesRef.current.push(unsubLog); - - const unsubForeignLoraLogLine = window.electronAPI.log.onLine((entry) => { - if (isForeignLoraLogCandidate(entry.message)) { - applyMeshtasticForeignLoraFromLog(entry.message); - } - applySdkRoutingErrorFromLog(entry.message); - }); - unsubscribesRef.current.push(unsubForeignLoraLogLine); - - unsubscribesRef.current.push( - installMeshtasticSdkRoutingErrorConsoleHook(applySdkRoutingErrorFromLog), - installMeshtasticSdkRoutingErrorUnhandledRejectionHandler(applySdkRoutingErrorFromRejection), - ); - - // Neighbor info → nodeStore via protocol ingress. - - // Waypoints → nodeStore via protocol; legacy handler only relays MQTT uplink. - const unsubWaypoint = device.events.onWaypointPacket.subscribe((packet) => { - touchLastData(); - const data = packet.data as { - id?: number; - latitudeI?: number; - longitudeI?: number; - name?: string; - description?: string; - icon?: number; - lockedTo?: number; - expire?: number; - }; - if (!data.id) return; - - const mp = packet as { from?: number; to?: number; channel?: number }; - const fromNode = mp.from; - const toNode = (mp.to ?? BROADCAST_ADDR) >>> 0; - const chanIdx = mp.channel ?? 0; - if ( - fromNode != null && - fromNode !== myNodeNumRef.current && - mqttStatusRef.current === 'connected' && - toNode === BROADCAST_ADDR - ) { - const chCfg = channelConfigsRef.current.find((c) => c.index === chanIdx); - if (chCfg?.uplinkEnabled) { - const wpMqtt = resolveMeshtasticMqttPublishFieldsForChannel( - chanIdx, - channelConfigsRef.current, - loadMeshtasticMqttManualChannelPsks(), - meshtasticMqttPublishOpts(!deviceRef.current), - ); - if (wpMqtt.channelName) { - void window.electronAPI.mqtt - .publishWaypoint({ - from: fromNode, - to: toNode, - channel: chanIdx, - channelName: wpMqtt.channelName, - pskBase64: wpMqtt.pskBase64, - publishJsonMirror: wpMqtt.publishJsonMirror, - waypoint: { - id: data.id, - latitudeI: data.latitudeI ?? 0, - longitudeI: data.longitudeI ?? 0, - name: data.name ?? '', - description: data.description ?? '', - icon: data.icon ?? 0, - lockedTo: data.lockedTo ?? 0, - expire: data.expire ?? 0, - }, - }) - .catch((e: unknown) => { - console.debug( - '[useMeshtasticRuntime] MQTT waypoint relay failed ' + errLikeToLogString(e), - ); - }); - } - } - } - }); - unsubscribesRef.current.push(unsubWaypoint); - - // Module config → deviceStore via protocol ingress (skipped during remote configure). - - const unsubRemoteAdmin = device.events.onMeshPacket.subscribe((meshPacket) => { - remoteAdminClientRef.current?.handleMeshPacket(meshPacket as never); - }); - unsubscribesRef.current.push(unsubRemoteAdmin); - - // ─── Remote Hardware packets ────────────────────────────────── - const unsubRemoteHardware = device.events.onRemoteHardwarePacket.subscribe((packet) => { - touchLastData(); - const data = packet.data as { raw?: Uint8Array }; - setRemoteHardwareMessages((prev) => { - const updated = new Map(prev); - const from = packet.from; - const existing = updated.get(from) ?? []; - updated.set(from, [ - ...existing.slice(-10), - { from, data: data.raw ?? new Uint8Array(), timestamp: Date.now() }, - ]); - return updated; - }); - }); - unsubscribesRef.current.push(unsubRemoteHardware); - - // ─── Audio packets ───────────────────────────────────────────── - const unsubAudio = device.events.onAudioPacket.subscribe((packet) => { - touchLastData(); - setAudioMessages((prev) => { - const updated = new Map(prev); - const from = packet.from; - const existing = updated.get(from) ?? []; - updated.set(from, [ - ...existing.slice(-50), - { from, data: packet.data, timestamp: Date.now() }, - ]); - return updated; - }); - }); - unsubscribesRef.current.push(unsubAudio); - - // ─── Detection Sensor packets ───────────────────────────────── - const unsubDetectionSensor = device.events.onDetectionSensorPacket.subscribe((packet) => { - touchLastData(); - setDetectionSensorEvents((prev) => - appendModulePortEvent(prev, { - from: packet.from, - data: packet.data, - timestamp: Date.now(), - }), - ); - }); - unsubscribesRef.current.push(unsubDetectionSensor); - - // ─── Ping/Reply packets ─────────────────────────────────────── - const unsubPing = device.events.onPingPacket.subscribe((packet) => { - touchLastData(); - setPingResponses((prev) => { - const updated = new Map(prev); - updated.set(packet.from, { - from: packet.from, - data: packet.data, - timestamp: Date.now(), - }); - return updated; - }); - }); - unsubscribesRef.current.push(unsubPing); - - // ─── IP Tunnel packets ───────────────────────────────────────── - const unsubIpTunnel = device.events.onIpTunnelPacket.subscribe((packet) => { - touchLastData(); - setIpTunnelMessages((prev) => { - const updated = new Map(prev); - const from = packet.from; - const existing = updated.get(from) ?? []; - updated.set(from, [ - ...existing.slice(-100), - { from, data: packet.data, timestamp: Date.now() }, - ]); - return updated; - }); - }); - unsubscribesRef.current.push(unsubIpTunnel); - - // ─── PaxCounter packets ─────────────────────────────────────── - const unsubPaxcounter = device.events.onPaxcounterPacket.subscribe((packet) => { - touchLastData(); - const pax = packet.data as { count?: number }; - setPaxCounterData((prev) => - appendPaxHistory(prev, { - from: packet.from, - count: pax.count ?? 0, - timestamp: Date.now(), - }), - ); - }); - unsubscribesRef.current.push(unsubPaxcounter); - - // ─── Serial packets ─────────────────────────────────────────── - const unsubSerial = device.events.onSerialPacket.subscribe((packet) => { - touchLastData(); - setSerialMessages((prev) => { - const updated = new Map(prev); - const from = packet.from; - const existing = updated.get(from) ?? []; - updated.set(from, [ - ...existing.slice(-100), - { from, data: packet.data, timestamp: Date.now() }, - ]); - return updated; - }); - }); - unsubscribesRef.current.push(unsubSerial); - - // ─── Store & Forward packets ─────────────────────────────────── - const unsubStoreForward = device.events.onStoreForwardPacket.subscribe((packet) => { - touchLastData(); - setStoreForwardMessages((prev) => { - const updated = new Map(prev); - const from = packet.from; - const existing = updated.get(from) ?? []; - updated.set(from, [ - ...existing.slice(-50), - { from, data: packet.data, timestamp: Date.now() }, - ]); - return updated; - }); - - const serverNodeId = packet.from; - const heartbeat = parseStoreForwardHeartbeat(packet.data); - if (serverNodeId && heartbeat) { - lastSfHeartbeatServerRef.current = serverNodeId; - lastSfHeartbeatChannelRef.current = packet.channel ?? 0; - lastSfHeartbeatPeriodRef.current = heartbeat.period; - if (heartbeat.secondary === 0 && deviceConfiguredRef.current) { - void requestStoreForwardHistoryRef.current({ serverNodeId, manual: false }); - } - } - - const from = packet.from; - const payloadText = decodeStoreForwardTextPayload(packet.data); - if (!from || !payloadText) return; - const sfChat: ChatMessage = { - sender_id: from, - sender_name: getNodeName(from), - payload: payloadText, - channel: packet.channel ?? 0, - timestamp: Date.now(), - isHistory: true, - receivedVia: 'rf', - viaStoreForward: true, - }; - setMessages((prev) => { - if (isDuplicateHistoryMessage(prev, sfChat)) return prev; - return trimChatMessagesToMax([...prev, sfChat], MAX_IN_MEMORY_CHAT_MESSAGES); - }); - void window.electronAPI.db.saveMessage(sfChat); - }); - unsubscribesRef.current.push(unsubStoreForward); - - // ─── Range Test packets ──────────────────────────────────────── - const unsubRangeTest = device.events.onRangeTestPacket.subscribe((packet) => { - touchLastData(); - setRangeTestPackets((prev) => - appendModulePortEvent(prev, { - from: packet.from, - data: packet.data, - timestamp: Date.now(), - }), - ); - }); - unsubscribesRef.current.push(unsubRangeTest); - - // ─── ZPS packets ─────────────────────────────────────────────── - const unsubZps = device.events.onZpsPacket.subscribe((packet) => { - touchLastData(); - setZpsMessages((prev) => { - const updated = new Map(prev); - const from = packet.from; - const existing = updated.get(from) ?? []; - updated.set(from, [ - ...existing.slice(-50), - { from, data: packet.data, timestamp: Date.now() }, - ]); - return updated; - }); - }); - unsubscribesRef.current.push(unsubZps); - - // ─── Simulator packets ──────────────────────────────────────── - const unsubSimulator = device.events.onSimulatorPacket.subscribe((packet) => { - touchLastData(); - setSimulatorPackets((prev) => { - const updated = new Map(prev); - const from = packet.from; - const existing = updated.get(from) ?? []; - updated.set(from, [ - ...existing.slice(-50), - { from, data: packet.data, timestamp: Date.now() }, - ]); - return updated; - }); - }); - unsubscribesRef.current.push(unsubSimulator); - - // ─── ATAK Plugin packets ─────────────────────────────────────── - const unsubAtakPlugin = device.events.onAtakPluginPacket.subscribe((packet) => { - touchLastData(); - setAtakMessages((prev) => { - const updated = new Map(prev); - const from = packet.from; - const existing = updated.get(from) ?? []; - updated.set(from, [ - ...existing.slice(-100), - { from, data: packet.data, timestamp: Date.now() }, - ]); - return updated; - }); - }); - unsubscribesRef.current.push(unsubAtakPlugin); - - // ─── Map Report packets ──────────────────────────────────────── - const unsubMapReport = device.events.onMapReportPacket.subscribe((packet) => { - touchLastData(); - setMapReports((prev) => { - const updated = new Map(prev); - updated.set(packet.from, { - from: packet.from, - data: packet.data, - timestamp: Date.now(), - }); - return updated; - }); - }); - unsubscribesRef.current.push(unsubMapReport); - - // ─── Private App packets ─────────────────────────────────────── - const unsubPrivate = device.events.onPrivatePacket.subscribe((packet) => { - touchLastData(); - setPrivateMessages((prev) => { - const updated = new Map(prev); - const from = packet.from; - const existing = updated.get(from) ?? []; - updated.set(from, [ - ...existing.slice(-50), - { from, data: packet.data, timestamp: Date.now() }, - ]); - return updated; - }); - }); - unsubscribesRef.current.push(unsubPrivate); - - // ─── ATAK Forwarder packets ──────────────────────────────────── - const unsubAtakForwarder = device.events.onAtakForwarderPacket.subscribe((packet) => { - touchLastData(); - setAtakMessages((prev) => { - const updated = new Map(prev); - const from = packet.from; - const existing = updated.get(from) ?? []; - updated.set(from, [ - ...existing.slice(-100), - { from, data: packet.data, timestamp: Date.now() }, - ]); - return updated; - }); - }); - unsubscribesRef.current.push(unsubAtakForwarder); - - pushMeshtasticTransportSideEffectUnsubs( - device, - type, - (unsub) => unsubscribesRef.current.push(unsub), - () => { - handleConnectionLostRef.current(); - }, - ); -} diff --git a/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.test.ts new file mode 100644 index 000000000..379d05d8c --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { packetRouter } from '../drivers/PacketRouter'; +import { attachMeshtasticModulePortSideEffects } from './meshtasticModulePortSideEffects'; + +describe('attachMeshtasticModulePortSideEffects', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('appends audio module payloads into the per-node map', () => { + const setAudioMessages = vi.fn(); + const touchLastData = vi.fn(); + const noop = vi.fn(); + const detach = attachMeshtasticModulePortSideEffects('id-1', { + touchLastData, + setRemoteHardwareMessages: noop, + setAudioMessages, + setDetectionSensorEvents: noop, + setPingResponses: noop, + setIpTunnelMessages: noop, + setPaxCounterData: noop, + setSerialMessages: noop, + setRangeTestPackets: noop, + setZpsMessages: noop, + setSimulatorPackets: noop, + setAtakMessages: noop, + setMapReports: noop, + setPrivateMessages: noop, + }); + packetRouter.dispatch( + { + type: 'meshtastic_module_port', + payload: { + portLabel: 'audio', + from: 9, + data: new Uint8Array([1, 2, 3]), + timestamp: Date.now(), + }, + }, + 'id-1', + ); + expect(touchLastData).toHaveBeenCalled(); + expect(setAudioMessages).toHaveBeenCalled(); + detach(); + }); +}); diff --git a/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.ts new file mode 100644 index 000000000..de4e368b4 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.ts @@ -0,0 +1,149 @@ +/** + * Meshtastic module-port UI maps (Remote Hardware, Audio, PaxCounter, …) driven + * by the `PacketRouter` `meshtastic_module_port` event. + * + * `MeshtasticProtocol.subscribe` attaches every typed module packet event and + * tags it with a `portLabel`, replacing 14 duplicate `device.events.on*` + * subscriptions in the runtime wire effects. + * + * Failure point: none — these maps are display-only session memory; an unknown + * `portLabel` is ignored. + */ +import type { Dispatch, SetStateAction } from 'react'; + +import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; +import type { DomainEvent, MeshtasticModulePortEvent } from '../protocols/Protocol'; +import type { IdentityId } from '../types'; +import { + appendModulePortEvent, + appendPaxHistory, + type ModulePortEvent, + type PaxCounterPoint, +} from './meshtasticModuleEvents'; + +interface RawModuleMessage { + from: number; + data: Uint8Array; + timestamp: number; +} + +type RawMessageMapSetter = Dispatch>>; + +export interface MeshtasticModulePortSideEffectsDeps { + touchLastData: () => void; + setRemoteHardwareMessages: RawMessageMapSetter; + setAudioMessages: RawMessageMapSetter; + setDetectionSensorEvents: Dispatch>>; + setPingResponses: Dispatch>>; + setIpTunnelMessages: RawMessageMapSetter; + setPaxCounterData: Dispatch>>; + setSerialMessages: RawMessageMapSetter; + setRangeTestPackets: Dispatch>>; + setZpsMessages: RawMessageMapSetter; + setSimulatorPackets: RawMessageMapSetter; + setAtakMessages: RawMessageMapSetter; + setMapReports: Dispatch< + SetStateAction> + >; + setPrivateMessages: RawMessageMapSetter; +} + +function toBytes(data: unknown): Uint8Array { + if (data instanceof Uint8Array) return data; + const raw = (data as { raw?: unknown } | null | undefined)?.raw; + if (raw instanceof Uint8Array) return raw; + return new Uint8Array(); +} + +/** Append to a per-node ring of raw module payloads (`keep` is the retained tail length). */ +function appendRawMessage( + setter: RawMessageMapSetter, + entry: RawModuleMessage, + keep: number, +): void { + setter((prev) => { + const updated = new Map(prev); + const existing = updated.get(entry.from) ?? []; + updated.set(entry.from, [...existing.slice(-keep), entry]); + return updated; + }); +} + +function handleModulePort( + payload: MeshtasticModulePortEvent, + deps: MeshtasticModulePortSideEffectsDeps, +): void { + deps.touchLastData(); + const from = payload.from; + const timestamp = payload.timestamp; + const entry: RawModuleMessage = { from, data: toBytes(payload.data), timestamp }; + + switch (payload.portLabel) { + case 'remoteHardware': + appendRawMessage(deps.setRemoteHardwareMessages, entry, 10); + break; + case 'audio': + appendRawMessage(deps.setAudioMessages, entry, 50); + break; + case 'detectionSensor': + deps.setDetectionSensorEvents((prev) => appendModulePortEvent(prev, entry)); + break; + case 'ping': + deps.setPingResponses((prev) => { + const updated = new Map(prev); + updated.set(from, entry); + return updated; + }); + break; + case 'ipTunnel': + appendRawMessage(deps.setIpTunnelMessages, entry, 100); + break; + case 'paxcounter': { + const pax = payload.data as { count?: number } | undefined; + deps.setPaxCounterData((prev) => + appendPaxHistory(prev, { from, count: pax?.count ?? 0, timestamp }), + ); + break; + } + case 'serial': + appendRawMessage(deps.setSerialMessages, entry, 100); + break; + case 'rangeTest': + deps.setRangeTestPackets((prev) => appendModulePortEvent(prev, entry)); + break; + case 'zps': + appendRawMessage(deps.setZpsMessages, entry, 50); + break; + case 'simulator': + appendRawMessage(deps.setSimulatorPackets, entry, 50); + break; + case 'atakPlugin': + case 'atakForwarder': + appendRawMessage(deps.setAtakMessages, entry, 100); + break; + case 'mapReport': + deps.setMapReports((prev) => { + const updated = new Map(prev); + updated.set(from, { from, data: payload.data, timestamp }); + return updated; + }); + break; + case 'private': + appendRawMessage(deps.setPrivateMessages, entry, 50); + break; + default: + break; + } +} + +/** Attach module-port UI map updates for one Meshtastic identity. */ +export function attachMeshtasticModulePortSideEffects( + identityId: IdentityId, + deps: MeshtasticModulePortSideEffectsDeps, +): () => void { + const listener: PacketRouterListener = (event: DomainEvent, routedIdentityId) => { + if (routedIdentityId !== identityId || event.type !== 'meshtastic_module_port') return; + handleModulePort(event.payload, deps); + }; + return packetRouter.addListener(listener); +} diff --git a/src/renderer/lib/meshtastic/meshtasticNodeAwaitingNodeInfo.ts b/src/renderer/lib/meshtastic/meshtasticNodeAwaitingNodeInfo.ts index 167829485..b1f5114a9 100644 --- a/src/renderer/lib/meshtastic/meshtasticNodeAwaitingNodeInfo.ts +++ b/src/renderer/lib/meshtastic/meshtasticNodeAwaitingNodeInfo.ts @@ -4,7 +4,7 @@ import { MS_PER_MINUTE } from '@/shared/timeConstants'; import { effectiveLastHeardMs } from '../nodeStatus'; import type { MeshNode } from '../types'; -/** Match legacy NODEINFO debounce window in meshtasticLegacyWireSubscriptions. */ +/** Match the NODEINFO request debounce window in meshtasticRuntimeWireEffects. */ export const MESHTASTIC_NODEINFO_AWAIT_MS = 5 * MS_PER_MINUTE; /** diff --git a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts new file mode 100644 index 000000000..9aa16b9e4 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts @@ -0,0 +1,541 @@ +/** + * Node identity, position, and telemetry side effects driven by the + * `PacketRouter` `node_info` / `position` / `telemetry` events. + * + * `MeshtasticProtocol` decodes UserPacket, NodeInfoPacket, PositionPacket, and + * TelemetryPacket already, so the runtime no longer keeps a second + * `device.events.on*` subscription for each of them. `PacketRouter` writes the + * canonical `nodeStore` record before listeners run; everything here is the + * extra runtime work that record does not cover — hook-local node map patches, + * diagnostics, position history, telemetry charts, and the BLE/serial display + * name caches. + * + * Failure point: every branch is additive. A missing node row skips the patch, + * localStorage name-cache writes are swallowed (non-critical), and SQLite + * `saveNode` is fire-and-forget — chat and node ingest have already been + * persisted by `PacketRouter`. + */ +import type { Dispatch, RefObject, SetStateAction } from 'react'; + +import { + meshtasticShortNameAfterClearingDefault, + preferNonEmptyTrimmedString, +} from '../../../shared/nodeNameUtils'; +import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; +import { usePositionHistoryStore } from '../../stores/positionHistoryStore'; +import { validateCoords } from '../coordUtils'; +import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; +import { shouldPreserveStaticGpsForSelfNode } from '../gpsSource'; +import { getIdentityNode } from '../identityStoreReads'; +import { + computeNodeInfoLastHeardMs, + mergeMeshtasticLivePacketLastHeard, + mergeMeshtasticUserPacketLastHeard, +} from '../meshtasticLastHeard'; +import { parseStoredJson } from '../parseStoredJson'; +import type { + DomainEvent, + NodeInfoEvent, + PositionEvent, + TelemetryEvent, +} from '../protocols/Protocol'; +import { MESHTASTIC_CAPABILITIES } from '../radio/BaseRadioProvider'; +import { LAST_SERIAL_PORT_KEY } from '../serialPortSignature'; +import { getStoredMeshProtocol } from '../storedMeshProtocol'; +import type { + ConnectionType, + EnvironmentTelemetryPoint, + IdentityId, + MeshNode, + TelemetryPoint, +} from '../types'; + +const MAX_TELEMETRY_POINTS = 50; +const ROLE_CLIENT_MUTE = 1; +const BLE_DEVICE_NAMES_KEY = 'mesh-client:bleDeviceNames'; +const SERIAL_PORT_NODE_NAMES_KEY = 'mesh-client:serialPortNodeNames'; + +export interface MeshtasticNodeSideEffectsDeps { + /** Transport of the active link — selects which display-name cache is written. */ + connectionType: ConnectionType; + getMyNodeNum: () => number; + /** True during NodeDB replay, when rxTime bumps must not move `last_heard`. */ + getIsConfiguring: () => boolean; + /** Web Bluetooth device id of the active BLE link, for the short-name cache. */ + getBluetoothDeviceId: () => string | undefined; + touchLastData: () => void; + updateNodes: (updater: (prev: Map) => Map) => void; + emptyNode: (nodeId: number) => MeshNode; + ensureNodeExists: (nodeNum: number, source: 'rf' | 'mqtt') => void; + /** Ask an unknown sender for its NodeInfo (throttled by the caller). */ + maybeRequestNodeInfoForNode: (nodeNum: number) => void; + applyOwnNodeBatteryFromDeviceMetrics: (batteryLevel: number) => void; + /** Nodes heard over RF this session; drives the MQTT-only badge. */ + rfHeardNodeIds: RefObject>; + setDeviceOwner: Dispatch< + SetStateAction<{ longName: string; shortName: string; isLicensed: boolean } | null> + >; + setTelemetry: Dispatch>; + setEnvironmentTelemetry: Dispatch>; +} + +function meshtasticPublicKeyHex(bytes: Uint8Array | undefined): string | undefined { + if (bytes?.length !== 32) return undefined; + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function processNodeDiagnostics( + identityId: IdentityId, + node: MeshNode, + myNodeNum: number, + homeNode: MeshNode | null, +): void { + if (getStoredMeshProtocol() !== 'meshtastic') return; + useDiagnosticsStore + .getState() + .processNodeUpdate(node, homeNode, myNodeNum, MESHTASTIC_CAPABILITIES); +} + +function cacheShortNameByKey(storageKey: string, cacheKey: string, shortName: string): void { + try { + const cache = + parseStoredJson>( + localStorage.getItem(storageKey), + `meshtasticNodeSideEffects ${storageKey}`, + ) ?? {}; + cache[cacheKey] = shortName; + localStorage.setItem(storageKey, JSON.stringify(cache)); + } catch { + // catch-no-log-ok localStorage write for transport display-name cache — non-critical + } +} + +/** UserPacket identity: live long/short name, hardware, role, and public key. */ +function handleUserPacketNodeInfo(info: NodeInfoEvent, deps: MeshtasticNodeSideEffectsDeps): void { + const nodeNum = info.nodeId; + deps.rfHeardNodeIds.current.add(nodeNum); + deps.updateNodes((prev) => { + const updated = new Map(prev); + const existing = updated.get(nodeNum) ?? deps.emptyNode(nodeNum); + const long_name = preferNonEmptyTrimmedString(info.longName, existing.long_name, { + nodeId: nodeNum, + }); + const short_name = meshtasticShortNameAfterClearingDefault( + long_name, + preferNonEmptyTrimmedString(info.shortName, existing.short_name), + nodeNum, + ); + const node: MeshNode = { + ...existing, + node_id: nodeNum, + long_name, + short_name, + hw_model: info.hwModel ?? existing.hw_model, + role: info.role ?? existing.role, + public_key_hex: meshtasticPublicKeyHex(info.publicKey) ?? existing.public_key_hex, + // During configure, skip rxTime bumps (NodeDB replay). After configure, use mesh rxTime. + last_heard: mergeMeshtasticUserPacketLastHeard( + existing.last_heard || 0, + info.lastHeardAt ?? 0, + deps.getIsConfiguring(), + ), + heard_via_mqtt_only: false, + via_mqtt: false, + source: 'rf', + }; + updated.set(nodeNum, node); + void window.electronAPI.db.saveNode(node); + return updated; + }); + if (nodeNum === deps.getMyNodeNum()) { + deps.setDeviceOwner({ + longName: preferNonEmptyTrimmedString(info.longName, ''), + shortName: preferNonEmptyTrimmedString(info.shortName, ''), + isLicensed: info.isLicensed ?? false, + }); + } +} + +/** Cache the connected node's short name against its BLE peripheral / serial port. */ +function cacheSelfNodeTransportName( + info: NodeInfoEvent, + deps: MeshtasticNodeSideEffectsDeps, +): void { + if (info.nodeId !== deps.getMyNodeNum()) return; + if (deps.connectionType === 'ble') { + const deviceId = deps.getBluetoothDeviceId(); + const shortName = preferNonEmptyTrimmedString(info.shortName, '') || null; + if (deviceId && shortName) { + cacheShortNameByKey(BLE_DEVICE_NAMES_KEY, deviceId, shortName); + } + return; + } + if (deps.connectionType === 'serial') { + const portId = localStorage.getItem(LAST_SERIAL_PORT_KEY); + const shortName = + preferNonEmptyTrimmedString(info.shortName, preferNonEmptyTrimmedString(info.longName, '')) || + null; + if (portId && shortName) { + cacheShortNameByKey(SERIAL_PORT_NODE_NAMES_KEY, portId, shortName); + } + } +} + +/** NodeDB NodeInfo: enriched row with SNR, hops, position, and device metrics. */ +function handleNodeDbNodeInfo( + identityId: IdentityId, + info: NodeInfoEvent, + deps: MeshtasticNodeSideEffectsDeps, +): void { + const nodeNum = info.nodeId; + const myNodeNum = deps.getMyNodeNum(); + const isSelf = nodeNum === myNodeNum; + deps.rfHeardNodeIds.current.add(nodeNum); + const prevOwnRole = isSelf ? getIdentityNode(identityId, nodeNum)?.role : undefined; + const hasPosition = info.latitude != null && info.longitude != null; + + deps.updateNodes((prev) => { + const updated = new Map(prev); + const existing = updated.get(nodeNum) ?? deps.emptyNode(nodeNum); + + let newLat = existing.latitude; + let newLon = existing.longitude; + const newAlt = info.altitude ?? existing.altitude; + let posWarn: string | undefined = existing.lastPositionWarning; + + if (hasPosition && !shouldPreserveStaticGpsForSelfNode(nodeNum, myNodeNum)) { + const r = validateCoords(info.latitude!, info.longitude!); + if (r.valid) { + newLat = info.latitude!; + newLon = info.longitude!; + posWarn = undefined; + } else if (!isSelf || (existing.latitude === 0 && existing.longitude === 0)) { + posWarn = r.warning; + } + } + + const lastHeardMs = computeNodeInfoLastHeardMs(info.lastHeardAt, existing.last_heard, isSelf); + const lastHeardStale = + lastHeardMs > 0 && Date.now() - lastHeardMs > MESHTASTIC_CAPABILITIES.nodeStaleThresholdMs; + + const long_name = preferNonEmptyTrimmedString(info.longName, existing.long_name, { + nodeId: nodeNum, + }); + const short_name = meshtasticShortNameAfterClearingDefault( + long_name, + preferNonEmptyTrimmedString(info.shortName, existing.short_name), + nodeNum, + ); + const node: MeshNode = { + ...existing, + node_id: nodeNum, + long_name, + short_name, + hw_model: info.hwModel ?? existing.hw_model, + snr: info.snr ?? existing.snr, + battery: info.batteryLevel ?? existing.battery, + last_heard: lastHeardMs, + latitude: newLat, + longitude: newLon, + role: info.role ?? existing.role, + // Stale NodeInfo still carries cached hops; don't show hop count for ghosts. + hops_away: isSelf + ? (info.hopsAway ?? 0) + : lastHeardStale + ? undefined + : (info.hopsAway ?? existing.hops_away), + via_mqtt: info.viaMqtt ?? false, + voltage: info.voltage ?? existing.voltage, + channel_utilization: info.channelUtilization ?? existing.channel_utilization, + air_util_tx: info.airUtilTx ?? existing.air_util_tx, + altitude: newAlt, + heard_via_mqtt_only: false, + source: 'rf', + lastPositionWarning: posWarn, + }; + updated.set(nodeNum, node); + void window.electronAPI.db.saveNode(node); + return updated; + }); + + if (isSelf && info.batteryLevel !== undefined) { + deps.applyOwnNodeBatteryFromDeviceMetrics(info.batteryLevel); + } + if ( + isSelf && + getIdentityNode(identityId, nodeNum)?.role === ROLE_CLIENT_MUTE && + prevOwnRole !== ROLE_CLIENT_MUTE + ) { + console.info( + '[meshtasticNodeSideEffects] Device role is Client Mute — position reports to device suppressed', + ); + } + const updatedRfNode = getIdentityNode(identityId, nodeNum); + if (updatedRfNode) { + processNodeDiagnostics( + identityId, + updatedRfNode, + myNodeNum, + getIdentityNode(identityId, myNodeNum) ?? null, + ); + } + if (hasPosition && validateCoords(info.latitude!, info.longitude!).valid) { + usePositionHistoryStore.getState().recordPosition(nodeNum, info.latitude!, info.longitude!); + } + cacheSelfNodeTransportName(info, deps); +} + +function handleNodeInfo( + identityId: IdentityId, + info: NodeInfoEvent, + deps: MeshtasticNodeSideEffectsDeps, +): void { + deps.touchLastData(); + if (!info.nodeId) return; + if (info.fromUserPacket) { + handleUserPacketNodeInfo(info, deps); + return; + } + handleNodeDbNodeInfo(identityId, info, deps); +} + +function handlePosition( + identityId: IdentityId, + position: PositionEvent, + deps: MeshtasticNodeSideEffectsDeps, +): void { + deps.touchLastData(); + const nodeNum = position.nodeId; + const myNodeNum = deps.getMyNodeNum(); + if (nodeNum !== 0) { + deps.rfHeardNodeIds.current.add(nodeNum); + } + + const r = validateCoords(position.latitude, position.longitude); + if (!r.valid) { + deps.updateNodes((prev) => { + const updated = new Map(prev); + const existing = updated.get(nodeNum) ?? deps.emptyNode(nodeNum); + // Don't flag our own node if we have valid fallback coords + if (nodeNum === myNodeNum && (existing.latitude != null || existing.longitude != null)) { + return prev; + } + updated.set(nodeNum, { + ...existing, + lastPositionWarning: r.warning, + last_heard: mergeMeshtasticLivePacketLastHeard( + existing.last_heard || 0, + Date.now(), + deps.getIsConfiguring(), + ), + }); + return updated; + }); + return; + } + + if (shouldPreserveStaticGpsForSelfNode(nodeNum, myNodeNum)) return; + + const homeNode = getIdentityNode(identityId, myNodeNum) ?? null; + const existing = getIdentityNode(identityId, nodeNum) ?? deps.emptyNode(nodeNum); + const node: MeshNode = { + ...existing, + latitude: position.latitude, + longitude: position.longitude, + altitude: position.altitude ?? existing.altitude, + // Position replays at connect must not bump last_heard (configure guard). + last_heard: mergeMeshtasticLivePacketLastHeard( + existing.last_heard || 0, + position.timestamp, + deps.getIsConfiguring(), + ), + lastPositionWarning: undefined, + source: 'rf', + heard_via_mqtt_only: false, + via_mqtt: false, + }; + deps.updateNodes((prev) => { + const updated = new Map(prev); + updated.set(nodeNum, node); + void window.electronAPI.db.saveNode(node); + return updated; + }); + processNodeDiagnostics(identityId, node, myNodeNum, homeNode); + usePositionHistoryStore.getState().recordPosition(nodeNum, position.latitude, position.longitude); + deps.maybeRequestNodeInfoForNode(nodeNum); +} + +/** Environment sensor variant: chart point plus the node row's `env_*` columns. */ +function handleEnvironmentTelemetry( + telemetry: TelemetryEvent, + deps: MeshtasticNodeSideEffectsDeps, +): void { + const nodeNum = telemetry.nodeId; + const point: EnvironmentTelemetryPoint = { + timestamp: Date.now(), + nodeNum, + temperature: telemetry.temperature, + relativeHumidity: telemetry.relativeHumidity, + barometricPressure: telemetry.barometricPressure, + gasResistance: telemetry.gasResistance, + iaq: telemetry.iaq, + lux: telemetry.lux, + windSpeed: telemetry.windSpeed, + windDirection: telemetry.windDirection, + windGust: telemetry.windGust, + windLull: telemetry.windLull, + weight: telemetry.weight, + rainfall1h: telemetry.rainfall1h, + rainfall24h: telemetry.rainfall24h, + }; + deps.setEnvironmentTelemetry((prev) => [...prev, point].slice(-MAX_TELEMETRY_POINTS)); + deps.updateNodes((prev) => { + const existing = prev.get(nodeNum); + if (!existing) return prev; + const updated = new Map(prev); + updated.set(nodeNum, { + ...existing, + env_temperature: telemetry.temperature ?? existing.env_temperature, + env_humidity: telemetry.relativeHumidity ?? existing.env_humidity, + env_pressure: telemetry.barometricPressure ?? existing.env_pressure, + env_iaq: telemetry.iaq ?? existing.env_iaq, + env_lux: telemetry.lux ?? existing.env_lux, + env_wind_speed: telemetry.windSpeed ?? existing.env_wind_speed, + env_wind_direction: telemetry.windDirection ?? existing.env_wind_direction, + last_heard: mergeMeshtasticLivePacketLastHeard( + existing.last_heard || 0, + telemetry.timestamp, + deps.getIsConfiguring(), + ), + source: 'rf', + heard_via_mqtt_only: false, + via_mqtt: false, + }); + return updated; + }); +} + +/** Connected node's own radio statistics (channel utilization, RX/TX counters). */ +function handleLocalStatsTelemetry( + identityId: IdentityId, + telemetry: TelemetryEvent, + deps: MeshtasticNodeSideEffectsDeps, +): void { + const myNodeNum = deps.getMyNodeNum(); + const existing = getIdentityNode(identityId, myNodeNum); + if (!existing) return; + const node: MeshNode = { + ...existing, + channel_utilization: telemetry.channelUtilization ?? existing.channel_utilization, + air_util_tx: telemetry.airUtilTx ?? existing.air_util_tx, + num_packets_rx_bad: telemetry.numPacketsRxBad ?? existing.num_packets_rx_bad, + num_rx_dupe: telemetry.numRxDupe ?? existing.num_rx_dupe, + num_packets_rx: telemetry.numPacketsRx ?? existing.num_packets_rx, + num_packets_tx: telemetry.numPacketsTx ?? existing.num_packets_tx, + source: 'rf', + heard_via_mqtt_only: false, + via_mqtt: false, + }; + deps.updateNodes((prev) => { + const updated = new Map(prev); + updated.set(myNodeNum, node); + void window.electronAPI.db.saveNode(node); + return updated; + }); + processNodeDiagnostics(identityId, node, myNodeNum, node); +} + +/** Device metrics (and any other variant): battery chart point plus node battery. */ +function handleDeviceMetricsTelemetry( + identityId: IdentityId, + telemetry: TelemetryEvent, + deps: MeshtasticNodeSideEffectsDeps, +): void { + const nodeNum = telemetry.nodeId; + const myNodeNum = deps.getMyNodeNum(); + const point: TelemetryPoint = { + timestamp: Date.now(), + batteryLevel: telemetry.batteryLevel, + voltage: telemetry.voltage, + }; + deps.setTelemetry((prev) => [...prev, point].slice(-MAX_TELEMETRY_POINTS)); + + if (telemetry.batteryLevel == null || !nodeNum) return; + deps.ensureNodeExists(nodeNum, 'rf'); + const existing = getIdentityNode(identityId, nodeNum); + if (existing) { + const node: MeshNode = { + ...existing, + battery: telemetry.batteryLevel, + last_heard: mergeMeshtasticLivePacketLastHeard( + existing.last_heard || 0, + telemetry.timestamp, + deps.getIsConfiguring(), + ), + source: 'rf', + heard_via_mqtt_only: false, + via_mqtt: false, + }; + deps.updateNodes((prev) => { + const updated = new Map(prev); + updated.set(nodeNum, node); + return updated; + }); + processNodeDiagnostics( + identityId, + node, + myNodeNum, + getIdentityNode(identityId, myNodeNum) ?? null, + ); + } + deps.maybeRequestNodeInfoForNode(nodeNum); + if (nodeNum === myNodeNum) { + deps.applyOwnNodeBatteryFromDeviceMetrics(telemetry.batteryLevel); + } +} + +function handleTelemetry( + identityId: IdentityId, + telemetry: TelemetryEvent, + deps: MeshtasticNodeSideEffectsDeps, +): void { + deps.touchLastData(); + // No variant case means the packet carried neither `variant.value` nor + // `deviceMetrics`, so there is nothing to chart or patch. + if (!telemetry.variantCase) return; + if (telemetry.variantCase === 'environmentMetrics') { + handleEnvironmentTelemetry(telemetry, deps); + return; + } + if (telemetry.variantCase === 'localStats' && telemetry.nodeId === deps.getMyNodeNum()) { + handleLocalStatsTelemetry(identityId, telemetry, deps); + return; + } + handleDeviceMetricsTelemetry(identityId, telemetry, deps); +} + +/** Attach node identity / position / telemetry side effects for one Meshtastic identity. */ +export function attachMeshtasticNodeSideEffects( + identityId: IdentityId, + deps: MeshtasticNodeSideEffectsDeps, +): () => void { + const listener: PacketRouterListener = (event: DomainEvent, routedIdentityId) => { + if (routedIdentityId !== identityId) return; + switch (event.type) { + case 'node_info': + handleNodeInfo(identityId, event.payload, deps); + break; + case 'position': + handlePosition(identityId, event.payload, deps); + break; + case 'telemetry': + handleTelemetry(identityId, event.payload, deps); + break; + default: + break; + } + }; + return packetRouter.addListener(listener); +} diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts new file mode 100644 index 000000000..a1b0cc399 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts @@ -0,0 +1,172 @@ +/** + * Sniffer log, RF diagnostics, and SNR/hop node patches driven by the + * `PacketRouter` `raw_packet` event. + * + * `MeshtasticProtocol.decodeRawPacket` already serializes every MeshPacket and + * carries hop/port metadata, so the runtime no longer needs its own + * `device.events.onMeshPacket` subscription for this. + * + * Failure point: diagnostics and the sniffer ring buffer are display-only — a + * missing node row simply skips the patch; chat/node ingest is unaffected. + */ +import type { Dispatch, SetStateAction } from 'react'; + +import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; +import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; +import { errLikeToLogString } from '../errLikeToLogString'; +import { getIdentityNode } from '../identityStoreReads'; +import { mergeMeshtasticLivePacketLastHeard } from '../meshtasticLastHeard'; +import type { DomainEvent, RawPacketEntry } from '../protocols/Protocol'; +import { MESHTASTIC_CAPABILITIES } from '../radio/BaseRadioProvider'; +import { + MAX_RAW_PACKET_LOG_ENTRIES, + type MeshtasticRawPacketEntry, +} from '../rawPacketLogConstants'; +import { getStoredMeshProtocol } from '../storedMeshProtocol'; +import type { IdentityId, MeshNode, TelemetryPoint } from '../types'; + +const MAX_TELEMETRY_POINTS = 50; + +export interface MeshtasticRawPacketSideEffectsDeps { + getMyNodeNum: () => number; + getIsConfiguring: () => boolean; + updateNodes: (updater: (prev: Map) => Map) => void; + setRawPackets: Dispatch>; + setSignalTelemetry: Dispatch>; + touchLastData: () => void; +} + +function appendRawPacketLog( + payload: RawPacketEntry, + deps: MeshtasticRawPacketSideEffectsDeps, +): void { + try { + const fromNodeId = payload.fromNodeId; + const entry: MeshtasticRawPacketEntry = { + ts: payload.ts, + snr: payload.snr, + rssi: payload.rssi, + raw: payload.raw, + fromNodeId, + portLabel: payload.portLabel, + viaMqtt: payload.viaMqtt, + isLocal: + fromNodeId === deps.getMyNodeNum() && + !payload.viaMqtt && + payload.portLabel === 'TELEMETRY_APP', + hopsAway: payload.hopsAway, + }; + deps.setRawPackets((prev) => { + const next = [...prev, entry]; + return next.length > MAX_RAW_PACKET_LOG_ENTRIES + ? next.slice(next.length - MAX_RAW_PACKET_LOG_ENTRIES) + : next; + }); + } catch (e) { + console.debug( + '[meshtasticRawPacketSideEffects] raw packet log entry failed ' + errLikeToLogString(e), + ); + } +} + +function applySignalAndHops( + identityId: IdentityId, + payload: RawPacketEntry, + from: number, + deps: MeshtasticRawPacketSideEffectsDeps, +): void { + const myNodeNum = deps.getMyNodeNum(); + const hasSignal = Boolean(payload.snr || payload.rssi); + const hasHopUpdate = payload.hopsAway !== undefined && from !== myNodeNum; + if (!hasSignal && !hasHopUpdate) return; + + const existing = getIdentityNode(identityId, from); + if (!existing) return; + + const isStale = + existing.last_heard > 0 && + Date.now() - existing.last_heard > MESHTASTIC_CAPABILITIES.nodeStaleThresholdMs; + const node: MeshNode = { + ...existing, + ...(payload.snr ? { snr: payload.snr } : {}), + ...(payload.rssi ? { rssi: payload.rssi } : {}), + ...(hasSignal + ? { + last_heard: mergeMeshtasticLivePacketLastHeard( + existing.last_heard || 0, + Date.now(), + deps.getIsConfiguring(), + ), + } + : {}), + ...(hasHopUpdate && !isStale ? { hops_away: payload.hopsAway } : {}), + source: 'rf', + heard_via_mqtt_only: false, + via_mqtt: payload.viaMqtt, + }; + deps.updateNodes((prev) => { + const updated = new Map(prev); + updated.set(from, node); + void window.electronAPI.db.saveNode(node); + return updated; + }); + if (getStoredMeshProtocol() === 'meshtastic') { + useDiagnosticsStore + .getState() + .processNodeUpdate( + node, + getIdentityNode(identityId, myNodeNum) ?? null, + myNodeNum, + MESHTASTIC_CAPABILITIES, + ); + } +} + +function handleRawPacket( + identityId: IdentityId, + payload: RawPacketEntry, + deps: MeshtasticRawPacketSideEffectsDeps, +): void { + deps.touchLastData(); + const from = payload.fromNodeId; + if (!from) return; + + const isMeshtasticTab = getStoredMeshProtocol() === 'meshtastic'; + if (isMeshtasticTab) { + appendRawPacketLog(payload, deps); + if (typeof payload.portnum === 'number') { + useDiagnosticsStore.getState().recordNoisePort(from, payload.portnum); + } + // Skip packet id 0 — protobuf assigns no unique id for no-ack / non-broadcast. + if (payload.packetId != null && payload.packetId !== 0) { + useDiagnosticsStore.getState().recordPacketPath(payload.packetId, from, { + transport: 'rf', + snr: payload.snr, + rssi: payload.rssi, + timestamp: Date.now(), + }); + } + } + + applySignalAndHops(identityId, payload, from, deps); + + if (payload.snr || payload.rssi) { + deps.setSignalTelemetry((prev) => + [...prev, { timestamp: Date.now(), snr: payload.snr, rssi: payload.rssi }].slice( + -MAX_TELEMETRY_POINTS, + ), + ); + } +} + +/** Attach sniffer / RF diagnostics side effects for one Meshtastic identity. */ +export function attachMeshtasticRawPacketSideEffects( + identityId: IdentityId, + deps: MeshtasticRawPacketSideEffectsDeps, +): () => void { + const listener: PacketRouterListener = (event: DomainEvent, routedIdentityId) => { + if (routedIdentityId !== identityId || event.type !== 'raw_packet') return; + handleRawPacket(identityId, event.payload, deps); + }; + return packetRouter.addListener(listener); +} diff --git a/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.test.ts new file mode 100644 index 000000000..d011bc465 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { packetRouter } from '../drivers/PacketRouter'; +import { + attachMeshtasticRouterSideEffects, + type MeshtasticRouterSideEffectsDeps, +} from './meshtasticRouterSideEffects'; + +vi.mock('../meshtasticMqttPublish', () => ({ + loadMeshtasticMqttManualChannelPsks: () => [], + resolveMeshtasticMqttPublishFieldsForChannel: () => ({ + channelName: 'LongFast', + pskBase64: 'AQ==', + publishJsonMirror: false, + }), +})); + +describe('attachMeshtasticRouterSideEffects', () => { + beforeEach(() => { + window.electronAPI = { + mqtt: { publish: vi.fn().mockResolvedValue(1) }, + } as unknown as typeof window.electronAPI; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('requests NodeInfo and uplinks broadcast text when MQTT uplink is enabled', () => { + const publish = window.electronAPI.mqtt.publish as ReturnType; + const requestNodeInfoForNode = vi.fn(); + const deps: MeshtasticRouterSideEffectsDeps = { + getMyNodeNum: () => 1, + getMqttStatus: () => 'connected', + getChannelConfigs: () => [ + { + index: 0, + name: 'LongFast', + role: 1, + psk: new Uint8Array(16), + uplinkEnabled: true, + downlinkEnabled: true, + positionPrecision: 0, + }, + ], + hasRfDevice: () => true, + getNodeName: () => 'Self', + registerMqttEchoPacketId: vi.fn(), + requestNodeInfoForNode, + applyForeignLoraFromLog: vi.fn(), + applyRoutingErrorFromLog: vi.fn(), + setFirmwareVersion: vi.fn(), + }; + const detach = attachMeshtasticRouterSideEffects('mesh-id', deps); + packetRouter.dispatch( + { + type: 'text_message', + payload: { + id: '7:1', + from: 7, + to: 0xffffffff, + payload: 'hello mqtt', + channelIndex: 0, + timestamp: Date.now(), + }, + }, + 'mesh-id', + ); + expect(requestNodeInfoForNode).toHaveBeenCalledWith(7); + expect(publish).toHaveBeenCalled(); + detach(); + }); + + it('forwards device_log lines to foreign LoRa and routing-error helpers', () => { + const applyForeignLoraFromLog = vi.fn(); + const applyRoutingErrorFromLog = vi.fn(); + const deps: MeshtasticRouterSideEffectsDeps = { + getMyNodeNum: () => 1, + getMqttStatus: () => 'disconnected', + getChannelConfigs: () => [], + hasRfDevice: () => true, + getNodeName: () => 'Self', + registerMqttEchoPacketId: vi.fn(), + requestNodeInfoForNode: vi.fn(), + applyForeignLoraFromLog, + applyRoutingErrorFromLog, + setFirmwareVersion: vi.fn(), + }; + const detach = attachMeshtasticRouterSideEffects('mesh-id', deps); + packetRouter.dispatch( + { + type: 'device_log', + payload: { + message: 'Error received for packet 9: TIMEOUT', + time: Date.now(), + source: 'radio', + level: 0, + }, + }, + 'mesh-id', + ); + expect(applyRoutingErrorFromLog).toHaveBeenCalled(); + expect(applyForeignLoraFromLog).toHaveBeenCalled(); + detach(); + }); +}); diff --git a/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.ts new file mode 100644 index 000000000..b92cc251b --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.ts @@ -0,0 +1,212 @@ +/** + * Meshtastic side effects driven by `PacketRouter` domain events. + * + * These used to live in a second `device.events.on*` subscription alongside the + * one `MeshtasticProtocol` already owns, so every text packet and log record was + * decoded twice. Everything here reacts to the event the Protocol emits instead, + * which keeps `messageStore` / `nodeStore` the single ingress path. + * + * Failure point: MQTT publish and OS notification are best-effort — failures are + * logged and never block chat ingest, which has already been persisted by + * `PacketRouter` + `meshtasticIngest`. + */ +import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; +import { errLikeToLogString } from '../errLikeToLogString'; +import { + loadMeshtasticMqttManualChannelPsks, + resolveMeshtasticMqttPublishFieldsForChannel, +} from '../meshtasticMqttPublish'; +import type { DomainEvent } from '../protocols/Protocol'; +import { stripControlCharacters } from '../stripControlCharacters'; +import type { IdentityId, MQTTStatus } from '../types'; + +const BROADCAST_ADDR = 0xffffffff; + +export interface MeshtasticRouterChannelConfig { + index: number; + name: string; + role: number; + psk: Uint8Array; + uplinkEnabled: boolean; + downlinkEnabled: boolean; + positionPrecision: number; +} + +export interface MeshtasticRouterSideEffectsDeps { + getMyNodeNum: () => number; + getMqttStatus: () => MQTTStatus; + getChannelConfigs: () => MeshtasticRouterChannelConfig[]; + /** False for MQTT-only sessions, which prefer manually entered channel PSKs. */ + hasRfDevice: () => boolean; + getNodeName: (nodeNum: number) => string; + /** Register the uplinked packet id so the MQTT echo is not shown twice. */ + registerMqttEchoPacketId: (senderId: number, packetId: number) => void; + /** Ask an unknown sender for its NodeInfo (throttled by the caller). */ + requestNodeInfoForNode: (nodeNum: number) => void; + applyForeignLoraFromLog: (message: string) => void; + applyRoutingErrorFromLog: (message: string) => void; + /** Hook-local `DeviceState.firmwareVersion`; the connection store copy is set by PacketRouter. */ + setFirmwareVersion: (firmwareVersion: string) => void; +} + +type TextMessageEvent = Extract; +type WaypointDomainEvent = Extract; + +/** Gateway uplink: forward inbound RF broadcasts to MQTT when the channel allows it. */ +function uplinkTextToMqtt(event: TextMessageEvent, deps: MeshtasticRouterSideEffectsDeps): void { + if (deps.getMqttStatus() !== 'connected') return; + const channelIndex = event.payload.channelIndex; + const channelConfigs = deps.getChannelConfigs(); + if (!channelConfigs.find((c) => c.index === channelIndex)?.uplinkEnabled) return; + + const uplink = resolveMeshtasticMqttPublishFieldsForChannel( + channelIndex, + channelConfigs, + loadMeshtasticMqttManualChannelPsks(), + deps.hasRfDevice() ? undefined : { preferManualOverRadio: true }, + ); + if (!uplink.channelName) return; + + window.electronAPI.mqtt + .publish({ + text: event.payload.payload, + from: event.payload.from, + channel: channelIndex, + destination: BROADCAST_ADDR, + channelName: uplink.channelName, + pskBase64: uplink.pskBase64, + publishJsonMirror: uplink.publishJsonMirror, + }) + .then((packetId) => { + deps.registerMqttEchoPacketId(event.payload.from, packetId); + }) + .catch((e: unknown) => { + console.debug( + '[meshtasticRouterSideEffects] MQTT uplink echo register non-fatal ' + + errLikeToLogString(e), + ); + }); +} + +/** + * OS toast when the window is hidden. Silent on purpose: App.tsx owns typed Web + * Audio via chatNotifications.ts, so sounding here would double-notify. + */ +function notifyHiddenWindow(event: TextMessageEvent, deps: MeshtasticRouterSideEffectsDeps): void { + if (!document.hidden) return; + try { + const safeSender = stripControlCharacters(deps.getNodeName(event.payload.from)).slice(0, 120); + const isDirect = event.payload.to !== 0 && event.payload.to !== BROADCAST_ADDR; + new Notification(isDirect ? `DM from ${safeSender}` : `Message from ${safeSender}`, { + body: stripControlCharacters(event.payload.payload).slice(0, 100), + silent: true, + }); + } catch (e) { + console.debug( + '[meshtasticRouterSideEffects] Notification not available ' + errLikeToLogString(e), + ); + } +} + +function handleTextMessage(event: TextMessageEvent, deps: MeshtasticRouterSideEffectsDeps): void { + const isEcho = event.payload.from === deps.getMyNodeNum(); + if (isEcho) return; + + deps.requestNodeInfoForNode(event.payload.from); + if (event.payload.tapback) return; + + const isDirect = event.payload.to !== 0 && event.payload.to !== BROADCAST_ADDR; + // DMs are never uplinked (privacy); reactions would duplicate the parent row. + if (!isDirect) uplinkTextToMqtt(event, deps); + notifyHiddenWindow(event, deps); +} + +/** + * Gateway uplink for inbound broadcast waypoints. The waypoint row itself is + * stored by `PacketRouter`; only the MQTT relay lives here. + */ +function uplinkWaypointToMqtt( + event: WaypointDomainEvent, + deps: MeshtasticRouterSideEffectsDeps, +): void { + const waypoint = event.payload; + if (!waypoint.id) return; + if (deps.getMqttStatus() !== 'connected') return; + const from = waypoint.from; + if (!from || from === deps.getMyNodeNum()) return; + const to = (waypoint.to ?? BROADCAST_ADDR) >>> 0; + if (to !== BROADCAST_ADDR) return; + + const channelIndex = waypoint.channelIndex ?? 0; + const channelConfigs = deps.getChannelConfigs(); + if (!channelConfigs.find((c) => c.index === channelIndex)?.uplinkEnabled) return; + + const uplink = resolveMeshtasticMqttPublishFieldsForChannel( + channelIndex, + channelConfigs, + loadMeshtasticMqttManualChannelPsks(), + deps.hasRfDevice() ? undefined : { preferManualOverRadio: true }, + ); + if (!uplink.channelName) return; + + void window.electronAPI.mqtt + .publishWaypoint({ + from, + to, + channel: channelIndex, + channelName: uplink.channelName, + pskBase64: uplink.pskBase64, + publishJsonMirror: uplink.publishJsonMirror, + waypoint: { + id: waypoint.id, + latitudeI: waypoint.latitudeI ?? 0, + longitudeI: waypoint.longitudeI ?? 0, + name: waypoint.name, + description: waypoint.description ?? '', + icon: waypoint.icon ?? 0, + lockedTo: waypoint.lockedTo ?? 0, + expire: waypoint.expire ?? 0, + }, + }) + .catch((e: unknown) => { + console.debug( + '[meshtasticRouterSideEffects] MQTT waypoint relay failed ' + errLikeToLogString(e), + ); + }); +} + +/** + * Attach post-router side effects for one Meshtastic identity. + * Returns a detach function; call once per active transport. + */ +export function attachMeshtasticRouterSideEffects( + identityId: IdentityId, + deps: MeshtasticRouterSideEffectsDeps, +): () => void { + const listener: PacketRouterListener = (event, routedIdentityId) => { + if (routedIdentityId !== identityId) return; + switch (event.type) { + case 'text_message': + handleTextMessage(event, deps); + break; + case 'device_log': { + const message = event.payload.message; + if (!message) break; + deps.applyForeignLoraFromLog(message); + deps.applyRoutingErrorFromLog(message); + break; + } + case 'waypoint': + uplinkWaypointToMqtt(event, deps); + break; + case 'device_metadata': { + const firmwareVersion = event.payload.firmwareVersion; + if (firmwareVersion) deps.setFirmwareVersion(firmwareVersion); + break; + } + default: + break; + } + }; + return packetRouter.addListener(listener); +} diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.diagnostics.contract.test.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.diagnostics.contract.test.ts new file mode 100644 index 000000000..4ae4b0a63 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.diagnostics.contract.test.ts @@ -0,0 +1,33 @@ +/** + * Regression guard: LocalStats and RF hop/signal ingest must feed diagnosticsStore + * so connected-node CU history, RF analysis, and hop-flapping detection stay live. + */ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; + +const NODE_SOURCE = readFileSync(join(__dirname, 'meshtasticNodeSideEffects.ts'), 'utf-8'); +const RAW_SOURCE = readFileSync(join(__dirname, 'meshtasticRawPacketSideEffects.ts'), 'utf-8'); + +function sliceFromMarker(source: string, marker: string, length = 1400): string { + const index = source.indexOf(marker); + if (index === -1) return ''; + return source.slice(index, index + length); +} + +describe('meshtastic diagnostics ingest contract', () => { + it('localStats handler calls processNodeUpdate when protocol is meshtastic', () => { + const body = sliceFromMarker(NODE_SOURCE, 'handleLocalStatsTelemetry'); + expect(body.length).toBeGreaterThan(0); + expect(body).toContain('processNodeDiagnostics'); + expect(NODE_SOURCE).toContain('processNodeUpdate'); + expect(NODE_SOURCE).toContain("getStoredMeshProtocol() !== 'meshtastic'"); + }); + + it('RF mesh packet hop/signal handler calls processNodeUpdate when protocol is meshtastic', () => { + const body = sliceFromMarker(RAW_SOURCE, 'if (!hasSignal && !hasHopUpdate)'); + expect(body.length).toBeGreaterThan(0); + expect(body).toContain('processNodeUpdate'); + expect(body).toContain("getStoredMeshProtocol() === 'meshtastic'"); + }); +}); diff --git a/src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.post-reboot.test.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts similarity index 94% rename from src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.post-reboot.test.ts rename to src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts index ad194ed4f..95e97a451 100644 --- a/src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.post-reboot.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts @@ -2,7 +2,7 @@ import type { MeshDevice } from '@meshtastic/core'; import { describe, expect, it, vi } from 'vitest'; import type { ConnectionType, DeviceState } from '../types'; -import { attachMeshtasticLegacyWireSubscriptions } from './meshtasticLegacyWireSubscriptions'; +import { attachMeshtasticRuntimeWireEffects } from './meshtasticRuntimeWireEffects'; function makeDeps() { const touchLastData = vi.fn(); @@ -56,10 +56,8 @@ function makeDeps() { meshtasticIdentityIdRef: { current: 'id-1' }, meshtasticIngestSessionRef, meshtasticIngressDetachRef: { current: null }, - messagesRef: { current: [] }, mqttStatusRef: { current: 'disconnected' as const }, myNodeNumRef: { current: 0 }, - nodesRef: { current: new Map() }, pendingTempIdRef: { current: undefined }, ackMeshPacketIdByTempIdRef: { current: new Map() }, pendingTracePacketIdToTargetRef: noopMapRef, @@ -141,7 +139,7 @@ function makeDeps() { }; } -describe('meshtasticLegacyWireSubscriptions DeviceRestarting', () => { +describe('meshtasticRuntimeWireEffects DeviceRestarting', () => { it('skips touchLastData and schedules post-reboot recovery on status 1', () => { const { deps, @@ -171,7 +169,7 @@ describe('meshtasticLegacyWireSubscriptions DeviceRestarting', () => { setHeartbeatInterval: vi.fn(), } as unknown as MeshDevice; - attachMeshtasticLegacyWireSubscriptions(device, 'ble', { driverIdentityId: 'id-1' }, deps); + attachMeshtasticRuntimeWireEffects(device, 'ble', { driverIdentityId: 'id-1' }, deps); for (const cb of statusSubscribers) cb(1); @@ -208,7 +206,7 @@ describe('meshtasticLegacyWireSubscriptions DeviceRestarting', () => { setHeartbeatInterval: vi.fn(), } as unknown as MeshDevice; - attachMeshtasticLegacyWireSubscriptions(device, 'ble', { driverIdentityId: 'id-1' }, deps); + attachMeshtasticRuntimeWireEffects(device, 'ble', { driverIdentityId: 'id-1' }, deps); for (const cb of statusSubscribers) cb(7); diff --git a/src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.telemetry-nodeinfo.test.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts similarity index 79% rename from src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.telemetry-nodeinfo.test.ts rename to src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts index 709ee99be..cf9f2f992 100644 --- a/src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.telemetry-nodeinfo.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts @@ -1,17 +1,26 @@ import type { MeshDevice } from '@meshtastic/core'; import { Portnums } from '@meshtastic/protobufs'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useNodeStore } from '../../stores/nodeStore'; +import { packetRouter } from '../drivers/PacketRouter'; +import { syncNodesMapToIdentityStore } from '../hydrateIdentityStoresFromDb'; import type { ConnectionType, MeshNode } from '../types'; -import { attachMeshtasticLegacyWireSubscriptions } from './meshtasticLegacyWireSubscriptions'; +import { attachMeshtasticRuntimeWireEffects } from './meshtasticRuntimeWireEffects'; const UNKNOWN_NODE = 0xabcd1234; +const IDENTITY = 'id-1'; + +/** Wire effects read nodes from `nodeStore`, so the mocks publish there too. */ +function publishNodes(nodes: Map): void { + syncNodesMapToIdentityStore(IDENTITY, nodes); +} function makeDeps() { - const nodesRef = { current: new Map() }; + const nodeMirror = new Map(); const ensureNodeExists = vi.fn((nodeNum: number, source: 'rf' | 'mqtt') => { - if (!nodesRef.current.has(nodeNum)) { - nodesRef.current.set(nodeNum, { + if (!nodeMirror.has(nodeNum)) { + nodeMirror.set(nodeNum, { node_id: nodeNum, long_name: '', short_name: '', @@ -25,13 +34,14 @@ function makeDeps() { source, heard_via_mqtt_only: false, }); + publishNodes(nodeMirror); } }); const noopRef = { current: null }; const noopMapRef = { current: new Map() }; return { - nodesRef, + nodeMirror, ensureNodeExists, deps: { channelConfigsRef: { current: [] }, @@ -56,15 +66,13 @@ function makeDeps() { lastSfHeartbeatPeriodRef: { current: 0 }, lastSfHeartbeatServerRef: { current: null }, localLoraConfigTimerRef: { current: undefined }, - meshtasticIdentityIdRef: { current: 'id-1' }, + meshtasticIdentityIdRef: { current: IDENTITY }, meshtasticIngestSessionRef: { current: { setConfiguring: vi.fn(), detach: vi.fn(), markPacketSeen: vi.fn() }, }, meshtasticIngressDetachRef: { current: null }, - messagesRef: { current: [] }, mqttStatusRef: { current: 'disconnected' as const }, myNodeNumRef: { current: 0 }, - nodesRef, pendingTempIdRef: { current: undefined }, ackMeshPacketIdByTempIdRef: { current: new Map() }, pendingTracePacketIdToTargetRef: noopMapRef, @@ -83,7 +91,10 @@ function makeDeps() { applyOwnNodeBatteryFromDeviceMetrics: vi.fn(), getNodeName: vi.fn(), updateNodes: vi.fn((fn: (prev: Map) => Map) => { - nodesRef.current = fn(nodesRef.current); + const next = fn(new Map(nodeMirror)); + nodeMirror.clear(); + for (const [id, node] of next) nodeMirror.set(id, node); + publishNodes(nodeMirror); }), startWatchdog: vi.fn(), stopWatchdog: vi.fn(), @@ -139,39 +150,38 @@ function makeDeps() { }; } -describe('meshtasticLegacyWireSubscriptions telemetry NodeInfo', () => { +describe('meshtasticRuntimeWireEffects telemetry NodeInfo', () => { + beforeEach(() => { + useNodeStore.setState({ nodes: {} }); + }); + it('creates stub and requests NodeInfo on first telemetry from unknown node', async () => { const { deps, ensureNodeExists } = makeDeps(); - const telemetrySubscribers = new Set<(packet: unknown) => void>(); const sendPacket = vi.fn().mockResolvedValue(undefined); const noopSub = { subscribe: () => () => {} }; const device = { sendPacket, events: new Proxy({} as MeshDevice['events'], { - get: (_target, prop) => { - if (prop === 'onTelemetryPacket') { - return { - subscribe: (cb: (packet: unknown) => void) => { - telemetrySubscribers.add(cb); - return () => telemetrySubscribers.delete(cb); - }, - }; - } - return noopSub; - }, + get: () => noopSub, }), setHeartbeatInterval: vi.fn(), } as unknown as MeshDevice; - attachMeshtasticLegacyWireSubscriptions(device, 'ble', { driverIdentityId: 'id-1' }, deps); + attachMeshtasticRuntimeWireEffects(device, 'ble', { driverIdentityId: IDENTITY }, deps); - for (const cb of telemetrySubscribers) { - cb({ - from: UNKNOWN_NODE, - rxTime: Math.floor(Date.now() / 1000), - data: { deviceMetrics: { batteryLevel: 85, voltage: 4.1 } }, - }); - } + packetRouter.dispatch( + { + type: 'telemetry', + payload: { + nodeId: UNKNOWN_NODE, + timestamp: Date.now(), + batteryLevel: 85, + voltage: 4.1, + variantCase: 'deviceMetrics', + }, + }, + IDENTITY, + ); await vi.waitFor(() => { expect(sendPacket).toHaveBeenCalled(); diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts new file mode 100644 index 000000000..2870c4241 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts @@ -0,0 +1,856 @@ +import { type MeshDevice, Types } from '@meshtastic/core'; +import { Admin, Portnums } from '@meshtastic/protobufs'; +import type { Dispatch, RefObject, SetStateAction } from 'react'; + +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { persistLastRfSelfNodeId } from '@/renderer/lib/meshtasticMqttIdentity'; +import type { MeshtasticLoraConfig } from '@/shared/meshtasticUrlEncoder'; + +import { meshtasticNodeLacksDisplayIdentity } from '../../../shared/nodeNameUtils'; +import { setConnection } from '../../stores/connectionStore'; +import { setMeshtasticConfigSlice } from '../../stores/deviceStore'; +import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; +import { updateIdentity } from '../../stores/identityStore'; +import { usePositionHistoryStore } from '../../stores/positionHistoryStore'; +import { safeDisconnect } from '../connection'; +import { connectionDriver } from '../drivers/ConnectionDriver'; +import { isForeignLoraLogCandidate } from '../foreignLoraDetection'; +import type { OurPosition } from '../gpsSource'; +import { getIdentityNode } from '../identityStoreReads'; +import { attachMeshtasticIngest, type MeshtasticIngestSession } from '../ingest/meshtasticIngest'; +import { attachMeshtasticProtocolIngress, meshtasticTransportParams } from '../meshIdentityBridge'; +import { setMeshtasticConnectedMyNodeNum } from '../meshtasticConnectedNodeRef'; +import type { MeshtasticRemoteAdminClient } from '../meshtasticRemoteAdmin'; +import type { MeshtasticRawPacketEntry } from '../rawPacketLogConstants'; +import { getStoredMeshProtocol } from '../storedMeshProtocol'; +import { + MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS, + MESHTASTIC_LOCAL_LORA_CONFIG_DELAY_MS, +} from '../timeConstants'; +import type { + ConnectionType, + DeviceState, + EnvironmentTelemetryPoint, + MeshNode, + MeshtasticRemoteConfigSnapshot, + MeshWaypoint, + MQTTStatus, + NeighborInfoRecord, + RemoteAdminStatus, + TelemetryPoint, +} from '../types'; +import { recordMeshtasticClientNotification } from './meshtasticClientNotification'; +import { shouldFetchLocalLoraConfigAfterConfigure } from './meshtasticLocalLoraConfig'; +import type { ModulePortEvent, PaxCounterPoint } from './meshtasticModuleEvents'; +import { attachMeshtasticModulePortSideEffects } from './meshtasticModulePortSideEffects'; +import type { MeshtasticMqttClientProxyBridge } from './meshtasticMqttClientProxy'; +import { attachMeshtasticNodeSideEffects } from './meshtasticNodeSideEffects'; +import { attachMeshtasticRawPacketSideEffects } from './meshtasticRawPacketSideEffects'; +import { attachMeshtasticRouterSideEffects } from './meshtasticRouterSideEffects'; +import { + installMeshtasticSdkRoutingErrorConsoleHook, + installMeshtasticSdkRoutingErrorUnhandledRejectionHandler, +} from './meshtasticSdkRoutingErrorConsoleHook'; +import { + applyMeshtasticOutboundRoutingErrorFromLog, + applyMeshtasticOutboundRoutingErrorFromRejection, +} from './meshtasticSdkRoutingErrorLog'; +import { attachMeshtasticStoreForwardSideEffects } from './meshtasticStoreForwardSideEffects'; +import { attachMeshtasticTraceSideEffects } from './meshtasticTraceSideEffects'; +import { pushMeshtasticTransportSideEffectUnsubs } from './meshtasticTransportSideEffects'; + +const REQUEST_NODEINFO_MIN_INTERVAL_MS = 120_000; +const { DeviceStatusEnum } = Types; + +export type RequestStoreForwardHistoryResult = + | { ok: true } + | { + ok: false; + code: + | 'no_server' + | 'not_configured' + | 'local_is_server' + | 'send_failed' + | 'cooldown' + | 'offline_gate' + | 'already_requested'; + }; + +export interface MeshtasticRuntimeWireEffectsDeps { + channelConfigsRef: RefObject< + { + index: number; + name: string; + role: number; + psk: Uint8Array; + uplinkEnabled: boolean; + downlinkEnabled: boolean; + positionPrecision: number; + }[] + >; + configureTargetNodeNumRef: RefObject; + configureTargetPersistRestoredRef: RefObject; + configureTimeoutRef: RefObject | null>; + connectionParamsRef: RefObject<{ + type: ConnectionType; + httpAddress?: string; + blePeripheralId?: string; + lastSerialPortId?: string | null; + } | null>; + deviceConfiguredRef: RefObject; + deviceGpsModeRef: RefObject; + deviceRef: RefObject; + handleConnectionLostRef: RefObject<() => void>; + schedulePostCommitRebootRecoveryRef: RefObject<(source?: string) => void>; + clearPostCommitRebootRecoveryRef: RefObject<() => void>; + isConfiguringRef: RefObject; + lastDataReceivedRef: RefObject; + lastNodeInfoRequestAtRef: RefObject>; + lastRfDisconnectAtRef: RefObject; + lastRfSelfNodeIdRef: RefObject; + lastSfHeartbeatChannelRef: RefObject; + lastSfHeartbeatPeriodRef: RefObject; + lastSfHeartbeatServerRef: RefObject; + localLoraConfigTimerRef: RefObject | undefined>; + meshtasticIdentityIdRef: RefObject; + meshtasticIngestSessionRef: RefObject; + meshtasticIngressDetachRef: RefObject<(() => void) | null>; + mqttClientProxyBridgeRef: RefObject; + mqttStatusRef: RefObject; + myNodeNumRef: RefObject; + ackMeshPacketIdByTempIdRef: RefObject>; + pendingTracePacketIdToTargetRef: RefObject>; + pendingTraceRequestsRef: RefObject>; + refreshOurPositionRef: RefObject<() => Promise>; + remoteAdminClientRef: RefObject; + remoteAdminStatusRef: RefObject; + requestStoreForwardHistoryRef: RefObject< + (options?: { + serverNodeId?: number; + manual?: boolean; + }) => Promise + >; + rfHeardNodeIds: RefObject>; + sfHistoryRequestedServersRef: RefObject>; + skipLocalLoraConfigRef: RefObject; + loraConfigRef: RefObject; + unsubscribesRef: RefObject<(() => void)[]>; + virtualNodeIdRef: RefObject; + touchLastData: () => void; + applyOwnNodeBatteryFromDeviceMetrics: (batteryLevel: number) => void; + getNodeName: (nodeNum: number) => string; + updateNodes: (updater: (prev: Map) => Map) => void; + startWatchdog: () => void; + stopWatchdog: () => void; + cleanupSubscriptions: () => void; + startGpsInterval: () => void; + stopGpsInterval: () => void; + isDuplicate: (senderId: number, packetId: number) => boolean; + ensureNodeExists: (nodeNum: number, source: 'rf' | 'mqtt') => void; + clearConfigureTimeout: () => void; + applyMeshtasticForeignLoraFromLog: (message: string) => void; + emptyNode: (nodeId: number) => MeshNode; + setMeshtasticIdentityId: Dispatch>; + setState: Dispatch>; + setQueueStatus: Dispatch>; + setDeviceLogs: Dispatch< + SetStateAction<{ message: string; time: number; source: string; level: number }[]> + >; + setTraceRouteResults: Dispatch< + SetStateAction> + >; + setNeighborInfo: Dispatch>>; + setWaypoints: Dispatch>>; + setModuleConfigs: Dispatch>>; + setSecurityConfig: Dispatch< + SetStateAction<{ + publicKey: Uint8Array; + privateKey: Uint8Array; + adminKey: Uint8Array[]; + isManaged: boolean; + serialEnabled: boolean; + debugLogApiEnabled: boolean; + adminChannelEnabled: boolean; + } | null> + >; + setLoraConfig: Dispatch>; + setConfigureTargetNodeNumState: Dispatch>; + setRemoteConfigSnapshot: Dispatch>; + setRemoteAdminStatus: Dispatch>; + setRemoteAdminError: Dispatch>; + setTelemetry: Dispatch>; + setSignalTelemetry: Dispatch>; + setEnvironmentTelemetry: Dispatch>; + setDeviceOwner: Dispatch< + SetStateAction<{ longName: string; shortName: string; isLicensed: boolean } | null> + >; + setDeviceGpsMode: Dispatch>; + setDeviceFixedPosition: Dispatch>; + setTelemetryDeviceUpdateInterval: Dispatch>; + setRawPackets: Dispatch>; + setRemoteHardwareMessages: Dispatch< + SetStateAction> + >; + setAudioMessages: Dispatch< + SetStateAction> + >; + setDetectionSensorEvents: Dispatch>>; + setPingResponses: Dispatch< + SetStateAction> + >; + setIpTunnelMessages: Dispatch< + SetStateAction> + >; + setPaxCounterData: Dispatch>>; + setSerialMessages: Dispatch< + SetStateAction> + >; + setStoreForwardMessages: Dispatch< + SetStateAction> + >; + setRangeTestPackets: Dispatch>>; + setZpsMessages: Dispatch< + SetStateAction> + >; + setSimulatorPackets: Dispatch< + SetStateAction> + >; + setAtakMessages: Dispatch< + SetStateAction> + >; + setMapReports: Dispatch< + SetStateAction> + >; + setPrivateMessages: Dispatch< + SetStateAction> + >; +} + +export function attachMeshtasticRuntimeWireEffects( + device: MeshDevice, + type: ConnectionType, + opts: { driverIdentityId?: string } | undefined, + deps: MeshtasticRuntimeWireEffectsDeps, +): void { + const { + channelConfigsRef, + configureTargetNodeNumRef, + configureTargetPersistRestoredRef, + configureTimeoutRef, + connectionParamsRef, + deviceConfiguredRef, + deviceGpsModeRef, + deviceRef, + handleConnectionLostRef, + schedulePostCommitRebootRecoveryRef, + clearPostCommitRebootRecoveryRef, + isConfiguringRef, + lastDataReceivedRef, + lastNodeInfoRequestAtRef, + lastRfDisconnectAtRef, + lastRfSelfNodeIdRef, + lastSfHeartbeatChannelRef, + lastSfHeartbeatPeriodRef, + lastSfHeartbeatServerRef, + localLoraConfigTimerRef, + meshtasticIdentityIdRef, + meshtasticIngestSessionRef, + meshtasticIngressDetachRef, + mqttClientProxyBridgeRef, + mqttStatusRef, + myNodeNumRef, + ackMeshPacketIdByTempIdRef, + pendingTracePacketIdToTargetRef, + pendingTraceRequestsRef, + refreshOurPositionRef, + remoteAdminClientRef, + remoteAdminStatusRef, + requestStoreForwardHistoryRef, + rfHeardNodeIds, + sfHistoryRequestedServersRef, + skipLocalLoraConfigRef, + loraConfigRef, + unsubscribesRef, + virtualNodeIdRef, + touchLastData, + applyOwnNodeBatteryFromDeviceMetrics, + getNodeName, + updateNodes, + startWatchdog, + stopWatchdog, + cleanupSubscriptions, + startGpsInterval, + stopGpsInterval, + isDuplicate, + ensureNodeExists, + clearConfigureTimeout, + applyMeshtasticForeignLoraFromLog, + emptyNode, + setMeshtasticIdentityId, + setState, + setQueueStatus, + setDeviceLogs, + setTraceRouteResults, + setNeighborInfo, + setWaypoints, + setModuleConfigs, + setSecurityConfig, + setLoraConfig, + setConfigureTargetNodeNumState, + setRemoteConfigSnapshot, + setRemoteAdminStatus, + setRemoteAdminError, + setTelemetry, + setSignalTelemetry, + setEnvironmentTelemetry, + setDeviceOwner, + setDeviceGpsMode, + setDeviceFixedPosition, + setTelemetryDeviceUpdateInterval, + setRawPackets, + setRemoteHardwareMessages, + setAudioMessages, + setDetectionSensorEvents, + setPingResponses, + setIpTunnelMessages, + setPaxCounterData, + setSerialMessages, + setStoreForwardMessages, + setRangeTestPackets, + setZpsMessages, + setSimulatorPackets, + setAtakMessages, + setMapReports, + setPrivateMessages, + } = deps; + + // Protocol ingress → identity-scoped stores. Handlers below cover only what + // `MeshtasticProtocol` does not emit yet: connection lifecycle (configure + // timeout, watchdog, GPS), MQTT client proxy, module-port UI maps, Store & + // Forward, and remote admin. Everything the Protocol emits is consumed from + // `PacketRouter` (see `meshtasticIngest` / `meshtasticRouterSideEffects`). + if (meshtasticIngressDetachRef.current) { + meshtasticIngressDetachRef.current(); + } + const cp = connectionParamsRef.current; + let identityId = opts?.driverIdentityId ?? null; + if (identityId) { + meshtasticIngressDetachRef.current = null; + meshtasticIdentityIdRef.current = identityId; + setMeshtasticIdentityId(identityId); + } else { + const ingress = attachMeshtasticProtocolIngress(device, type, { + peripheralId: cp?.blePeripheralId, + host: cp?.httpAddress, + }); + meshtasticIngressDetachRef.current = ingress.detach; + identityId = ingress.identityId; + meshtasticIdentityIdRef.current = identityId; + setMeshtasticIdentityId(identityId); + } + if (meshtasticIngestSessionRef.current) { + meshtasticIngestSessionRef.current.detach(); + } + if (identityId) { + meshtasticIngestSessionRef.current = attachMeshtasticIngest(identityId, { + getIsConfiguring: () => isConfiguringRef.current, + getMyNodeNum: () => myNodeNumRef.current, + }); + } + + // ─── Device status ───────────────────────────────────────── + const unsub1 = device.events.onDeviceStatus.subscribe((status) => { + if (status !== DeviceStatusEnum.DeviceRestarting) { + touchLastData(); + } + const statusMap: Record = { + [DeviceStatusEnum.DeviceRestarting]: 'connecting', + [DeviceStatusEnum.DeviceDisconnected]: 'disconnected', + [DeviceStatusEnum.DeviceConnecting]: 'connecting', + [DeviceStatusEnum.DeviceReconnecting]: 'connecting', + [DeviceStatusEnum.DeviceConnected]: 'connected', + [DeviceStatusEnum.DeviceConfiguring]: 'connecting', + [DeviceStatusEnum.DeviceConfigured]: 'configured', + }; + const mapped = statusMap[status] ?? 'connected'; + setState((s) => ({ + ...s, + status: mapped, + ...(mapped === 'configured' || mapped === 'connected' ? { connectionLoss: false } : {}), + })); + + if (status === DeviceStatusEnum.DeviceRestarting) { + deviceConfiguredRef.current = false; + isConfiguringRef.current = true; + meshtasticIngestSessionRef.current?.setConfiguring(true); + schedulePostCommitRebootRecoveryRef.current('DeviceRestarting'); + } + + // Track configuring phase so packet replays are marked as historical + if ( + status === DeviceStatusEnum.DeviceConnecting || + status === DeviceStatusEnum.DeviceConnected || + status === DeviceStatusEnum.DeviceConfiguring + ) { + isConfiguringRef.current = true; + meshtasticIngestSessionRef.current?.setConfiguring(true); + if ( + status === DeviceStatusEnum.DeviceConfiguring && + type === 'ble' && + !configureTimeoutRef.current + ) { + configureTimeoutRef.current = setTimeout(() => { + console.warn('[useMeshtasticRuntime] configure timeout (BLE 30s) — forcing disconnect'); + const activeDevice = deviceRef.current; + deviceRef.current = null; + if (activeDevice) { + void safeDisconnect(activeDevice).catch((e: unknown) => { + console.debug( + '[useMeshtasticRuntime] configure timeout safeDisconnect ' + errLikeToLogString(e), + ); + }); + } + cleanupSubscriptions(); + stopWatchdog(); + stopGpsInterval(); + setState({ + status: 'disconnected', + myNodeNum: 0, + connectionType: null, + batteryPercent: undefined, + batteryCharging: undefined, + }); + clearConfigureTimeout(); + }, 30000); + } + } + + // Start watchdog when configured + if (status === DeviceStatusEnum.DeviceConfigured) { + clearPostCommitRebootRecoveryRef.current(); + clearConfigureTimeout(); + isConfiguringRef.current = false; + meshtasticIngestSessionRef.current?.setConfiguring(false); + lastDataReceivedRef.current = Date.now(); + startWatchdog(); + void refreshOurPositionRef.current(); + startGpsInterval(); + setQueueStatus({ free: 16, maxlen: 16, res: 0 }); + deviceConfiguredRef.current = true; + mqttClientProxyBridgeRef.current?.flushPendingToDevice(); + const myNode = myNodeNumRef.current; + if (myNode > 0) { + const requestMetadataAfterConfigure = (attempt: 1 | 2): void => { + void device.getMetadata(myNode).catch((e: unknown) => { + console.debug( + '[useMeshtasticRuntime] getMetadata after configure failed ' + + errLikeToLogString(e) + + (attempt === 2 ? ' (retry)' : ''), + ); + if (attempt === 1) { + setTimeout(() => { + requestMetadataAfterConfigure(2); + }, MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS); + } + }); + }; + requestMetadataAfterConfigure(1); + } + if (localLoraConfigTimerRef.current != null) { + clearTimeout(localLoraConfigTimerRef.current); + } + localLoraConfigTimerRef.current = setTimeout(() => { + localLoraConfigTimerRef.current = undefined; + if ( + !shouldFetchLocalLoraConfigAfterConfigure({ + skipLocalLoraConfig: skipLocalLoraConfigRef.current, + configureTargetNodeNum: configureTargetNodeNumRef.current, + remoteAdminStatus: remoteAdminStatusRef.current, + loraConfig: loraConfigRef.current, + }) + ) { + return; + } + void deviceRef.current + ?.getConfig(Admin.AdminMessage_ConfigType.LORA_CONFIG) + .catch((e: unknown) => { + console.debug( + '[useMeshtasticRuntime] LoRa config request failed ' + errLikeToLogString(e), + ); + }); + }, MESHTASTIC_LOCAL_LORA_CONFIG_DELAY_MS); + } + + // Always clean up on disconnect, even if we never reached configured + if (status === DeviceStatusEnum.DeviceDisconnected) { + if (localLoraConfigTimerRef.current != null) { + clearTimeout(localLoraConfigTimerRef.current); + localLoraConfigTimerRef.current = undefined; + } + skipLocalLoraConfigRef.current = false; + lastRfDisconnectAtRef.current = Date.now(); + rfHeardNodeIds.current.clear(); + lastNodeInfoRequestAtRef.current.clear(); + clearConfigureTimeout(); + isConfiguringRef.current = false; + stopWatchdog(); + stopGpsInterval(); + cleanupSubscriptions(); + setTraceRouteResults(new Map()); + setQueueStatus(null); + setDeviceLogs([]); + usePositionHistoryStore.getState().clearHistory(); + setNeighborInfo(new Map()); + setWaypoints(new Map()); + setModuleConfigs({}); + setSecurityConfig(null); + setLoraConfig(null); + setConfigureTargetNodeNumState(null); + configureTargetNodeNumRef.current = null; + configureTargetPersistRestoredRef.current = false; + setRemoteConfigSnapshot(null); + setRemoteAdminStatus('idle'); + setRemoteAdminError(undefined); + remoteAdminClientRef.current?.resetEditState(); + remoteAdminClientRef.current?.sessionStore.clear(); + deviceRef.current = null; + deviceConfiguredRef.current = false; + sfHistoryRequestedServersRef.current = new Set(); + setState((s) => ({ + ...s, + status: 'disconnected', + connectionType: null, + firmwareVersion: undefined, + batteryPercent: undefined, + batteryCharging: undefined, + })); + } + }); + unsubscribesRef.current.push(unsub1); + + // ─── My node info ────────────────────────────────────────── + const unsub2 = device.events.onMyNodeInfo.subscribe((info) => { + console.debug(`[useMeshtasticRuntime] onMyNodeInfo: myNodeNum=${info.myNodeNum}`); + touchLastData(); + const virtualNodeId = virtualNodeIdRef.current; + if (virtualNodeId !== info.myNodeNum) { + window.electronAPI.db.deleteNode(virtualNodeId).catch((e: unknown) => { + console.debug('[useMeshtasticRuntime] deleteNode virtual ' + errLikeToLogString(e)); + }); + } + myNodeNumRef.current = info.myNodeNum; + const identityId = meshtasticIdentityIdRef.current; + if (identityId) { + const cp = connectionParamsRef.current; + if (cp) { + const transportParams = meshtasticTransportParams(cp.type, { + peripheralId: cp.blePeripheralId, + host: cp.httpAddress, + }); + connectionDriver.remapMeshtasticNodeSignature(identityId, transportParams, info.myNodeNum); + } else { + updateIdentity(identityId, { + selfNodeNum: info.myNodeNum, + signature: `meshtastic:node:${info.myNodeNum}`, + }); + } + setConnection(identityId, { myNodeNum: info.myNodeNum, status: 'configured' }); + } + setMeshtasticConnectedMyNodeNum(info.myNodeNum); + lastRfSelfNodeIdRef.current = info.myNodeNum; + persistLastRfSelfNodeId(info.myNodeNum); + if (getStoredMeshProtocol() === 'meshtastic') { + useDiagnosticsStore.getState().migrateForeignLoraFromZero(info.myNodeNum); + } + setState((s) => ({ + ...s, + myNodeNum: info.myNodeNum, + batteryPercent: undefined, + batteryCharging: undefined, + })); + updateNodes((prev) => { + const updated = new Map(prev); + if (virtualNodeId !== info.myNodeNum) updated.delete(virtualNodeId); + const existing = updated.get(info.myNodeNum); + if (!existing) { + const selfNode: MeshNode = { + ...emptyNode(info.myNodeNum), + hops_away: 0, + last_heard: Date.now(), + source: 'rf', + heard_via_mqtt_only: false, + }; + updated.set(info.myNodeNum, selfNode); + } else { + const selfNode: MeshNode = { + ...existing, + hops_away: 0, + source: 'rf', + heard_via_mqtt_only: false, + }; + updated.set(info.myNodeNum, selfNode); + void window.electronAPI.db.saveNode(selfNode); + } + return updated; + }); + }); + unsubscribesRef.current.push(unsub2); + + const maybeRequestNodeInfoForNode = (from: number): void => { + if (from === 0 || from === myNodeNumRef.current) return; + if (isConfiguringRef.current) return; + const existing = getIdentityNode(meshtasticIdentityIdRef.current, from); + if (existing && !meshtasticNodeLacksDisplayIdentity(existing, from)) return; + const now = Date.now(); + const last = lastNodeInfoRequestAtRef.current.get(from) ?? 0; + if (now - last < REQUEST_NODEINFO_MIN_INTERVAL_MS) return; + lastNodeInfoRequestAtRef.current.set(from, now); + void (async () => { + try { + await device.sendPacket(new Uint8Array(), Portnums.PortNum.NODEINFO_APP, from); + console.debug(`[useMeshtasticRuntime] NODEINFO request sent for 0x${from.toString(16)}`); + } catch (e: unknown) { + console.debug( + '[useMeshtasticRuntime] NODEINFO request failed', + e instanceof Error ? e.message : e, + ); + } + })(); + }; + + // ─── PacketRouter side effects (Protocol owns SDK packet decode) ─── + if (identityId) { + unsubscribesRef.current.push( + attachMeshtasticRouterSideEffects(identityId, { + getMyNodeNum: () => myNodeNumRef.current, + getMqttStatus: () => mqttStatusRef.current, + getChannelConfigs: () => channelConfigsRef.current, + hasRfDevice: () => !!deviceRef.current, + getNodeName, + registerMqttEchoPacketId: (senderId, packetId) => { + isDuplicate(senderId, packetId); + }, + requestNodeInfoForNode: maybeRequestNodeInfoForNode, + applyForeignLoraFromLog: applyMeshtasticForeignLoraFromLog, + applyRoutingErrorFromLog: (message) => { + applySdkRoutingErrorFromLog(message); + }, + setFirmwareVersion: (firmwareVersion) => { + setState((s) => ({ ...s, firmwareVersion })); + }, + }), + attachMeshtasticTraceSideEffects(identityId, { + pendingTracePacketIdToTargetRef, + pendingTraceRequestsRef, + setTraceRouteResults, + touchLastData, + }), + attachMeshtasticRawPacketSideEffects(identityId, { + getMyNodeNum: () => myNodeNumRef.current, + getIsConfiguring: () => isConfiguringRef.current, + updateNodes, + setRawPackets, + setSignalTelemetry, + touchLastData, + }), + attachMeshtasticModulePortSideEffects(identityId, { + touchLastData, + setRemoteHardwareMessages, + setAudioMessages, + setDetectionSensorEvents, + setPingResponses, + setIpTunnelMessages, + setPaxCounterData, + setSerialMessages, + setRangeTestPackets, + setZpsMessages, + setSimulatorPackets, + setAtakMessages, + setMapReports, + setPrivateMessages, + }), + attachMeshtasticStoreForwardSideEffects(identityId, { + touchLastData, + getNodeName, + getIsDeviceConfigured: () => deviceConfiguredRef.current, + recordHeartbeat: ({ serverNodeId, channel, period }) => { + lastSfHeartbeatServerRef.current = serverNodeId; + lastSfHeartbeatChannelRef.current = channel; + lastSfHeartbeatPeriodRef.current = period; + }, + requestStoreForwardHistory: (options) => { + void requestStoreForwardHistoryRef.current(options); + }, + setStoreForwardMessages, + }), + attachMeshtasticNodeSideEffects(identityId, { + connectionType: type, + getMyNodeNum: () => myNodeNumRef.current, + getIsConfiguring: () => isConfiguringRef.current, + getBluetoothDeviceId: () => + (device.transport as { __bluetoothDevice?: { id?: string } })?.__bluetoothDevice?.id, + touchLastData, + updateNodes, + emptyNode, + ensureNodeExists, + maybeRequestNodeInfoForNode, + applyOwnNodeBatteryFromDeviceMetrics, + rfHeardNodeIds, + setDeviceOwner, + setTelemetry, + setEnvironmentTelemetry, + }), + ); + } + + // Node identity, position, and telemetry → meshtasticNodeSideEffects + // (node_info / position / telemetry DomainEvents). + + // Channel pills and channel configs → deviceStore via MeshtasticProtocol + // `channel` events; the runtime reads them back through `resolvedChannelConfigs`. + + // SNR/RSSI + sniffer → meshtasticRawPacketSideEffects (raw_packet DomainEvent). + + // ─── Mesh heartbeat (built-in liveness signal) ───────────── + const unsub10 = device.events.onMeshHeartbeat.subscribe(() => { + touchLastData(); + }); + unsubscribesRef.current.push(unsub10); + + // ─── Device config (track GPS mode and telemetry) ─────────── + const unsubConfig = device.events.onConfigPacket.subscribe((config) => { + if (configureTargetNodeNumRef.current != null) return; + const cfg = config as { + payloadVariant?: { + case?: string; + value?: { + gpsMode?: number; + device_update_interval?: number; + deviceUpdateInterval?: number; + }; + }; + }; + if (cfg.payloadVariant?.case === 'position' && cfg.payloadVariant.value?.gpsMode != null) { + deviceGpsModeRef.current = cfg.payloadVariant.value.gpsMode; + setDeviceGpsMode(cfg.payloadVariant.value.gpsMode); + const fixedPosition = (cfg.payloadVariant.value as { fixedPosition?: boolean }).fixedPosition; + if (typeof fixedPosition === 'boolean') { + setDeviceFixedPosition(fixedPosition); + } + } + if (cfg.payloadVariant?.case === 'telemetry' && cfg.payloadVariant.value != null) { + const interval = + cfg.payloadVariant.value.device_update_interval ?? + cfg.payloadVariant.value.deviceUpdateInterval; + if (typeof interval === 'number') { + setTelemetryDeviceUpdateInterval(interval); + } + } + if (cfg.payloadVariant?.case === 'security' && cfg.payloadVariant.value != null) { + setSecurityConfig( + cfg.payloadVariant.value as { + publicKey: Uint8Array; + privateKey: Uint8Array; + adminKey: Uint8Array[]; + isManaged: boolean; + serialEnabled: boolean; + debugLogApiEnabled: boolean; + adminChannelEnabled: boolean; + }, + ); + } + if (cfg.payloadVariant?.case === 'lora' && cfg.payloadVariant.value != null) { + setLoraConfig(cfg.payloadVariant.value as MeshtasticLoraConfig); + } + const configCase = cfg.payloadVariant?.case; + const configValue = cfg.payloadVariant?.value; + const identityId = meshtasticIdentityIdRef.current; + if (configCase && configValue != null && identityId) { + setMeshtasticConfigSlice(identityId, configCase, configValue); + } + }); + unsubscribesRef.current.push(unsubConfig); + + const unsubFromRadio = device.events.onFromRadio.subscribe((packet) => { + const variant = packet.payloadVariant; + if (variant?.case === 'mqttClientProxyMessage') { + void mqttClientProxyBridgeRef.current?.handleFromRadio(packet).catch((e: unknown) => { + console.warn( + '[useMeshtasticRuntime] mqttClientProxy FromRadio failed ' + errLikeToLogString(e), + ); + }); + return; + } + if (variant?.case === 'clientNotification') { + const message = variant.value?.message; + if (typeof message === 'string' && message.trim()) { + recordMeshtasticClientNotification(message); + } + } + }); + unsubscribesRef.current.push(unsubFromRadio); + + // Traceroute correlation → meshtasticTraceSideEffects (trace_route DomainEvent). + + // Queue status → connectionStore via MeshtasticProtocol + PacketRouter. + + const applySdkRoutingErrorFromLog = (logMessage: string): boolean => { + return applyMeshtasticOutboundRoutingErrorFromLog(logMessage, { + myNodeNum: myNodeNumRef.current, + identityId: meshtasticIdentityIdRef.current, + tempIdToWirePacketId: ackMeshPacketIdByTempIdRef.current, + }); + }; + + const applySdkRoutingErrorFromRejection = (reason: unknown): boolean => { + const uiApplied = applyMeshtasticOutboundRoutingErrorFromRejection(reason, { + myNodeNum: myNodeNumRef.current, + identityId: meshtasticIdentityIdRef.current, + tempIdToWirePacketId: ackMeshPacketIdByTempIdRef.current, + }); + if (!uiApplied) { + const parsed = reason as { id?: number; packetId?: number; error?: number }; + const packetId = parsed.id ?? parsed.packetId; + console.debug('[meshtasticSdkRoutingErrorLog] SDK queue rejection', packetId, parsed.error); + } + return uiApplied; + }; + + // Device log records → deviceStore via protocol; foreign-LoRa and SDK routing + // error parsing run from the PacketRouter `device_log` listener attached above. + // Main-process log lines are not a protocol event, so they stay subscribed here. + const unsubForeignLoraLogLine = window.electronAPI.log.onLine((entry) => { + if (isForeignLoraLogCandidate(entry.message)) { + applyMeshtasticForeignLoraFromLog(entry.message); + } + applySdkRoutingErrorFromLog(entry.message); + }); + unsubscribesRef.current.push(unsubForeignLoraLogLine); + + unsubscribesRef.current.push( + installMeshtasticSdkRoutingErrorConsoleHook(applySdkRoutingErrorFromLog), + installMeshtasticSdkRoutingErrorUnhandledRejectionHandler(applySdkRoutingErrorFromRejection), + ); + + // Neighbor info → nodeStore via protocol ingress. + + // Waypoint MQTT uplink → meshtasticRouterSideEffects (waypoint DomainEvent). + + // Module config → deviceStore via protocol ingress (skipped during remote configure). + + const unsubRemoteAdmin = device.events.onMeshPacket.subscribe((meshPacket) => { + remoteAdminClientRef.current?.handleMeshPacket(meshPacket as never); + }); + unsubscribesRef.current.push(unsubRemoteAdmin); + + // Module ports + Store & Forward → PacketRouter side-effect modules. + + pushMeshtasticTransportSideEffectUnsubs( + device, + type, + (unsub) => unsubscribesRef.current.push(unsub), + () => { + handleConnectionLostRef.current(); + }, + ); +} diff --git a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts index f16c4dcb6..87102fa43 100644 --- a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts @@ -4,10 +4,12 @@ vi.mock('@/renderer/lib/i18n', () => ({ default: { t: (key: string) => key }, })); +const storeMessages: Record> = {}; + vi.mock('@/renderer/stores/messageStore', () => ({ updateMessageStatus: vi.fn(), useMessageStore: { - getState: () => ({ messages: {} }), + getState: () => ({ messages: storeMessages }), }, })); @@ -32,10 +34,53 @@ import { parseMeshtasticSdkRoutingErrorLog, } from './meshtasticSdkRoutingErrorLog'; +const IDENTITY = 'id-1'; +/** Mesh.Routing_Error.TIMEOUT — mapped to chatPanel.routingErrors.timeout */ +const ROUTING_TIMEOUT = 3; + +interface SeedRow { + packetId: number; + payload?: string; + channelIndex?: number; + timestamp?: number; + from?: number; +} + +function clearStoreMessages(): void { + for (const key of Object.keys(storeMessages)) { + storeMessages[key] = undefined as unknown as Record; + Reflect.deleteProperty(storeMessages, key); + } +} + +/** Seed identity-scoped outbound rows — the only source routing errors read. */ +function seedOutbound(rows: SeedRow[]): void { + clearStoreMessages(); + storeMessages[IDENTITY] = Object.fromEntries( + rows.map((row) => [ + String(row.packetId), + { + id: String(row.packetId), + from: row.from ?? 42, + senderName: 'Me', + to: 0xffffffff, + payload: row.payload ?? 'hello', + channelIndex: row.channelIndex ?? 0, + timestamp: row.timestamp ?? Date.now(), + status: 'sending', + }, + ]), + ); +} + describe('meshtasticSdkRoutingErrorLog', () => { beforeEach(() => { resetMeshtasticOutboundCoordinationForTests(); + vi.mocked(updateMessageStatus).mockClear(); + clearStoreMessages(); vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), electronAPI: { db: { updateMessageStatus: vi.fn().mockResolvedValue(undefined) }, }, @@ -75,321 +120,145 @@ describe('meshtasticSdkRoutingErrorLog', () => { }); it('marks matching outbound message failed', () => { - const messagesRef = { - current: [ - { - id: 1, - sender_id: 42, - sender_name: 'Me', - packetId: 669520633, - payload: 'hello', - status: 'sending' as const, - channel: 0, - timestamp: 1, - }, - ], - }; - const setMessages = vi.fn(); + seedOutbound([{ packetId: 669520633 }]); const applied = applyMeshtasticOutboundRoutingErrorFromLog( 'Error received for packet 669520633: PKI_SEND_FAIL_PUBLIC_KEY', - { - myNodeNum: 42, - identityId: 'id-1', - messagesRef, - setMessages, - }, + { myNodeNum: 42, identityId: IDENTITY }, ); expect(applied).toBe(true); - expect(setMessages).toHaveBeenCalledTimes(1); expect(updateMessageStatus).toHaveBeenCalledWith( - 'id-1', + IDENTITY, '669520633', 'failed', 'chatPanel.routingErrors.pkiMissingRecipientKey', ); }); - it('matches optimistic temp packet id via tempIdToWirePacketId map', () => { - const messagesRef = { - current: [ - { - id: 1, - sender_id: 42, - sender_name: 'Me', - packetId: 100, - payload: 'hello', - status: 'sending' as const, - channel: 0, - timestamp: Date.now(), - }, - ], - }; - const setMessages = vi.fn(); + it('marks matching outbound message failed from timeout log', () => { + seedOutbound([{ packetId: 711859058 }]); const applied = applyMeshtasticOutboundRoutingErrorFromLog( - 'Error received for packet 669520633: PKI_SEND_FAIL_PUBLIC_KEY', - { - myNodeNum: 42, - identityId: null, - messagesRef, - setMessages, - tempIdToWirePacketId: new Map([[100, 669520633]]), - }, + 'Packet 711859058 of type packet timed out', + { myNodeNum: 42, identityId: IDENTITY }, ); expect(applied).toBe(true); - expect(setMessages).toHaveBeenCalledTimes(1); + expect(updateMessageStatus).toHaveBeenCalledWith( + IDENTITY, + '711859058', + 'failed', + 'chatPanel.routingErrors.timeout', + ); }); - it('marks packet timeout log lines as failed outbound chat', () => { - const messagesRef = { - current: [ - { - id: 1, - sender_id: 42, - sender_name: 'Me', - packetId: 711859058, - payload: 'hello', - status: 'sending' as const, - channel: 0, - timestamp: Date.now(), - }, - ], - }; - const setMessages = vi.fn(); + it('matches optimistic temp packet id via tempIdToWirePacketId map', () => { + seedOutbound([{ packetId: 1001 }]); const applied = applyMeshtasticOutboundRoutingErrorFromLog( - 'Packet 711859058 of type packet timed out', + 'Error received for packet 669520633: PKI_SEND_FAIL_PUBLIC_KEY', { myNodeNum: 42, - identityId: null, - messagesRef, - setMessages, + identityId: IDENTITY, + tempIdToWirePacketId: new Map([[1001, 669520633]]), }, ); expect(applied).toBe(true); - expect(setMessages).toHaveBeenCalledTimes(1); + expect(updateMessageStatus).toHaveBeenCalled(); }); - it('falls back to a single recent sending outbound when packet id differs', () => { - const messagesRef = { - current: [ - { - id: 1, - sender_id: 42, - sender_name: 'Me', - packetId: 999, - payload: 'hello', - status: 'sending' as const, - channel: 0, - timestamp: Date.now(), - }, - ], - }; - const setMessages = vi.fn(); + it('falls back to a single recent sending outbound when packet id is unmatched', () => { + seedOutbound([{ packetId: 55, timestamp: Date.now() }]); const applied = applyMeshtasticOutboundRoutingErrorFromLog( 'Error received for packet 669520633: PKI_SEND_FAIL_PUBLIC_KEY', - { - myNodeNum: 42, - identityId: null, - messagesRef, - setMessages, - }, + { myNodeNum: 42, identityId: IDENTITY }, ); expect(applied).toBe(true); - expect(setMessages).toHaveBeenCalledTimes(1); - // DB row still holds the optimistic temp packet id (999) — the update must - // target it, not the wire id from the NAK (669520633). - expect(window.electronAPI.db.updateMessageStatus).toHaveBeenCalledWith( - 999, - 'failed', - 'chatPanel.routingErrors.pkiMissingRecipientKey', - ); + expect(updateMessageStatus).toHaveBeenCalled(); }); - it('does not misattribute a registered non-chat wire id to a sending chat row', () => { - registerMeshtasticNonChatWirePacketId(883268679); - const messagesRef = { - current: [ - { - id: 1, - sender_id: 42, - sender_name: 'Me', - packetId: 1979522383, - payload: '📍 Shared location: 40.1, -105.0', - status: 'sending' as const, - channel: 1, - timestamp: Date.now(), - }, - ], - }; - const setMessages = vi.fn(); + it('does not apply when no outbound rows exist', () => { + seedOutbound([]); const applied = applyMeshtasticOutboundRoutingErrorFromLog( - 'Error received for packet 883268679: MAX_RETRANSMIT', - { - myNodeNum: 42, - identityId: null, - messagesRef, - setMessages, - }, + 'Error received for packet 669520633: PKI_SEND_FAIL_PUBLIC_KEY', + { myNodeNum: 42, identityId: IDENTITY }, ); expect(applied).toBe(false); - expect(setMessages).not.toHaveBeenCalled(); + expect(updateMessageStatus).not.toHaveBeenCalled(); }); - it('skips the single-sending fallback while a non-chat outbound is in flight', () => { - beginMeshtasticNonChatOutbound(); - const messagesRef = { - current: [ - { - id: 1, - sender_id: 42, - sender_name: 'Me', - packetId: 1979522383, - payload: '📍 Shared location: 40.1, -105.0', - status: 'sending' as const, - channel: 1, - timestamp: Date.now(), - }, - ], - }; - const setMessages = vi.fn(); + it('does not apply when identity is null', () => { + seedOutbound([{ packetId: 669520633 }]); const applied = applyMeshtasticOutboundRoutingErrorFromLog( - 'Error received for packet 883268679: MAX_RETRANSMIT', - { - myNodeNum: 42, - identityId: null, - messagesRef, - setMessages, - }, + 'Error received for packet 669520633: PKI_SEND_FAIL_PUBLIC_KEY', + { myNodeNum: 42, identityId: null }, ); expect(applied).toBe(false); - expect(setMessages).not.toHaveBeenCalled(); - endMeshtasticNonChatOutbound(); + expect(updateMessageStatus).not.toHaveBeenCalled(); }); - it('humanizes queue rejections to chat i18n text or routing error name', () => { - expect(humanizeMeshtasticSdkQueueRejectionError({ id: 327029706, error: 39 })).toBe( - 'chatPanel.routingErrors.pkiMissingRecipientKey', - ); - expect(humanizeMeshtasticSdkQueueRejectionError({ packetId: 1, error: 3 })).toBe( - 'chatPanel.routingErrors.timeout', - ); - // No chat mapping — fall back to the enum name. - expect(humanizeMeshtasticSdkQueueRejectionError({ id: 1, error: 38 })).toBe( - 'RATE_LIMIT_EXCEEDED', - ); - expect(humanizeMeshtasticSdkQueueRejectionError(new Error('boom'))).toBeNull(); - expect(humanizeMeshtasticSdkQueueRejectionError('nope')).toBeNull(); - }); - - it('returns false for unknown SDK routing error names', () => { - expect(chatRoutingErrorKeyForSdkErrorName('UNKNOWN_ROUTING_ERROR')).toBeNull(); - const messagesRef = { - current: [ - { - id: 1, - sender_id: 42, - sender_name: 'Me', - packetId: 669520633, - payload: 'hello', - status: 'sending' as const, - channel: 0, - timestamp: Date.now(), - }, - ], - }; - const setMessages = vi.fn(); + it('does not apply when two recent sending outbounds are ambiguous', () => { + const now = Date.now(); + seedOutbound([ + { packetId: 1, timestamp: now }, + { packetId: 2, timestamp: now }, + ]); const applied = applyMeshtasticOutboundRoutingErrorFromLog( - 'Error received for packet 669520633: UNKNOWN_ROUTING_ERROR', - { - myNodeNum: 42, - identityId: null, - messagesRef, - setMessages, - }, + 'Error received for packet 669520633: PKI_SEND_FAIL_PUBLIC_KEY', + { myNodeNum: 42, identityId: IDENTITY }, ); expect(applied).toBe(false); - expect(setMessages).not.toHaveBeenCalled(); + expect(updateMessageStatus).not.toHaveBeenCalled(); }); - it('does not apply when two recent sending outbounds are ambiguous', () => { - const now = Date.now(); - const messagesRef = { - current: [ - { - id: 1, - sender_id: 42, - sender_name: 'Me', - packetId: 111, - payload: 'first', - status: 'sending' as const, - channel: 0, - timestamp: now, - }, - { - id: 2, - sender_id: 42, - sender_name: 'Me', - packetId: 222, - payload: 'second', - status: 'sending' as const, - channel: 0, - timestamp: now - 1000, - }, - ], - }; - const setMessages = vi.fn(); + it('does not apply to a known non-chat wire packet id', () => { + seedOutbound([{ packetId: 669520633 }]); + registerMeshtasticNonChatWirePacketId(669520633); const applied = applyMeshtasticOutboundRoutingErrorFromLog( 'Error received for packet 669520633: PKI_SEND_FAIL_PUBLIC_KEY', - { - myNodeNum: 42, - identityId: null, - messagesRef, - setMessages, - }, + { myNodeNum: 42, identityId: IDENTITY }, ); expect(applied).toBe(false); - expect(setMessages).not.toHaveBeenCalled(); + expect(updateMessageStatus).not.toHaveBeenCalled(); }); - it('parses SDK queue rejections with id or packetId', () => { - expect(parseMeshtasticSdkQueueRejection({ id: 397127051, error: 3 })).toEqual({ - packetId: 397127051, - errorName: 'TIMEOUT', - }); - expect(parseMeshtasticSdkQueueRejection({ packetId: 42, error: 8 })).toEqual({ - packetId: 42, - errorName: 'NO_RESPONSE', - }); - expect(parseMeshtasticSdkQueueRejection({ id: 1, error: 'TIMEOUT' })).toBeNull(); - expect(parseMeshtasticSdkQueueRejection('timeout')).toBeNull(); + it('skips fallback while non-chat outbound is in flight', () => { + seedOutbound([{ packetId: 55, timestamp: Date.now() }]); + beginMeshtasticNonChatOutbound(); + try { + const applied = applyMeshtasticOutboundRoutingErrorFromLog( + 'Error received for packet 669520633: PKI_SEND_FAIL_PUBLIC_KEY', + { myNodeNum: 42, identityId: IDENTITY }, + ); + expect(applied).toBe(false); + } finally { + endMeshtasticNonChatOutbound(); + } }); it('marks matching outbound message failed from queue rejection', () => { - const messagesRef = { - current: [ - { - id: 1, - sender_id: 42, - sender_name: 'Me', - packetId: 397127051, - payload: 'hello', - status: 'sending' as const, - channel: 0, - timestamp: Date.now(), - }, - ], - }; - const setMessages = vi.fn(); + seedOutbound([{ packetId: 669520633 }]); const applied = applyMeshtasticOutboundRoutingErrorFromRejection( - { id: 397127051, error: 3 }, - { - myNodeNum: 42, - identityId: null, - messagesRef, - setMessages, - }, + { id: 669520633, error: ROUTING_TIMEOUT }, + { myNodeNum: 42, identityId: IDENTITY }, ); expect(applied).toBe(true); - expect(setMessages).toHaveBeenCalledTimes(1); + expect(updateMessageStatus).toHaveBeenCalled(); + }); + + it('parses queue rejection shapes', () => { + expect(parseMeshtasticSdkQueueRejection({ id: 1, error: 5 })).toEqual({ + packetId: 1, + errorName: expect.any(String), + }); + expect(parseMeshtasticSdkQueueRejection({ packetId: 2, error: 5 })).toEqual({ + packetId: 2, + errorName: expect.any(String), + }); + expect(parseMeshtasticSdkQueueRejection('nope')).toBeNull(); + }); + + it('humanizes queue rejection errors', () => { + expect( + humanizeMeshtasticSdkQueueRejectionError({ id: 1, error: ROUTING_TIMEOUT }), + ).toBeTruthy(); + expect(humanizeMeshtasticSdkQueueRejectionError('x')).toBeNull(); }); }); diff --git a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts index 9a1ef6507..76f86d2af 100644 --- a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts +++ b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts @@ -1,5 +1,3 @@ -import type { Dispatch, RefObject, SetStateAction } from 'react'; - import i18n from '@/renderer/lib/i18n'; import { meshtasticPacketIdsEqual } from '@/renderer/lib/meshtasticMessageDedup'; import { resolveMeshtasticOutboundStoreKey } from '@/renderer/lib/sessions/meshtasticSession'; @@ -90,9 +88,8 @@ export function chatRoutingErrorKeyForSdkErrorName(errorName: string): string | export interface ApplyMeshtasticOutboundRoutingErrorContext { myNodeNum: number; + /** Identity whose `messageStore` bucket holds the outbound row. */ identityId: string | null; - messagesRef: RefObject; - setMessages: Dispatch>; /** tempId → wire packet id assigned by the SDK (may differ from optimistic id). */ tempIdToWirePacketId?: ReadonlyMap; } @@ -133,43 +130,30 @@ function findFallbackSendingOutbound( return candidates.length === 1 ? candidates[0] : undefined; } +function storeMessagesAsChat(identityId: string): ChatMessage[] { + return messageRecordsToChatMessages( + Object.values(useMessageStore.getState().messages[identityId] ?? {}), + ); +} + function findOutboundTargetForWirePacketId( wirePacketId: number, ctx: ApplyMeshtasticOutboundRoutingErrorContext, ): ChatMessage | undefined { - const { myNodeNum, messagesRef, identityId, tempIdToWirePacketId } = ctx; - const fromLegacy = messagesRef.current.find((m) => + const { myNodeNum, identityId, tempIdToWirePacketId } = ctx; + if (!identityId) return undefined; + + const messages = storeMessagesAsChat(identityId); + const matched = messages.find((m) => outboundMatchesWirePacketId(m, myNodeNum, wirePacketId, tempIdToWirePacketId), ); - if (fromLegacy) return fromLegacy; - - if (identityId) { - const storeMsgs = messageRecordsToChatMessages( - Object.values(useMessageStore.getState().messages[identityId] ?? {}), - ); - const fromStore = storeMsgs.find((m) => - outboundMatchesWirePacketId(m, myNodeNum, wirePacketId, tempIdToWirePacketId), - ); - if (fromStore) return fromStore; - } + if (matched) return matched; // An unmatched wire id may belong to a non-chat wantAck packet (e.g. the // share-location Waypoint) — do not misattribute its NAK to a pending chat row. if (hasMeshtasticNonChatOutboundInFlight()) return undefined; - return findFallbackSendingOutbound(messagesRef.current, myNodeNum); -} - -function messageMatchesTarget( - msg: ChatMessage, - target: ChatMessage, - myNodeNum: number, - wirePacketId: number, - tempIdToWirePacketId?: ReadonlyMap, -): boolean { - if (msg === target) return true; - if (target.packetId == null) return false; - return outboundMatchesWirePacketId(msg, myNodeNum, wirePacketId, tempIdToWirePacketId); + return findFallbackSendingOutbound(messages, myNodeNum); } function resolveStoreMessageId(target: ChatMessage, wirePacketId: number): string { @@ -187,7 +171,7 @@ export function applyMeshtasticOutboundRoutingError( return false; } const errorText = i18n.t(i18nKey); - const { myNodeNum, identityId, setMessages, tempIdToWirePacketId } = ctx; + const { identityId } = ctx; // Known non-chat packet (e.g. share-location Waypoint): its NAK must never be // applied to a chat row — the same log line is re-applied asynchronously via // the main-process log echo after the in-flight window closes. @@ -195,23 +179,11 @@ export function applyMeshtasticOutboundRoutingError( return false; } const target = findOutboundTargetForWirePacketId(parsed.packetId, ctx); - if (!target) { + if (!target || !identityId) { return false; } const storeMessageId = resolveStoreMessageId(target, parsed.packetId); - const matches = (m: ChatMessage) => - messageMatchesTarget(m, target, myNodeNum, parsed.packetId, tempIdToWirePacketId); - - setMessages((prev) => - prev.map((m) => - matches(m) - ? { ...m, status: 'failed' as const, error: errorText, packetId: parsed.packetId } - : m, - ), - ); - if (identityId) { - updateMessageStatus(identityId, storeMessageId, 'failed', errorText); - } + updateMessageStatus(identityId, storeMessageId, 'failed', errorText); // The DB row may still hold the optimistic temp packet id (device never acked, // so updateMessagePacketId never ran) — key the update on the row's own id, // not the wire id from the radio NAK, or the UPDATE matches zero rows. diff --git a/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.ts new file mode 100644 index 000000000..cc67c1428 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.ts @@ -0,0 +1,115 @@ +/** + * Store & Forward module side effects driven by the `PacketRouter` + * `meshtastic_store_forward` event. + * + * `MeshtasticProtocol` forwards the raw S&F packet, so the runtime no longer + * needs its own `device.events.onStoreForwardPacket` subscription for the + * heartbeat tracking, auto history request, and replayed-text ingest below. + * + * Failure point: replayed history text is written straight to `messageStore` + + * SQLite because it is not a Protocol chat event. A failed DB write is logged + * and leaves the in-memory row intact; a missed heartbeat only delays the next + * automatic history request until the following heartbeat. + */ +import type { Dispatch, SetStateAction } from 'react'; + +import { addMessage } from '../../stores/messageStore'; +import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; +import { errLikeToLogString } from '../errLikeToLogString'; +import { getIdentityChatMessages } from '../identityStoreReads'; +import { + decodeStoreForwardTextPayload, + isDuplicateHistoryMessage, + parseStoreForwardHeartbeat, +} from '../meshtasticBacklogUtils'; +import type { DomainEvent } from '../protocols/Protocol'; +import { chatMessageToMessageRecord } from '../storeRecordAdapters'; +import type { ChatMessage, IdentityId } from '../types'; + +const MAX_STORE_FORWARD_MESSAGES_PER_NODE = 50; + +export interface MeshtasticStoreForwardSideEffectsDeps { + touchLastData: () => void; + getNodeName: (nodeNum: number) => string; + /** False until the radio finishes configure — auto history must not race it. */ + getIsDeviceConfigured: () => boolean; + /** Last seen router heartbeat, used by the manual Chat catch-up control. */ + recordHeartbeat: (info: { serverNodeId: number; channel: number; period: number }) => void; + requestStoreForwardHistory: (options: { serverNodeId: number; manual: boolean }) => void; + setStoreForwardMessages: Dispatch< + SetStateAction> + >; +} + +type StoreForwardDomainEvent = Extract; + +function storeForwardBytes(raw: unknown): Uint8Array | null { + const data = (raw as { data?: unknown } | null | undefined)?.data; + return data instanceof Uint8Array ? data : null; +} + +function appendReplayedHistoryText(identityId: IdentityId, chat: ChatMessage): void { + if (isDuplicateHistoryMessage(getIdentityChatMessages(identityId), chat)) return; + addMessage(identityId, chatMessageToMessageRecord(chat)); + void window.electronAPI.db.saveMessage(chat).catch((e: unknown) => { + console.debug( + '[meshtasticStoreForwardSideEffects] saveMessage failed ' + errLikeToLogString(e), + ); + }); +} + +function handleStoreForward( + identityId: IdentityId, + event: StoreForwardDomainEvent['payload'], + deps: MeshtasticStoreForwardSideEffectsDeps, +): void { + deps.touchLastData(); + const data = storeForwardBytes(event.raw); + if (!data) return; + + const from = event.from; + const timestamp = event.timestamp; + deps.setStoreForwardMessages((prev) => { + const updated = new Map(prev); + const existing = updated.get(from) ?? []; + updated.set(from, [ + ...existing.slice(-MAX_STORE_FORWARD_MESSAGES_PER_NODE), + { from, data, timestamp }, + ]); + return updated; + }); + + const heartbeat = parseStoreForwardHeartbeat(data); + if (from && heartbeat) { + deps.recordHeartbeat({ serverNodeId: from, channel: event.channel, period: heartbeat.period }); + // secondary === 0 marks the primary router; only it replays chat history. + if (heartbeat.secondary === 0 && deps.getIsDeviceConfigured()) { + deps.requestStoreForwardHistory({ serverNodeId: from, manual: false }); + } + } + + const payloadText = decodeStoreForwardTextPayload(data); + if (!from || !payloadText) return; + appendReplayedHistoryText(identityId, { + sender_id: from, + sender_name: deps.getNodeName(from), + payload: payloadText, + channel: event.channel, + timestamp: Date.now(), + isHistory: true, + receivedVia: 'rf', + viaStoreForward: true, + }); +} + +/** Attach Store & Forward side effects for one Meshtastic identity. */ +export function attachMeshtasticStoreForwardSideEffects( + identityId: IdentityId, + deps: MeshtasticStoreForwardSideEffectsDeps, +): () => void { + const listener: PacketRouterListener = (event, routedIdentityId) => { + if (routedIdentityId !== identityId || event.type !== 'meshtastic_store_forward') return; + handleStoreForward(identityId, event.payload, deps); + }; + return packetRouter.addListener(listener); +} diff --git a/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.test.ts new file mode 100644 index 000000000..875a69f4e --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { packetRouter } from '../drivers/PacketRouter'; +import { attachMeshtasticTraceSideEffects } from './meshtasticTraceSideEffects'; + +describe('attachMeshtasticTraceSideEffects', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('correlates replyId to pending target and merges route results', () => { + const pendingTracePacketIdToTargetRef = { current: new Map([[42, 99]]) }; + const pendingTraceRequestsRef = { current: new Map([[99, Date.now()]]) }; + const setTraceRouteResults = vi.fn(); + const touchLastData = vi.fn(); + const detach = attachMeshtasticTraceSideEffects('id-1', { + pendingTracePacketIdToTargetRef, + pendingTraceRequestsRef, + setTraceRouteResults, + touchLastData, + }); + packetRouter.dispatch( + { + type: 'trace_route', + payload: { + from: 7, + to: 0, + route: [1, 2], + routeBack: [3], + timestamp: Date.now(), + replyId: 42, + }, + }, + 'id-1', + ); + expect(touchLastData).toHaveBeenCalled(); + expect(pendingTracePacketIdToTargetRef.current.has(42)).toBe(false); + expect(setTraceRouteResults).toHaveBeenCalled(); + detach(); + }); +}); diff --git a/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.ts new file mode 100644 index 000000000..35bd076a1 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.ts @@ -0,0 +1,109 @@ +/** + * Traceroute reply correlation driven by the `PacketRouter` `trace_route` event. + * + * `MeshtasticProtocol` decodes both wire shapes (RouteDiscovery inside a decoded + * MeshPacket, and the typed `onTraceRoutePacket`), so this module replaces the + * two duplicate `device.events.on*` subscriptions the runtime used to own. + * + * Failure point: none — correlation maps are best effort and expire on a 2 minute + * cutoff, so a dropped reply only means the row is keyed by mesh `from`. + */ +import type { Dispatch, RefObject, SetStateAction } from 'react'; + +import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; +import { + mergeMeshtasticTraceRouteIntoResultsMap, + type MeshtasticTraceRouteEntry, + meshtasticTraceRouteLookupKeys, +} from '../meshtasticTraceRouteLookupKeys'; +import type { DomainEvent } from '../protocols/Protocol'; +import type { IdentityId } from '../types'; + +const PENDING_TRACE_TTL_MS = 2 * 60_000; + +export interface MeshtasticTraceSideEffectsDeps { + /** Outbound traceroute packet id → traced node, filled by the send path. */ + pendingTracePacketIdToTargetRef: RefObject>; + /** Traced node → request start time. */ + pendingTraceRequestsRef: RefObject>; + setTraceRouteResults: Dispatch>>; + touchLastData: () => void; +} + +type TraceRouteEvent = Extract; + +function normalizedPacketId(value: number | undefined): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined; + const id = value >>> 0; + return id === 0 ? undefined : id; +} + +/** + * Correlate a reply back to the node the user asked to trace, then merge it into + * the per-node results map under every key the UI may look it up by. + */ +export function applyMeshtasticTracerouteReply( + event: TraceRouteEvent['payload'], + deps: MeshtasticTraceSideEffectsDeps, +): void { + const rd = { route: event.route, routeBack: event.routeBack ?? [] }; + const baseLookupKeys = meshtasticTraceRouteLookupKeys({ + from: event.from, + data: rd, + dataLayerDest: event.dataLayerDest, + dataLayerSource: event.dataLayerSource, + }); + + let correlatedDest: number | undefined; + for (const id of [normalizedPacketId(event.replyId), normalizedPacketId(event.requestId)]) { + if (id === undefined) continue; + const mapped = deps.pendingTracePacketIdToTargetRef.current.get(id); + if (mapped !== undefined) { + correlatedDest = mapped >>> 0; + deps.pendingTracePacketIdToTargetRef.current.delete(id); + break; + } + } + + const correlatedAdditionalKeys = correlatedDest !== undefined ? [correlatedDest] : []; + for (const key of new Set([...baseLookupKeys, ...correlatedAdditionalKeys])) { + deps.pendingTraceRequestsRef.current.delete(key); + } + + const cutoff = Date.now() - PENDING_TRACE_TTL_MS; + for (const [target, startedAt] of deps.pendingTraceRequestsRef.current) { + if (startedAt < cutoff) deps.pendingTraceRequestsRef.current.delete(target); + } + for (const [packetId, dest] of [...deps.pendingTracePacketIdToTargetRef.current.entries()]) { + if (!deps.pendingTraceRequestsRef.current.has(dest)) { + deps.pendingTracePacketIdToTargetRef.current.delete(packetId); + } + } + + deps.setTraceRouteResults((prev) => + mergeMeshtasticTraceRouteIntoResultsMap( + prev, + event.from, + rd, + event.dataLayerDest, + correlatedAdditionalKeys, + event.dataLayerSource, + ), + ); + + // last_heard for traceroute participants is bumped in nodeStore by the + // PacketRouter `trace_route` case (bumpMeshtasticNodesLastHeardAt). +} + +/** Attach traceroute correlation for one Meshtastic identity. Returns a detach fn. */ +export function attachMeshtasticTraceSideEffects( + identityId: IdentityId, + deps: MeshtasticTraceSideEffectsDeps, +): () => void { + const listener: PacketRouterListener = (event, routedIdentityId) => { + if (routedIdentityId !== identityId || event.type !== 'trace_route') return; + deps.touchLastData(); + applyMeshtasticTracerouteReply(event.payload, deps); + }; + return packetRouter.addListener(listener); +} diff --git a/src/renderer/lib/meshtastic/meshtasticLegacyDeviceEvents.test.ts b/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts similarity index 99% rename from src/renderer/lib/meshtastic/meshtasticLegacyDeviceEvents.test.ts rename to src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts index eb319513f..6de8b0171 100644 --- a/src/renderer/lib/meshtastic/meshtasticLegacyDeviceEvents.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts @@ -2,8 +2,8 @@ import type { MeshDevice } from '@meshtastic/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { pushMeshtasticTransportSideEffectUnsubs } from './meshtasticLegacyDeviceEvents'; import { attachMeshtasticTransportLossWatch } from './meshtasticTransportLossDetection'; +import { pushMeshtasticTransportSideEffectUnsubs } from './meshtasticTransportSideEffects'; vi.mock('./meshtasticTransportLossDetection', () => ({ attachMeshtasticTransportLossWatch: vi.fn(() => () => {}), diff --git a/src/renderer/lib/meshtastic/meshtasticLegacyDeviceEvents.ts b/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.ts similarity index 93% rename from src/renderer/lib/meshtastic/meshtasticLegacyDeviceEvents.ts rename to src/renderer/lib/meshtastic/meshtasticTransportSideEffects.ts index 9bece217a..c2dc9d09d 100644 --- a/src/renderer/lib/meshtastic/meshtasticLegacyDeviceEvents.ts +++ b/src/renderer/lib/meshtastic/meshtasticTransportSideEffects.ts @@ -26,7 +26,7 @@ export function pushMeshtasticTransportSideEffectUnsubs( device.setHeartbeatInterval(60_000); } catch (e) { console.warn( - `[meshtasticLegacyDeviceEvents] ${type}: setHeartbeatInterval failed ` + + `[meshtasticTransportSideEffects] ${type}: setHeartbeatInterval failed ` + errLikeToLogString(e), ); } diff --git a/src/renderer/lib/meshtasticSourceIcons.test.ts b/src/renderer/lib/meshtasticSourceIcons.test.ts index 3da0fbfaf..5c3ace288 100644 --- a/src/renderer/lib/meshtasticSourceIcons.test.ts +++ b/src/renderer/lib/meshtasticSourceIcons.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + isMeshtasticSelfHybridPath, meshtasticNodeShowsHybridMqttPath, resolveMeshtasticPathBadge, } from './meshtasticSourceIcons'; @@ -76,3 +77,12 @@ describe('resolveMeshtasticPathBadge', () => { expect(meshtasticNodeShowsHybridMqttPath(node({ heard_via_mqtt_only: false }))).toBe(false); }); }); + +describe('isMeshtasticSelfHybridPath', () => { + it('is true only when self with both MQTT and radio connected', () => { + expect(isMeshtasticSelfHybridPath(true, true, true)).toBe(true); + expect(isMeshtasticSelfHybridPath(true, true, false)).toBe(false); + expect(isMeshtasticSelfHybridPath(true, false, true)).toBe(false); + expect(isMeshtasticSelfHybridPath(false, true, true)).toBe(false); + }); +}); diff --git a/src/renderer/lib/meshtasticSourceIcons.tsx b/src/renderer/lib/meshtasticSourceIcons.tsx index 47a0e7de7..562c28d9f 100644 --- a/src/renderer/lib/meshtasticSourceIcons.tsx +++ b/src/renderer/lib/meshtasticSourceIcons.tsx @@ -21,6 +21,15 @@ export interface ResolveMeshtasticPathBadgeInput { radioConnected?: boolean; } +/** Self-node session shortcut: radio + MQTT both up (list/detail hybrid tooltip). */ +export function isMeshtasticSelfHybridPath( + isSelf: boolean, + mqttConnected: boolean, + radioConnected: boolean, +): boolean { + return isSelf && mqttConnected && radioConnected; +} + /** Resolves RF/MQTT path badge for list column and node detail (Meshtastic only). */ export function resolveMeshtasticPathBadge({ node, @@ -29,7 +38,7 @@ export function resolveMeshtasticPathBadge({ radioConnected = false, }: ResolveMeshtasticPathBadgeInput): MeshtasticPathBadgeKind { if (node.heard_via_mqtt_only) return 'mqttOnly'; - if (isSelf && mqttConnected && radioConnected) return 'hybrid'; + if (isMeshtasticSelfHybridPath(isSelf, mqttConnected, radioConnected)) return 'hybrid'; if (isSelf && mqttConnected) return 'mqttOnly'; if (meshtasticNodeShowsHybridMqttPath(node)) return 'hybrid'; if (isSelf && radioConnected) return 'rfOnly'; diff --git a/src/renderer/lib/protocols/MeshCoreProtocol.ts b/src/renderer/lib/protocols/MeshCoreProtocol.ts index 6f1011e0c..bd01ddcce 100644 --- a/src/renderer/lib/protocols/MeshCoreProtocol.ts +++ b/src/renderer/lib/protocols/MeshCoreProtocol.ts @@ -40,6 +40,9 @@ const EVENT_DIRECT_MESSAGE = 7; const EVENT_CHANNEL_MESSAGE = 8; const EVENT_NEW_CONTACT = 138; const EVENT_PATH_UPDATED = 129; +const EVENT_DM_ACK = 130; +const EVENT_WAITING_MESSAGES = 131; +const EVENT_RF_RX = 136; const EVENT_RX = 'rx'; const EVENT_DISCONNECTED = 'disconnected'; @@ -207,6 +210,15 @@ export class MeshCoreProtocol implements Protocol { const onRx = (data: unknown) => { this.decodeRx(data).forEach(emit); }; + const onDmAck = (data: unknown) => { + this.decodeDmAck(data).forEach(emit); + }; + const onWaitingMessages = () => { + emit({ type: 'meshcore_waiting_messages', payload: {} }); + }; + const onRfRx = (data: unknown) => { + this.decodeRfRx(data).forEach(emit); + }; const onDisconnected = () => { emit({ type: 'device_status', payload: { status: 'disconnected' } }); }; @@ -216,6 +228,9 @@ export class MeshCoreProtocol implements Protocol { bus.on(EVENT_CHANNEL_MESSAGE, onChannel); bus.on(EVENT_NEW_CONTACT, onContact); bus.on(EVENT_PATH_UPDATED, onPathUpdated); + bus.on(EVENT_DM_ACK, onDmAck); + bus.on(EVENT_WAITING_MESSAGES, onWaitingMessages); + bus.on(EVENT_RF_RX, onRfRx); bus.on(EVENT_RX, onRx); bus.on(EVENT_DISCONNECTED, onDisconnected); @@ -225,6 +240,9 @@ export class MeshCoreProtocol implements Protocol { bus.off(EVENT_CHANNEL_MESSAGE, onChannel); bus.off(EVENT_NEW_CONTACT, onContact); bus.off(EVENT_PATH_UPDATED, onPathUpdated); + bus.off(EVENT_DM_ACK, onDmAck); + bus.off(EVENT_WAITING_MESSAGES, onWaitingMessages); + bus.off(EVENT_RF_RX, onRfRx); bus.off(EVENT_RX, onRx); bus.off(EVENT_DISCONNECTED, onDisconnected); pubKeyByNodeId.clear(); @@ -521,7 +539,7 @@ export class MeshCoreProtocol implements Protocol { return events; } - /** MeshCore hop ACK / path-hash summaries — device log only, not chat (see legacy event 7/8). */ + /** MeshCore hop ACK / path-hash summaries — device log only, not chat. */ private decodeTransportStatusChatLine(text: string): DomainEvent[] { const line = text.length > 220 ? `${text.slice(0, 220)}…` : text; return [ @@ -599,6 +617,37 @@ export class MeshCoreProtocol implements Protocol { ); } + private decodeDmAck(raw: unknown): DomainEvent[] { + const d = raw as { ackCode?: number; roundTrip?: number }; + if (typeof d.ackCode !== 'number' || !Number.isFinite(d.ackCode)) { + console.warn('[MeshCoreProtocol] event 130: non-numeric ackCode', d.ackCode); + return []; + } + return [ + { + type: 'meshcore_dm_ack', + payload: { + ackCode: d.ackCode, + ...(typeof d.roundTrip === 'number' ? { roundTrip: d.roundTrip } : {}), + }, + }, + ]; + } + + private decodeRfRx(raw: unknown): DomainEvent[] { + const d = raw as { lastSnr?: number; lastRssi?: number; raw?: unknown }; + return [ + { + type: 'meshcore_rf_rx', + payload: { + lastSnr: d.lastSnr ?? 0, + lastRssi: d.lastRssi ?? 0, + raw: d.raw instanceof Uint8Array && d.raw.length > 0 ? d.raw : null, + }, + }, + ]; + } + private decodeRx(raw: unknown): DomainEvent[] { const frame = meshcoreCoerceRadioRxFrame(raw); const autoadd = frame ? parseAutoaddConfigResponse(frame) : null; diff --git a/src/renderer/lib/protocols/MeshtasticProtocol.test.ts b/src/renderer/lib/protocols/MeshtasticProtocol.test.ts index 30ce4a13b..ceabd56b0 100644 --- a/src/renderer/lib/protocols/MeshtasticProtocol.test.ts +++ b/src/renderer/lib/protocols/MeshtasticProtocol.test.ts @@ -14,23 +14,15 @@ function mockMeshDevice() { }); return { device: { - events: { - onDeviceStatus: subscribe('onDeviceStatus'), - onMeshPacket: subscribe('onMeshPacket'), - onNodeInfoPacket: subscribe('onNodeInfoPacket'), - onPositionPacket: subscribe('onPositionPacket'), - onTelemetryPacket: subscribe('onTelemetryPacket'), - onWaypointPacket: subscribe('onWaypointPacket'), - onTraceRoutePacket: subscribe('onTraceRoutePacket'), - onChannelPacket: subscribe('onChannelPacket'), - onConfigPacket: subscribe('onConfigPacket'), - onModuleConfigPacket: subscribe('onModuleConfigPacket'), - onQueueStatus: subscribe('onQueueStatus'), - onLogRecord: subscribe('onLogRecord'), - onDeviceMetadataPacket: subscribe('onDeviceMetadataPacket'), - onNeighborInfoPacket: subscribe('onNeighborInfoPacket'), - onMyNodeInfo: subscribe('onMyNodeInfo'), - }, + events: new Proxy( + {}, + { + get: (_target, prop) => { + if (typeof prop !== 'string') return undefined; + return subscribe(prop); + }, + }, + ), }, emit: (name: string, payload: unknown) => subs.get(name)?.(payload), }; diff --git a/src/renderer/lib/protocols/MeshtasticProtocol.ts b/src/renderer/lib/protocols/MeshtasticProtocol.ts index 2b3d7ae9c..96d137a94 100644 --- a/src/renderer/lib/protocols/MeshtasticProtocol.ts +++ b/src/renderer/lib/protocols/MeshtasticProtocol.ts @@ -1,4 +1,4 @@ -import { create, toBinary } from '@bufbuild/protobuf'; +import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; import { type MeshDevice, Types } from '@meshtastic/core'; import { Admin, Channel as ProtobufChannel, Mesh, Portnums } from '@meshtastic/protobufs'; @@ -197,6 +197,109 @@ export class MeshtasticProtocol implements Protocol { } }), ); + push( + device.events.onUserPacket.subscribe((p) => { + fire(this.decodeUserPacket(p)); + }), + ); + push( + device.events.onStoreForwardPacket.subscribe((p) => { + const packet = p as { from?: number; channel?: number }; + emit({ + type: 'meshtastic_store_forward', + payload: { + from: packet.from ?? 0, + channel: packet.channel ?? 0, + raw: p, + timestamp: Date.now(), + }, + }); + }), + ); + + const emitModulePort = (portLabel: string, packet: unknown) => { + const p = packet as { from?: number; channel?: number; data?: unknown }; + emit({ + type: 'meshtastic_module_port', + payload: { + portLabel, + from: p.from ?? 0, + data: p.data ?? packet, + channel: p.channel, + timestamp: Date.now(), + }, + }); + }; + push( + device.events.onRemoteHardwarePacket.subscribe((p) => { + emitModulePort('remoteHardware', p); + }), + ); + push( + device.events.onAudioPacket.subscribe((p) => { + emitModulePort('audio', p); + }), + ); + push( + device.events.onDetectionSensorPacket.subscribe((p) => { + emitModulePort('detectionSensor', p); + }), + ); + push( + device.events.onPingPacket.subscribe((p) => { + emitModulePort('ping', p); + }), + ); + push( + device.events.onIpTunnelPacket.subscribe((p) => { + emitModulePort('ipTunnel', p); + }), + ); + push( + device.events.onPaxcounterPacket.subscribe((p) => { + emitModulePort('paxcounter', p); + }), + ); + push( + device.events.onSerialPacket.subscribe((p) => { + emitModulePort('serial', p); + }), + ); + push( + device.events.onRangeTestPacket.subscribe((p) => { + emitModulePort('rangeTest', p); + }), + ); + push( + device.events.onZpsPacket.subscribe((p) => { + emitModulePort('zps', p); + }), + ); + push( + device.events.onSimulatorPacket.subscribe((p) => { + emitModulePort('simulator', p); + }), + ); + push( + device.events.onAtakPluginPacket.subscribe((p) => { + emitModulePort('atakPlugin', p); + }), + ); + push( + device.events.onMapReportPacket.subscribe((p) => { + emitModulePort('mapReport', p); + }), + ); + push( + device.events.onPrivatePacket.subscribe((p) => { + emitModulePort('private', p); + }), + ); + push( + device.events.onAtakForwarderPacket.subscribe((p) => { + emitModulePort('atakForwarder', p); + }), + ); return () => { for (const u of unsubs) { @@ -464,11 +567,60 @@ export class MeshtasticProtocol implements Protocol { ]; } + private decodeUserPacket(raw: unknown): DomainEvent[] { + const p = raw as { + from: number; + rxTime?: number; + data?: { + longName?: string; + shortName?: string; + hwModel?: number; + role?: number; + publicKey?: Uint8Array; + isLicensed?: boolean; + }; + }; + if (!p.from) return []; + const user = p.data ?? {}; + return [ + { + type: 'node_info', + payload: { + nodeId: p.from, + longName: user.longName, + shortName: user.shortName, + hwModel: user.hwModel != null ? meshtasticHwModelName(user.hwModel) : undefined, + role: user.role, + publicKey: user.publicKey, + isLicensed: user.isLicensed, + lastHeardAt: p.rxTime ? p.rxTime * 1000 : Date.now(), + fromUserPacket: true, + }, + }, + ]; + } + private decodeNodeInfo(raw: unknown): DomainEvent[] { const p = raw as { num?: number; - user?: { longName?: string; shortName?: string; hwModel?: number; role?: number }; + user?: { + longName?: string; + shortName?: string; + hwModel?: number; + role?: number; + publicKey?: Uint8Array; + }; lastHeard?: number; + snr?: number; + hopsAway?: number; + viaMqtt?: boolean; + position?: { latitudeI?: number; longitudeI?: number; altitude?: number }; + deviceMetrics?: { + batteryLevel?: number; + voltage?: number; + channelUtilization?: number; + airUtilTx?: number; + }; }; if (!p.num) return []; return [ @@ -480,7 +632,18 @@ export class MeshtasticProtocol implements Protocol { shortName: p.user?.shortName, hwModel: p.user?.hwModel != null ? meshtasticHwModelName(p.user.hwModel) : undefined, role: p.user?.role, + publicKey: p.user?.publicKey, lastHeardAt: p.lastHeard, + snr: p.snr, + hopsAway: p.hopsAway, + viaMqtt: p.viaMqtt, + latitude: p.position?.latitudeI != null ? p.position.latitudeI / 1e7 : undefined, + longitude: p.position?.longitudeI != null ? p.position.longitudeI / 1e7 : undefined, + altitude: p.position?.altitude, + batteryLevel: p.deviceMetrics?.batteryLevel, + voltage: p.deviceMetrics?.voltage, + channelUtilization: p.deviceMetrics?.channelUtilization, + airUtilTx: p.deviceMetrics?.airUtilTx, }, }, ]; @@ -511,25 +674,41 @@ export class MeshtasticProtocol implements Protocol { from: number; rxTime?: number; data: { - variant?: { value?: Record }; + variant?: { case?: string; value?: Record }; deviceMetrics?: Record; }; }; const m: Record = p.data?.variant?.value ?? p.data?.deviceMetrics ?? {}; + const num = (key: string): number | undefined => m[key] as number | undefined; return [ { type: 'telemetry', payload: { nodeId: p.from, timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(), - batteryLevel: m.batteryLevel as number | undefined, - voltage: m.voltage as number | undefined, - channelUtilization: m.channelUtilization as number | undefined, - airUtilTx: m.airUtilTx as number | undefined, - temperature: m.temperature as number | undefined, - relativeHumidity: m.relativeHumidity as number | undefined, - barometricPressure: m.barometricPressure as number | undefined, - iaq: m.iaq as number | undefined, + variantCase: + p.data?.variant?.case ?? (p.data?.deviceMetrics ? 'deviceMetrics' : undefined), + batteryLevel: num('batteryLevel'), + voltage: num('voltage'), + channelUtilization: num('channelUtilization'), + airUtilTx: num('airUtilTx'), + temperature: num('temperature'), + relativeHumidity: num('relativeHumidity'), + barometricPressure: num('barometricPressure'), + iaq: num('iaq'), + gasResistance: num('gasResistance'), + lux: num('lux'), + windSpeed: num('windSpeed'), + windDirection: num('windDirection'), + windGust: num('windGust'), + windLull: num('windLull'), + weight: num('weight'), + rainfall1h: num('rainfall1h'), + rainfall24h: num('rainfall24h'), + numPacketsRxBad: num('numPacketsRxBad'), + numRxDupe: num('numRxDupe'), + numPacketsRx: num('numPacketsRx'), + numPacketsTx: num('numPacketsTx'), }, }, ]; @@ -538,6 +717,8 @@ export class MeshtasticProtocol implements Protocol { private decodeWaypoint(raw: unknown): DomainEvent[] { const p = raw as { from: number; + to?: number; + channel?: number; rxTime?: number; data: { id?: number; @@ -545,11 +726,14 @@ export class MeshtasticProtocol implements Protocol { latitudeI?: number; longitudeI?: number; description?: string; + icon?: number; lockedTo?: number; expire?: number; }; }; if (!p.data?.id) return []; + const latitudeI = p.data.latitudeI ?? 0; + const longitudeI = p.data.longitudeI ?? 0; return [ { type: 'waypoint', @@ -557,11 +741,16 @@ export class MeshtasticProtocol implements Protocol { id: p.data.id, name: p.data.name ?? '', description: p.data.description, - latitude: (p.data.latitudeI ?? 0) / 1e7, - longitude: (p.data.longitudeI ?? 0) / 1e7, + latitude: latitudeI / 1e7, + longitude: longitudeI / 1e7, + latitudeI, + longitudeI, + icon: p.data.icon, lockedTo: p.data.lockedTo, expire: p.data.expire, from: p.from, + to: p.to, + channelIndex: p.channel, timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(), }, }, @@ -573,17 +762,72 @@ export class MeshtasticProtocol implements Protocol { from: number; to?: number; rxTime?: number; - data: { route?: readonly number[]; routeBack?: readonly number[] }; + data?: { route?: readonly number[]; routeBack?: readonly number[] }; + payloadVariant?: { + case?: string; + value?: { + portnum?: number; + payload?: Uint8Array; + dest?: number; + source?: number; + replyId?: number; + requestId?: number; + }; + }; }; + + let route: number[]; + let routeBack: number[] | undefined; + let dataLayerDest: number | undefined; + let dataLayerSource: number | undefined; + let replyId: number | undefined; + let requestId: number | undefined; + + if (p.payloadVariant?.case === 'decoded' && p.payloadVariant.value?.payload) { + try { + const rd = fromBinary( + Mesh.RouteDiscoverySchema, + p.payloadVariant.value.payload, + ) as unknown as { + route: readonly number[]; + routeBack: readonly number[]; + }; + route = Array.from(rd.route ?? []); + routeBack = rd.routeBack ? Array.from(rd.routeBack) : undefined; + } catch { + // catch-no-log-ok non-traceroute payload on TRACEROUTE_APP + return []; + } + const dp = p.payloadVariant.value; + const rawDest = dp.dest; + dataLayerDest = typeof rawDest === 'number' && Number.isFinite(rawDest) ? rawDest : undefined; + const rawSource = dp.source; + dataLayerSource = + typeof rawSource === 'number' && Number.isFinite(rawSource) ? rawSource : undefined; + const rawReply = dp.replyId; + replyId = typeof rawReply === 'number' && Number.isFinite(rawReply) ? rawReply : undefined; + const rawReq = dp.requestId; + requestId = typeof rawReq === 'number' && Number.isFinite(rawReq) ? rawReq : undefined; + } else if (p.data) { + route = Array.from(p.data.route ?? []); + routeBack = p.data.routeBack ? Array.from(p.data.routeBack) : undefined; + } else { + return []; + } + return [ { type: 'trace_route', payload: { from: p.from, - to: p.to ?? 0, - route: Array.from(p.data?.route ?? []), - routeBack: p.data?.routeBack ? Array.from(p.data.routeBack) : undefined, + to: p.to ?? dataLayerDest ?? 0, + route, + routeBack, timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(), + dataLayerDest, + dataLayerSource, + replyId, + requestId, }, }, ]; @@ -709,12 +953,21 @@ export class MeshtasticProtocol implements Protocol { rxSnr?: number; rxRssi?: number; from?: number; + hopLimit?: number; + hopStart?: number; viaMqtt?: boolean; + payloadVariant?: { case?: string; value?: { portnum?: number } }; }; if (!mp.from) return []; try { const serialized = toBinary(Mesh.MeshPacketSchema, raw as never); const portLabel = this.rawPacketPortLabel(raw); + const portnum = + mp.payloadVariant?.case === 'decoded' && + typeof mp.payloadVariant.value?.portnum === 'number' + ? mp.payloadVariant.value.portnum + : undefined; + const hopsAway = meshtasticComputedRfHopsAway(mp); const payload: RawPacketEntry = { ts: Date.now(), snr: mp.rxSnr ?? 0, @@ -723,6 +976,12 @@ export class MeshtasticProtocol implements Protocol { fromNodeId: mp.from, portLabel, viaMqtt: mp.viaMqtt === true, + isLocal: false, + hopsAway, + packetId: typeof mp.id === 'number' ? mp.id >>> 0 : undefined, + hopLimit: mp.hopLimit, + hopStart: mp.hopStart, + portnum, }; return [{ type: 'raw_packet', payload }]; } catch { diff --git a/src/renderer/lib/protocols/Protocol.ts b/src/renderer/lib/protocols/Protocol.ts index db7680b32..6a2ed976a 100644 --- a/src/renderer/lib/protocols/Protocol.ts +++ b/src/renderer/lib/protocols/Protocol.ts @@ -31,6 +31,19 @@ export interface NodeInfoEvent { role?: number; lastHeardAt?: number; publicKey?: Uint8Array; + /** True when decoded from UserPacket (live identity), not NodeDB NodeInfo. */ + fromUserPacket?: boolean; + snr?: number; + rssi?: number; + hopsAway?: number; + viaMqtt?: boolean; + latitude?: number; + longitude?: number; + altitude?: number; + batteryLevel?: number; + voltage?: number; + channelUtilization?: number; + airUtilTx?: number; } /** MeshCore path-updated push (event 129). */ @@ -39,6 +52,29 @@ export interface MeshcorePathUpdatedEvent { publicKey: Uint8Array; } +/** MeshCore hop-ACK push (event 130): 0x80 = ACK, 0x81 = NACK. */ +export interface MeshcoreDmAckEvent { + ackCode: number; + roundTrip?: number; +} + +/** MeshCore message-waiting push (event 131) — signal only; the drain is an RPC. */ +export type MeshcoreWaitingMessagesEvent = Record; + +/** MeshCore CLI data response (direct message with `txtType === 1`). */ +export interface MeshcoreCliResponseEvent { + text: string; + senderNodeId: number; + pubKeyPrefixHex: string; +} + +/** MeshCore radio RF RX push (event 136). */ +export interface MeshcoreRfRxEvent { + lastSnr: number; + lastRssi: number; + raw: Uint8Array | null; +} + // --- Contact / self-info events (populated by protocol implementations) --- export interface DeviceSelfInfoEvent { @@ -161,6 +197,8 @@ export interface PositionEvent { export interface TelemetryEvent { nodeId: number; timestamp: number; + /** Meshtastic `Telemetry.variant` case: deviceMetrics / environmentMetrics / localStats. */ + variantCase?: string; batteryLevel?: number; voltage?: number; channelUtilization?: number; @@ -170,6 +208,19 @@ export interface TelemetryEvent { relativeHumidity?: number; barometricPressure?: number; iaq?: number; + gasResistance?: number; + lux?: number; + windSpeed?: number; + windDirection?: number; + windGust?: number; + windLull?: number; + weight?: number; + rainfall1h?: number; + rainfall24h?: number; + numPacketsRxBad?: number; + numRxDupe?: number; + numPacketsRx?: number; + numPacketsTx?: number; } export interface TraceRouteEvent { @@ -178,6 +229,12 @@ export interface TraceRouteEvent { route: number[]; routeBack?: number[]; timestamp: number; + /** `meshtastic.Data.dest` on the decoded wrapper (traceroute correlation). */ + dataLayerDest?: number; + /** `meshtastic.Data.source` on the decoded wrapper. */ + dataLayerSource?: number; + replyId?: number; + requestId?: number; } export interface WaypointEvent { @@ -186,9 +243,15 @@ export interface WaypointEvent { description?: string; latitude: number; longitude: number; + /** Raw protobuf integers for MQTT gateway uplink. */ + latitudeI?: number; + longitudeI?: number; + icon?: number; lockedTo?: number; expire?: number; from: number; + to?: number; + channelIndex?: number; timestamp: number; } @@ -261,6 +324,23 @@ export interface MeshtasticConfigSliceEvent { value: unknown; } +/** Typed Meshtastic module-port payloads (audio, pax, serial, …) for hook Maps. */ +export interface MeshtasticModulePortEvent { + portLabel: string; + from: number; + data: unknown; + channel?: number; + timestamp: number; +} + +/** Store & Forward module packet (heartbeat / history / text replay). */ +export interface MeshtasticStoreForwardEvent { + from: number; + channel: number; + raw: unknown; + timestamp: number; +} + export type DomainEvent = | { type: 'text_message'; payload: TextMessageEvent } | { type: 'node_info'; payload: NodeInfoEvent } @@ -274,6 +354,8 @@ export type DomainEvent = | { type: 'module_config'; payload: ModuleConfigEvent } | { type: 'telemetry_interval'; payload: TelemetryIntervalEvent } | { type: 'meshtastic_config_slice'; payload: MeshtasticConfigSliceEvent } + | { type: 'meshtastic_module_port'; payload: MeshtasticModulePortEvent } + | { type: 'meshtastic_store_forward'; payload: MeshtasticStoreForwardEvent } | { type: 'queue_status'; payload: QueueStatusEvent } | { type: 'device_log'; payload: DeviceLogEvent } | { type: 'raw_packet'; payload: RawPacketEntry } @@ -284,7 +366,11 @@ export type DomainEvent = | { type: 'device_contacts'; payload: { contacts: ContactRecord[] } } | { type: 'device_autoadd'; payload: AutoaddConfigEvent } | { type: 'meshcore_channel'; payload: MeshcoreChannelEvent } - | { type: 'meshcore_path_updated'; payload: MeshcorePathUpdatedEvent }; + | { type: 'meshcore_path_updated'; payload: MeshcorePathUpdatedEvent } + | { type: 'meshcore_dm_ack'; payload: MeshcoreDmAckEvent } + | { type: 'meshcore_waiting_messages'; payload: MeshcoreWaitingMessagesEvent } + | { type: 'meshcore_cli_response'; payload: MeshcoreCliResponseEvent } + | { type: 'meshcore_rf_rx'; payload: MeshcoreRfRxEvent }; // --- Raw packet log --- @@ -297,6 +383,11 @@ export interface RawPacketEntry { portLabel: string; viaMqtt: boolean; isLocal?: boolean; + hopsAway?: number; + packetId?: number; + hopLimit?: number; + hopStart?: number; + portnum?: number; } // --- Outbound send options --- diff --git a/src/renderer/lib/storeRecordAdapters.ts b/src/renderer/lib/storeRecordAdapters.ts index 663884c8a..76cec7839 100644 --- a/src/renderer/lib/storeRecordAdapters.ts +++ b/src/renderer/lib/storeRecordAdapters.ts @@ -150,6 +150,7 @@ export function nodeRecordToMeshNode(record: NodeRecord): MeshNode { last_heard: record.lastHeardAt ?? 0, latitude: record.latitude ?? null, longitude: record.longitude ?? null, + altitude: record.altitude, role: record.role, hops_away: record.hopsAway, via_mqtt: record.viaMqtt, @@ -163,7 +164,13 @@ export function nodeRecordToMeshNode(record: NodeRecord): MeshNode { voltage: record.voltage, channel_utilization: record.channelUtilization, air_util_tx: record.airUtilTx, + lastPositionWarning: record.lastPositionWarning, + num_packets_rx_bad: record.numPacketsRxBad, + num_rx_dupe: record.numRxDupe, + num_packets_rx: record.numPacketsRx, + num_packets_tx: record.numPacketsTx, meshcore_local_stats: record.meshcoreLocalStats, + ...(record.publicKeyHex ? { public_key_hex: record.publicKeyHex } : {}), ...(record.reticulumDestinationHash ? { reticulum_destination_hash: record.reticulumDestinationHash } : {}), @@ -186,6 +193,7 @@ function nodeRecordsShallowEqual(a: NodeRecord, b: NodeRecord): boolean { a.lastHeardAt === b.lastHeardAt && a.latitude === b.latitude && a.longitude === b.longitude && + a.altitude === b.altitude && a.role === b.role && a.hopsAway === b.hopsAway && a.viaMqtt === b.viaMqtt && @@ -199,7 +207,13 @@ function nodeRecordsShallowEqual(a: NodeRecord, b: NodeRecord): boolean { a.voltage === b.voltage && a.channelUtilization === b.channelUtilization && a.airUtilTx === b.airUtilTx && + a.lastPositionWarning === b.lastPositionWarning && + a.numPacketsRxBad === b.numPacketsRxBad && + a.numRxDupe === b.numRxDupe && + a.numPacketsRx === b.numPacketsRx && + a.numPacketsTx === b.numPacketsTx && a.meshcoreLocalStats === b.meshcoreLocalStats && + a.publicKeyHex === b.publicKeyHex && a.reticulumDestinationHash === b.reticulumDestinationHash ); } @@ -260,6 +274,7 @@ export function meshNodeToNodeRecord(node: MeshNode): NodeRecord { lastHeardAt: node.last_heard, latitude: node.latitude ?? undefined, longitude: node.longitude ?? undefined, + altitude: node.altitude, role: role != null && Number.isFinite(role) ? role : undefined, hopsAway: node.hops_away, viaMqtt: node.via_mqtt, @@ -273,7 +288,13 @@ export function meshNodeToNodeRecord(node: MeshNode): NodeRecord { voltage: node.voltage, channelUtilization: node.channel_utilization, airUtilTx: node.air_util_tx, + lastPositionWarning: node.lastPositionWarning, + numPacketsRxBad: node.num_packets_rx_bad, + numRxDupe: node.num_rx_dupe, + numPacketsRx: node.num_packets_rx, + numPacketsTx: node.num_packets_tx, meshcoreLocalStats: node.meshcore_local_stats, + publicKeyHex: node.public_key_hex, reticulumDestinationHash: node.reticulum_destination_hash, }; } diff --git a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts index cb1bcb131..7355a9514 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts @@ -8,7 +8,7 @@ import { extractUseCallbackBody } from '../lib/sourceContractTestHelpers'; const RUNTIME_SOURCE = readFileSync(join(__dirname, '../runtime/useMeshcoreRuntime.ts'), 'utf-8'); const CONN_EVENTS_SOURCE = readFileSync( - join(__dirname, '../hooks/meshcore/meshcoreLegacyConnEvents.ts'), + join(__dirname, '../hooks/meshcore/meshcoreConnSideEffects.ts'), 'utf-8', ); @@ -209,10 +209,11 @@ describe('useMeshcoreRuntime manual disconnect must not auto-reconnect', () => { }); }); -describe('meshcoreLegacyConnEvents disconnected handler (regression)', () => { +describe('meshcoreConnSideEffects disconnected handler (regression)', () => { it('triggers handleConnectionLost when an operational session drops', () => { + expect(CONN_EVENTS_SOURCE).toMatch(/case 'device_status':[\s\S]{0,400}handleDisconnected\(\)/); expect(CONN_EVENTS_SOURCE).toMatch( - /onMeshcoreConn\('disconnected'[\s\S]{0,2000}handleConnectionLostRef\.current\(\)/, + /handleDisconnected[\s\S]{0,2000}handleConnectionLostRef\.current\(\)/, ); }); diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 79089ec55..979369f98 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -21,6 +21,11 @@ import { meshcorePathHashSizeFromTraceFlags, } from '../../shared/meshcorePathHash'; import { withTimeout } from '../../shared/withTimeout'; +import { attachMeshcoreConnSideEffects } from '../hooks/meshcore/meshcoreConnSideEffects'; +import type { + MeshcoreConnSideEffectsCtx, + ProcessWaitingMessagesOptions, +} from '../hooks/meshcore/meshcoreConnSideEffectsCtx'; import { buildMeshcoreNodeMapFromDb, contactToDbRow, @@ -62,14 +67,6 @@ import { serializeErrorLike, upgradeMeshcoreCrossTransportMessage, } from '../hooks/meshcore/meshcoreHookPreamble'; -import { - attachMeshcoreLegacyConnEvents, - syncMeshcoreDmAckToMessageStore, -} from '../hooks/meshcore/meshcoreLegacyConnEvents'; -import type { - MeshcoreLegacyConnEventsCtx, - ProcessWaitingMessagesOptions, -} from '../hooks/meshcore/meshcoreLegacyConnEventsCtx'; import { openMeshCoreTransport } from '../hooks/openMeshCoreTransport'; import { getAppSettingsRaw, @@ -90,9 +87,15 @@ import { hasStoredStaticGps, readStoredStaticGps, resolveOurPosition } from '../ import { loadMeshcoreMessagesForHydration, loadMeshcoreSavedHopRowsForHydration, + meshcoreHydratedMessageRecords, syncNodesMapToIdentityStore, } from '../lib/hydrateIdentityStoresFromDb'; import { getIdentityIdForProtocol } from '../lib/identityByProtocol'; +import { + getIdentityChatMessages, + getIdentityNode, + getIdentityNodeMap, +} from '../lib/identityStoreReads'; import { attachMeshcoreIngest } from '../lib/ingest/meshcoreIngest'; import { repairMeshcoreChannelSenderIdsInStore } from '../lib/ingest/meshcoreSenderRepair'; import { @@ -105,6 +108,7 @@ import { } from '../lib/letsMeshJwt'; import { assignCayenneTemperatureFields } from '../lib/meshcore/meshcoreCayenneTemperature'; import { ensureMeshcoreChatSenderInNodeStore } from '../lib/meshcore/meshcoreChatSenderNode'; +import { syncMeshcoreDmAckToMessageStore } from '../lib/meshcore/meshcoreDmAckRuntime'; import type { CayenneLppEntry, DeviceLogEntry, @@ -135,7 +139,11 @@ import { meshcoreUserMessageKey, serializeMeshcoreUserMessage, } from '../lib/meshcore/meshcoreMessageI18n'; -import { refreshMeshcoreOutPathAfterPathUpdated } from '../lib/meshcore/meshcorePathUpdatedRuntime'; +import { + MESHCORE_PATH_UPDATED_CONTACTS_REBUILD_DEBOUNCE_MS, + rebuildMeshcoreContactsAfterPathUpdated, + refreshMeshcoreOutPathAfterPathUpdated, +} from '../lib/meshcore/meshcorePathUpdatedRuntime'; import { clearMeshcorePubKeyRegistry, copyMeshcorePubKeyRegistryToRefs, @@ -325,7 +333,7 @@ import { shouldRunMeshcoreWaitingMessagesPeriodicPoll, } from '../lib/meshcoreWaitingMessagesDrain'; import { - bindMeshcoreIngress, + attachMeshcoreProtocolIngress, finalizeMeshcoreDriverIdentity, meshcoreTransportParams, } from '../lib/meshIdentityBridge'; @@ -394,7 +402,11 @@ import type { } from '../lib/types'; import { mirrorMqttStatusForProtocol, setConnection } from '../stores/connectionStore'; import { useDiagnosticsStore } from '../stores/diagnosticsStore'; -import { updateMessageStatus, useMessageStore } from '../stores/messageStore'; +import { + updateMessageStatus, + upsertMessageRecordsForIdentity, + useMessageStore, +} from '../stores/messageStore'; import { patchMeshcoreNodeLastHeardAt, patchNodeFavorited, @@ -562,19 +574,41 @@ export function useMeshcoreRuntime() { const outPathMapRef = useRef>(new Map()); // nodeId → nickname (from JSON import or DB) const nicknameMapRef = useRef>(new Map()); - // Stable ref to current nodes so event listeners don't form stale closures - const nodesRef = useRef>(new Map()); /** Skip mount DB hydration commit when live ingest/import ran before async reload finishes. */ const skipMountDbHydrationCommitRef = useRef(false); - /** SQLite hydration snapshot set synchronously before `setNodes` so initConn can merge hops when `nodesRef` has not flushed yet. */ + /** SQLite hydration snapshot set synchronously before `setNodes` so initConn can merge hops before the store catches up. */ const meshcoreLastPersistedNodesRef = useRef>(new Map()); - /** Mount DB load — initConn awaits this so an immediate connect does not skip persisted hop counts. */ - /** Same baseline as initConn: avoid empty `nodesRef` during contact rebuilds (debounced 129 / refresh). */ - const meshcorePreviousNodesBaselineForBuild = useCallback( - () => (nodesRef.current.size > 0 ? nodesRef.current : meshcoreLastPersistedNodesRef.current), - [], + /** + * True once this runtime applied a node map (initConn cache or radio contacts). The mount DB + * load is per-instance, so it cannot use store emptiness — App hydration fills the same bucket. + */ + const meshcoreNodesAppliedRef = useRef(false); + /** Store bucket for this tab: live identity, pending driver identity, else the offline slot. */ + const resolveMeshcoreStoreIdentityId = useCallback((): string | null => { + return ( + meshcoreIdentityIdRef.current ?? + meshcorePendingDriverIdentityRef.current ?? + getOfflineIdentityIdForProtocol('meshcore') + ); + }, []); + /** + * Canonical node map for this identity. `nodeStore` sees both ingress paths + * (Protocol → PacketRouter and this runtime), so send/RPC/dedup read it directly. + */ + const readMeshcoreNodes = useCallback( + (): Map => getIdentityNodeMap(resolveMeshcoreStoreIdentityId()), + [resolveMeshcoreStoreIdentityId], + ); + const readMeshcoreMessages = useCallback( + (): ChatMessage[] => getIdentityChatMessages(resolveMeshcoreStoreIdentityId()), + [resolveMeshcoreStoreIdentityId], ); - const messagesRef = useRef([]); + /** Mount DB load — initConn awaits this so an immediate connect does not skip persisted hop counts. */ + /** Same baseline as initConn: avoid an empty store map during contact rebuilds (debounced 129 / refresh). */ + const meshcorePreviousNodesBaselineForBuild = useCallback(() => { + const fromStore = readMeshcoreNodes(); + return fromStore.size > 0 ? fromStore : meshcoreLastPersistedNodesRef.current; + }, [readMeshcoreNodes]); const mqttPlaceholderSavedRef = useRef>(new Set()); const rawPacketsRef = useRef([]); // Stable ref to own node ID so event listeners don't form stale closures @@ -825,7 +859,6 @@ export function useMeshcoreRuntime() { }, [selfInfo]); useEffect(() => { - nodesRef.current = nodes; setMeshcoreDiagnosticsNodes(nodes, myNodeNumRef.current); }, [nodes, state.myNodeNum]); @@ -841,10 +874,6 @@ export function useMeshcoreRuntime() { meshcoreTraceResultsRef.current = meshcoreTraceResults; }, [meshcoreTraceResults]); - useEffect(() => { - messagesRef.current = messages; - }, [messages]); - useEffect(() => { waitingMessagesCountRef.current = waitingMessagesCount; }, [waitingMessagesCount]); @@ -991,6 +1020,7 @@ export function useMeshcoreRuntime() { meshcoreLastPersistedNodesRef.current = new Map(mergedInitial); setNodes(mergedInitial); + if (mergedInitial.size > 0) meshcoreNodesAppliedRef.current = true; const storeId = meshcoreIdentityIdRef.current ?? meshcorePendingDriverIdentityRef.current ?? @@ -1000,6 +1030,10 @@ export function useMeshcoreRuntime() { } if (opts?.hydrateMessages && mapped.length > 0) { setMessages((prev) => mergeMeshcoreDbHydrationWithLive(prev, mapped)); + // Send/reply/dedup read `messageStore`, so mount hydration has to land there too. + if (storeId) { + upsertMessageRecordsForIdentity(storeId, meshcoreHydratedMessageRecords(mapped)); + } } }, [], @@ -1009,7 +1043,8 @@ export function useMeshcoreRuntime() { queueMicrotask(() => { void reloadMeshcoreNodesFromDb({ hydrateMessages: true, - beforeCommit: () => !skipMountDbHydrationCommitRef.current && nodesRef.current.size === 0, + beforeCommit: () => + !skipMountDbHydrationCommitRef.current && !meshcoreNodesAppliedRef.current, }).catch((e: unknown) => { console.warn('[useMeshcoreRuntime] initial db reload failed ' + errLikeToLogString(e)); }); @@ -1214,7 +1249,7 @@ export function useMeshcoreRuntime() { rawText: m.text, fromNodeId, recordSenderName: m.senderName, - nodes: nodesRef.current, + nodes: readMeshcoreNodes(), }); const resolvedId = resolved.senderId; const displayName = resolved.displayName; @@ -1270,7 +1305,7 @@ export function useMeshcoreRuntime() { } const normProbe = normalizeMeshcoreIncomingText(m.text); const rawForBuild = normProbe.senderName ? m.text : `${displayName}: ${m.text}`; - const prior = storeId ? meshcoreSortedStorePrior(storeId) : messagesRef.current; + const prior = storeId ? meshcoreSortedStorePrior(storeId) : []; addMessage( parseMeshcoreChannelIncomingFromThread(prior, { rawText: rawForBuild, @@ -1282,7 +1317,7 @@ export function useMeshcoreRuntime() { }), ); }); - }, [addMessage]); + }, [addMessage, readMeshcoreNodes]); const buildNodesFromContacts = useCallback( async ( @@ -1490,24 +1525,16 @@ export function useMeshcoreRuntime() { buildNodesFromContactsRef.current = buildNodesFromContacts; }, [buildNodesFromContacts]); - const resolveMeshcoreStoreIdentityId = useCallback((): string | null => { - return ( - meshcoreIdentityIdRef.current ?? - meshcorePendingDriverIdentityRef.current ?? - getOfflineIdentityIdForProtocol('meshcore') - ); - }, []); - const applyMeshcoreNodesToUi = useCallback( (nodeMap: Map) => { - const mergedForStore = mergeMeshcoreChatStubNodes(nodesRef.current, nodeMap); - // Keep ref in sync before connect-time side effects (room auto-login filters on hw_model). - nodesRef.current = mergedForStore; + const mergedForStore = mergeMeshcoreChatStubNodes(readMeshcoreNodes(), nodeMap); setNodes(mergedForStore); + if (mergedForStore.size > 0) meshcoreNodesAppliedRef.current = true; + // Publish before React commits — connect-time side effects (room auto-login) filter on hw_model. const storeId = resolveMeshcoreStoreIdentityId(); if (storeId) syncNodesMapToIdentityStore(storeId, mergedForStore); }, - [resolveMeshcoreStoreIdentityId], + [resolveMeshcoreStoreIdentityId, readMeshcoreNodes], ); const deferMeshcoreDbContactMerge = useCallback( @@ -1575,9 +1602,33 @@ export function useMeshcoreRuntime() { meshcorePathUpdatePendingRef.current, ); } + // Path updates change hop counts; debounce a full contacts rebuild for updated outPathLen. + if (meshcoreContactsRefreshTimerRef.current) { + clearTimeout(meshcoreContactsRefreshTimerRef.current); + } + meshcoreContactsRefreshTimerRef.current = setTimeout(() => { + meshcoreContactsRefreshTimerRef.current = null; + const liveConn = connRef.current; + const buildFn = buildNodesFromContactsRef.current; + if (!liveConn || !buildFn) return; + const pendingIds = meshcorePathUpdatePendingRef.current; + meshcorePathUpdatePendingRef.current = new Set(); + void rebuildMeshcoreContactsAfterPathUpdated({ + conn: liveConn, + buildNodesFromContacts: buildFn, + self: selfInfoRef.current, + myNodeId: myNodeNumRef.current, + previousNodes: meshcorePreviousNodesBaselineForBuild(), + pendingPathUpdateNodeIds: pendingIds, + onContacts: setMeshcoreContactsForTelemetry, + onNodes: (newNodes) => { + setNodes((prev) => mergeMeshcoreChatStubNodes(prev, newNodes)); + }, + }); + }, MESHCORE_PATH_UPDATED_CONTACTS_REBUILD_DEBOUNCE_MS); requestChatOutboxDrain('meshcore'); }, - [], + [meshcorePreviousNodesBaselineForBuild], ); /** Returned by {@link setupEventListeners}; run before `conn.close()` or replacing the connection. */ @@ -1690,8 +1741,10 @@ export function useMeshcoreRuntime() { [stopMeshcoreSerialWatchdog], ); - const meshcoreLegacyConnEventsCtx = useMemo( + const meshcoreConnSideEffectsCtx = useMemo( () => ({ + resolveIdentityId: () => + meshcoreIdentityIdRef.current ?? meshcorePendingDriverIdentityRef.current, meshcoreIdentityIdRef, meshcoreDriverConnectedRef, connRef, @@ -1699,16 +1752,13 @@ export function useMeshcoreRuntime() { lastPacketLogPublishFailureLogAtRef, meshcoreContactsRefreshTimerRef, meshcoreHookMountedRef, - meshcorePathUpdatePendingRef, meshcoreSessionPathUpdatedNodeIdsRef, meshcoreWaitingMessagesPollRef, meshcoreConnectTypeRef, - messagesRef, mqttStatusRef, myNodeNumRef, nicknameMapRef, - nodesRef, - outPathMapRef, + readNodes: readMeshcoreNodes, pendingAcksRef, processWaitingMessagesRef, pubKeyMapRef, @@ -1716,10 +1766,7 @@ export function useMeshcoreRuntime() { rawPacketsRef, repeaterCommandServiceRef, selfInfoRef, - buildNodesFromContactsRef, setDeviceLogs, - setMeshcoreAutoadd, - setMeshcoreContactsForTelemetry, setMeshcorePingRouteReadyEpoch, setMessages, setNodes, @@ -1732,21 +1779,18 @@ export function useMeshcoreRuntime() { setWaitingMessagesSyncProgress, setWaitingMessagesSilentDrainActive, setWaitingMessagesDrainDeferred, - addMessage, addMessagesBatch, addCliHistoryEntry, teardownMeshcoreConnEventListeners, - meshcorePreviousNodesBaselineForBuild, handleConnectionLostRef: handleMeshcoreConnectionLostRef, meshcoreExplicitDisconnectRef, bumpLastDataReceived: bumpMeshcoreLastDataReceived, }), [ - addMessage, + readMeshcoreNodes, addMessagesBatch, addCliHistoryEntry, teardownMeshcoreConnEventListeners, - meshcorePreviousNodesBaselineForBuild, bumpMeshcoreLastDataReceived, ], ); @@ -1843,8 +1887,8 @@ export function useMeshcoreRuntime() { }, []); const setupEventListeners = useCallback( - (conn: MeshCoreConnection) => attachMeshcoreLegacyConnEvents(conn, meshcoreLegacyConnEventsCtx), - [meshcoreLegacyConnEventsCtx], + (conn: MeshCoreConnection) => attachMeshcoreConnSideEffects(conn, meshcoreConnSideEffectsCtx), + [meshcoreConnSideEffectsCtx], ); /** Reject promptly when `disconnect()` bumps `meshcoreSetupGenerationRef` (avoids hanging on getChannels, etc.). */ @@ -2081,9 +2125,9 @@ export function useMeshcoreRuntime() { if (meshcoreIngressDetachRef.current) { meshcoreIngressDetachRef.current(); } - // MeshCore protocol ingress covers advert/DM/channel only; waiting messages, - // stats, MQTT, and repeater RPCs stay in this hook until fully protocol-scoped (#375 / #377). - const ingress = bindMeshcoreIngress( + // MeshCore protocol ingress owns every inbound push; companion RPCs + // (stats, repeater admin) remain hook-owned request/response paths. + const ingress = attachMeshcoreProtocolIngress( conn as unknown as Connection, transportType, {}, @@ -2178,7 +2222,7 @@ export function useMeshcoreRuntime() { } } - // Re-resolve map/App GPS after nodesRef picks up getSelfInfo advert coords (same tick as setNodes is too early). + // Re-resolve map/App GPS after the node store picks up getSelfInfo advert coords (same tick as setNodes is too early). requestAnimationFrame(() => { queueMicrotask(() => { void refreshOurPositionMeshCoreRef.current().catch((e: unknown) => { @@ -3138,7 +3182,7 @@ export function useMeshcoreRuntime() { channelIndex: channelIdx, destination: destNodeId, myNodeNum: myNodeNumRef.current, - messages: messagesRef.current, + messages: readMeshcoreMessages(), openWireCompat, }); const replyField: number | undefined = @@ -3161,7 +3205,7 @@ export function useMeshcoreRuntime() { ); // Calculate dynamic timeout based on hop count for multi-hop paths - const destNode = nodesRef.current.get(destNodeId); + const destNode = getIdentityNode(meshcoreIdentityIdRef.current, destNodeId); const hopsAway = destNode?.hops_away ?? 0; const hopBasedTimeoutMs = MESHCORE_ROOM_LOGIN_HOP_BASE_MS + hopsAway * MESHCORE_ROOM_LOGIN_HOP_INCREMENT_MS; @@ -3305,7 +3349,7 @@ export function useMeshcoreRuntime() { replyTo: replyId != null ? String(replyId) : undefined, channelIndex: channelIdx, myNodeNum: myNodeNumRef.current, - messages: messagesRef.current, + messages: readMeshcoreMessages(), openWireCompat, }); const replyField: number | undefined = @@ -3377,7 +3421,7 @@ export function useMeshcoreRuntime() { } } }, - [addMessage, selfInfo, fetchAndUpdateLocalStats], + [addMessage, readMeshcoreMessages, selfInfo, fetchAndUpdateLocalStats], ); const refreshContacts = useCallback(async () => { @@ -3632,7 +3676,7 @@ export function useMeshcoreRuntime() { for (const contact of contacts) { const id = pubkeyToNodeId(contact.publicKey); if (id === myId) continue; - const prevHops = nodesRef.current.get(id)?.hops_away; + const prevHops = getIdentityNode(meshcoreIdentityIdRef.current, id)?.hops_away; const base = meshcoreContactToMeshNode(contact); const mergedHops = meshcoreMergeContactHopsAwayFromPrevious(base.hops_away, prevHops, 0); pendingDbRows.push( @@ -3812,7 +3856,7 @@ export function useMeshcoreRuntime() { /** Successful Status/Ping prove reachability; sync `last_heard` when firmware `lastAdvert` is stale. */ const bumpMeshcoreNodeLastHeardFromRpc = useCallback( (nodeId: number) => { - const existing = nodesRef.current.get(nodeId); + const existing = getIdentityNode(meshcoreIdentityIdRef.current, nodeId); if (!existing) return; const nowSec = Math.floor(Date.now() / 1000); const lat = existing.latitude ?? null; @@ -3963,7 +4007,7 @@ export function useMeshcoreRuntime() { const setupGen = meshcoreSetupGenerationRef.current; const isTraceAborted = (): boolean => meshcoreSetupGenerationRef.current !== setupGen || resolveMeshcoreConn() !== conn; - const hopsAway = nodesRef.current.get(nodeId)?.hops_away; + const hopsAway = getIdentityNode(meshcoreIdentityIdRef.current, nodeId)?.hops_away; let storedPath = outPathMapRef.current.get(nodeId); if (storedPath && !meshcoreIsUsableTraceStoredPath(storedPath, hopsAway, pubKey)) { outPathMapRef.current.delete(nodeId); @@ -4000,7 +4044,7 @@ export function useMeshcoreRuntime() { outPathMapRef.current.set(nodeId, routeStoredPath); } const relayKeysForSynth = meshcoreDirectRepeaterRelayPubKeys( - nodesRef.current, + readMeshcoreNodes(), pubKeyMapRef.current, nodeId, ); @@ -4086,7 +4130,7 @@ export function useMeshcoreRuntime() { pubKey, hopsAway, nodeId, - nodes: nodesRef.current, + nodes: readMeshcoreNodes(), pubKeyByNodeId: pubKeyMapRef.current, pathByNodeId: outPathMapRef.current, }); @@ -4222,7 +4266,7 @@ export function useMeshcoreRuntime() { convertedLastSnr, result.tag, ); - const existingForRf = nodesRef.current.get(nodeId); + const existingForRf = getIdentityNode(meshcoreIdentityIdRef.current, nodeId); setNodes((prev) => { const existing = prev.get(nodeId); if (!existing) return prev; @@ -4261,7 +4305,7 @@ export function useMeshcoreRuntime() { }); return true; } catch (e: unknown) { - const failedHops = nodesRef.current.get(nodeId)?.hops_away; + const failedHops = getIdentityNode(meshcoreIdentityIdRef.current, nodeId)?.hops_away; if ((failedHops ?? 0) >= 1) { outPathMapRef.current.delete(nodeId); } @@ -4299,6 +4343,7 @@ export function useMeshcoreRuntime() { bumpMeshcoreNodeLastHeardFromRpc, clearMeshcorePingNoRouteExpiryTimer, ensureNodePubKey, + readMeshcoreNodes, refreshRepeaterContactPathFromRadio, resolveMeshcoreConn, ], @@ -4461,7 +4506,7 @@ export function useMeshcoreRuntime() { conn, nodeId, pubKey, - nodesRef.current.get(nodeId)?.hw_model, + getIdentityNode(meshcoreIdentityIdRef.current, nodeId)?.hw_model, repeaterRemoteRpcRef.current, ); const raw = await runMeshcoreRepeaterTelemetryRequest( @@ -4767,11 +4812,11 @@ export function useMeshcoreRuntime() { conn, nodeId, pubKey, - nodesRef.current.get(nodeId)?.hw_model, + getIdentityNode(meshcoreIdentityIdRef.current, nodeId)?.hw_model, repeaterRemoteRpcRef.current, ); - const node = nodesRef.current.get(nodeId); + const node = getIdentityNode(meshcoreIdentityIdRef.current, nodeId); const trace = meshcoreTraceResults.get(nodeId); const hopCount = computeRepeaterCliHopCount( node?.hops_away, @@ -4848,7 +4893,7 @@ export function useMeshcoreRuntime() { const resolveRoomLoginHopsForNode = useCallback((nodeId: number): number => { return resolveMeshcoreRoomLoginHopsAway( - nodesRef.current.get(nodeId), + getIdentityNode(meshcoreIdentityIdRef.current, nodeId), outPathMapRef.current.get(nodeId), ); }, []); @@ -4957,7 +5002,7 @@ export function useMeshcoreRuntime() { const guestPassword = opts?.guestPassword ?? password; const adminPassword = opts?.adminPassword ?? ''; const hopsAway = resolveRoomLoginHopsForNode(nodeId); - const uiHops = nodesRef.current.get(nodeId)?.hops_away; + const uiHops = getIdentityNode(meshcoreIdentityIdRef.current, nodeId)?.hops_away; const outPathLen = outPathMapRef.current.get(nodeId)?.length ?? 0; console.debug( `[useMeshcoreRuntime] loginRoom node=0x${nodeId.toString(16)} hopsAway=${hopsAway} uiHops=${String(uiHops ?? 'n/a')} outPathLen=${outPathLen}`, @@ -4979,7 +5024,7 @@ export function useMeshcoreRuntime() { activeConn, nodeId, pubKey, - nodesRef.current.get(nodeId), + getIdentityNode(meshcoreIdentityIdRef.current, nodeId), storedPath, hopsAway, (fn) => repeaterRemoteRpcRef.current(fn), @@ -5119,7 +5164,9 @@ export function useMeshcoreRuntime() { const candidateIds = fromUi.length > 0 ? fromUi - : [...savedIds].filter((id) => nodesRef.current.get(id)?.hw_model === 'Room'); + : [...savedIds].filter( + (id) => getIdentityNode(meshcoreIdentityIdRef.current, id)?.hw_model === 'Room', + ); const nodeIds = candidateIds.filter((id) => !meshcoreIsRoomLoggedIn(id)); for (const nodeId of nodeIds) { const cred = getMeshcoreRoomCredential(nodeId); @@ -5157,7 +5204,7 @@ export function useMeshcoreRuntime() { } const roomNodes: RoomSyncSchedulerNode[] = listMeshcoreRoomSyncEnabledNodeIds() - .filter((id) => nodesRef.current.get(id)?.hw_model === 'Room') + .filter((id) => getIdentityNode(meshcoreIdentityIdRef.current, id)?.hw_model === 'Room') .map((id) => { const cfg = getMeshcoreRoomSyncConfig(id); return { @@ -5200,7 +5247,7 @@ export function useMeshcoreRuntime() { activeConn, target.nodeId, pubKey, - nodesRef.current.get(target.nodeId), + getIdentityNode(meshcoreIdentityIdRef.current, target.nodeId), storedPath, syncHops, (fn) => repeaterRemoteRpcRef.current(fn), @@ -5273,7 +5320,9 @@ export function useMeshcoreRuntime() { return; } const configuredIds = listMeshcoreRoomAutoLoginOnConnectNodeIds(); - const nodeIds = configuredIds.filter((id) => nodesRef.current.get(id)?.hw_model === 'Room'); + const nodeIds = configuredIds.filter( + (id) => getIdentityNode(meshcoreIdentityIdRef.current, id)?.hw_model === 'Room', + ); const targets = nodeIds.filter((nodeId) => { if (meshcoreIsRoomLoggedIn(nodeId)) return false; if (!getMeshcoreRoomCredential(nodeId)) return false; @@ -5312,7 +5361,7 @@ export function useMeshcoreRuntime() { } const now = Date.now(); const roomNodes: RoomSyncSchedulerNode[] = listMeshcoreRoomSyncEnabledNodeIds() - .filter((id) => nodesRef.current.get(id)?.hw_model === 'Room') + .filter((id) => getIdentityNode(meshcoreIdentityIdRef.current, id)?.hw_model === 'Room') .map((id) => { const cfg = getMeshcoreRoomSyncConfig(id); return { @@ -5341,7 +5390,7 @@ export function useMeshcoreRuntime() { activeConn, target.nodeId, pubKey, - nodesRef.current.get(target.nodeId), + getIdentityNode(meshcoreIdentityIdRef.current, target.nodeId), storedPath, syncHops, (fn) => repeaterRemoteRpcRef.current(fn), @@ -5438,7 +5487,7 @@ export function useMeshcoreRuntime() { const storeId = meshcoreIdentityIdRef.current; const canonicalId = addMessage(tempMsg); try { - const hopsAway = nodesRef.current.get(nodeId)?.hops_away ?? 0; + const hopsAway = getIdentityNode(meshcoreIdentityIdRef.current, nodeId)?.hops_away ?? 0; console.debug( `[useMeshcoreRuntime] sendRoomPost mode=post txtType=${MESHCORE_TXT_TYPE_PLAIN} bodyLen=${new TextEncoder().encode(text).length} room=0x${nodeId.toString(16)} hops=${hopsAway} transport=${meshcoreConnectTypeRef.current ?? 'unknown'}`, ); @@ -5558,7 +5607,7 @@ export function useMeshcoreRuntime() { const sendRoomAdminCliCommand = useCallback( async (nodeId: number, command: string): Promise => { - const node = nodesRef.current.get(nodeId); + const node = getIdentityNode(meshcoreIdentityIdRef.current, nodeId); if (node?.hw_model !== 'Room') { return sendRepeaterCliCommand(nodeId, command); } @@ -5860,7 +5909,7 @@ export function useMeshcoreRuntime() { } const dbLastAdvertById = new Map(dbRows.map((r) => [r.node_id, r.last_advert])); const dbHopsById = new Map(dbRows.map((r) => [r.node_id, r.hops_away])); - /** Built inside `setNodes` so we read merged `last_heard` before `nodesRef` catches up. */ + /** Built inside `setNodes` so we read merged `last_heard` before the store catches up. */ const lastAdvertForDbByNodeId = new Map(); setNodes((prev) => { @@ -5947,48 +5996,53 @@ export function useMeshcoreRuntime() { return { imported: validEntries.length, skipped, errors }; }, []); - const setNodeFavorited = useCallback(async (nodeId: number, favorited: boolean) => { - const storeId = meshcoreIdentityIdRef.current ?? getIdentityIdForProtocol('meshcore') ?? null; - const storeRecord = storeId ? useNodeStore.getState().nodes[storeId]?.[nodeId] : undefined; - const runtimeNode = nodesRef.current.get(nodeId); - if (!runtimeNode && !storeRecord) return; + const setNodeFavorited = useCallback( + async (nodeId: number, favorited: boolean) => { + const storeId = + meshcoreIdentityIdRef.current ?? + getIdentityIdForProtocol('meshcore') ?? + resolveMeshcoreStoreIdentityId(); + const storeRecord = storeId ? useNodeStore.getState().nodes[storeId]?.[nodeId] : undefined; + if (!storeRecord) return; - const prevFav = runtimeNode?.favorited ?? storeRecord?.favorited ?? false; - if (storeId) { - patchNodeFavorited(storeId, nodeId, favorited); - } - setNodes((prev) => { - const n = prev.get(nodeId); - if (!n) return prev; - const next = new Map(prev); - next.set(nodeId, { ...n, favorited }); - return next; - }); - const pk = pubKeyMapRef.current.get(nodeId) ?? storeRecord?.publicKey; - const hex = - pk != null - ? Array.from(pk) - .map((b) => b.toString(16).padStart(2, '0')) - .join('') - : meshcoreSyntheticPlaceholderPubKeyHex(nodeId); - try { - await window.electronAPI.db.updateMeshcoreContactFavorited(nodeId, favorited, hex); - } catch (e) { - console.warn( - '[useMeshcoreRuntime] updateMeshcoreContactFavorited error ' + errLikeToLogString(e), - ); + const prevFav = storeRecord.favorited ?? false; if (storeId) { - patchNodeFavorited(storeId, nodeId, prevFav); + patchNodeFavorited(storeId, nodeId, favorited); } setNodes((prev) => { const n = prev.get(nodeId); if (!n) return prev; const next = new Map(prev); - next.set(nodeId, { ...n, favorited: prevFav }); + next.set(nodeId, { ...n, favorited }); return next; }); - } - }, []); + const pk = pubKeyMapRef.current.get(nodeId) ?? storeRecord.publicKey; + const hex = + pk != null + ? Array.from(pk) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + : meshcoreSyntheticPlaceholderPubKeyHex(nodeId); + try { + await window.electronAPI.db.updateMeshcoreContactFavorited(nodeId, favorited, hex); + } catch (e) { + console.warn( + '[useMeshcoreRuntime] updateMeshcoreContactFavorited error ' + errLikeToLogString(e), + ); + if (storeId) { + patchNodeFavorited(storeId, nodeId, prevFav); + } + setNodes((prev) => { + const n = prev.get(nodeId); + if (!n) return prev; + const next = new Map(prev); + next.set(nodeId, { ...n, favorited: prevFav }); + return next; + }); + } + }, + [resolveMeshcoreStoreIdentityId], + ); const sendReaction = useCallback( async (glyph: string, replyId: number, channel: number) => { @@ -5999,16 +6053,9 @@ export function useMeshcoreRuntime() { if (!parsed) { throw new Error('Invalid reaction emoji'); } - const storeId = meshcoreIdentityIdRef.current; - const storeMessages = - storeId != null - ? messageRecordsToChatMessages( - Object.values(useMessageStore.getState().messages[storeId] ?? {}), - ) - : []; - const reactedTo = - storeMessages.find((m) => m.packetId === replyId || m.timestamp === replyId) ?? - messagesRef.current.find((m) => m.packetId === replyId || m.timestamp === replyId); + const reactedTo = readMeshcoreMessages().find( + (m) => m.packetId === replyId || m.timestamp === replyId, + ); if (reactedTo == null) { throw new Error('Reaction target message not found'); } @@ -6075,7 +6122,7 @@ export function useMeshcoreRuntime() { }); } }, - [addMessage, selfInfo?.name], + [addMessage, readMeshcoreMessages, selfInfo?.name], ); // ─── MeshCore Device Time ──────────────────────────────────────── @@ -6192,14 +6239,14 @@ export function useMeshcoreRuntime() { } try { const hops = resolveMeshcoreRoomLoginHopsAway( - nodesRef.current.get(nodeId), + getIdentityNode(meshcoreIdentityIdRef.current, nodeId), outPathMapRef.current.get(nodeId), ); const result = await syncMeshcoreRoomContactPathBeforeLogin( conn, nodeId, pubKey, - nodesRef.current.get(nodeId), + getIdentityNode(meshcoreIdentityIdRef.current, nodeId), Uint8Array.from(path), hops, ); @@ -6374,7 +6421,7 @@ export function useMeshcoreRuntime() { }, [fetchAndUpdateLocalStats]); const refreshOurPositionNoop = useCallback(async () => { - const myNode = nodesRef.current.get(myNodeNumRef.current); + const myNode = getIdentityNode(meshcoreIdentityIdRef.current, myNodeNumRef.current); const storedStatic = readStoredStaticGps(); const staticLat = storedStatic?.lat; const staticLon = storedStatic?.lon; @@ -6523,24 +6570,27 @@ export function useMeshcoreRuntime() { console.warn('[useMeshcoreRuntime] refreshNodesFromDb error ' + errLikeToLogString(e)); } }, [resolveMeshcoreStoreIdentityId]); - const refreshMessagesFromDb = useCallback(async (opts?: { replaceFromDb?: boolean }) => { - try { - const dbMsgs = (await window.electronAPI.db.getMeshcoreMessages( - undefined, - 500, - )) as MeshcoreMessageDbRow[]; - const mapped = repairMeshcoreHydratedMessages( - mapMeshcoreDbRowsToChatMessages(dbMsgs), - meshcoreRoomServerIdsFromNodes(nodesRef.current.values()), - myNodeNumRef.current, - ); - void persistMeshcoreMessageSenderRepairs(dbMsgs, mapped); - setNodes((prev) => mergeStubNodesFromMeshcoreMessages(prev, mapped)); - setMessages((prev) => mergeMeshcoreDbHydrationWithLive(prev, mapped, opts)); - } catch (e) { - console.warn('[useMeshcoreRuntime] refreshMessagesFromDb error ' + errLikeToLogString(e)); - } - }, []); + const refreshMessagesFromDb = useCallback( + async (opts?: { replaceFromDb?: boolean }) => { + try { + const dbMsgs = (await window.electronAPI.db.getMeshcoreMessages( + undefined, + 500, + )) as MeshcoreMessageDbRow[]; + const mapped = repairMeshcoreHydratedMessages( + mapMeshcoreDbRowsToChatMessages(dbMsgs), + meshcoreRoomServerIdsFromNodes(readMeshcoreNodes().values()), + myNodeNumRef.current, + ); + void persistMeshcoreMessageSenderRepairs(dbMsgs, mapped); + setNodes((prev) => mergeStubNodesFromMeshcoreMessages(prev, mapped)); + setMessages((prev) => mergeMeshcoreDbHydrationWithLive(prev, mapped, opts)); + } catch (e) { + console.warn('[useMeshcoreRuntime] refreshMessagesFromDb error ' + errLikeToLogString(e)); + } + }, + [readMeshcoreNodes], + ); const meshcoreMessagesFromStore = useMessageStore((s) => meshcoreIdentityId ? s.messages[meshcoreIdentityId] : undefined, @@ -6599,7 +6649,10 @@ export function useMeshcoreRuntime() { const outPathRaw = destNodeId != null ? outPathMapRef.current.get(destNodeId) : undefined; const sendPathBytes = outPathRaw && outPathRaw.length > 0 ? Array.from(outPathRaw) : []; const sendPathHash = sendPathBytes.length > 0 ? computePathHash(sendPathBytes) : ''; - const hopsAway = destNodeId != null ? (nodesRef.current.get(destNodeId)?.hops_away ?? 0) : 0; + const hopsAway = + destNodeId != null + ? (getIdentityNode(meshcoreIdentityIdRef.current, destNodeId)?.hops_away ?? 0) + : 0; if (sendPathBytes.length > 0 && destNodeId != null) { usePathHistoryStore .getState() @@ -6681,7 +6734,9 @@ export function useMeshcoreRuntime() { messages: resolvedMessages, channels, selfInfo, - meshcoreLocalStats: nodesRef.current.get(myNodeNumRef.current)?.meshcore_local_stats ?? null, + meshcoreLocalStats: + getIdentityNode(meshcoreIdentityIdRef.current, myNodeNumRef.current) + ?.meshcore_local_stats ?? null, connect, disconnect, onPowerSuspend, diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 6fbb65299..84bffb101 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -85,6 +85,11 @@ import { syncNodesMapToIdentityStore, } from '../lib/hydrateIdentityStoresFromDb'; import { getIdentityIdForProtocol } from '../lib/identityByProtocol'; +import { + getIdentityChatMessages, + getIdentityNode, + getIdentityNodeMap, +} from '../lib/identityStoreReads'; import type { MeshtasticIngestSession } from '../lib/ingest/meshtasticIngest'; import { rehydrateMeshtasticConnectionParamsFromStorage } from '../lib/lastConnectionStorage'; import { @@ -93,10 +98,6 @@ import { } from '../lib/meshcoreDualNobleBleInit'; import { meshtasticTransportParams } from '../lib/meshIdentityBridge'; import { configureMeshtasticDeviceWithRetry } from '../lib/meshtastic/meshtasticConfigureRetry'; -import { - attachMeshtasticLegacyWireSubscriptions, - type MeshtasticLegacyWireSubscriptionDeps, -} from '../lib/meshtastic/meshtasticLegacyWireSubscriptions'; import type { ModulePortEvent, PaxCounterPoint } from '../lib/meshtastic/meshtasticModuleEvents'; import { normalizeMeshtasticMqttChatMessage } from '../lib/meshtastic/meshtasticMqttChatNormalize'; import { MeshtasticMqttClientProxyBridge } from '../lib/meshtastic/meshtasticMqttClientProxy'; @@ -114,6 +115,10 @@ import { endMeshtasticNonChatOutbound, registerMeshtasticNonChatWirePacketId, } from '../lib/meshtastic/meshtasticOutboundCoordination'; +import { + attachMeshtasticRuntimeWireEffects, + type MeshtasticRuntimeWireEffectsDeps, +} from '../lib/meshtastic/meshtasticRuntimeWireEffects'; import { meshtasticXmodemDownload, meshtasticXmodemUpload, @@ -303,8 +308,6 @@ export function useMeshtasticRuntime() { // Track own node number in a ref so event callbacks can access it // without relying on the private device.myNodeInfo property const myNodeNumRef = useRef(0); - // Use a ref for nodes so event callbacks always see the latest value - const nodesRef = useRef>(new Map()); // Track event unsubscribe functions for cleanup const unsubscribesRef = useRef<(() => void)[]>([]); /** Protocol ingress + ConnectionDriver handle registration (issues #375 / #377). */ @@ -414,10 +417,6 @@ export function useMeshtasticRuntime() { isLicensed: boolean; } | null>(null); const [messages, setMessages] = useState([]); - const messagesRef = useRef([]); - useEffect(() => { - messagesRef.current = messages; - }, [messages]); const [nodes, setNodes] = useState>(new Map()); const [telemetry, setTelemetry] = useState([]); const [signalTelemetry, setSignalTelemetry] = useState([]); @@ -542,7 +541,7 @@ export function useMeshtasticRuntime() { const ensureNonConflictingVirtualNodeId = useCallback((): number => { let virtualId = virtualNodeIdRef.current; const conflictsWithKnownRfNode = (id: number): boolean => { - const existing = nodesRef.current.get(id); + const existing = getIdentityNode(meshtasticIdentityIdRef.current, id); return !!existing && existing.source === 'rf' && id !== myNodeNumRef.current; }; if (conflictsWithKnownRfNode(virtualId)) { @@ -555,18 +554,25 @@ export function useMeshtasticRuntime() { return virtualId; }, []); - // Keep nodesRef in sync with state and identity-scoped nodeStore (App reads via useNodeStore). const updateNodes = useCallback( (updater: (prev: Map) => Map) => { - setNodes((prev) => { - const next = updater(prev); - nodesRef.current = next; - return next; - }); + setNodes((prev) => updater(prev)); }, [], ); + // `nodeStore` / `messageStore` see both ingress paths (Protocol → PacketRouter + // and this runtime), so they are the only read source for send/dedup/RPC. + const readIdentityNodes = useCallback( + (): Map => getIdentityNodeMap(meshtasticIdentityIdRef.current), + [], + ); + + const readIdentityMessages = useCallback( + (): ChatMessage[] => getIdentityChatMessages(meshtasticIdentityIdRef.current), + [], + ); + // Push runtime node map into identity-scoped Zustand after commit — never inside setState updaters. useEffect(() => { const storeId = meshtasticIdentityIdRef.current ?? meshtasticPendingDriverIdentityRef.current; @@ -576,7 +582,9 @@ export function useMeshtasticRuntime() { const ensureNodeExists = useCallback( (nodeNum: number, source: 'rf' | 'mqtt') => { - if (nodesRef.current.has(nodeNum) || nodeNum === 0) return; + if (nodeNum === 0 || getIdentityNode(meshtasticIdentityIdRef.current, nodeNum) != null) { + return; + } updateNodes((prev) => { if (prev.has(nodeNum)) return prev; const created = createChatStubNode(nodeNum, source); @@ -589,11 +597,6 @@ export function useMeshtasticRuntime() { [updateNodes], ); - // Keep channelConfigsRef in sync so MQTT callbacks always see current config - useEffect(() => { - channelConfigsRef.current = channelConfigs; - }, [channelConfigs]); - const pushMqttChannelKeys = useCallback(() => { if (mqttStatusRef.current !== 'connected') return; let entries = meshtasticMqttChannelKeyEntries(channelConfigsRef.current); @@ -664,7 +667,7 @@ export function useMeshtasticRuntime() { // Compact display name: short_name, truncated long_name, or hex ID const getNodeName = useCallback((nodeNum: number): string => { - const node = nodesRef.current.get(nodeNum); + const node = getIdentityNode(meshtasticIdentityIdRef.current, nodeNum); if (node?.short_name) return node.short_name; if (node?.long_name) return node.long_name.length > 7 ? node.long_name.slice(0, 7) : node.long_name; @@ -674,7 +677,7 @@ export function useMeshtasticRuntime() { // Picker-style label: "icon_XXXX" (same format as BLE picker). If short_name // already ends with _ + 4 hex digits, use it; else append _ + last 4 hex of node ID. const getPickerStyleNodeLabel = useCallback((nodeNum: number): string => { - const node = nodesRef.current.get(nodeNum); + const node = getIdentityNode(meshtasticIdentityIdRef.current, nodeNum); const fourHex = nodeNum.toString(16).slice(-4); if (node?.short_name) { if (/_[0-9a-fA-F]{4}$/.test(node.short_name)) return node.short_name; @@ -690,7 +693,7 @@ export function useMeshtasticRuntime() { // Extended label: short_name + hex suffix, long_name, or hex fallback. // Used in the header for the connected node display. const getFullNodeLabel = useCallback((nodeNum: number): string => { - const node = nodesRef.current.get(nodeNum); + const node = getIdentityNode(meshtasticIdentityIdRef.current, nodeNum); const hexId = formatMeshtasticNodeId(nodeNum); if (node?.short_name) { // Avoid double-appending hex if short_name already contains it @@ -979,7 +982,7 @@ export function useMeshtasticRuntime() { () => myNodeNumRef.current, (nodeNum) => resolveMeshtasticDestPublicKeyBytes({ - publicKeyHex: nodesRef.current.get(nodeNum)?.public_key_hex, + publicKeyHex: getIdentityNode(meshtasticIdentityIdRef.current, nodeNum)?.public_key_hex, adminKeyBase64: remoteAdminKeysByNodeRef.current[String(nodeNum)], }), ); @@ -1118,7 +1121,7 @@ export function useMeshtasticRuntime() { lastRfSelfNodeIdRef.current, virtualNodeIdRef.current, ); - const selfNode = nodesRef.current.get(presenceFrom); + const selfNode = getIdentityNode(meshtasticIdentityIdRef.current, presenceFrom); const useRealIdentity = lastRfSelfNodeIdRef.current > 0 && presenceFrom === lastRfSelfNodeIdRef.current; const presenceMqtt = resolveMeshtasticMqttPublishFieldsForChannel( @@ -1266,13 +1269,13 @@ export function useMeshtasticRuntime() { void window.electronAPI.db.saveNode(node); return updated; }); - const updatedMqttNode = nodesRef.current.get(nodeUpdate.node_id); + const updatedMqttNode = getIdentityNode(meshtasticIdentityIdRef.current, nodeUpdate.node_id); if (updatedMqttNode && getStoredMeshProtocol() === 'meshtastic') { useDiagnosticsStore .getState() .processNodeUpdate( updatedMqttNode, - nodesRef.current.get(myNodeNumRef.current) ?? null, + getIdentityNode(meshtasticIdentityIdRef.current, myNodeNumRef.current) ?? null, myNodeNumRef.current, MESHTASTIC_CAPABILITIES, ); @@ -1354,19 +1357,9 @@ export function useMeshtasticRuntime() { receivedVia: 'mqtt', isHistory: mqttTreatAsBacklog || undefined, }; - const mqttWithPreviews = enrichMeshtasticReplyPreviews( - mqttMsg, - messagesRef.current, - getNodeName, - ); - const storeId = meshtasticIdentityIdRef.current; - const storeMsgs = storeId - ? messageRecordsToChatMessages( - Object.values(useMessageStore.getState().messages[storeId] ?? {}), - ) - : []; - const dedupSource = storeMsgs.length > 0 ? storeMsgs : messagesRef.current; + const dedupSource = getIdentityChatMessages(storeId); + const mqttWithPreviews = enrichMeshtasticReplyPreviews(mqttMsg, dedupSource, getNodeName); const crossDup = findMeshtasticCrossTransportDuplicate(dedupSource, mqttWithPreviews); if (crossDup) { @@ -1380,7 +1373,7 @@ export function useMeshtasticRuntime() { }); if (storeId) { const { messages: storeNext, matched } = mapMeshtasticCrossTransportUpgrade( - storeMsgs, + dedupSource, mqttWithPreviews, ); if (matched) { @@ -1516,33 +1509,35 @@ export function useMeshtasticRuntime() { }; }, [cleanupSubscriptions, clearConfigureTimeout, stopWatchdog, stopGpsInterval]); - const applyMeshtasticForeignLoraFromLog = useCallback((message: string) => { - if (myNodeNumRef.current === 0) return; - const match = matchForeignLoraFromMeshtasticLog(message); - if (!match) return; - const meshcoreSelfId = getMeshcoreDiagnosticsSelfNodeId(); - const senderId = match.packetClass === 'meshcore' ? match.senderId : undefined; - let displayName: string | undefined; - if (match.packetClass === 'meshcore' && meshcoreSelfId > 0 && senderId === meshcoreSelfId) { - const selfNode = getMergedNodesForForeignLoraDiagnostics(nodesRef.current).get( - meshcoreSelfId, - ); - displayName = selfNode?.long_name ?? selfNode?.short_name; - } - useDiagnosticsStore - .getState() - .recordForeignLora( - myNodeNumRef.current, - match.packetClass, - match.rssi, - match.snr, - senderId, - () => getMergedNodesForForeignLoraDiagnostics(nodesRef.current), - 'meshtastic-rf', - undefined, - displayName, - ); - }, []); + const applyMeshtasticForeignLoraFromLog = useCallback( + (message: string) => { + if (myNodeNumRef.current === 0) return; + const match = matchForeignLoraFromMeshtasticLog(message); + if (!match) return; + const meshcoreSelfId = getMeshcoreDiagnosticsSelfNodeId(); + const senderId = match.packetClass === 'meshcore' ? match.senderId : undefined; + let displayName: string | undefined; + if (match.packetClass === 'meshcore' && meshcoreSelfId > 0 && senderId === meshcoreSelfId) { + const selfNode = + getMergedNodesForForeignLoraDiagnostics(readIdentityNodes()).get(meshcoreSelfId); + displayName = selfNode?.long_name ?? selfNode?.short_name; + } + useDiagnosticsStore + .getState() + .recordForeignLora( + myNodeNumRef.current, + match.packetClass, + match.rssi, + match.snr, + senderId, + () => getMergedNodesForForeignLoraDiagnostics(readIdentityNodes()), + 'meshtastic-rf', + undefined, + displayName, + ); + }, + [readIdentityNodes], + ); const requestStoreForwardHistoryRef = useRef< (options?: { @@ -1681,7 +1676,7 @@ export function useMeshtasticRuntime() { requestStoreForwardHistoryRef.current = requestStoreForwardHistory; }, [requestStoreForwardHistory]); - /** All transports use `ConnectionDriver.connect`; legacy handlers stay in `wireSubscriptions`. */ + /** All transports use `ConnectionDriver.connect`; SDK handlers stay in `wireSubscriptions`. */ const openMeshtasticTransport = useCallback( async ( type: ConnectionType, @@ -1721,7 +1716,7 @@ export function useMeshtasticRuntime() { ); // ─── Wire up all event subscriptions for a device ───────────── - const meshtasticLegacyWireSubscriptionDeps = useMemo( + const meshtasticRuntimeWireEffectsDeps = useMemo( () => ({ channelConfigsRef, configureTargetNodeNumRef, @@ -1746,11 +1741,8 @@ export function useMeshtasticRuntime() { meshtasticIdentityIdRef, meshtasticIngestSessionRef, meshtasticIngressDetachRef, - messagesRef, mqttStatusRef, myNodeNumRef, - nodesRef, - pendingTempIdRef, ackMeshPacketIdByTempIdRef, pendingTracePacketIdToTargetRef, pendingTraceRequestsRef, @@ -1792,13 +1784,10 @@ export function useMeshtasticRuntime() { setRemoteConfigSnapshot, setRemoteAdminStatus, setRemoteAdminError, - setMessages, setTelemetry, setSignalTelemetry, setEnvironmentTelemetry, setDeviceOwner, - setChannels, - setChannelConfigs, setDeviceGpsMode, setDeviceFixedPosition, setTelemetryDeviceUpdateInterval, @@ -1838,14 +1827,9 @@ export function useMeshtasticRuntime() { const wireSubscriptions = useCallback( (device: MeshDevice, type: ConnectionType, opts?: { driverIdentityId?: string }) => { - attachMeshtasticLegacyWireSubscriptions( - device, - type, - opts, - meshtasticLegacyWireSubscriptionDeps, - ); + attachMeshtasticRuntimeWireEffects(device, type, opts, meshtasticRuntimeWireEffectsDeps); }, - [meshtasticLegacyWireSubscriptionDeps], + [meshtasticRuntimeWireEffectsDeps], ); // ─── Connection lost handler ────────────────────────────────── @@ -2246,7 +2230,6 @@ export function useMeshtasticRuntime() { const applyMeshtasticNodesToUi = useCallback( (driverIdentityId: string, nodeMap: Map) => { - nodesRef.current = nodeMap; setNodes(nodeMap); syncNodesMapToIdentityStore(driverIdentityId, nodeMap); }, @@ -2589,7 +2572,7 @@ export function useMeshtasticRuntime() { updateMessageStatus(identityId, resolvedIdStr, 'acked'); } outboundSendByTempIdRef.current.delete(tempId); - const ackSenderId = messagesRef.current.find((m) => + const ackSenderId = readIdentityMessages().find((m) => meshtasticOutboundSendMatchesTempId(m, tempId), )?.sender_id; void ( @@ -2647,7 +2630,7 @@ export function useMeshtasticRuntime() { } } }, - [isDuplicate, meshtasticOutboundSendMatchesTempId], + [isDuplicate, meshtasticOutboundSendMatchesTempId, readIdentityMessages], ); // Keep the ref in sync so TransportManager always invokes the latest handler @@ -2672,13 +2655,7 @@ export function useMeshtasticRuntime() { let wireReplyId: number | undefined; if (replyId != null) { - const identityIdForReply = meshtasticIdentityIdRef.current; - const storeMsgsForReply = identityIdForReply - ? messageRecordsToChatMessages( - Object.values(useMessageStore.getState().messages[identityIdForReply] ?? {}), - ) - : messagesRef.current; - wireReplyId = resolveMeshtasticWireReplyId(storeMsgsForReply, replyId); + wireReplyId = resolveMeshtasticWireReplyId(readIdentityMessages(), replyId); if (wireReplyId == null || wireReplyId === 0) { throw new Error( 'Reply requires the message RF packet id (wait for send ack or refresh chat).', @@ -2706,7 +2683,7 @@ export function useMeshtasticRuntime() { to: destination != null && destination >>> 0 !== BROADCAST_ADDR ? destination : undefined, replyId: wireReplyId, }, - messagesRef.current, + readIdentityMessages(), getNodeName, ); outboundSendByTempIdRef.current.set(tempId, { @@ -2746,7 +2723,7 @@ export function useMeshtasticRuntime() { from, ); }, - [getNodeName, isDuplicate], + [getNodeName, isDuplicate, readIdentityMessages], ); const clearRemoteAdminLoadingIfNoForegroundInflight = (): void => { @@ -2798,7 +2775,7 @@ export function useMeshtasticRuntime() { setRemoteAdminError('remoteAdmin.errors.noLocalRadio'); return; } - const destNode = nodesRef.current.get(destNodeNum); + const destNode = getIdentityNode(meshtasticIdentityIdRef.current, destNodeNum); const configuredAdminKey = getRemoteAdminKeyForNode(destNodeNum); if (!configuredAdminKey || !isValidMeshtasticAdminKeyBase64(configuredAdminKey)) { setRemoteAdminStatus('error'); @@ -3623,7 +3600,6 @@ export function useMeshtasticRuntime() { void loadMeshtasticNodeMapFromDb() .then((nodeMap) => { console.debug(`[useMeshtasticRuntime] refreshNodesFromDb: loaded ${nodeMap.size} nodes`); - nodesRef.current = nodeMap; setNodes(nodeMap); const storeId = meshtasticIdentityIdRef.current ?? meshtasticPendingDriverIdentityRef.current; @@ -3687,7 +3663,7 @@ export function useMeshtasticRuntime() { } setGpsLoading(true); try { - const myNode = nodesRef.current.get(myNodeNumRef.current); + const myNode = getIdentityNode(meshtasticIdentityIdRef.current, myNodeNumRef.current); const storedStatic = readStoredStaticGps(); const staticLat = storedStatic?.lat; const staticLon = storedStatic?.lon; @@ -3731,7 +3707,9 @@ export function useMeshtasticRuntime() { }); } - const isClientMute = nodesRef.current.get(myNodeNumRef.current)?.role === ROLE_CLIENT_MUTE; + const isClientMute = + getIdentityNode(meshtasticIdentityIdRef.current, myNodeNumRef.current)?.role === + ROLE_CLIENT_MUTE; const wouldSendWithoutMute = deviceRef.current && (pos.source === 'static' || (pos.source === 'browser' && deviceGpsModeRef.current === 2)); @@ -3769,7 +3747,11 @@ export function useMeshtasticRuntime() { const sendPositionToDevice = useCallback(async (lat: number, lon: number, alt?: number) => { if (!deviceRef.current) return; - if (nodesRef.current.get(myNodeNumRef.current)?.role === ROLE_CLIENT_MUTE) return; + if ( + getIdentityNode(meshtasticIdentityIdRef.current, myNodeNumRef.current)?.role === + ROLE_CLIENT_MUTE + ) + return; await deviceRef.current.setPosition( create(Mesh.PositionSchema, { latitudeI: Math.round(lat * 1e7), @@ -3816,11 +3798,7 @@ export function useMeshtasticRuntime() { setState((prev) => ({ ...prev, myNodeNum: from })); } const identityId = meshtasticIdentityIdRef.current; - const storeMsgs = identityId - ? messageRecordsToChatMessages( - Object.values(useMessageStore.getState().messages[identityId] ?? {}), - ) - : messagesRef.current; + const storeMsgs = readIdentityMessages(); const repliedMsg = storeMsgs.find((m) => m.packetId === replyId) ?? storeMsgs.find((m) => m.timestamp === replyId) ?? @@ -3884,7 +3862,7 @@ export function useMeshtasticRuntime() { ); }); } else { - // Legacy runtime-only path (no identity store yet) + // No identity bound yet — fall back to the hook-local message state. setMessages((prev) => { const isDup = prev.some( (m) => @@ -3945,7 +3923,7 @@ export function useMeshtasticRuntime() { return Promise.resolve(); }, - [getNodeName, isDuplicate], + [getNodeName, isDuplicate, readIdentityMessages], ); const sendStatusEvents = useCallback(() => { @@ -3978,14 +3956,14 @@ export function useMeshtasticRuntime() { : 0; const virtualNodeId = virtualNodeIdRef.current; - const getNodes = useCallback(() => nodesRef.current, []); + const getNodes = useCallback(() => readIdentityNodes(), [readIdentityNodes]); const clearRawPackets = useCallback(() => { setRawPackets([]); }, []); // Read identity-scoped store slices synchronously (no zustand subscribe here — App - // subscribes via useMessages/useNodes; legacy setState still triggers re-renders). + // subscribes via useMessages/useNodes; hook setState still triggers re-renders). const meshtasticDeviceRecord = useDeviceStore((s) => meshtasticIdentityId ? s.devices[meshtasticIdentityId] : undefined, ); @@ -4064,6 +4042,13 @@ export function useMeshtasticRuntime() { return channelConfigs; }, [meshtasticIdentityId, channelConfigs, meshtasticDeviceRecord]); + // Channel configs now arrive through MeshtasticProtocol → deviceStore, so the + // ref MQTT uplink reads must follow the resolved list (store first, hook state + // for MQTT-only presets) rather than the hook state alone. + useEffect(() => { + channelConfigsRef.current = resolvedChannelConfigs; + }, [resolvedChannelConfigs]); + const resolvedModuleConfigs = useMemo(() => { if (!meshtasticIdentityId) return moduleConfigs; if (meshtasticDeviceRecord && Object.keys(meshtasticDeviceRecord.moduleConfigs).length > 0) { diff --git a/src/renderer/stores/nodeStore.ts b/src/renderer/stores/nodeStore.ts index c9476c6d5..af9445580 100644 --- a/src/renderer/stores/nodeStore.ts +++ b/src/renderer/stores/nodeStore.ts @@ -84,6 +84,8 @@ export interface NodeRecord { neighbors?: MeshNeighbor[]; meshcoreLocalStats?: MeshCoreLocalStats; publicKey?: Uint8Array; + /** Meshtastic PKC public key hex from NodeInfo/User (remote admin destination key). */ + publicKeyHex?: string; // MeshCore per-node op state (results of on-demand requests for repeaters / // remote nodes). Optional fields; non-MeshCore nodes leave them undefined. meshcoreNodeStatus?: StatusResult; diff --git a/vitest.config.ts b/vitest.config.ts index b58b34c01..70a7ed0da 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,7 +37,6 @@ const RENDERER_LOGIC_INCLUDE = [ 'src/renderer/stores/nodeStore.test.ts', 'src/renderer/hooks/meshcore/buildMeshcoreNodeMapFromDb.test.ts', 'src/renderer/hooks/meshcore/meshcoreHookPreamble.crossTransport.test.ts', - 'src/renderer/hooks/meshcore/meshcoreLegacyConnEvents.test.ts', 'src/renderer/hooks/openMeshCoreTransport.test.ts', 'src/renderer/components/LogPanel.filtering.test.ts', 'src/renderer/components/MentionAutocomplete.test.ts', @@ -100,9 +99,9 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/meshtasticMqttSettingsStorage.test.ts', 'src/renderer/lib/meshtasticRemoteAdminKeyStorage.test.ts', 'src/renderer/lib/meshcore/meshcoreLiveContactPersist.test.ts', - 'src/renderer/lib/meshtastic/meshtasticLegacyDeviceEvents.test.ts', - 'src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.post-reboot.test.ts', - 'src/renderer/lib/meshtastic/meshtasticLegacyWireSubscriptions.telemetry-nodeinfo.test.ts', + 'src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts', + 'src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts', + 'src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts', 'src/renderer/lib/meshcoreDualNobleBleInit.test.ts', 'src/renderer/lib/meshcoreKeyBackupStorage.test.ts', 'src/renderer/lib/meshcoreMqttSettingsStorage.test.ts', From 3742d9a4f74d7f4bb040ed917fe7576d6ce4880a Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 09:45:29 -0600 Subject: [PATCH 04/13] test: cover migrated Meshtastic PacketRouter side effects The node, raw-packet, and store-forward listeners shipped without dedicated unit tests after the #484 dual-ingress removal. Add PacketRouter-driven coverage for identity patches, sniffer/SNR hops, and the S&F heartbeat plus replayed-text path into messageStore. Route the diagnostics-dependent cases through renderer-ui (jsdom). Full suite is green (6122). --- .../meshtasticNodeSideEffects.test.ts | 174 +++++++++++++++++ .../meshtasticRawPacketSideEffects.test.ts | 183 ++++++++++++++++++ .../meshtasticStoreForwardSideEffects.test.ts | 179 +++++++++++++++++ vitest.config.ts | 2 + 4 files changed, 538 insertions(+) create mode 100644 src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.test.ts diff --git a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts new file mode 100644 index 000000000..175c276f4 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts @@ -0,0 +1,174 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useNodeStore } from '../../stores/nodeStore'; +import { packetRouter } from '../drivers/PacketRouter'; +import { syncNodesMapToIdentityStore } from '../hydrateIdentityStoresFromDb'; +import { getIdentityNode } from '../identityStoreReads'; +import { MESH_PROTOCOL_STORAGE_KEY } from '../storedMeshProtocol'; +import type { MeshNode } from '../types'; +import { + attachMeshtasticNodeSideEffects, + type MeshtasticNodeSideEffectsDeps, +} from './meshtasticNodeSideEffects'; + +const IDENTITY = 'id-node-se'; +const MY_NODE = 1; +const PEER = 42; + +function emptyNode(nodeId: number): MeshNode { + return { + node_id: nodeId, + long_name: '', + short_name: '', + hw_model: '', + battery: 0, + snr: 0, + rssi: 0, + last_heard: 0, + latitude: null, + longitude: null, + source: 'rf', + heard_via_mqtt_only: false, + }; +} + +function makeDeps(overrides: Partial = {}) { + const nodeMirror = new Map(); + const updateNodes = vi.fn((fn: (prev: Map) => Map) => { + const next = fn(new Map(nodeMirror)); + nodeMirror.clear(); + for (const [id, node] of next) nodeMirror.set(id, node); + syncNodesMapToIdentityStore(IDENTITY, nodeMirror); + }); + const deps: MeshtasticNodeSideEffectsDeps = { + connectionType: 'ble', + getMyNodeNum: () => MY_NODE, + getIsConfiguring: () => false, + getBluetoothDeviceId: () => 'ble-1', + touchLastData: vi.fn(), + updateNodes, + emptyNode, + ensureNodeExists: vi.fn((nodeNum: number) => { + if (!nodeMirror.has(nodeNum)) { + nodeMirror.set(nodeNum, emptyNode(nodeNum)); + syncNodesMapToIdentityStore(IDENTITY, nodeMirror); + } + }), + maybeRequestNodeInfoForNode: vi.fn(), + applyOwnNodeBatteryFromDeviceMetrics: vi.fn(), + rfHeardNodeIds: { current: new Set() }, + setDeviceOwner: vi.fn(), + setTelemetry: vi.fn(), + setEnvironmentTelemetry: vi.fn(), + ...overrides, + }; + return { deps, nodeMirror }; +} + +describe('attachMeshtasticNodeSideEffects', () => { + beforeEach(() => { + useNodeStore.setState({ nodes: {} }); + localStorage.setItem(MESH_PROTOCOL_STORAGE_KEY, 'meshtastic'); + window.electronAPI = { + db: { saveNode: vi.fn().mockResolvedValue(undefined) }, + } as unknown as typeof window.electronAPI; + }); + + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it('applies UserPacket node_info into the hook map and marks RF-heard', () => { + const { deps, nodeMirror } = makeDeps(); + const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'node_info', + payload: { + nodeId: PEER, + longName: 'Peer Node', + shortName: 'PEER', + hwModel: 'TBEAM', + fromUserPacket: true, + lastHeardAt: Date.now(), + }, + }, + IDENTITY, + ); + expect(deps.touchLastData).toHaveBeenCalled(); + expect(deps.rfHeardNodeIds.current.has(PEER)).toBe(true); + expect(nodeMirror.get(PEER)?.long_name).toBe('Peer Node'); + expect(nodeMirror.get(PEER)?.short_name).toBe('PEER'); + expect(window.electronAPI.db.saveNode).toHaveBeenCalled(); + detach(); + }); + + it('patches valid position and requests NodeInfo for the sender', () => { + const { deps, nodeMirror } = makeDeps(); + nodeMirror.set(PEER, emptyNode(PEER)); + syncNodesMapToIdentityStore(IDENTITY, nodeMirror); + const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'position', + payload: { + nodeId: PEER, + latitude: 39.7392, + longitude: -104.9903, + altitude: 1600, + timestamp: Date.now(), + }, + }, + IDENTITY, + ); + expect(nodeMirror.get(PEER)?.latitude).toBeCloseTo(39.7392); + expect(nodeMirror.get(PEER)?.longitude).toBeCloseTo(-104.9903); + expect(deps.maybeRequestNodeInfoForNode).toHaveBeenCalledWith(PEER); + detach(); + }); + + it('charts deviceMetrics telemetry and updates battery on an existing node', () => { + const { deps, nodeMirror } = makeDeps(); + nodeMirror.set(PEER, emptyNode(PEER)); + syncNodesMapToIdentityStore(IDENTITY, nodeMirror); + const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'telemetry', + payload: { + nodeId: PEER, + timestamp: Date.now(), + batteryLevel: 77, + voltage: 4.05, + variantCase: 'deviceMetrics', + }, + }, + IDENTITY, + ); + expect(deps.setTelemetry).toHaveBeenCalled(); + expect(getIdentityNode(IDENTITY, PEER)?.battery).toBe(77); + expect(deps.maybeRequestNodeInfoForNode).toHaveBeenCalledWith(PEER); + detach(); + }); + + it('ignores events routed for a different identity', () => { + const { deps } = makeDeps(); + const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'node_info', + payload: { + nodeId: PEER, + longName: 'Other', + shortName: 'OTH', + fromUserPacket: true, + }, + }, + 'other-id', + ); + expect(deps.touchLastData).not.toHaveBeenCalled(); + detach(); + }); +}); diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts new file mode 100644 index 000000000..9d9f797f9 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; +import { useNodeStore } from '../../stores/nodeStore'; +import { packetRouter } from '../drivers/PacketRouter'; +import { syncNodesMapToIdentityStore } from '../hydrateIdentityStoresFromDb'; +import { getIdentityNode } from '../identityStoreReads'; +import { MESH_PROTOCOL_STORAGE_KEY } from '../storedMeshProtocol'; +import type { MeshNode } from '../types'; +import { + attachMeshtasticRawPacketSideEffects, + type MeshtasticRawPacketSideEffectsDeps, +} from './meshtasticRawPacketSideEffects'; + +const IDENTITY = 'id-raw-se'; +const MY_NODE = 1; +const PEER = 99; + +function emptyNode(nodeId: number): MeshNode { + return { + node_id: nodeId, + long_name: `N${nodeId}`, + short_name: `N${nodeId}`, + hw_model: '', + battery: 0, + snr: 0, + rssi: 0, + last_heard: Date.now(), + latitude: null, + longitude: null, + source: 'rf', + heard_via_mqtt_only: false, + }; +} + +function makeDeps(overrides: Partial = {}) { + const nodeMirror = new Map([[PEER, emptyNode(PEER)]]); + syncNodesMapToIdentityStore(IDENTITY, nodeMirror); + const updateNodes = vi.fn((fn: (prev: Map) => Map) => { + const next = fn(new Map(nodeMirror)); + nodeMirror.clear(); + for (const [id, node] of next) nodeMirror.set(id, node); + syncNodesMapToIdentityStore(IDENTITY, nodeMirror); + }); + const deps: MeshtasticRawPacketSideEffectsDeps = { + getMyNodeNum: () => MY_NODE, + getIsConfiguring: () => false, + updateNodes, + setRawPackets: vi.fn(), + setSignalTelemetry: vi.fn(), + touchLastData: vi.fn(), + ...overrides, + }; + return { deps, nodeMirror }; +} + +describe('attachMeshtasticRawPacketSideEffects', () => { + beforeEach(() => { + useNodeStore.setState({ nodes: {} }); + localStorage.setItem(MESH_PROTOCOL_STORAGE_KEY, 'meshtastic'); + window.electronAPI = { + db: { saveNode: vi.fn().mockResolvedValue(undefined) }, + } as unknown as typeof window.electronAPI; + }); + + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it('appends a sniffer log entry and records noise/path diagnostics', () => { + const { deps } = makeDeps(); + const recordNoisePort = vi.spyOn(useDiagnosticsStore.getState(), 'recordNoisePort'); + const recordPacketPath = vi.spyOn(useDiagnosticsStore.getState(), 'recordPacketPath'); + const detach = attachMeshtasticRawPacketSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'raw_packet', + payload: { + ts: Date.now(), + snr: 7.5, + rssi: -90, + raw: new Uint8Array([0xde, 0xad, 0xbe, 0xef]), + fromNodeId: PEER, + portLabel: 'TEXT_MESSAGE_APP', + viaMqtt: false, + hopsAway: 2, + packetId: 12345, + portnum: 1, + }, + }, + IDENTITY, + ); + expect(deps.touchLastData).toHaveBeenCalled(); + expect(deps.setRawPackets).toHaveBeenCalled(); + expect(deps.setSignalTelemetry).toHaveBeenCalled(); + expect(recordNoisePort).toHaveBeenCalledWith(PEER, 1); + expect(recordPacketPath).toHaveBeenCalledWith( + 12345, + PEER, + expect.objectContaining({ transport: 'rf', snr: 7.5, rssi: -90 }), + ); + detach(); + }); + + it('patches SNR and hops onto an existing store node', () => { + const { deps } = makeDeps(); + const detach = attachMeshtasticRawPacketSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'raw_packet', + payload: { + ts: Date.now(), + snr: 9.25, + rssi: -80, + raw: new Uint8Array([0xaa, 0xbb]), + fromNodeId: PEER, + portLabel: 'NODEINFO_APP', + viaMqtt: false, + hopsAway: 1, + packetId: 7, + }, + }, + IDENTITY, + ); + const node = getIdentityNode(IDENTITY, PEER); + expect(node?.snr).toBe(9.25); + expect(node?.rssi).toBe(-80); + expect(node?.hops_away).toBe(1); + expect(window.electronAPI.db.saveNode).toHaveBeenCalled(); + detach(); + }); + + it('skips sniffer log when the active protocol tab is not meshtastic', () => { + localStorage.setItem(MESH_PROTOCOL_STORAGE_KEY, 'meshcore'); + const { deps } = makeDeps(); + const detach = attachMeshtasticRawPacketSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'raw_packet', + payload: { + ts: Date.now(), + snr: 1, + rssi: -100, + raw: new Uint8Array([0x01]), + fromNodeId: PEER, + portLabel: 'TEXT_MESSAGE_APP', + viaMqtt: false, + packetId: 1, + portnum: 1, + }, + }, + IDENTITY, + ); + expect(deps.setRawPackets).not.toHaveBeenCalled(); + // SNR patch still applies — diagnostics gating is tab-scoped, signal is not. + expect(getIdentityNode(IDENTITY, PEER)?.snr).toBe(1); + detach(); + }); + + it('ignores events routed for a different identity', () => { + const { deps } = makeDeps(); + const detach = attachMeshtasticRawPacketSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'raw_packet', + payload: { + ts: Date.now(), + snr: 1, + rssi: -100, + raw: new Uint8Array([0x02]), + fromNodeId: PEER, + portLabel: 'TEXT_MESSAGE_APP', + viaMqtt: false, + }, + }, + 'other-id', + ); + expect(deps.touchLastData).not.toHaveBeenCalled(); + detach(); + }); +}); diff --git a/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.test.ts new file mode 100644 index 000000000..47e313f57 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment jsdom +import { create, toBinary } from '@bufbuild/protobuf'; +import { StoreForward } from '@meshtastic/protobufs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useMessageStore } from '../../stores/messageStore'; +import { packetRouter } from '../drivers/PacketRouter'; +import { getIdentityChatMessages } from '../identityStoreReads'; +import { + attachMeshtasticStoreForwardSideEffects, + type MeshtasticStoreForwardSideEffectsDeps, +} from './meshtasticStoreForwardSideEffects'; + +const IDENTITY = 'id-sf-se'; +const SERVER = 0x1111; + +function sfPacket(rr: number, variant: { case: string; value: unknown }): Uint8Array { + const msg = create(StoreForward.StoreAndForwardSchema, { rr, variant } as Parameters< + typeof create + >[1]); + return toBinary(StoreForward.StoreAndForwardSchema, msg); +} + +function makeDeps(overrides: Partial = {}) { + const deps: MeshtasticStoreForwardSideEffectsDeps = { + touchLastData: vi.fn(), + getNodeName: (n) => `Node ${n}`, + getIsDeviceConfigured: () => true, + recordHeartbeat: vi.fn(), + requestStoreForwardHistory: vi.fn(), + setStoreForwardMessages: vi.fn(), + ...overrides, + }; + return deps; +} + +describe('attachMeshtasticStoreForwardSideEffects', () => { + beforeEach(() => { + useMessageStore.setState({ messages: {} }); + window.electronAPI = { + db: { saveMessage: vi.fn().mockResolvedValue(undefined) }, + } as unknown as typeof window.electronAPI; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('records a primary router heartbeat and auto-requests history when configured', () => { + const deps = makeDeps(); + const detach = attachMeshtasticStoreForwardSideEffects(IDENTITY, deps); + const data = sfPacket(StoreForward.StoreAndForward_RequestResponse.ROUTER_HEARTBEAT, { + case: 'heartbeat', + value: create(StoreForward.StoreAndForward_HeartbeatSchema, { period: 120, secondary: 0 }), + }); + packetRouter.dispatch( + { + type: 'meshtastic_store_forward', + payload: { from: SERVER, channel: 0, timestamp: Date.now(), raw: { data } }, + }, + IDENTITY, + ); + expect(deps.touchLastData).toHaveBeenCalled(); + expect(deps.setStoreForwardMessages).toHaveBeenCalled(); + expect(deps.recordHeartbeat).toHaveBeenCalledWith({ + serverNodeId: SERVER, + channel: 0, + period: 120, + }); + expect(deps.requestStoreForwardHistory).toHaveBeenCalledWith({ + serverNodeId: SERVER, + manual: false, + }); + detach(); + }); + + it('records a secondary heartbeat without requesting history', () => { + const deps = makeDeps(); + const detach = attachMeshtasticStoreForwardSideEffects(IDENTITY, deps); + const data = sfPacket(StoreForward.StoreAndForward_RequestResponse.ROUTER_HEARTBEAT, { + case: 'heartbeat', + value: create(StoreForward.StoreAndForward_HeartbeatSchema, { period: 60, secondary: 1 }), + }); + packetRouter.dispatch( + { + type: 'meshtastic_store_forward', + payload: { from: SERVER, channel: 1, timestamp: Date.now(), raw: { data } }, + }, + IDENTITY, + ); + expect(deps.recordHeartbeat).toHaveBeenCalled(); + expect(deps.requestStoreForwardHistory).not.toHaveBeenCalled(); + detach(); + }); + + it('skips auto history when the radio has not finished configure', () => { + const deps = makeDeps({ getIsDeviceConfigured: () => false }); + const detach = attachMeshtasticStoreForwardSideEffects(IDENTITY, deps); + const data = sfPacket(StoreForward.StoreAndForward_RequestResponse.ROUTER_HEARTBEAT, { + case: 'heartbeat', + value: create(StoreForward.StoreAndForward_HeartbeatSchema, { period: 90, secondary: 0 }), + }); + packetRouter.dispatch( + { + type: 'meshtastic_store_forward', + payload: { from: SERVER, channel: 0, timestamp: Date.now(), raw: { data } }, + }, + IDENTITY, + ); + expect(deps.recordHeartbeat).toHaveBeenCalled(); + expect(deps.requestStoreForwardHistory).not.toHaveBeenCalled(); + detach(); + }); + + it('replays ROUTER_TEXT_BROADCAST into messageStore with viaStoreForward', () => { + const deps = makeDeps(); + const detach = attachMeshtasticStoreForwardSideEffects(IDENTITY, deps); + const data = sfPacket(StoreForward.StoreAndForward_RequestResponse.ROUTER_TEXT_BROADCAST, { + case: 'text', + value: new TextEncoder().encode(' summit check-in '), + }); + packetRouter.dispatch( + { + type: 'meshtastic_store_forward', + payload: { from: SERVER, channel: 2, timestamp: Date.now(), raw: { data } }, + }, + IDENTITY, + ); + const messages = getIdentityChatMessages(IDENTITY); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + sender_id: SERVER, + sender_name: `Node ${SERVER}`, + payload: 'summit check-in', + channel: 2, + isHistory: true, + viaStoreForward: true, + receivedVia: 'rf', + }); + expect(window.electronAPI.db.saveMessage).toHaveBeenCalled(); + detach(); + }); + + it('does not duplicate replayed history within the dedup window', () => { + const deps = makeDeps(); + const detach = attachMeshtasticStoreForwardSideEffects(IDENTITY, deps); + const data = sfPacket(StoreForward.StoreAndForward_RequestResponse.ROUTER_TEXT_BROADCAST, { + case: 'text', + value: new TextEncoder().encode('same payload'), + }); + const event = { + type: 'meshtastic_store_forward' as const, + payload: { from: SERVER, channel: 0, timestamp: Date.now(), raw: { data } }, + }; + packetRouter.dispatch(event, IDENTITY); + packetRouter.dispatch(event, IDENTITY); + expect(getIdentityChatMessages(IDENTITY)).toHaveLength(1); + detach(); + }); + + it('ignores events routed for a different identity', () => { + const deps = makeDeps(); + const detach = attachMeshtasticStoreForwardSideEffects(IDENTITY, deps); + const data = sfPacket(StoreForward.StoreAndForward_RequestResponse.ROUTER_TEXT_BROADCAST, { + case: 'text', + value: new TextEncoder().encode('nope'), + }); + packetRouter.dispatch( + { + type: 'meshtastic_store_forward', + payload: { from: SERVER, channel: 0, timestamp: Date.now(), raw: { data } }, + }, + 'other-id', + ); + expect(deps.touchLastData).not.toHaveBeenCalled(); + expect(getIdentityChatMessages(IDENTITY)).toHaveLength(0); + detach(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 70a7ed0da..0adc32d93 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -102,6 +102,8 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/meshtastic/meshtasticTransportSideEffects.test.ts', 'src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts', 'src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts', + 'src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts', + 'src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts', 'src/renderer/lib/meshcoreDualNobleBleInit.test.ts', 'src/renderer/lib/meshcoreKeyBackupStorage.test.ts', 'src/renderer/lib/meshcoreMqttSettingsStorage.test.ts', From ccecf03176c96375fdfc3f568e96fc0740b92e64 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 10:45:42 -0600 Subject: [PATCH 05/13] fix: remediate PacketRouter ingress regressions and hardening Collapse Meshtastic RF node patches onto nodeStore, restore remote-config write suppression, unify packet dedupe/helpers, harden Protocol/MeshCore ingress, and backfill i18n/docs/tests from the branch audit. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 + docs/development-environment.md | 2 +- docs/diagnostics.md | 2 +- docs/meshcore-meshtastic-parity.md | 2 +- eslint.config.mjs | 1 + src/renderer/components/ChatComposer.test.tsx | 16 + src/renderer/components/LogPanel.test.tsx | 127 +++++- src/renderer/components/NodeInfoBody.test.tsx | 10 + src/renderer/components/NodeInfoBody.tsx | 19 +- .../components/NodeListPanel.test.tsx | 11 +- src/renderer/components/NodeListPanel.tsx | 28 +- .../meshcore/meshcoreConnSideEffects.test.ts | 59 +++ .../hooks/meshcore/meshcoreConnSideEffects.ts | 19 +- .../hooks/meshcore/meshcoreHookPreamble.ts | 3 +- .../meshcoreWaitingMessagesSyncState.test.ts | 24 + .../meshcoreWaitingMessagesSyncState.ts | 27 +- .../lib/drivers/ConnectionDriver.test.ts | 30 ++ src/renderer/lib/drivers/ConnectionDriver.ts | 29 +- src/renderer/lib/drivers/PacketRouter.test.ts | 66 +++ src/renderer/lib/drivers/PacketRouter.ts | 92 +++- .../drivers/attachTypedPacketListener.test.ts | 110 +++++ .../lib/drivers/attachTypedPacketListener.ts | 54 +++ .../lib/drivers/packetDedupeRegistry.test.ts | 54 +++ .../lib/drivers/packetDedupeRegistry.ts | 78 ++++ .../lib/hydrateIdentityStoresFromDb.test.ts | 33 ++ .../lib/hydrateIdentityStoresFromDb.ts | 32 +- src/renderer/lib/i18n.test.ts | 1 + .../lib/ingest/meshtasticIngest.test.ts | 13 + src/renderer/lib/ingest/meshtasticIngest.ts | 4 + src/renderer/lib/meshIdentityBridge.test.ts | 59 ++- src/renderer/lib/meshIdentityBridge.ts | 43 +- .../lib/meshcore/meshcoreDmAckRuntime.test.ts | 56 +++ .../lib/meshcore/meshcoreDmAckRuntime.ts | 17 +- .../meshcore/meshcoreLiveContactPersist.ts | 12 +- .../meshcoreMqttPacketLogThrottle.test.ts | 38 ++ .../meshcore/meshcoreMqttPacketLogThrottle.ts | 43 ++ .../meshcorePathUpdatedRuntime.test.ts | 163 +++++++ .../meshcoreProcessWaitingMessageItem.test.ts | 27 +- .../lib/meshcoreProcessWaitingMessageItem.ts | 114 ++--- .../meshtasticConfigIngressGuard.test.ts | 24 + .../meshtasticConfigIngressGuard.ts | 22 + .../meshtasticModulePortSideEffects.test.ts | 190 +++++++- .../meshtasticModulePortSideEffects.ts | 54 ++- .../meshtasticNodeSideEffects.test.ts | 100 ++-- .../meshtastic/meshtasticNodeSideEffects.ts | 430 ++++++++---------- .../meshtasticProcessNodeDiagnostics.ts | 15 + .../meshtasticRawPacketSideEffects.test.ts | 9 +- .../meshtasticRawPacketSideEffects.ts | 41 +- .../meshtasticRouterSideEffects.test.ts | 69 +++ .../meshtastic/meshtasticRouterSideEffects.ts | 142 +++--- ...meWireEffects.diagnostics.contract.test.ts | 15 +- ...sticRuntimeWireEffects.post-reboot.test.ts | 7 +- ...timeWireEffects.telemetry-nodeinfo.test.ts | 7 +- .../meshtasticRuntimeWireEffects.ts | 22 +- .../meshtasticStoreForwardSideEffects.test.ts | 62 +++ .../meshtasticStoreForwardSideEffects.ts | 86 +++- .../meshtasticTraceSideEffects.test.ts | 55 ++- .../meshtastic/meshtasticTraceSideEffects.ts | 61 ++- .../lib/meshtasticSourceIcons.test.ts | 20 + src/renderer/lib/meshtasticSourceIcons.tsx | 31 +- src/renderer/lib/packetPayload.test.ts | 49 ++ src/renderer/lib/packetPayload.ts | 43 ++ .../lib/protocols/MeshCoreProtocol.test.ts | 58 +++ .../lib/protocols/MeshCoreProtocol.ts | 103 +---- .../lib/protocols/MeshtasticProtocol.test.ts | 271 ++++++++++- .../lib/protocols/MeshtasticProtocol.ts | 327 +++++++++---- src/renderer/lib/protocols/Protocol.ts | 11 + src/renderer/lib/sessionMemoryCaps.ts | 16 + src/renderer/lib/storeRecordAdapters.test.ts | 52 ++- src/renderer/lib/storeRecordAdapters.ts | 22 + src/renderer/lib/timeConstants.ts | 5 + src/renderer/locales/cs/translation.json | 32 +- src/renderer/locales/de/translation.json | 31 +- src/renderer/locales/en/translation.json | 5 +- src/renderer/locales/es/translation.json | 27 +- src/renderer/locales/fr/translation.json | 29 +- src/renderer/locales/id/translation.json | 25 +- src/renderer/locales/it/translation.json | 31 +- src/renderer/locales/ja/translation.json | 25 +- src/renderer/locales/ko/translation.json | 25 +- src/renderer/locales/nl/translation.json | 35 +- src/renderer/locales/pl/translation.json | 31 +- src/renderer/locales/pt-BR/translation.json | 25 +- src/renderer/locales/ru/translation.json | 25 +- src/renderer/locales/tr/translation.json | 25 +- src/renderer/locales/uk/translation.json | 25 +- src/renderer/locales/zh/translation.json | 25 +- src/renderer/runtime/useMeshcoreRuntime.ts | 10 +- ...htasticRuntime.reconnect-hardening.test.ts | 9 + src/renderer/runtime/useMeshtasticRuntime.ts | 175 ++++--- src/renderer/stores/nodeStore.test.ts | 116 +++++ src/renderer/stores/nodeStore.ts | 7 +- vitest.config.ts | 7 +- 94 files changed, 3621 insertions(+), 1065 deletions(-) create mode 100644 src/renderer/lib/drivers/attachTypedPacketListener.test.ts create mode 100644 src/renderer/lib/drivers/attachTypedPacketListener.ts create mode 100644 src/renderer/lib/drivers/packetDedupeRegistry.test.ts create mode 100644 src/renderer/lib/drivers/packetDedupeRegistry.ts create mode 100644 src/renderer/lib/meshcore/meshcoreMqttPacketLogThrottle.test.ts create mode 100644 src/renderer/lib/meshcore/meshcoreMqttPacketLogThrottle.ts create mode 100644 src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.test.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticConfigIngressGuard.test.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticConfigIngressGuard.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticProcessNodeDiagnostics.ts create mode 100644 src/renderer/lib/packetPayload.test.ts create mode 100644 src/renderer/lib/packetPayload.ts diff --git a/AGENTS.md b/AGENTS.md index 5698d105b..56351fd97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ See **Renderer: hooks vs runtime vs lib** (layout map above). Legacy `useDevice` Do **not** remount protocol runtimes in child components. Do **not** compare `protocol === 'meshcore'` for feature gates; use `ProtocolCapabilities` / `useRadioProvider(protocol)`. -Protocol SDK adapters: `src/renderer/lib/protocols/`. Connection lifecycle: `ConnectionDriver`; inbound domain events: `PacketRouter` → stores. **Meshtastic post-router side effects:** `lib/ingest/meshtasticIngest.ts`, `meshtasticRouterSideEffects.ts` (MQTT uplink / notifications / device_log), `meshtasticNodeSideEffects.ts`, `meshtasticRawPacketSideEffects.ts`, `meshtasticTraceSideEffects.ts`, `meshtasticModulePortSideEffects.ts`, `meshtasticStoreForwardSideEffects.ts`; lifecycle-only SDK attach remains in `meshtasticRuntimeWireEffects.ts` (DeviceStatus / MyNodeInfo / FromRadio / heartbeat / config / remote-admin). **MeshCore post-router side effects:** `lib/ingest/meshcoreIngest.ts` (chat persist, `last_heard`, path-updated), `hooks/meshcore/meshcoreConnSideEffects.ts` (DM ack 130, waiting drain 131, RF RX 136, CLI, disconnect), `lib/meshcore/meshcoreLiveContactPersist.ts` (SQLite contact rows), `lib/meshcore/meshcorePubKeyRegistry.ts` (DM/trace pubkeys). Live UI nodes/messages read `nodeStore` / `messageStore` via `identityStoreReads` (`getIdentityNode` / `getIdentityChatMessages`); runtimes do not keep hook-local node/message wire mirrors. **Favorites:** `setNodeFavorited` patches `meshcoreIdentityIdRef` (fallback `getIdentityIdForProtocol('meshcore')`). **Dedup windows:** cross-transport and channel RF **5 min**; room/tapback **60 s**. Path-updated (129) for existing contacts does not bump SQLite `last_advert` until the next advert (128). +Protocol SDK adapters: `src/renderer/lib/protocols/`. Connection lifecycle: `ConnectionDriver` (`lib/drivers/`); inbound domain events: Protocol → `PacketRouter` → identity stores, then side-effect listeners (ingest already applied). **Meshtastic post-router side effects:** `lib/ingest/meshtasticIngest.ts`, `meshtasticRouterSideEffects.ts` (MQTT uplink / notifications / device_log), `meshtasticNodeSideEffects.ts`, `meshtasticRawPacketSideEffects.ts`, `meshtasticTraceSideEffects.ts`, `meshtasticModulePortSideEffects.ts`, `meshtasticStoreForwardSideEffects.ts`; `meshtasticTransportSideEffects.ts` handles transport-state cleanup, while lifecycle-only SDK attach remains in `meshtasticRuntimeWireEffects.ts` (DeviceStatus / MyNodeInfo / FromRadio / heartbeat / config / remote-admin — not a second packet decode path). **MeshCore post-router side effects:** `lib/ingest/meshcoreIngest.ts` (chat persist, `last_heard`, path-updated), `hooks/meshcore/meshcoreConnSideEffects.ts` + `MeshcoreConnSideEffectsCtx` (`meshcoreConnSideEffectsCtx.ts`) (DM ack 130, waiting drain 131, RF RX 136, CLI, disconnect), `lib/meshcore/meshcoreLiveContactPersist.ts` (SQLite contact rows), `lib/meshcore/meshcorePubKeyRegistry.ts` (DM/trace pubkeys). Live UI nodes/messages read `nodeStore` / `messageStore` via `identityStoreReads` (`getIdentityNode` / `getIdentityChatMessages`); runtimes do not keep hook-local node/message wire mirrors. Transport params / Protocol attach helpers: `meshIdentityBridge`. **Favorites:** `setNodeFavorited` patches `meshcoreIdentityIdRef` (fallback `getIdentityIdForProtocol('meshcore')`). **Dedup windows:** cross-transport and channel RF **5 min**; room/tapback **60 s**. Path-updated (129) for existing contacts does not bump SQLite `last_advert` until the next advert (128). **Identity-scoped UI stores:** `identityStore`, `nodeStore`, `messageStore`, `connectionStore` — nodes/messages keyed by `identityId`. **MQTT status bridge:** `mirrorMqttStatusToConnection` copies main-process `mqtt.onStatus` IPC into `connectionStore.mqttStatus` from runtime handlers until MQTT moves fully into `ConnectionDriver`. **SQLite → UI:** `lib/hydrateIdentityStoresFromDb.ts` (coordinator: `identityHydrationCoordinator.ts`; Meshtastic node map: `meshtasticDbCacheHydration.ts`; message cap: `meshtasticMessageLoadLimit.ts`); manual refresh via `hooks/useDbRefresh.ts`. Identity-scoped Zustand hydration is the canonical UI path ([#375]). **MeshCore contacts DB:** `meshcore_contacts.last_advert` is Unix **seconds**; age prune uses `src/shared/meshcoreContactAgeCutoff.ts` (do not compare in ms). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 122193c05..b0d078d06 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -76,6 +76,8 @@ Sanitize user-controlled strings before logs and IPC per [AGENTS.md](AGENTS.md). **Renderer layers:** `runtime/` (single-mount protocol runtimes), `hooks/` (facades and store selectors), `lib/` (drivers, sessions, types), `stores/` (identity-scoped UI: `identityStore`, `nodeStore`, `messageStore`, `connectionStore`; Reticulum also uses session-global `reticulumIdentityStore` for sidecar identity status). Prefer `useProtocolFacade(protocol)` in App for new wiring. Hook/runtime boundaries: [AGENTS.md](AGENTS.md#renderer-hook-architecture-multi-protocol) ([#375](https://github.com/Colorado-Mesh/mesh-client/issues/375), [#377](https://github.com/Colorado-Mesh/mesh-client/issues/377)). +**Drivers / identity bridge:** `lib/drivers/ConnectionDriver.ts` owns RF/MQTT session lifecycle and dispatches Protocol events into `lib/drivers/PacketRouter.ts` (store ingest first, then side-effect listeners). `lib/meshIdentityBridge.ts` builds transport params and attaches Meshtastic Protocol ingress; `lib/identityStoreReads.ts` is the canonical read path for identity-scoped nodes/messages (`getIdentityNode` / `getIdentityChatMessages`). + ### Protocols - **Meshtastic:** `runtime/useMeshtasticRuntime.ts`, `lib/protocols/MeshtasticProtocol.ts`, `lib/connection.ts` (`createConnection`). diff --git a/docs/development-environment.md b/docs/development-environment.md index d05305812..20114a5ac 100644 --- a/docs/development-environment.md +++ b/docs/development-environment.md @@ -532,7 +532,7 @@ By default all three Vitest projects run in **parallel** (`groupOrder: 0`). On m CI runs coverage in three parallel jobs (`renderer-ui`, `renderer-logic`, `main`) and merges blob reports via `pnpm run test:coverage:merge` (see [`.github/workflows/tests.yaml`](../.github/workflows/tests.yaml)). -Monolithic protocol runtimes (`useMeshtasticRuntime`, `useMeshcoreRuntime`) also use **source contract tests** (read `.ts` files and assert wiring strings) where full `renderHook` integration would require heavy BLE/MQTT mocking; see `*.reconnect*.test.ts` beside those runtimes. +Monolithic protocol runtimes (`useMeshtasticRuntime`, `useMeshcoreRuntime`) also use **source contract tests** (read `.ts` files and assert wiring strings) where full `renderHook` integration would require heavy BLE/MQTT mocking; see `*.reconnect*.test.ts` beside those runtimes. Another example: [`meshtasticRuntimeWireEffects.diagnostics.contract.test.ts`](../src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.diagnostics.contract.test.ts) asserts LocalStats / RF hop-SNR still call `processNodeUpdate` from `meshtasticNodeSideEffects` / `meshtasticRawPacketSideEffects`. #### Browser dev without Electron diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 970e5ca60..a40c74782 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -683,7 +683,7 @@ Meshtastic short/full header rules (unchanged): #### Connected-node telemetry ingest (Meshtastic) -`meshtasticRuntimeWireEffects` calls `processNodeUpdate` on **LocalStats** telemetry and on RF mesh packets that update `hops_away`/SNR so `cuHistory`, hop history, and incremental RF analysis stay live (previously only device-metrics / NodeInfo paths fed the store). +`meshtasticNodeSideEffects` feeds **LocalStats** into `diagnosticsStore.processNodeUpdate` (CU history / connected-node RF analysis). `meshtasticRawPacketSideEffects` feeds RF mesh packets that update `hops_away`/SNR so hop history and incremental RF analysis stay live. (Previously only device-metrics / NodeInfo paths — and briefly `meshtasticRuntimeWireEffects` — fed the store.) Guarded by `meshtasticRuntimeWireEffects.diagnostics.contract.test.ts`. #### Proximity classification (`classifyProximity()`) diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index 4535e20dd..3d4c6fa94 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -64,7 +64,7 @@ Room servers (`hw_model === 'Room'`, contact type 3) are BBS nodes on the mesh. ## MeshCore: identity-scoped UI stores -Live Chat and Nodes read **identity-scoped** `nodeStore` / `messageStore` (keyed by `identityId`). Hook-local refs in `useMeshcoreRuntime` (`nodesRef`, pubkeys) remain for send/RPC until contact rebuild syncs. Hydration: [`hydrateIdentityStoresFromDb.ts`](../src/renderer/lib/hydrateIdentityStoresFromDb.ts). **Chat-driven `last_heard`** (`meshcoreIngest`, `ensureMeshcoreChatSenderInNodeStore`) updates node freshness on text traffic, not only adverts. +Live Chat and Nodes read **identity-scoped** `nodeStore` / `messageStore` (keyed by `identityId`) via `identityStoreReads` (`getIdentityNode` / `getIdentityChatMessages`). DM/trace pubkeys live in `meshcorePubKeyRegistry` (mirrored into runtime maps for send/RPC). Hydration: [`hydrateIdentityStoresFromDb.ts`](../src/renderer/lib/hydrateIdentityStoresFromDb.ts). **Chat-driven `last_heard`** (`meshcoreIngest`, `ensureMeshcoreChatSenderInNodeStore`) updates node freshness on text traffic, not only adverts. ## MeshCore: Rooms scroll UX diff --git a/eslint.config.mjs b/eslint.config.mjs index 077f2d62b..f8665476f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,7 @@ export default tseslint.config( '**/build/**', '**/out/**', '**/node_modules/**', + '.cache/**', 'src/renderer/lib/nomad/vendor/**', 'eslint.config.mjs', '**/*.d.ts', diff --git a/src/renderer/components/ChatComposer.test.tsx b/src/renderer/components/ChatComposer.test.tsx index 1968a3369..291150ead 100644 --- a/src/renderer/components/ChatComposer.test.tsx +++ b/src/renderer/components/ChatComposer.test.tsx @@ -1,7 +1,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; import { MESHTASTIC_PAYLOAD_LIMIT } from '@/renderer/lib/chatComposerLimits'; import { draftsStorageKey } from '@/renderer/lib/chatPanelProtocolStorage'; @@ -59,6 +61,20 @@ describe('ChatComposer', () => { localStorage.clear(); }); + it('has no axe violations when connected', async () => { + const { container } = render( + , + ); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + it('clears input after successful send', async () => { const onSendChunk = vi.fn().mockResolvedValue(undefined); const user = userEvent.setup(); diff --git a/src/renderer/components/LogPanel.test.tsx b/src/renderer/components/LogPanel.test.tsx index 3b3c0eea5..c31617016 100644 --- a/src/renderer/components/LogPanel.test.tsx +++ b/src/renderer/components/LogPanel.test.tsx @@ -1,14 +1,42 @@ -import { act, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; + import LogPanel from './LogPanel'; +interface LogLine { + ts: number; + level: string; + source: string; + message: string; +} + +async function renderWithRecentLines(lines: LogLine[], props?: Parameters[0]) { + vi.mocked(window.electronAPI.log.getRecentLines).mockResolvedValue(lines); + const view = render(); + await waitFor(() => { + expect(window.electronAPI.log.getRecentLines).toHaveBeenCalled(); + }); + await act(async () => { + await Promise.resolve(); + }); + return view; +} + describe('LogPanel accessibility', () => { + beforeEach(() => { + localStorage.clear(); + vi.mocked(window.electronAPI.log.getRecentLines).mockResolvedValue([]); + vi.mocked(window.electronAPI.log.onLine).mockReturnValue(() => {}); + }); + it('has no axe violations with empty log', async () => { const { container } = render(); await act(async () => {}); + hydrateAxeThemeColors(container); const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -28,3 +56,98 @@ describe('LogPanel accessibility', () => { consoleWarnSpy.mockRestore(); }); }); + +describe('LogPanel filtering / resize / virtualization', () => { + beforeEach(() => { + localStorage.clear(); + vi.mocked(window.electronAPI.log.getRecentLines).mockResolvedValue([]); + vi.mocked(window.electronAPI.log.onLine).mockReturnValue(() => {}); + }); + + it('filters app lines by level checkboxes', async () => { + const user = userEvent.setup(); + await renderWithRecentLines([ + { ts: 1, level: 'info', source: 'main', message: 'info line' }, + { ts: 2, level: 'warn', source: 'main', message: 'warn line' }, + { ts: 3, level: 'debug', source: 'main', message: 'debug line' }, + ]); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'App (3)' })).toBeInTheDocument(); + }); + + // Default filters hide debug; turning all levels off shows the empty-state copy. + await user.click(screen.getByRole('checkbox', { name: 'Log / Info' })); + await user.click(screen.getByRole('checkbox', { name: 'Warn / Error' })); + expect(screen.getByText(/All level filters are off/)).toBeInTheDocument(); + + await user.click(screen.getByRole('checkbox', { name: 'Debug' })); + expect(screen.queryByText(/All level filters are off/)).not.toBeInTheDocument(); + expect(screen.getByRole('log').querySelector('.relative')).toHaveStyle({ height: '18px' }); + }); + + it('switches between app and device sources', async () => { + const user = userEvent.setup(); + await renderWithRecentLines( + [{ ts: 1, level: 'info', source: 'main', message: 'app only line' }], + { + protocol: 'meshcore', + deviceLogs: [{ ts: 2, level: 'info', source: 'meshcore', message: 'device only line' }], + }, + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'App (1)' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Device (1)' })).toBeInTheDocument(); + }); + + const appBtn = screen.getByRole('button', { name: 'App (1)' }); + const deviceBtn = screen.getByRole('button', { name: 'Device (1)' }); + expect(appBtn.className).toContain('text-brand-green'); + + await user.click(deviceBtn); + expect(deviceBtn.className).toContain('text-brand-green'); + expect(appBtn.className).not.toContain('text-brand-green'); + expect(screen.getByRole('log').querySelector('.relative')).toHaveStyle({ height: '18px' }); + }); + + it('widens and narrows the sidebar panel width', async () => { + const user = userEvent.setup(); + localStorage.setItem('mesh-client:logPanelWidth', '320'); + render(); + await act(async () => {}); + expect(screen.getByText('320px')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Widen log panel' })); + expect(screen.getByText('400px')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Narrow log panel' })); + expect(screen.getByText('320px')).toBeInTheDocument(); + }); + + it('virtualizes a large device log list via total height', async () => { + const deviceLogs = Array.from({ length: 200 }, (_, i) => ({ + ts: i + 1, + level: 'info', + source: 'meshcore', + message: `device line ${i}`, + })); + render(); + await act(async () => {}); + await userEvent.setup().click(screen.getByRole('button', { name: /Device \(200\)/ })); + + const virtualRoot = screen.getByRole('log').querySelector('.relative'); + expect(virtualRoot).toHaveStyle({ height: '3600px' }); // 200 × estimateSize 18 + }); + + it('persists drag resize width on mouse up', async () => { + localStorage.setItem('mesh-client:logPanelWidth', '320'); + render(); + await act(async () => {}); + const handle = screen.getByRole('button', { name: 'Drag to resize log panel' }); + fireEvent.mouseDown(handle, { clientX: 500 }); + fireEvent.mouseMove(document, { clientX: 400 }); + fireEvent.mouseUp(document); + await waitFor(() => { + expect(Number(localStorage.getItem('mesh-client:logPanelWidth'))).toBeGreaterThan(320); + }); + }); +}); diff --git a/src/renderer/components/NodeInfoBody.test.tsx b/src/renderer/components/NodeInfoBody.test.tsx index fe75efd3f..f77f9cf09 100644 --- a/src/renderer/components/NodeInfoBody.test.tsx +++ b/src/renderer/components/NodeInfoBody.test.tsx @@ -1,6 +1,8 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import type { MeshNode } from '../lib/types'; import NodeInfoBody from './NodeInfoBody'; @@ -51,6 +53,14 @@ vi.mock('../stores/positionHistoryStore', () => ({ })); describe('NodeInfoBody', () => { + it('has no axe violations for a basic node card', async () => { + const { container } = render( + , + ); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + it('shows last tracked position when live position is missing', () => { positionHistoryStoreState.history = new Map([ [ diff --git a/src/renderer/components/NodeInfoBody.tsx b/src/renderer/components/NodeInfoBody.tsx index b4eb7444c..d7f9314dd 100644 --- a/src/renderer/components/NodeInfoBody.tsx +++ b/src/renderer/components/NodeInfoBody.tsx @@ -40,6 +40,7 @@ import { meshcoreTracePathLenToHops } from '../lib/meshcoreUtils'; import { isMeshtasticSelfHybridPath, MeshtasticHybridPathIcons, + meshtasticHybridPathLabels, MeshtasticMqttOnlyPathIcons, MeshtasticRfPathIcon, resolveMeshtasticPathBadge, @@ -122,21 +123,11 @@ function NodeSourceBadge({ const displayBadge = pathBadge === 'none' ? 'rfOnly' : pathBadge; if (displayBadge === 'hybrid') { - const isSelfHybrid = isMeshtasticSelfHybridPath(isSelf, mqttConnected, radioConnected); - return ( - + const labels = meshtasticHybridPathLabels( + t, + isMeshtasticSelfHybridPath(isSelf, mqttConnected, radioConnected), ); + return ; } if (displayBadge === 'mqttOnly') { const title = node.heard_via_mqtt_only diff --git a/src/renderer/components/NodeListPanel.test.tsx b/src/renderer/components/NodeListPanel.test.tsx index 3e98c6bc6..e57121e93 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -9,10 +9,11 @@ import { MESHTASTIC_CONTACT_GROUP_BUILTIN_GPS, MESHTASTIC_CONTACT_GROUP_BUILTIN_RF_MQTT, } from '../lib/meshtasticContactGroupUtils'; -import { MESHTASTIC_HYBRID_MQTT_PATH_ARIA_LABEL } from '../lib/meshtasticSourceIcons'; import type { MeshNode } from '../lib/types'; import NodeListPanel from './NodeListPanel'; +const HYBRID_MQTT_PATH_ARIA = 'RF and MQTT path'; + function makeNode(partial: Partial & Pick): MeshNode { return { long_name: 'N', @@ -302,7 +303,7 @@ describe('NodeListPanel import contacts', () => { />, ); expect(screen.getByText('RelayPeer')).toBeInTheDocument(); - expect(screen.getByLabelText(MESHTASTIC_HYBRID_MQTT_PATH_ARIA_LABEL)).toBeInTheDocument(); + expect(screen.getByLabelText(HYBRID_MQTT_PATH_ARIA)).toBeInTheDocument(); expect(screen.queryByText('relay')).not.toBeInTheDocument(); expect(await axe(container)).toHaveNoViolations(); }); @@ -331,7 +332,7 @@ describe('NodeListPanel import contacts', () => { />, ); expect(screen.getByText('SessionHybrid')).toBeInTheDocument(); - expect(screen.getByLabelText(MESHTASTIC_HYBRID_MQTT_PATH_ARIA_LABEL)).toBeInTheDocument(); + expect(screen.getByLabelText(HYBRID_MQTT_PATH_ARIA)).toBeInTheDocument(); }); it('shows hybrid path icons for self node when RF and MQTT are connected', () => { @@ -370,7 +371,7 @@ describe('NodeListPanel import contacts', () => { />, ); expect(screen.queryByLabelText('Connected via RF and MQTT')).not.toBeInTheDocument(); - expect(screen.queryByLabelText(MESHTASTIC_HYBRID_MQTT_PATH_ARIA_LABEL)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(HYBRID_MQTT_PATH_ARIA)).not.toBeInTheDocument(); expect(screen.getByLabelText('Connected via MQTT')).toBeInTheDocument(); }); @@ -419,7 +420,7 @@ describe('NodeListPanel import contacts', () => { />, ); expect(screen.queryByLabelText('Connected via RF and MQTT')).not.toBeInTheDocument(); - expect(screen.queryByLabelText(MESHTASTIC_HYBRID_MQTT_PATH_ARIA_LABEL)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(HYBRID_MQTT_PATH_ARIA)).not.toBeInTheDocument(); }); it('does not show Import Contacts button when onImportContacts not provided in meshcore mode', () => { diff --git a/src/renderer/components/NodeListPanel.tsx b/src/renderer/components/NodeListPanel.tsx index f59f9cbe1..c0847ac06 100644 --- a/src/renderer/components/NodeListPanel.tsx +++ b/src/renderer/components/NodeListPanel.tsx @@ -55,6 +55,7 @@ import { import { isMeshtasticSelfHybridPath, MeshtasticHybridPathIcons, + meshtasticHybridPathLabels, MeshtasticMqttOnlyPathIcons, resolveMeshtasticPathBadge, } from '../lib/meshtasticSourceIcons'; @@ -1390,23 +1391,14 @@ export default function NodeListPanel({ ); } if (pathBadge === 'hybrid') { - const isSelfHybrid = isMeshtasticSelfHybridPath( - isSelf, - mqttConnected, - radioConnected, + const labels = meshtasticHybridPathLabels( + t, + isMeshtasticSelfHybridPath(isSelf, mqttConnected, radioConnected), ); return ( ); } @@ -1545,7 +1537,13 @@ export default function NodeListPanel({ ? 'text-gray-300' : 'text-muted' }`} - title={red ? `${red.score}% connection health` : undefined} + title={ + red + ? t('nodeListPanel.echoesConnectionHealthTooltip', { + score: red.score, + }) + : undefined + } > {echoes > 0 ? `+${echoes}` : '-'} diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts index bd6e84291..a045a2ba4 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts @@ -280,6 +280,65 @@ describe('attachMeshcoreConnSideEffects', () => { expect(window.electronAPI.db.updateMeshcoreMessageStatus).toHaveBeenCalledWith(0x81, 'failed'); }); + it('keeps accepting events for identity captured at attach when resolveIdentityId later returns null', () => { + const h = makeHarness({ handleResponseResult: true }); + h.ctx.resolveIdentityId = () => ID; + h.ctx.meshcoreIdentityIdRef.current = null; + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + h.ctx.resolveIdentityId = () => null; + h.ctx.meshcoreIdentityIdRef.current = null; + + dispatch({ + type: 'meshcore_cli_response', + payload: { text: 'A1|frozen', senderNodeId: 0x55, pubKeyPrefixHex: 'aabbcc' }, + }); + expect(h.handleResponse).toHaveBeenCalledWith('A1|frozen', 0x55); + }); + + it('prefers finalized meshcoreIdentityIdRef over pending attach identity', () => { + const h = makeHarness({ handleResponseResult: true }); + h.ctx.resolveIdentityId = () => 'pending-driver-id'; + h.ctx.meshcoreIdentityIdRef.current = null; + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + h.ctx.meshcoreIdentityIdRef.current = ID; + + dispatch( + { + type: 'meshcore_cli_response', + payload: { text: 'A1|final', senderNodeId: 1, pubKeyPrefixHex: 'aabbcc' }, + }, + ID, + ); + expect(h.handleResponse).toHaveBeenCalledWith('A1|final', 1); + + h.handleResponse.mockClear(); + dispatch( + { + type: 'meshcore_cli_response', + payload: { text: 'A1|stale-pending', senderNodeId: 1, pubKeyPrefixHex: 'aabbcc' }, + }, + 'pending-driver-id', + ); + expect(h.handleResponse).not.toHaveBeenCalled(); + }); + + it('rate-limits MQTT packet-log publishes with a token bucket', () => { + const h = makeHarness(); + h.ctx.mqttStatusRef.current = 'connected'; + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + const publish = vi.mocked(window.electronAPI.mqtt.publishMeshcorePacketLog); + + for (let i = 0; i < 20; i += 1) { + dispatch({ + type: 'meshcore_rf_rx', + payload: { lastSnr: 1, lastRssi: -70, raw: Uint8Array.from([i, 0x22, 0x33, 0x44]) }, + }); + } + + expect(publish.mock.calls.length).toBeLessThan(20); + expect(publish.mock.calls.length).toBeGreaterThan(0); + }); + it('schedules a silent drain on the message-waiting signal', async () => { vi.useFakeTimers(); const h = makeHarness(); diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index 0a86d57ad..c6bdee96c 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -29,6 +29,10 @@ import type { MeshCoreConnection, RxPacketEntry, } from '../../lib/meshcore/meshcoreHookTypes'; +import { + createMeshcoreMqttPacketLogBucket, + tryTakeMeshcoreMqttPacketLogToken, +} from '../../lib/meshcore/meshcoreMqttPacketLogThrottle'; import { processMeshcoreWaitingMessageItem } from '../../lib/meshcoreProcessWaitingMessageItem'; import { meshcoreRawPacketLogFromBytesFallback, @@ -77,6 +81,7 @@ import { requestMeshcoreWaitingMessagesFollowUp, requestMeshcoreWaitingMessagesManualFollowUp, resetMeshcoreProcessWaitingMessagesSync, + resetMeshcoreWaitingMessagesSilentFollowUpChain, setMeshcoreProcessWaitingMessagesInFlight, takeMeshcoreWaitingMessagesFollowUp, takeMeshcoreWaitingMessagesManualFollowUp, @@ -136,6 +141,10 @@ export function attachMeshcoreConnSideEffects( bumpLastDataReceived, } = ctx; + // Capture identity at attach; prefer finalized identity once configure completes. + const identityIdAtAttach = resolveIdentityId(); + const mqttPacketLogBucket = createMeshcoreMqttPacketLogBucket(); + const storePriorForIngest = (): ChatMessage[] => { const storeId = meshcoreIdentityIdRef.current; return storeId ? meshcoreSortedStorePrior(storeId) : []; @@ -243,7 +252,10 @@ export function attachMeshcoreConnSideEffects( if (!meshcoreHookMountedRef.current) return; const manual = takeMeshcoreWaitingMessagesManualFollowUp(); const silent = takeMeshcoreWaitingMessagesFollowUp(); - if (!manual && !silent) return; + if (!manual && !silent) { + resetMeshcoreWaitingMessagesSilentFollowUpChain(); + return; + } const showSyncBanner = manual; scheduleSilentWaitingMessageDrain(() => processWaitingMessages({ showSyncBanner }).catch((e: unknown) => { @@ -861,7 +873,7 @@ export function attachMeshcoreConnSideEffects( if (mqttStatusRef.current === 'connected') { const nowMs = Date.now(); - if (nowMs - lastPacketLogAtRef.current >= 100) { + if (tryTakeMeshcoreMqttPacketLogToken(mqttPacketLogBucket, nowMs)) { lastPacketLogAtRef.current = nowMs; void window.electronAPI.mqtt .publishMeshcorePacketLog({ @@ -938,7 +950,8 @@ export function attachMeshcoreConnSideEffects( }; const detachListener = packetRouter.addListener((event, routedIdentityId) => { - if (routedIdentityId !== resolveIdentityId()) return; + const expectedIdentityId = meshcoreIdentityIdRef.current ?? identityIdAtAttach; + if (!expectedIdentityId || routedIdentityId !== expectedIdentityId) return; bumpLastDataReceived?.(); switch (event.type) { case 'meshcore_dm_ack': diff --git a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts index 3428a8e78..0e10b58c1 100644 --- a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts +++ b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts @@ -37,6 +37,7 @@ import { mergeMeshcoreLastHeardFromAdvert, } from '../../lib/nodeStatus'; import { normalizeReactionEmoji } from '../../lib/reactions'; +import { MAX_TELEMETRY_POINTS as SHARED_MAX_TELEMETRY_POINTS } from '../../lib/sessionMemoryCaps'; import { MESHCORE_CHANNEL_RF_DEDUP_WINDOW_MS, MESHCORE_CROSS_TRANSPORT_DEDUP_WINDOW_MS, @@ -309,7 +310,7 @@ export const MESHCORE_TRACE_TIMEOUT_MS = MESHCORE_REPEATER_RPC_TIMEOUT_MS; export const MESHCORE_NEIGHBORS_MAX_RECOMMENDED_HOPS = 8; /** Request page size for GetNeighbours; firmware reply buffers often return fewer rows (~11 at 6-byte prefixes). */ export const MESHCORE_NEIGHBORS_PAGE_SIZE = 50; -export const MAX_TELEMETRY_POINTS = 50; +export const MAX_TELEMETRY_POINTS = SHARED_MAX_TELEMETRY_POINTS; export const MAX_ENV_TELEMETRY_POINTS = 50; diff --git a/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.test.ts b/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.test.ts index 61b7e54cc..6fa2a1250 100644 --- a/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.test.ts @@ -1,11 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MESHCORE_WAITING_MESSAGES_SILENT_FOLLOW_UP_CHAIN_MAX } from '../../lib/timeConstants'; import { clearMeshcoreWaitingMessagesFollowUp, getMeshcoreProcessWaitingMessagesInFlight, + getMeshcoreWaitingMessagesSilentFollowUpChainCount, requestMeshcoreWaitingMessagesFollowUp, requestMeshcoreWaitingMessagesManualFollowUp, resetMeshcoreProcessWaitingMessagesSync, + resetMeshcoreWaitingMessagesSilentFollowUpChain, setMeshcoreProcessWaitingMessagesInFlight, takeMeshcoreWaitingMessagesFollowUp, takeMeshcoreWaitingMessagesManualFollowUp, @@ -15,6 +18,7 @@ describe('meshcoreWaitingMessagesSyncState follow-up chaining', () => { beforeEach(() => { setMeshcoreProcessWaitingMessagesInFlight(null); clearMeshcoreWaitingMessagesFollowUp(); + resetMeshcoreWaitingMessagesSilentFollowUpChain(); }); it('requests follow-up only while a drain is in flight', () => { @@ -56,6 +60,25 @@ describe('meshcoreWaitingMessagesSyncState follow-up chaining', () => { expect(takeMeshcoreWaitingMessagesManualFollowUp()).toBe(false); }); + it('caps silent follow-up chain depth without blocking manual follow-up', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + setMeshcoreProcessWaitingMessagesInFlight(Promise.resolve()); + for (let i = 0; i < MESHCORE_WAITING_MESSAGES_SILENT_FOLLOW_UP_CHAIN_MAX; i += 1) { + requestMeshcoreWaitingMessagesFollowUp(); + expect(takeMeshcoreWaitingMessagesFollowUp()).toBe(true); + } + expect(getMeshcoreWaitingMessagesSilentFollowUpChainCount()).toBe( + MESHCORE_WAITING_MESSAGES_SILENT_FOLLOW_UP_CHAIN_MAX, + ); + requestMeshcoreWaitingMessagesFollowUp(); + expect(takeMeshcoreWaitingMessagesFollowUp()).toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('silent follow-up chain capped')); + + requestMeshcoreWaitingMessagesManualFollowUp(); + expect(takeMeshcoreWaitingMessagesManualFollowUp()).toBe(true); + warnSpy.mockRestore(); + }); + it('clears follow-up and silent drain UI on reset', () => { const setWaitingMessagesCount = vi.fn(); const setWaitingMessagesSyncActive = vi.fn(); @@ -76,6 +99,7 @@ describe('meshcoreWaitingMessagesSyncState follow-up chaining', () => { expect(getMeshcoreProcessWaitingMessagesInFlight()).toBeNull(); expect(takeMeshcoreWaitingMessagesFollowUp()).toBe(false); + expect(getMeshcoreWaitingMessagesSilentFollowUpChainCount()).toBe(0); expect(setWaitingMessagesSilentDrainActive).toHaveBeenCalledWith(false); expect(setWaitingMessagesDrainDeferred).toHaveBeenCalledWith(false); }); diff --git a/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.ts b/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.ts index 0bcd2f5ba..540dea0c3 100644 --- a/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.ts +++ b/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.ts @@ -1,8 +1,12 @@ import type { Dispatch, SetStateAction } from 'react'; +import { MESHCORE_WAITING_MESSAGES_SILENT_FOLLOW_UP_CHAIN_MAX } from '../../lib/timeConstants'; + let processWaitingMessagesInFlight: Promise | null = null; let processWaitingMessagesFollowUpRequested = false; let processWaitingMessagesManualFollowUpRequested = false; +/** Silent follow-ups taken in the current 131 drain chain (reset when chain settles). */ +let silentFollowUpChainCount = 0; export function getMeshcoreProcessWaitingMessagesInFlight(): Promise | null { return processWaitingMessagesInFlight; @@ -14,14 +18,22 @@ export function setMeshcoreProcessWaitingMessagesInFlight(inFlight: Promise= MESHCORE_WAITING_MESSAGES_SILENT_FOLLOW_UP_CHAIN_MAX) { + console.warn( + `[meshcoreWaitingMessagesSyncState] silent follow-up chain capped at ${MESHCORE_WAITING_MESSAGES_SILENT_FOLLOW_UP_CHAIN_MAX}`, + ); + return; } + processWaitingMessagesFollowUpRequested = true; } export function takeMeshcoreWaitingMessagesFollowUp(): boolean { const requested = processWaitingMessagesFollowUpRequested; processWaitingMessagesFollowUpRequested = false; + if (requested) { + silentFollowUpChainCount += 1; + } return requested; } @@ -43,6 +55,16 @@ export function clearMeshcoreWaitingMessagesFollowUp(): void { processWaitingMessagesManualFollowUpRequested = false; } +/** Reset the silent follow-up chain counter when a drain settles with no further work. */ +export function resetMeshcoreWaitingMessagesSilentFollowUpChain(): void { + silentFollowUpChainCount = 0; +} + +/** Test hook — current silent follow-up chain depth. */ +export function getMeshcoreWaitingMessagesSilentFollowUpChainCount(): number { + return silentFollowUpChainCount; +} + /** Clear module in-flight guard and Chat waiting-message UI (disconnect / listener teardown). */ export function resetMeshcoreProcessWaitingMessagesSync( setWaitingMessagesCount: Dispatch>, @@ -56,6 +78,7 @@ export function resetMeshcoreProcessWaitingMessagesSync( processWaitingMessagesInFlight = null; processWaitingMessagesFollowUpRequested = false; processWaitingMessagesManualFollowUpRequested = false; + silentFollowUpChainCount = 0; setWaitingMessagesCount(0); setWaitingMessagesSyncActive(false); setWaitingMessagesSyncProgress(null); diff --git a/src/renderer/lib/drivers/ConnectionDriver.test.ts b/src/renderer/lib/drivers/ConnectionDriver.test.ts index 8f1c40a6b..a185b7fc9 100644 --- a/src/renderer/lib/drivers/ConnectionDriver.test.ts +++ b/src/renderer/lib/drivers/ConnectionDriver.test.ts @@ -128,6 +128,36 @@ describe('ConnectionDriver', () => { await connectionDriver.disconnect(identityId); }); + it('bounds the signature alias table but keeps aliases of connected identities', async () => { + const peripheralId = `alias-bound-${Date.now()}`; + const params: TransportParams = { type: 'ble', peripheralId }; + vi.spyOn(meshtasticProtocol, 'createDevice').mockResolvedValue({} as unknown as MeshDevice); + vi.spyOn(meshtasticProtocol, 'subscribe').mockReturnValue(() => {}); + vi.spyOn(meshtasticProtocol, 'destroyDevice').mockResolvedValue(undefined); + + const connectedId = await connectionDriver.connect('meshtastic', params); + const connectedKey = `meshtastic:ble:${peripheralId}`; + + const staleId = `stale-${Date.now()}`; + addIdentity({ + id: staleId, + protocol: meshtasticProtocol, + signature: 'meshtastic:node:stale-signature', + transports: [], + createdAt: Date.now(), + lastSeenAt: Date.now(), + }); + for (let i = 0; i < 600; i++) { + connectionDriver.registerTransportKeys(staleId, `meshtastic:ble:stale-${i}`); + } + + expect(connectionDriver.lookupIdentityId(connectedKey)).toBe(connectedId); + expect(connectionDriver.lookupIdentityId('meshtastic:ble:stale-0')).toBeNull(); + expect(connectionDriver.lookupIdentityId('meshtastic:ble:stale-599')).toBe(staleId); + + await connectionDriver.disconnect(connectedId); + }); + it('removeIdentity clears transport keys and identity record', async () => { const peripheralId = `remove-${Date.now()}`; const transportKey = `meshtastic:ble:${peripheralId}`; diff --git a/src/renderer/lib/drivers/ConnectionDriver.ts b/src/renderer/lib/drivers/ConnectionDriver.ts index c0a78bb49..37ba857dc 100644 --- a/src/renderer/lib/drivers/ConnectionDriver.ts +++ b/src/renderer/lib/drivers/ConnectionDriver.ts @@ -41,6 +41,13 @@ interface TransportSlot { lastDataAt: number; } +/** + * Ceiling on cached identity-signature aliases. Each connect registers at most + * two (provisional transport key + resolved node key), so this holds hundreds + * of distinct devices before the oldest disconnected aliases are dropped. + */ +const MAX_TRANSPORT_KEY_ALIASES = 512; + function transportTypeToConnectionType(type: TransportType): ConnectionType | null { switch (type) { case 'ble': @@ -88,7 +95,27 @@ export class ConnectionDriver { /** Register all signature aliases for one identity (provisional transport + resolved node). */ registerTransportKeys(identityId: IdentityId, ...keys: string[]): void { for (const key of keys) { - if (key) this.transportKeyMap.set(key, identityId); + if (!key) continue; + // Re-insert so a refreshed alias moves to the young end for eviction. + this.transportKeyMap.delete(key); + this.transportKeyMap.set(key, identityId); + } + this.pruneTransportKeys(); + } + + /** + * Cap the signature alias table. A long-lived session that scans many serial + * ports or BLE peripherals would otherwise grow it without bound. Aliases of + * identities with a live transport slot are never evicted. + */ + private pruneTransportKeys(): void { + if (this.transportKeyMap.size <= MAX_TRANSPORT_KEY_ALIASES) return; + const connectedIdentityIds = new Set(); + for (const slot of this.slots.values()) connectedIdentityIds.add(slot.identityId); + for (const [key, id] of this.transportKeyMap) { + if (this.transportKeyMap.size <= MAX_TRANSPORT_KEY_ALIASES) break; + if (connectedIdentityIds.has(id)) continue; + this.transportKeyMap.delete(key); } } diff --git a/src/renderer/lib/drivers/PacketRouter.test.ts b/src/renderer/lib/drivers/PacketRouter.test.ts index d84567418..74059e08f 100644 --- a/src/renderer/lib/drivers/PacketRouter.test.ts +++ b/src/renderer/lib/drivers/PacketRouter.test.ts @@ -1,11 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useConnectionStore } from '../../stores/connectionStore'; +import { getDevice, useDeviceStore } from '../../stores/deviceStore'; import { addIdentity, useIdentityStore } from '../../stores/identityStore'; import { useMessageStore } from '../../stores/messageStore'; import { useNodeStore } from '../../stores/nodeStore'; +import { + clearMeshtasticConfigIngressGuardsForTests, + setMeshtasticRemoteConfigTarget, +} from '../meshtastic/meshtasticConfigIngressGuard'; import { meshtasticProtocol } from '../protocols/MeshtasticProtocol'; import type { DomainEvent } from '../protocols/Protocol'; +import { attachTypedPacketListener } from './attachTypedPacketListener'; import { packetRouter } from './PacketRouter'; const ID = 'packet-router-test'; @@ -21,6 +27,8 @@ describe('PacketRouter', () => { useNodeStore.setState({ nodes: {}, traceRoutes: {}, waypoints: {}, neighborInfo: {} }); useConnectionStore.setState({ connections: {} }); useIdentityStore.setState({ identities: {}, activeIdentityId: null }); + useDeviceStore.setState({ devices: {} }); + clearMeshtasticConfigIngressGuardsForTests(); }); const cases: { event: DomainEvent; assert: () => void }[] = [ @@ -318,6 +326,64 @@ describe('PacketRouter', () => { expect(byId[realId]).toBeDefined(); }); + it('does not write local config events while remote admin targets another node', () => { + packetRouter.dispatch( + { + type: 'module_config', + payload: { configType: 'mqtt', value: { enabled: false } }, + }, + ID, + ); + expect(getDevice(ID).moduleConfigs.mqtt).toEqual({ enabled: false }); + + setMeshtasticRemoteConfigTarget(ID, 0x1234); + packetRouter.dispatch( + { + type: 'module_config', + payload: { configType: 'mqtt', value: { enabled: true } }, + }, + ID, + ); + packetRouter.dispatch( + { + type: 'meshtastic_config_slice', + payload: { configCase: 'lora', value: { region: 1 } }, + }, + ID, + ); + + expect(getDevice(ID).moduleConfigs.mqtt).toEqual({ enabled: false }); + expect(getDevice(ID).meshtasticConfigSlices.lora).toBeUndefined(); + }); + + it('dispatches general and typed listeners in registration order', () => { + const calls: string[] = []; + const detachGeneral = packetRouter.addListener(() => calls.push('general')); + const detachTyped = attachTypedPacketListener(ID, 'queue_status', () => calls.push('typed')); + + packetRouter.dispatch({ type: 'queue_status', payload: { free: 1, maxlen: 2 } }, ID); + + expect(calls).toEqual(['general', 'typed']); + detachGeneral(); + detachTyped(); + }); + + it('uses a listener snapshot when a callback detaches another listener', () => { + const calls: string[] = []; + let detachSecond: () => void = () => undefined; + const detachFirst = packetRouter.addListener(() => { + calls.push('first'); + detachSecond(); + }); + detachSecond = packetRouter.addListener(() => calls.push('second')); + + packetRouter.dispatch({ type: 'queue_status', payload: { free: 1, maxlen: 2 } }, ID); + packetRouter.dispatch({ type: 'queue_status', payload: { free: 1, maxlen: 2 } }, ID); + + expect(calls).toEqual(['first', 'second', 'first']); + detachFirst(); + }); + it('ignores unknown event types without throwing', () => { expect(() => { packetRouter.dispatch({ type: 'nonexistent' } as unknown as DomainEvent, ID); diff --git a/src/renderer/lib/drivers/PacketRouter.ts b/src/renderer/lib/drivers/PacketRouter.ts index 11d4cdd8c..e7c255f5d 100644 --- a/src/renderer/lib/drivers/PacketRouter.ts +++ b/src/renderer/lib/drivers/PacketRouter.ts @@ -33,8 +33,9 @@ import { } from '../../stores/nodeStore'; import { errLikeToLogString } from '../errLikeToLogString'; import { ensureMeshtasticChatSenderInNodeStore } from '../meshtastic/meshtasticChatSenderNode'; +import { shouldSuppressMeshtasticLocalConfigWrite } from '../meshtastic/meshtasticConfigIngressGuard'; import { meshtasticTracerouteLastHeardNodeIds } from '../meshtasticLastHeard'; -import type { DomainEvent } from '../protocols/Protocol'; +import type { DomainEvent, DomainEventType } from '../protocols/Protocol'; import { retargetMeshtasticOutboundTempId } from '../sessions/meshtasticSession'; import { MESHCORE_ROOM_POST_DEDUP_WINDOW_MS, @@ -58,8 +59,28 @@ function upsertByIndex(arr: T[], item: T): T[] { return next.sort((a, b) => a.index - b.index); } +/** + * Post-store hook invoked for every dispatched event. + * + * Ordering contract, relied on by ingest and the `attach*SideEffects` modules: + * 1. `dispatch` applies the store mutation for the event type first, so a + * listener always observes `messageStore` / `nodeStore` / `deviceStore` + * already updated for the event it is handling. + * 2. Listeners then run in registration order. A throwing listener is logged + * and skipped; later listeners and the store write still happen. + * + * Prefer `attachTypedPacketListener` over registering raw listeners so the + * identity filter and payload narrowing stay in one place. + */ export type PacketRouterListener = (event: DomainEvent, identityId: IdentityId) => void; +interface PacketRouterListenerEntry { + id: number; + listener: PacketRouterListener; +} + +const MAX_PACKET_ROUTER_LISTENERS = 256; + function roomPostWireBody(payload: string): string { return payload.length > 4 ? payload.slice(4) : payload; } @@ -95,15 +116,66 @@ function findRoomPostOptimistic( } class PacketRouter { - private listeners: PacketRouterListener[] = []; + private listeners: PacketRouterListenerEntry[] = []; + private typedListeners = new Map(); + private nextListenerId = 1; + /** Registers a listener at the end of the dispatch order. Returns a detach fn. */ addListener(listener: PacketRouterListener): () => void { - this.listeners.push(listener); + const entry = { id: this.nextListenerId++, listener }; + this.listeners.push(entry); + this.enforceListenerLimit(); + return () => { + this.removeListener(entry.id); + }; + } + + addTypedListener(type: DomainEventType, listener: PacketRouterListener): () => void { + return this.addTypedListeners([type], listener); + } + + addTypedListeners(types: readonly DomainEventType[], listener: PacketRouterListener): () => void { + const entry = { id: this.nextListenerId++, listener }; + for (const type of new Set(types)) { + const entries = this.typedListeners.get(type) ?? []; + entries.push(entry); + this.typedListeners.set(type, entries); + } + this.enforceListenerLimit(); return () => { - this.listeners = this.listeners.filter((l) => l !== listener); + this.removeListener(entry.id); }; } + private removeListener(id: number): void { + this.listeners = this.listeners.filter((entry) => entry.id !== id); + for (const [type, entries] of this.typedListeners) { + const next = entries.filter((entry) => entry.id !== id); + if (next.length === 0) this.typedListeners.delete(type); + else this.typedListeners.set(type, next); + } + } + + private enforceListenerLimit(): void { + const ids = new Set(this.listeners.map((entry) => entry.id)); + for (const entries of this.typedListeners.values()) { + for (const entry of entries) ids.add(entry.id); + } + if (ids.size <= MAX_PACKET_ROUTER_LISTENERS) return; + this.removeListener(Math.min(...ids)); + console.warn('[PacketRouter] evicted oldest listener after reaching session cap'); + } + + /** Live listener count. Exported for leak assertions in tests. */ + listenerCount(): number { + const ids = new Set(this.listeners.map((entry) => entry.id)); + for (const entries of this.typedListeners.values()) { + for (const entry of entries) ids.add(entry.id); + } + return ids.size; + } + + /** Applies the store mutation for `event`, then notifies listeners in order. */ dispatch(event: DomainEvent, identityId: IdentityId): void { switch (event.type) { case 'text_message': { @@ -212,6 +284,7 @@ class PacketRouter { upsertWaypoint(identityId, event.payload); break; case 'channel': { + if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) break; const e = event.payload; const existing = getDevice(identityId); const name = e.name || (e.index === 0 ? 'Primary' : `Channel ${e.index}`); @@ -233,12 +306,15 @@ class PacketRouter { break; } case 'device_gps_state': + if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) break; setDeviceGpsState(identityId, event.payload.gpsMode, event.payload.fixedPosition); break; case 'security_config': + if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) break; setSecurityConfig(identityId, event.payload); break; case 'module_config': { + if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) break; const current = getDevice(identityId).moduleConfigs; setModuleConfigs(identityId, { ...current, @@ -247,9 +323,11 @@ class PacketRouter { break; } case 'telemetry_interval': + if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) break; setTelemetryDeviceUpdateInterval(identityId, event.payload.interval); break; case 'meshtastic_config_slice': + if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) break; setMeshtasticConfigSlice(identityId, event.payload.configCase, event.payload.value); break; case 'queue_status': { @@ -295,7 +373,11 @@ class PacketRouter { upsertMeshcoreChannel(identityId, event.payload); break; } - for (const listener of this.listeners) { + const listeners = [...this.listeners, ...(this.typedListeners.get(event.type) ?? [])].sort( + (a, b) => a.id - b.id, + ); + // Snapshot before invoking: listeners may detach themselves during dispatch. + for (const { listener } of listeners) { try { listener(event, identityId); } catch (e) { diff --git a/src/renderer/lib/drivers/attachTypedPacketListener.test.ts b/src/renderer/lib/drivers/attachTypedPacketListener.test.ts new file mode 100644 index 000000000..7dcc7da84 --- /dev/null +++ b/src/renderer/lib/drivers/attachTypedPacketListener.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { useMessageStore } from '../../stores/messageStore'; +import { useNodeStore } from '../../stores/nodeStore'; +import { attachTypedPacketListener, attachTypedPacketListeners } from './attachTypedPacketListener'; +import { packetRouter } from './PacketRouter'; + +const ID = 'typed-listener-identity'; +const OTHER_ID = 'typed-listener-other-identity'; + +function textMessage(id: string) { + return { + type: 'text_message' as const, + payload: { id, from: 1, to: 2, payload: 'hi', channelIndex: 0, timestamp: 1000 }, + }; +} + +describe('attachTypedPacketListener', () => { + afterEach(() => { + useMessageStore.setState({ messages: {} }); + useNodeStore.setState({ nodes: {}, traceRoutes: {}, waypoints: {}, neighborInfo: {} }); + }); + + it('delivers only the requested event type for the requested identity', () => { + const seen: string[] = []; + const detach = attachTypedPacketListener(ID, 'text_message', (payload) => { + seen.push(payload.payload); + }); + + packetRouter.dispatch(textMessage('1'), ID); + packetRouter.dispatch(textMessage('2'), OTHER_ID); + packetRouter.dispatch({ type: 'node_info', payload: { nodeId: 5 } }, ID); + + expect(seen).toEqual(['hi']); + detach(); + }); + + it('runs after the store mutation for the same event', () => { + let payloadInStore: string | undefined; + const detach = attachTypedPacketListener(ID, 'text_message', (payload) => { + payloadInStore = useMessageStore.getState().messages[ID]?.[payload.id]?.payload; + }); + + packetRouter.dispatch(textMessage('42'), ID); + + expect(payloadInStore).toBe('hi'); + detach(); + }); + + it('detach removes the listener', () => { + let calls = 0; + const detach = attachTypedPacketListener(ID, 'text_message', () => { + calls += 1; + }); + const before = packetRouter.listenerCount(); + detach(); + expect(packetRouter.listenerCount()).toBe(before - 1); + + packetRouter.dispatch(textMessage('3'), ID); + expect(calls).toBe(0); + }); +}); + +describe('attachTypedPacketListeners', () => { + afterEach(() => { + useMessageStore.setState({ messages: {} }); + useNodeStore.setState({ nodes: {}, traceRoutes: {}, waypoints: {}, neighborInfo: {} }); + }); + + it('routes each event type to its handler and ignores unmapped types', () => { + const seen: string[] = []; + const detach = attachTypedPacketListeners(ID, { + text_message: (payload) => seen.push(`text:${payload.id}`), + node_info: (payload) => seen.push(`node:${payload.nodeId}`), + }); + + packetRouter.dispatch(textMessage('7'), ID); + packetRouter.dispatch({ type: 'node_info', payload: { nodeId: 9 } }, ID); + packetRouter.dispatch({ type: 'queue_status', payload: { free: 1, maxlen: 2 } }, ID); + + expect(seen).toEqual(['text:7', 'node:9']); + detach(); + }); + + it('registers a single router listener for the whole handler map', () => { + const before = packetRouter.listenerCount(); + const detach = attachTypedPacketListeners(ID, { + text_message: () => {}, + node_info: () => {}, + waypoint: () => {}, + }); + expect(packetRouter.listenerCount()).toBe(before + 1); + detach(); + expect(packetRouter.listenerCount()).toBe(before); + }); + + it('preserves registration order across modules', () => { + const order: string[] = []; + const detachFirst = attachTypedPacketListeners(ID, { + text_message: () => order.push('first'), + }); + const detachSecond = attachTypedPacketListener(ID, 'text_message', () => order.push('second')); + + packetRouter.dispatch(textMessage('8'), ID); + + expect(order).toEqual(['first', 'second']); + detachFirst(); + detachSecond(); + }); +}); diff --git a/src/renderer/lib/drivers/attachTypedPacketListener.ts b/src/renderer/lib/drivers/attachTypedPacketListener.ts new file mode 100644 index 000000000..79ca33f75 --- /dev/null +++ b/src/renderer/lib/drivers/attachTypedPacketListener.ts @@ -0,0 +1,54 @@ +/** + * Typed `PacketRouter` subscription helpers. + * + * Every post-router side effect used to hand-roll the same guard — compare the + * routed identity, narrow `event.type`, then read `event.payload`. These + * wrappers do that once against the {@link DomainEventPayloadMap} registry so a + * handler receives an already-narrowed payload. + * + * Ordering contract (see `PacketRouter.dispatch`): store mutations run before + * any listener, and listeners run in registration order. A module that + * registers several handlers through {@link attachTypedPacketListeners} gets + * one router listener, so its handlers keep their relative order with respect + * to listeners registered by other modules. + */ +import type { DomainEventPayloadMap, DomainEventType } from '../protocols/Protocol'; +import type { IdentityId } from '../types'; +import { packetRouter } from './PacketRouter'; + +export type TypedPacketHandler = ( + payload: DomainEventPayloadMap[T], + identityId: IdentityId, +) => void; + +export type TypedPacketHandlers = { + [T in DomainEventType]?: TypedPacketHandler; +}; + +/** Subscribe to one event type for one identity. Returns a detach function. */ +export function attachTypedPacketListener( + identityId: IdentityId, + type: T, + handler: TypedPacketHandler, +): () => void { + return packetRouter.addTypedListener(type, (event, routedIdentityId) => { + if (routedIdentityId !== identityId || event.type !== type) return; + handler(event.payload as DomainEventPayloadMap[T], identityId); + }); +} + +/** + * Subscribe to several event types for one identity using a single router + * listener, preserving dispatch order relative to other modules. + */ +export function attachTypedPacketListeners( + identityId: IdentityId, + handlers: TypedPacketHandlers, +): () => void { + const types = Object.keys(handlers) as DomainEventType[]; + return packetRouter.addTypedListeners(types, (event, routedIdentityId) => { + if (routedIdentityId !== identityId) return; + const handler = handlers[event.type] as TypedPacketHandler | undefined; + handler?.(event.payload, identityId); + }); +} diff --git a/src/renderer/lib/drivers/packetDedupeRegistry.test.ts b/src/renderer/lib/drivers/packetDedupeRegistry.test.ts new file mode 100644 index 000000000..6cf9afbad --- /dev/null +++ b/src/renderer/lib/drivers/packetDedupeRegistry.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { createPacketDedupeRegistry } from './packetDedupeRegistry'; + +describe('createPacketDedupeRegistry', () => { + it('reports the first sighting as new and repeats as seen', () => { + const registry = createPacketDedupeRegistry({ ttlMs: 1000, maxEntries: 10 }); + expect(registry.markSeen(42, 0)).toBe(false); + expect(registry.markSeen(42, 100)).toBe(true); + expect(registry.markSeen(43, 100)).toBe(false); + }); + + it('treats numeric and string forms of a key as the same packet', () => { + const registry = createPacketDedupeRegistry({ ttlMs: 1000, maxEntries: 10 }); + expect(registry.markSeen(7, 0)).toBe(false); + expect(registry.markSeen('7', 0)).toBe(true); + }); + + it('forgets keys once the TTL elapses', () => { + const registry = createPacketDedupeRegistry({ ttlMs: 1000, maxEntries: 10 }); + registry.markSeen('a', 0); + expect(registry.hasSeen('a', 999)).toBe(true); + expect(registry.hasSeen('a', 1000)).toBe(false); + expect(registry.markSeen('a', 1000)).toBe(false); + }); + + it('keeps a repeatedly seen key alive relative to its last sighting', () => { + const registry = createPacketDedupeRegistry({ ttlMs: 1000, maxEntries: 10 }); + registry.markSeen('a', 0); + expect(registry.markSeen('a', 900)).toBe(true); + expect(registry.hasSeen('a', 1500)).toBe(true); + }); + + it('evicts the oldest keys past maxEntries', () => { + const registry = createPacketDedupeRegistry({ ttlMs: 60_000, maxEntries: 3 }); + for (const key of ['a', 'b', 'c', 'd']) registry.markSeen(key, 0); + expect(registry.size(0)).toBe(3); + expect(registry.hasSeen('a', 0)).toBe(false); + expect(registry.hasSeen('d', 0)).toBe(true); + }); + + it('hasSeen does not record the key', () => { + const registry = createPacketDedupeRegistry({ ttlMs: 1000, maxEntries: 10 }); + expect(registry.hasSeen('a', 0)).toBe(false); + expect(registry.markSeen('a', 0)).toBe(false); + }); + + it('clear drops all retained keys', () => { + const registry = createPacketDedupeRegistry({ ttlMs: 1000, maxEntries: 10 }); + registry.markSeen('a', 0); + registry.clear(); + expect(registry.size(0)).toBe(0); + }); +}); diff --git a/src/renderer/lib/drivers/packetDedupeRegistry.ts b/src/renderer/lib/drivers/packetDedupeRegistry.ts new file mode 100644 index 000000000..b69c33d0e --- /dev/null +++ b/src/renderer/lib/drivers/packetDedupeRegistry.ts @@ -0,0 +1,78 @@ +/** + * Bounded, TTL-scoped "have I already handled this packet?" registry. + * + * Several ingress paths can observe the same wire packet: the Meshtastic SDK + * dispatches `onMeshPacket` for every packet and then a second typed event for + * the same payload, and Store & Forward routers re-send identical heartbeats. + * Callers key by whatever identifies the packet (numeric id, `port:id`, a + * content hash) and skip work when {@link PacketDedupeRegistry.markSeen} + * reports the key was already recorded. + * + * Failure point: the registry is session memory only. Eviction is oldest-first + * once `maxEntries` is exceeded, so a burst larger than the cap can let a late + * duplicate through — callers must stay correct (not just efficient) in that + * case. + */ + +export interface PacketDedupeRegistryOptions { + /** Entries older than this are treated as unseen. */ + ttlMs: number; + /** Hard ceiling on retained keys; oldest are evicted first. */ + maxEntries: number; +} + +export interface PacketDedupeRegistry { + /** Records `key` and returns true when it was already present and unexpired. */ + markSeen(key: string | number, now?: number): boolean; + /** True when `key` is present and unexpired, without recording it. */ + hasSeen(key: string | number, now?: number): boolean; + clear(): void; + /** Retained key count after pruning at `now`. Exposed for bounds assertions. */ + size(now?: number): number; +} + +export function createPacketDedupeRegistry( + options: PacketDedupeRegistryOptions, +): PacketDedupeRegistry { + const ttlMs = Math.max(0, options.ttlMs); + const maxEntries = Math.max(1, Math.floor(options.maxEntries)); + const seenAt = new Map(); + + const prune = (now: number): void => { + for (const [key, recordedAt] of seenAt) { + if (now - recordedAt >= ttlMs) seenAt.delete(key); + } + // Map iteration is insertion-ordered, so the first keys are the oldest. + for (const key of seenAt.keys()) { + if (seenAt.size <= maxEntries) break; + seenAt.delete(key); + } + }; + + const isFresh = (key: string, now: number): boolean => { + const recordedAt = seenAt.get(key); + return recordedAt !== undefined && now - recordedAt < ttlMs; + }; + + return { + markSeen(key, now = Date.now()) { + const stringKey = String(key); + const alreadySeen = isFresh(stringKey, now); + // Re-insert so a repeatedly seen key stays at the young end of the map. + seenAt.delete(stringKey); + seenAt.set(stringKey, now); + prune(now); + return alreadySeen; + }, + hasSeen(key, now = Date.now()) { + return isFresh(String(key), now); + }, + clear() { + seenAt.clear(); + }, + size(now = Date.now()) { + prune(now); + return seenAt.size; + }, + }; +} diff --git a/src/renderer/lib/hydrateIdentityStoresFromDb.test.ts b/src/renderer/lib/hydrateIdentityStoresFromDb.test.ts index f5c54634c..d617dde88 100644 --- a/src/renderer/lib/hydrateIdentityStoresFromDb.test.ts +++ b/src/renderer/lib/hydrateIdentityStoresFromDb.test.ts @@ -21,6 +21,7 @@ import { messageRecordsToChatMessages } from './storeRecordAdapters'; const ID_MT = 'id-hydrate-mt'; const ID_MC = 'id-hydrate-mc'; +const ID_RT = 'id-hydrate-rt'; describe('hydrateIdentityStoresFromDb', () => { beforeEach(() => { @@ -338,6 +339,38 @@ describe('hydrateIdentityStoresFromDb', () => { expect(Object.keys(useMessageStore.getState().messages[ID_MT] ?? {})).toHaveLength(200); }); + it('skips Reticulum destination rows with an unusable hash and keeps the valid ones', async () => { + const validHash = 'a'.repeat(32); + vi.spyOn(window.electronAPI.db, 'getReticulumDestinations').mockResolvedValue([ + { destination_hash: validHash, display_name: 'Peer', last_heard: 1000 }, + { destination_hash: null }, + { destination_hash: 'not-a-hash' }, + { destination_hash: 42 as unknown as string }, + ]); + vi.spyOn(window.electronAPI.db, 'getReticulumMessages').mockResolvedValue([]); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await hydrateIdentityStoresFromDb('reticulum', ID_RT); + + const nodes = Object.values(useNodeStore.getState().nodes[ID_RT] ?? {}); + expect(nodes).toHaveLength(1); + expect(nodes[0]?.reticulumDestinationHash).toBe(validHash); + expect(warn).toHaveBeenCalledTimes(3); + }); + + it('normalizes uppercase Reticulum destination hashes', async () => { + const upper = 'B'.repeat(32); + vi.spyOn(window.electronAPI.db, 'getReticulumDestinations').mockResolvedValue([ + { destination_hash: upper }, + ]); + vi.spyOn(window.electronAPI.db, 'getReticulumMessages').mockResolvedValue([]); + + await hydrateIdentityStoresFromDb('reticulum', ID_RT); + + const nodes = Object.values(useNodeStore.getState().nodes[ID_RT] ?? {}); + expect(nodes[0]?.reticulumDestinationHash).toBe(upper.toLowerCase()); + }); + it('upsertNodeRecordsForIdentity merges large batches in one store update', () => { const records = Array.from({ length: 350 }, (_, i) => ({ nodeId: i + 1, diff --git a/src/renderer/lib/hydrateIdentityStoresFromDb.ts b/src/renderer/lib/hydrateIdentityStoresFromDb.ts index 195f1776e..54eb391ee 100644 --- a/src/renderer/lib/hydrateIdentityStoresFromDb.ts +++ b/src/renderer/lib/hydrateIdentityStoresFromDb.ts @@ -1,3 +1,5 @@ +import { canonicalizeReticulumDestinationHash } from '@/shared/reticulumDestinationHash'; + import { buildMeshcoreNodeMapFromDb, isMeshcoreRoomChatMessage, @@ -217,7 +219,11 @@ export async function hydrateMeshcoreMessagesFromDb( roomServerIds, loadPersistedMeshcoreSelfNodeId(), ); - void persistMeshcoreMessageSenderRepairs(rows, mapped); + void Promise.resolve(persistMeshcoreMessageSenderRepairs(rows, mapped)).catch((e: unknown) => { + console.warn( + '[hydrateMeshcoreMessagesFromDb] sender repair persist failed ' + errLikeToLogString(e), + ); + }); const trimmed = trimChatMessagesToMax(mapped, MAX_IN_MEMORY_CHAT_MESSAGES); const records = meshcoreHydratedMessageRecords(trimmed); if (messagesMode === 'replace') { @@ -291,17 +297,25 @@ function hydrateReticulumIdentity( }[]; const { reticulumHashToNodeId, registerReticulumDestinationHash } = await import('./reticulum/destHash'); - const records: NodeRecord[] = rows.map((row) => { - const nodeId = reticulumHashToNodeId(row.destination_hash); - registerReticulumDestinationHash(nodeId, row.destination_hash); - return { + const records: NodeRecord[] = []; + for (const row of rows) { + // A row without a canonical hash cannot be keyed or displayed; skipping it + // keeps one bad SQLite row from failing the whole hydration pass. + const destinationHash = canonicalizeReticulumDestinationHash(row.destination_hash); + if (!destinationHash) { + console.warn('[hydrateReticulumIdentity] skipped destination row with invalid hash'); + continue; + } + const nodeId = reticulumHashToNodeId(destinationHash); + registerReticulumDestinationHash(nodeId, destinationHash); + records.push({ nodeId, - longName: row.display_name ?? row.destination_hash.slice(0, 16), + longName: row.display_name ?? destinationHash.slice(0, 16), shortName: row.display_name?.slice(0, 4) ?? 'RT', lastHeardAt: row.last_heard ?? undefined, - reticulumDestinationHash: row.destination_hash, - }; - }); + reticulumDestinationHash: destinationHash, + }); + } upsertNodeRecordsForIdentity(identityId, records); } catch (e) { console.warn('[hydrateReticulumIdentity] destinations ' + errLikeToLogString(e)); diff --git a/src/renderer/lib/i18n.test.ts b/src/renderer/lib/i18n.test.ts index 0b9d0850b..1364b4bbe 100644 --- a/src/renderer/lib/i18n.test.ts +++ b/src/renderer/lib/i18n.test.ts @@ -74,6 +74,7 @@ describe('i18n', () => { it.each([ ['cs', 2, '2 skoky'], + ['cs', 5, '5 skoků'], ['pl', 2, '2 przeskoki'], ['pl', 5, '5 przeskoków'], ['ru', 2, '2 перехода'], diff --git a/src/renderer/lib/ingest/meshtasticIngest.test.ts b/src/renderer/lib/ingest/meshtasticIngest.test.ts index 2b0e587ff..fa92f24bc 100644 --- a/src/renderer/lib/ingest/meshtasticIngest.test.ts +++ b/src/renderer/lib/ingest/meshtasticIngest.test.ts @@ -166,6 +166,19 @@ describe('attachMeshtasticIngest', () => { session.detach(); }); + it('shares duplicate checks with external MQTT/runtime ingress', () => { + const session = attachMeshtasticIngest(ID, { + getIsConfiguring: () => false, + getMyNodeNum: () => 0, + }); + + expect(session.isDuplicatePacket(1, 5)).toBe(false); + expect(session.isDuplicatePacket(1, 5)).toBe(true); + expect(session.isDuplicatePacket(2, 5)).toBe(false); + + session.detach(); + }); + it('exposes hopCount as rxHops through store adapter round-trip', () => { const session = attachMeshtasticIngest(ID, { getIsConfiguring: () => false, diff --git a/src/renderer/lib/ingest/meshtasticIngest.ts b/src/renderer/lib/ingest/meshtasticIngest.ts index 9d15734ec..e1f48565f 100644 --- a/src/renderer/lib/ingest/meshtasticIngest.ts +++ b/src/renderer/lib/ingest/meshtasticIngest.ts @@ -39,6 +39,8 @@ export interface MeshtasticIngestSession { setConfiguring: (value: boolean) => void; /** Register a packet id as seen (e.g. after MQTT ingest) to suppress duplicate RF rows. */ markPacketSeen: (senderId: number, packetId: number) => void; + /** Shared RF/MQTT duplicate check and mark for this identity. */ + isDuplicatePacket: (senderId: number, packetId: number) => boolean; } function pruneSeenPackets(seen: Map, now: number): void { @@ -218,5 +220,7 @@ export function attachMeshtasticIngest( markPacketSeen: (senderId: number, packetId: number) => { if (packetId !== 0) isPacketSeen(seenPacketIds, senderId, packetId); }, + isDuplicatePacket: (senderId: number, packetId: number) => + packetId !== 0 && isPacketSeen(seenPacketIds, senderId, packetId), }; } diff --git a/src/renderer/lib/meshIdentityBridge.test.ts b/src/renderer/lib/meshIdentityBridge.test.ts index ad0925dfd..888708157 100644 --- a/src/renderer/lib/meshIdentityBridge.test.ts +++ b/src/renderer/lib/meshIdentityBridge.test.ts @@ -4,7 +4,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getIdentity } from '../stores/identityStore'; import { addMessage } from '../stores/messageStore'; import { connectionDriver } from './drivers/ConnectionDriver'; -import { attachMeshtasticProtocolIngress, meshtasticTransportParams } from './meshIdentityBridge'; +import { + attachMeshtasticProtocolIngress, + meshcoreTransportParams, + meshtasticTransportParams, +} from './meshIdentityBridge'; import { meshtasticProtocol } from './protocols/MeshtasticProtocol'; function mockMeshDevice(): MeshDevice { @@ -55,4 +59,57 @@ describe('meshtasticTransportParams', () => { it('defaults tcp host to empty string when omitted', () => { expect(meshtasticTransportParams('tcp', {})).toEqual({ type: 'tcp', host: '' }); }); + + it('maps ble and serial identifiers through unchanged', () => { + expect(meshtasticTransportParams('ble', { peripheralId: 'aa:bb' })).toEqual({ + type: 'ble', + peripheralId: 'aa:bb', + }); + expect(meshtasticTransportParams('serial', { portSignature: 'usb-1a86' })).toEqual({ + type: 'serial', + portSignature: 'usb-1a86', + }); + expect(meshtasticTransportParams('http', { host: '10.0.0.5' })).toEqual({ + type: 'http', + host: '10.0.0.5', + }); + }); + + it('throws on an unsupported connection type', () => { + expect(() => meshtasticTransportParams('carrier-pigeon' as unknown as 'tcp', {})).toThrow( + /meshtasticTransportParams/, + ); + }); +}); + +describe('transport params parity', () => { + const shared = [ + ['ble', { peripheralId: 'aa:bb:cc' }], + ['serial', { portSignature: 'usb-1a86-0001' }], + ['tcp', { host: '192.168.1.9:4403' }], + ] as const; + + it.each(shared)('meshtastic and meshcore shape %s params identically', (type, opts) => { + // A divergence here would give the same physical device two identity + // signatures across protocols. + expect(meshcoreTransportParams(type, opts)).toEqual(meshtasticTransportParams(type, opts)); + }); + + it('defaults a missing host to empty string for both protocols', () => { + expect(meshcoreTransportParams('tcp', {})).toEqual(meshtasticTransportParams('tcp', {})); + }); + + it('leaves missing ble/serial identifiers undefined for both protocols', () => { + expect(meshcoreTransportParams('ble', {})).toEqual({ type: 'ble', peripheralId: undefined }); + expect(meshcoreTransportParams('serial', {})).toEqual({ + type: 'serial', + portSignature: undefined, + }); + }); + + it('throws on an unsupported meshcore transport type', () => { + expect(() => meshcoreTransportParams('carrier-pigeon' as unknown as 'tcp', {})).toThrow( + /meshcoreTransportParams/, + ); + }); }); diff --git a/src/renderer/lib/meshIdentityBridge.ts b/src/renderer/lib/meshIdentityBridge.ts index af46cdc8b..76ba9c06f 100644 --- a/src/renderer/lib/meshIdentityBridge.ts +++ b/src/renderer/lib/meshIdentityBridge.ts @@ -81,9 +81,22 @@ export interface MeshtasticProtocolIngressAttach { detach: () => void; } -export function meshtasticTransportParams( +/** Connect inputs collected by ConnectionPanel, before protocol-specific shaping. */ +export interface MeshTransportParamOptions { + peripheralId?: string; + portSignature?: string; + host?: string; +} + +/** + * Shared transport-params builder. Meshtastic and MeshCore accept different + * transport subsets but must shape the shared ones identically, otherwise the + * same physical device yields two different identity signatures. + */ +function buildMeshTransportParams( + caller: string, type: ConnectionType, - opts: { peripheralId?: string; portSignature?: string; host?: string }, + opts: MeshTransportParamOptions, ): TransportParams { switch (type) { case 'ble': @@ -96,13 +109,18 @@ export function meshtasticTransportParams( return { type: 'tcp', host: opts.host ?? '' }; default: { const _exhaustive: never = type; - throw new Error( - `meshtasticTransportParams: unsupported connection type ${String(_exhaustive)}`, - ); + throw new Error(`${caller}: unsupported connection type ${String(_exhaustive)}`); } } } +export function meshtasticTransportParams( + type: ConnectionType, + opts: MeshTransportParamOptions, +): TransportParams { + return buildMeshTransportParams('meshtasticTransportParams', type, opts); +} + /** * Registers a Meshtastic identity, wires protocol ingress into PacketRouter, * and exposes the SDK handle to ConnectionDriver action hooks. @@ -165,20 +183,9 @@ export interface MeshcoreProtocolIngressAttach { export function meshcoreTransportParams( type: 'ble' | 'serial' | 'tcp', - opts: { peripheralId?: string; portSignature?: string; host?: string }, + opts: MeshTransportParamOptions, ): TransportParams { - switch (type) { - case 'ble': - return { type: 'ble', peripheralId: opts.peripheralId }; - case 'serial': - return { type: 'serial', portSignature: opts.portSignature }; - case 'tcp': - return { type: 'tcp', host: opts.host ?? '' }; - default: { - const _exhaustive: never = type; - throw new Error(`meshcoreTransportParams: unsupported transport type ${String(_exhaustive)}`); - } - } + return buildMeshTransportParams('meshcoreTransportParams', type, opts); } export function attachMeshcoreProtocolIngress( diff --git a/src/renderer/lib/meshcore/meshcoreDmAckRuntime.test.ts b/src/renderer/lib/meshcore/meshcoreDmAckRuntime.test.ts index 80c0d8abc..8c9174c09 100644 --- a/src/renderer/lib/meshcore/meshcoreDmAckRuntime.test.ts +++ b/src/renderer/lib/meshcore/meshcoreDmAckRuntime.test.ts @@ -66,4 +66,60 @@ describe('meshcoreDmAckRuntime', () => { expect(syncMeshcoreDmAckToMessageStore(ID, 42, 1, 'acked')).toBe(true); expect(useMessageStore.getState().messages[ID]?.['42']?.status).toBe('acked'); }); + + it('does not apply ambiguous single-inflight DM ack fallback', () => { + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + addIdentity({ + id: ID, + protocol: meshcoreProtocol, + signature: 'meshcore:test:dm-ack-ambiguous', + transports: [], + createdAt: Date.now(), + lastSeenAt: Date.now(), + }); + upsertMessage(ID, { + id: 'temp-pending', + from: 1, + to: 99, + payload: 'only one sending', + channelIndex: -1, + timestamp: Date.now(), + status: 'sending', + }); + expect(syncMeshcoreDmAckToMessageStore(ID, 42, 1, 'acked')).toBe(false); + expect(useMessageStore.getState().messages[ID]?.['temp-pending']?.status).toBe('sending'); + expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('dropping unmatched DM ack')); + }); + + it('does not apply unmatched ack when multiple outbound DMs are in flight', () => { + addIdentity({ + id: ID, + protocol: meshcoreProtocol, + signature: 'meshcore:test:dm-ack-multi', + transports: [], + createdAt: Date.now(), + lastSeenAt: Date.now(), + }); + upsertMessage(ID, { + id: '10', + from: 1, + to: 99, + payload: 'a', + channelIndex: -1, + timestamp: Date.now(), + status: 'sending', + }); + upsertMessage(ID, { + id: '11', + from: 1, + to: 100, + payload: 'b', + channelIndex: -1, + timestamp: Date.now(), + status: 'sending', + }); + expect(syncMeshcoreDmAckToMessageStore(ID, 99, 1, 'acked')).toBe(false); + expect(useMessageStore.getState().messages[ID]?.['10']?.status).toBe('sending'); + expect(useMessageStore.getState().messages[ID]?.['11']?.status).toBe('sending'); + }); }); diff --git a/src/renderer/lib/meshcore/meshcoreDmAckRuntime.ts b/src/renderer/lib/meshcore/meshcoreDmAckRuntime.ts index b3f3313b7..1da81ce71 100644 --- a/src/renderer/lib/meshcore/meshcoreDmAckRuntime.ts +++ b/src/renderer/lib/meshcore/meshcoreDmAckRuntime.ts @@ -11,7 +11,7 @@ import { meshcoreDmAckKeyU32, type PendingDmAckEntry, } from '../../hooks/meshcore/meshcoreHookPreamble'; -import { renameMessageId, updateMessageStatus, useMessageStore } from '../../stores/messageStore'; +import { updateMessageStatus, useMessageStore } from '../../stores/messageStore'; import type { IdentityId } from '../types'; /** MeshCore firmware RESP codes: 0x80 = ACK, 0x81 = NACK (may arrive signed). */ @@ -89,16 +89,15 @@ export function syncMeshcoreDmAckToMessageStore( } if (matched) return true; + // Do not apply ambiguous single-inflight fallback: a late/mis-keyed ACK 130 must + // not rename/ack the sole outbound DM when packet ids do not match. const inflight = Object.entries(byId).filter( ([, rec]) => rec.status === 'sending' && rec.from === selfId && isOutboundMeshcoreDmRecord(rec), ); - if (inflight.length !== 1) return false; - - const [id] = inflight[0]; - const ackId = String(ackKeyU32); - if (id !== ackId) { - renameMessageId(identityId, id, ackId); + if (inflight.length > 0) { + console.debug( + `[meshcoreDmAckRuntime] dropping unmatched DM ack ${ackKeyU32} (${inflight.length} inflight outbound)`, + ); } - updateMessageStatus(identityId, ackId, newStatus); - return true; + return false; } diff --git a/src/renderer/lib/meshcore/meshcoreLiveContactPersist.ts b/src/renderer/lib/meshcore/meshcoreLiveContactPersist.ts index d38f6e9fc..ca07028b0 100644 --- a/src/renderer/lib/meshcore/meshcoreLiveContactPersist.ts +++ b/src/renderer/lib/meshcore/meshcoreLiveContactPersist.ts @@ -5,6 +5,8 @@ * Failure point: DB IPC — logged; Zustand store remains authoritative for UI. * Fallback: skip DB write; reconnect refreshContacts repairs rows. */ +import { bytesToHex } from '@/shared/hexBytes'; + import { useNodeStore } from '../../stores/nodeStore'; import { usePositionHistoryStore } from '../../stores/positionHistoryStore'; import { errLikeToLogString } from '../errLikeToLogString'; @@ -29,12 +31,6 @@ export interface PersistMeshcoreNodeInfoOpts { longitudeDeg?: number | null; } -function publicKeyHex(publicKey: Uint8Array): string { - return Array.from(publicKey) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); -} - /** * After PacketRouter `upsertNode`, mirror legacy event 128/138 SQLite + pubkey registry side effects. */ @@ -97,7 +93,7 @@ export function persistMeshcoreNodeInfoAfterAdvert( void window.electronAPI.db .saveMeshcoreContact({ node_id: nodeId, - public_key: publicKeyHex(publicKey), + public_key: bytesToHex(publicKey), adv_name: typeof event.longName === 'string' && event.longName.trim() ? event.longName.trim() @@ -161,7 +157,7 @@ export function persistMeshcorePathUpdatedNewContact( void window.electronAPI.db .saveMeshcoreContact({ node_id: nodeId, - public_key: publicKeyHex(publicKey), + public_key: bytesToHex(publicKey), adv_name: null, contact_type: 0, last_advert: lastAdvertSec, diff --git a/src/renderer/lib/meshcore/meshcoreMqttPacketLogThrottle.test.ts b/src/renderer/lib/meshcore/meshcoreMqttPacketLogThrottle.test.ts new file mode 100644 index 000000000..c4ab262d1 --- /dev/null +++ b/src/renderer/lib/meshcore/meshcoreMqttPacketLogThrottle.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { + createMeshcoreMqttPacketLogBucket, + MESHCORE_MQTT_PACKET_LOG_BUCKET_CAPACITY, + MESHCORE_MQTT_PACKET_LOG_REFILL_INTERVAL_MS, + tryTakeMeshcoreMqttPacketLogToken, +} from './meshcoreMqttPacketLogThrottle'; + +describe('meshcoreMqttPacketLogThrottle', () => { + it('allows a burst up to capacity then denies until refill', () => { + const bucket = createMeshcoreMqttPacketLogBucket(1_000); + for (let i = 0; i < MESHCORE_MQTT_PACKET_LOG_BUCKET_CAPACITY; i += 1) { + expect(tryTakeMeshcoreMqttPacketLogToken(bucket, 1_000)).toBe(true); + } + expect(tryTakeMeshcoreMqttPacketLogToken(bucket, 1_000)).toBe(false); + expect( + tryTakeMeshcoreMqttPacketLogToken( + bucket, + 1_000 + MESHCORE_MQTT_PACKET_LOG_REFILL_INTERVAL_MS, + ), + ).toBe(true); + expect( + tryTakeMeshcoreMqttPacketLogToken( + bucket, + 1_000 + MESHCORE_MQTT_PACKET_LOG_REFILL_INTERVAL_MS, + ), + ).toBe(false); + }); + + it('refills multiple tokens for longer idle gaps without exceeding capacity', () => { + const bucket = createMeshcoreMqttPacketLogBucket(0); + bucket.tokens = 0; + const now = MESHCORE_MQTT_PACKET_LOG_REFILL_INTERVAL_MS * 100; + expect(tryTakeMeshcoreMqttPacketLogToken(bucket, now)).toBe(true); + expect(bucket.tokens).toBe(MESHCORE_MQTT_PACKET_LOG_BUCKET_CAPACITY - 1); + }); +}); diff --git a/src/renderer/lib/meshcore/meshcoreMqttPacketLogThrottle.ts b/src/renderer/lib/meshcore/meshcoreMqttPacketLogThrottle.ts new file mode 100644 index 000000000..a053f0374 --- /dev/null +++ b/src/renderer/lib/meshcore/meshcoreMqttPacketLogThrottle.ts @@ -0,0 +1,43 @@ +/** + * Token bucket for MeshCore RF → MQTT packet-log publishes. + * Sustained RF floods must not enqueue unbounded MQTT IPC. + */ + +import { MS_PER_SECOND } from '../timeConstants'; + +/** Burst capacity before MQTT packet-log publishes start dropping. */ +export const MESHCORE_MQTT_PACKET_LOG_BUCKET_CAPACITY = 8; + +/** One token refilled every this many ms (~5 publishes/sec sustained). */ +export const MESHCORE_MQTT_PACKET_LOG_REFILL_INTERVAL_MS = 200; + +/** @deprecated Prefer the token bucket; kept for callers that still track last publish time. */ +export const MESHCORE_MQTT_PACKET_LOG_MIN_INTERVAL_MS = Math.floor(MS_PER_SECOND / 10); + +export interface MeshcoreMqttPacketLogBucket { + tokens: number; + lastRefillMs: number; +} + +export function createMeshcoreMqttPacketLogBucket(now = Date.now()): MeshcoreMqttPacketLogBucket { + return { + tokens: MESHCORE_MQTT_PACKET_LOG_BUCKET_CAPACITY, + lastRefillMs: now, + }; +} + +/** Refill by elapsed intervals, then consume one token when available. */ +export function tryTakeMeshcoreMqttPacketLogToken( + bucket: MeshcoreMqttPacketLogBucket, + now = Date.now(), +): boolean { + const elapsed = Math.max(0, now - bucket.lastRefillMs); + const refill = Math.floor(elapsed / MESHCORE_MQTT_PACKET_LOG_REFILL_INTERVAL_MS); + if (refill > 0) { + bucket.tokens = Math.min(MESHCORE_MQTT_PACKET_LOG_BUCKET_CAPACITY, bucket.tokens + refill); + bucket.lastRefillMs += refill * MESHCORE_MQTT_PACKET_LOG_REFILL_INTERVAL_MS; + } + if (bucket.tokens < 1) return false; + bucket.tokens -= 1; + return true; +} diff --git a/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.test.ts b/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.test.ts new file mode 100644 index 000000000..582053790 --- /dev/null +++ b/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { usePathHistoryStore } from '../../stores/pathHistoryStore'; +import { pubkeyToNodeId } from '../meshcoreUtils'; +import type { MeshNode } from '../types'; +import type { MeshCoreConnection, MeshCoreContactRaw } from './meshcoreHookTypes'; +import { + rebuildMeshcoreContactsAfterPathUpdated, + refreshMeshcoreOutPathAfterPathUpdated, +} from './meshcorePathUpdatedRuntime'; + +function makePubKey(seed: number): Uint8Array { + const key = new Uint8Array(32); + key[0] = seed; + key[1] = seed + 1; + return key; +} + +function contact( + overrides: Partial & { publicKey: Uint8Array }, +): MeshCoreContactRaw { + return { + type: 1, + advName: 'Peer', + lastAdvert: 1_700_000_000, + advLat: 0, + advLon: 0, + flags: 0, + ...overrides, + }; +} + +describe('meshcorePathUpdatedRuntime', () => { + beforeEach(() => { + vi.stubGlobal('window', { + electronAPI: { + db: { + upsertMeshcorePathHistory: vi.fn().mockResolvedValue(undefined), + }, + }, + }); + usePathHistoryStore.setState({ records: new Map(), lruOrder: [] }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('refreshMeshcoreOutPathAfterPathUpdated stores outPath and clears pending', async () => { + const publicKey = makePubKey(10); + const nodeId = pubkeyToNodeId(publicKey); + const outPath = Uint8Array.from([0xab, 0xcd]); + const conn = { + getContacts: vi + .fn() + .mockResolvedValue([ + contact({ publicKey, outPathLen: 2, outPath }), + contact({ publicKey: makePubKey(99), outPathLen: 1, outPath: Uint8Array.from([0x11]) }), + ]), + } as unknown as MeshCoreConnection; + const outPathMap = new Map(); + const pending = new Set([nodeId]); + + await refreshMeshcoreOutPathAfterPathUpdated(conn, nodeId, outPathMap, pending); + + expect(outPathMap.get(nodeId)).toEqual(outPath); + expect(pending.has(nodeId)).toBe(false); + expect(usePathHistoryStore.getState().records.get(nodeId)?.length).toBeGreaterThan(0); + }); + + it('refreshMeshcoreOutPathAfterPathUpdated leaves pending when getContacts fails', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const publicKey = makePubKey(11); + const nodeId = pubkeyToNodeId(publicKey); + const conn = { + getContacts: vi.fn().mockRejectedValue(new Error('getContacts timeout')), + } as unknown as MeshCoreConnection; + const pending = new Set([nodeId]); + + await refreshMeshcoreOutPathAfterPathUpdated(conn, nodeId, new Map(), pending); + + expect(pending.has(nodeId)).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[meshcorePathUpdatedRuntime] getContacts refresh failed'), + ); + }); + + it('rebuildMeshcoreContactsAfterPathUpdated rebuilds nodes and records pending paths', async () => { + const publicKey = makePubKey(12); + const nodeId = pubkeyToNodeId(publicKey); + const outPath = Uint8Array.from([0x01, 0x02, 0x03]); + const contacts = [contact({ publicKey, outPathLen: 3, outPath, advName: 'Relay' })]; + const conn = { + getContacts: vi.fn().mockResolvedValue(contacts), + } as unknown as MeshCoreConnection; + const onContacts = vi.fn(); + const onNodes = vi.fn(); + const nodeMap = new Map([ + [ + nodeId, + { + node_id: nodeId, + long_name: 'Relay', + short_name: '', + hw_model: 'Repeater', + snr: 0, + rssi: 0, + last_heard: 0, + battery: 0, + latitude: null, + longitude: null, + hops_away: 2, + }, + ], + ]); + const buildNodesFromContacts = vi.fn().mockResolvedValue(nodeMap); + + await rebuildMeshcoreContactsAfterPathUpdated({ + conn, + buildNodesFromContacts, + self: null, + myNodeId: 1, + previousNodes: new Map(), + pendingPathUpdateNodeIds: new Set([nodeId]), + onContacts, + onNodes, + }); + + expect(onContacts).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ publicKey, flags: 0 })]), + ); + expect(onNodes).toHaveBeenCalledWith(nodeMap); + expect(buildNodesFromContacts).toHaveBeenCalled(); + expect(usePathHistoryStore.getState().records.has(nodeId)).toBe(true); + }); + + it('rebuildMeshcoreContactsAfterPathUpdated logs and keeps prior nodes on failure', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const onContacts = vi.fn(); + const onNodes = vi.fn(); + const conn = { + getContacts: vi.fn().mockRejectedValue(new Error('radio busy')), + } as unknown as MeshCoreConnection; + + await rebuildMeshcoreContactsAfterPathUpdated({ + conn, + buildNodesFromContacts: vi.fn(), + self: null, + myNodeId: 1, + previousNodes: new Map(), + pendingPathUpdateNodeIds: new Set([7]), + onContacts, + onNodes, + }); + + expect(onContacts).not.toHaveBeenCalled(); + expect(onNodes).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[meshcorePathUpdatedRuntime] debounced contacts refresh error'), + ); + }); +}); diff --git a/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts b/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts index c3f082606..2bd676282 100644 --- a/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts +++ b/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts @@ -112,7 +112,7 @@ describe('processMeshcoreWaitingMessageItem', () => { expect(result.nodesDirty).toBe(true); }); - it('warns and uses senderId 0 for unknown pubKeyPrefix', () => { + it('warns and skips ingest for unknown pubKeyPrefix (senderId 0)', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const pubKey = makePubKey(99); const prefixBytes = pubKey.slice(0, 6); @@ -129,7 +129,30 @@ describe('processMeshcoreWaitingMessageItem', () => { ); expect(warnSpy).toHaveBeenCalled(); - expect(result.pendingMessages[0]?.sender_id).toBe(0); + expect(result.pendingMessages).toHaveLength(0); + expect(result.nodesDirty).toBe(false); + warnSpy.mockRestore(); + }); + + it('still logs transport status lines when senderId is 0', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const logTransportLineAsDevice = vi.fn(); + const pubKey = makePubKey(98); + const prefixBytes = pubKey.slice(0, 6); + + const result = processMeshcoreWaitingMessageItem( + { + contactMessage: { + pubKeyPrefix: prefixBytes, + text: 'ack @peer', + senderTimestamp: 1_700_000_201, + }, + }, + baseDeps({ logTransportLineAsDevice }), + ); + + expect(result.pendingMessages).toHaveLength(0); + expect(logTransportLineAsDevice).toHaveBeenCalledWith('ack @peer'); warnSpy.mockRestore(); }); diff --git a/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts b/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts index 220836644..8f4c5ad37 100644 --- a/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts +++ b/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts @@ -1,4 +1,5 @@ import { packetRouter } from '@/renderer/lib/drivers/PacketRouter'; +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { parseMeshcoreChannelIncomingFromThread, parseMeshcoreDmIncomingFromThread, @@ -48,12 +49,14 @@ export function processMeshcoreWaitingMessageItem( const senderId = deps.pubKeyPrefixMap.get(prefix) ?? 0; if (senderId === 0) { console.warn( - '[meshcoreProcessWaitingMessageItem] unknown pubKeyPrefix in queued DM, sender will be 0', + '[meshcoreProcessWaitingMessageItem] unknown pubKeyPrefix in queued DM, skipping ingest', prefix, ); - } - const sender = deps.workingNodes.get(senderId); - if (senderId !== 0) { + if (isMeshcoreTransportStatusChatLine(d.text)) { + deps.logTransportLineAsDevice(d.text); + } + } else { + const sender = deps.workingNodes.get(senderId); const existing = deps.workingNodes.get(senderId); if (existing) { deps.workingNodes.set(senderId, { @@ -62,58 +65,63 @@ export function processMeshcoreWaitingMessageItem( }); nodesDirty = true; } - } - if (isMeshcoreTransportStatusChatLine(d.text)) { - deps.logTransportLineAsDevice(d.text); - } else if (sender?.hw_model === 'Room') { - // Room posts route through PacketRouter → meshcoreIngest, matching live event-7 posts. - const postTs = effectiveMessageTimestampMs(d.senderTimestamp * 1000); - const identityId = deps.meshcoreIdentityId; - if (identityId) { - const roomNodeIds = new Set(); - for (const [nodeId, node] of deps.workingNodes) { - if (node.hw_model === 'Room') roomNodeIds.add(nodeId); + if (isMeshcoreTransportStatusChatLine(d.text)) { + deps.logTransportLineAsDevice(d.text); + } else if (sender?.hw_model === 'Room') { + // Room posts route through PacketRouter → meshcoreIngest, matching live event-7 posts. + const postTs = effectiveMessageTimestampMs(d.senderTimestamp * 1000); + const identityId = deps.meshcoreIdentityId; + if (identityId) { + const roomNodeIds = new Set(); + for (const [nodeId, node] of deps.workingNodes) { + if (node.hw_model === 'Room') roomNodeIds.add(nodeId); + } + dispatchMeshcoreWaitingContactMessage( + identityId, + { + pubKeyPrefix: d.pubKeyPrefix, + text: d.text, + senderTimestamp: d.senderTimestamp, + ...(d.txtType != null ? { txtType: d.txtType } : {}), + ...(d.pathLen != null ? { pathLen: d.pathLen } : {}), + }, + deps.pubKeyPrefixMap, + roomNodeIds, + (event, id) => { + packetRouter.dispatch(event, id); + }, + deps.logTransportLineAsDevice, + ); + roomDispatched = true; + void setMeshcoreRoomLastPostAt(senderId, postTs).catch((e: unknown) => { + console.warn( + '[meshcoreProcessWaitingMessageItem] setMeshcoreRoomLastPostAt failed ' + + errLikeToLogString(e), + ); + }); + } else { + console.warn( + '[meshcoreProcessWaitingMessageItem] room post skipped (no identityId)', + senderId, + ); } - dispatchMeshcoreWaitingContactMessage( - identityId, - { - pubKeyPrefix: d.pubKeyPrefix, - text: d.text, - senderTimestamp: d.senderTimestamp, - ...(d.txtType != null ? { txtType: d.txtType } : {}), - ...(d.pathLen != null ? { pathLen: d.pathLen } : {}), - }, - deps.pubKeyPrefixMap, - roomNodeIds, - (event, id) => { - packetRouter.dispatch(event, id); - }, - deps.logTransportLineAsDevice, - ); - roomDispatched = true; - void setMeshcoreRoomLastPostAt(senderId, postTs); } else { - console.warn( - '[meshcoreProcessWaitingMessageItem] room post skipped (no identityId)', - senderId, - ); + const dmRxHops = meshcoreCompanionRxPathLenToHopCount(d.pathLen); + pendingMessages.push({ + ...parseMeshcoreDmIncomingFromThread(deps.storePriorForBatch(), { + rawText: d.text, + senderId, + displayName: sender?.long_name ?? `Node-${senderId.toString(16).toUpperCase()}`, + timestamp: effectiveMessageTimestampMs(d.senderTimestamp * 1000), + receivedVia: 'rf', + peerNodeId: senderId, + myNodeId: deps.myNodeNum || 0, + to: deps.myNodeNum || undefined, + ...(dmRxHops != null ? { rxHops: dmRxHops } : {}), + }), + isHistory: true, + }); } - } else { - const dmRxHops = meshcoreCompanionRxPathLenToHopCount(d.pathLen); - pendingMessages.push({ - ...parseMeshcoreDmIncomingFromThread(deps.storePriorForBatch(), { - rawText: d.text, - senderId, - displayName: sender?.long_name ?? `Node-${senderId.toString(16).toUpperCase()}`, - timestamp: effectiveMessageTimestampMs(d.senderTimestamp * 1000), - receivedVia: 'rf', - peerNodeId: senderId, - myNodeId: deps.myNodeNum || 0, - to: deps.myNodeNum || undefined, - ...(dmRxHops != null ? { rxHops: dmRxHops } : {}), - }), - isHistory: true, - }); } } diff --git a/src/renderer/lib/meshtastic/meshtasticConfigIngressGuard.test.ts b/src/renderer/lib/meshtastic/meshtasticConfigIngressGuard.test.ts new file mode 100644 index 000000000..f45eb3f1a --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticConfigIngressGuard.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + clearMeshtasticConfigIngressGuardsForTests, + setMeshtasticRemoteConfigTarget, + shouldSuppressMeshtasticLocalConfigWrite, +} from './meshtasticConfigIngressGuard'; + +describe('meshtasticConfigIngressGuard', () => { + afterEach(() => { + clearMeshtasticConfigIngressGuardsForTests(); + }); + + it('suppresses local config writes only while a remote target is active', () => { + expect(shouldSuppressMeshtasticLocalConfigWrite('identity-a')).toBe(false); + + setMeshtasticRemoteConfigTarget('identity-a', 0x1234); + expect(shouldSuppressMeshtasticLocalConfigWrite('identity-a')).toBe(true); + expect(shouldSuppressMeshtasticLocalConfigWrite('identity-b')).toBe(false); + + setMeshtasticRemoteConfigTarget('identity-a', null); + expect(shouldSuppressMeshtasticLocalConfigWrite('identity-a')).toBe(false); + }); +}); diff --git a/src/renderer/lib/meshtastic/meshtasticConfigIngressGuard.ts b/src/renderer/lib/meshtastic/meshtasticConfigIngressGuard.ts new file mode 100644 index 000000000..35157fa68 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticConfigIngressGuard.ts @@ -0,0 +1,22 @@ +import type { IdentityId } from '../types'; + +const remoteTargets = new Map(); + +export function setMeshtasticRemoteConfigTarget( + identityId: IdentityId, + nodeNum: number | null, +): void { + if (nodeNum == null) { + remoteTargets.delete(identityId); + return; + } + remoteTargets.set(identityId, nodeNum); +} + +export function shouldSuppressMeshtasticLocalConfigWrite(identityId: IdentityId): boolean { + return remoteTargets.has(identityId); +} + +export function clearMeshtasticConfigIngressGuardsForTests(): void { + remoteTargets.clear(); +} diff --git a/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.test.ts index 379d05d8c..925562252 100644 --- a/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.test.ts @@ -1,7 +1,29 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { packetRouter } from '../drivers/PacketRouter'; -import { attachMeshtasticModulePortSideEffects } from './meshtasticModulePortSideEffects'; +import { + attachMeshtasticModulePortSideEffects, + type MeshtasticModulePortSideEffectsDeps, +} from './meshtasticModulePortSideEffects'; + +function makeDeps(): MeshtasticModulePortSideEffectsDeps { + return { + touchLastData: vi.fn(), + setRemoteHardwareMessages: vi.fn(), + setAudioMessages: vi.fn(), + setDetectionSensorEvents: vi.fn(), + setPingResponses: vi.fn(), + setIpTunnelMessages: vi.fn(), + setPaxCounterData: vi.fn(), + setSerialMessages: vi.fn(), + setRangeTestPackets: vi.fn(), + setZpsMessages: vi.fn(), + setSimulatorPackets: vi.fn(), + setAtakMessages: vi.fn(), + setMapReports: vi.fn(), + setPrivateMessages: vi.fn(), + }; +} describe('attachMeshtasticModulePortSideEffects', () => { afterEach(() => { @@ -9,25 +31,8 @@ describe('attachMeshtasticModulePortSideEffects', () => { }); it('appends audio module payloads into the per-node map', () => { - const setAudioMessages = vi.fn(); - const touchLastData = vi.fn(); - const noop = vi.fn(); - const detach = attachMeshtasticModulePortSideEffects('id-1', { - touchLastData, - setRemoteHardwareMessages: noop, - setAudioMessages, - setDetectionSensorEvents: noop, - setPingResponses: noop, - setIpTunnelMessages: noop, - setPaxCounterData: noop, - setSerialMessages: noop, - setRangeTestPackets: noop, - setZpsMessages: noop, - setSimulatorPackets: noop, - setAtakMessages: noop, - setMapReports: noop, - setPrivateMessages: noop, - }); + const deps = makeDeps(); + const detach = attachMeshtasticModulePortSideEffects('id-1', deps); packetRouter.dispatch( { type: 'meshtastic_module_port', @@ -40,8 +45,149 @@ describe('attachMeshtasticModulePortSideEffects', () => { }, 'id-1', ); - expect(touchLastData).toHaveBeenCalled(); - expect(setAudioMessages).toHaveBeenCalled(); + expect(deps.touchLastData).toHaveBeenCalled(); + expect(deps.setAudioMessages).toHaveBeenCalled(); + detach(); + }); + + it.each([ + ['remoteHardware', 'setRemoteHardwareMessages'], + ['detectionSensor', 'setDetectionSensorEvents'], + ['ping', 'setPingResponses'], + ['ipTunnel', 'setIpTunnelMessages'], + ['paxcounter', 'setPaxCounterData'], + ['serial', 'setSerialMessages'], + ['rangeTest', 'setRangeTestPackets'], + ['zps', 'setZpsMessages'], + ['simulator', 'setSimulatorPackets'], + ['atakPlugin', 'setAtakMessages'], + ['atakForwarder', 'setAtakMessages'], + ['mapReport', 'setMapReports'], + ['private', 'setPrivateMessages'], + ] as const)('routes %s payloads to %s', (portLabel, setter) => { + const deps = makeDeps(); + const detach = attachMeshtasticModulePortSideEffects('id-1', deps); + packetRouter.dispatch( + { + type: 'meshtastic_module_port', + payload: { + portLabel, + from: 9, + data: portLabel === 'paxcounter' ? { wifi: 3, ble: 2 } : new Uint8Array([1]), + timestamp: Date.now(), + }, + }, + 'id-1', + ); + + expect(deps[setter]).toHaveBeenCalledOnce(); + detach(); + }); + + it('totals paxcounter wifi and ble device counts', () => { + const deps = makeDeps(); + const detach = attachMeshtasticModulePortSideEffects('id-1', deps); + let history = new Map(); + deps.setPaxCounterData = vi.fn((updater) => { + history = typeof updater === 'function' ? updater(history) : updater; + }); + + packetRouter.dispatch( + { + type: 'meshtastic_module_port', + payload: { + portLabel: 'paxcounter', + from: 9, + data: { wifi: 3, ble: 2, uptime: 900 }, + timestamp: 1000, + }, + }, + 'id-1', + ); + + expect(history.get(9)?.at(-1)?.count).toBe(5); + detach(); + }); + + it('prefers an explicit paxcounter count and floors malformed values at zero', () => { + const deps = makeDeps(); + const detach = attachMeshtasticModulePortSideEffects('id-1', deps); + let history = new Map(); + deps.setPaxCounterData = vi.fn((updater) => { + history = typeof updater === 'function' ? updater(history) : updater; + }); + + packetRouter.dispatch( + { + type: 'meshtastic_module_port', + payload: { portLabel: 'paxcounter', from: 9, data: { count: 12 }, timestamp: 1000 }, + }, + 'id-1', + ); + expect(history.get(9)?.at(-1)?.count).toBe(12); + + packetRouter.dispatch( + { + type: 'meshtastic_module_port', + payload: { + portLabel: 'paxcounter', + from: 9, + data: { wifi: Number.NaN, ble: undefined }, + timestamp: 2000, + }, + }, + 'id-1', + ); + expect(history.get(9)?.at(-1)?.count).toBe(0); + detach(); + }); + + it('caps the per-node raw payload ring at its retention limit', () => { + const deps = makeDeps(); + const detach = attachMeshtasticModulePortSideEffects('id-1', deps); + let audio = new Map(); + deps.setAudioMessages = vi.fn((updater) => { + audio = typeof updater === 'function' ? updater(audio) : updater; + }); + + for (let i = 0; i < 60; i++) { + packetRouter.dispatch( + { + type: 'meshtastic_module_port', + payload: { + portLabel: 'audio', + from: 9, + data: new Uint8Array([i]), + timestamp: i, + }, + }, + 'id-1', + ); + } + + const entries = audio.get(9) ?? []; + expect(entries).toHaveLength(50); + expect(entries.at(-1)?.timestamp).toBe(59); + detach(); + }); + + it('ignores unknown module labels after touching liveness', () => { + const deps = makeDeps(); + const detach = attachMeshtasticModulePortSideEffects('id-1', deps); + packetRouter.dispatch( + { + type: 'meshtastic_module_port', + payload: { + portLabel: 'future-module', + from: 9, + data: new Uint8Array([1]), + timestamp: Date.now(), + }, + }, + 'id-1', + ); + expect(deps.touchLastData).toHaveBeenCalledOnce(); + expect(deps.setAudioMessages).not.toHaveBeenCalled(); detach(); }); }); diff --git a/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.ts index de4e368b4..bf72a0d3c 100644 --- a/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.ts @@ -11,8 +11,10 @@ */ import type { Dispatch, SetStateAction } from 'react'; -import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; -import type { DomainEvent, MeshtasticModulePortEvent } from '../protocols/Protocol'; +import { attachTypedPacketListener } from '../drivers/attachTypedPacketListener'; +import { toPacketPayloadBytes } from '../packetPayload'; +import type { MeshtasticModulePortEvent } from '../protocols/Protocol'; +import { appendToRingMap } from '../sessionMemoryCaps'; import type { IdentityId } from '../types'; import { appendModulePortEvent, @@ -48,25 +50,27 @@ export interface MeshtasticModulePortSideEffectsDeps { setPrivateMessages: RawMessageMapSetter; } -function toBytes(data: unknown): Uint8Array { - if (data instanceof Uint8Array) return data; - const raw = (data as { raw?: unknown } | null | undefined)?.raw; - if (raw instanceof Uint8Array) return raw; - return new Uint8Array(); -} - /** Append to a per-node ring of raw module payloads (`keep` is the retained tail length). */ function appendRawMessage( setter: RawMessageMapSetter, entry: RawModuleMessage, keep: number, ): void { - setter((prev) => { - const updated = new Map(prev); - const existing = updated.get(entry.from) ?? []; - updated.set(entry.from, [...existing.slice(-keep), entry]); - return updated; - }); + setter((prev) => appendToRingMap(prev, entry.from, entry, keep)); +} + +/** + * Meshtastic `Paxcount` reports Wi-Fi and BLE device counts separately; the + * panel plots a single total. JSON/MQTT mirrors may already carry `count`. + */ +function paxCount(data: unknown): number { + const pax = data as { count?: unknown; wifi?: unknown; ble?: unknown } | null | undefined; + if (typeof pax?.count === 'number' && Number.isFinite(pax.count)) { + return Math.max(0, Math.trunc(pax.count)); + } + const wifi = typeof pax?.wifi === 'number' && Number.isFinite(pax.wifi) ? pax.wifi : 0; + const ble = typeof pax?.ble === 'number' && Number.isFinite(pax.ble) ? pax.ble : 0; + return Math.max(0, Math.trunc(wifi) + Math.trunc(ble)); } function handleModulePort( @@ -76,7 +80,11 @@ function handleModulePort( deps.touchLastData(); const from = payload.from; const timestamp = payload.timestamp; - const entry: RawModuleMessage = { from, data: toBytes(payload.data), timestamp }; + const entry: RawModuleMessage = { + from, + data: toPacketPayloadBytes(payload.data), + timestamp, + }; switch (payload.portLabel) { case 'remoteHardware': @@ -98,13 +106,11 @@ function handleModulePort( case 'ipTunnel': appendRawMessage(deps.setIpTunnelMessages, entry, 100); break; - case 'paxcounter': { - const pax = payload.data as { count?: number } | undefined; + case 'paxcounter': deps.setPaxCounterData((prev) => - appendPaxHistory(prev, { from, count: pax?.count ?? 0, timestamp }), + appendPaxHistory(prev, { from, count: paxCount(payload.data), timestamp }), ); break; - } case 'serial': appendRawMessage(deps.setSerialMessages, entry, 100); break; @@ -141,9 +147,7 @@ export function attachMeshtasticModulePortSideEffects( identityId: IdentityId, deps: MeshtasticModulePortSideEffectsDeps, ): () => void { - const listener: PacketRouterListener = (event: DomainEvent, routedIdentityId) => { - if (routedIdentityId !== identityId || event.type !== 'meshtastic_module_port') return; - handleModulePort(event.payload, deps); - }; - return packetRouter.addListener(listener); + return attachTypedPacketListener(identityId, 'meshtastic_module_port', (payload) => { + handleModulePort(payload, deps); + }); } diff --git a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts index 175c276f4..f80dca43c 100644 --- a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts @@ -1,9 +1,9 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; import { useNodeStore } from '../../stores/nodeStore'; import { packetRouter } from '../drivers/PacketRouter'; -import { syncNodesMapToIdentityStore } from '../hydrateIdentityStoresFromDb'; import { getIdentityNode } from '../identityStoreReads'; import { MESH_PROTOCOL_STORAGE_KEY } from '../storedMeshProtocol'; import type { MeshNode } from '../types'; @@ -34,27 +34,14 @@ function emptyNode(nodeId: number): MeshNode { } function makeDeps(overrides: Partial = {}) { - const nodeMirror = new Map(); - const updateNodes = vi.fn((fn: (prev: Map) => Map) => { - const next = fn(new Map(nodeMirror)); - nodeMirror.clear(); - for (const [id, node] of next) nodeMirror.set(id, node); - syncNodesMapToIdentityStore(IDENTITY, nodeMirror); - }); const deps: MeshtasticNodeSideEffectsDeps = { connectionType: 'ble', getMyNodeNum: () => MY_NODE, getIsConfiguring: () => false, getBluetoothDeviceId: () => 'ble-1', touchLastData: vi.fn(), - updateNodes, emptyNode, - ensureNodeExists: vi.fn((nodeNum: number) => { - if (!nodeMirror.has(nodeNum)) { - nodeMirror.set(nodeNum, emptyNode(nodeNum)); - syncNodesMapToIdentityStore(IDENTITY, nodeMirror); - } - }), + ensureNodeExists: vi.fn(), maybeRequestNodeInfoForNode: vi.fn(), applyOwnNodeBatteryFromDeviceMetrics: vi.fn(), rfHeardNodeIds: { current: new Set() }, @@ -63,7 +50,7 @@ function makeDeps(overrides: Partial = {}) { setEnvironmentTelemetry: vi.fn(), ...overrides, }; - return { deps, nodeMirror }; + return { deps }; } describe('attachMeshtasticNodeSideEffects', () => { @@ -81,7 +68,7 @@ describe('attachMeshtasticNodeSideEffects', () => { }); it('applies UserPacket node_info into the hook map and marks RF-heard', () => { - const { deps, nodeMirror } = makeDeps(); + const { deps } = makeDeps(); const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); packetRouter.dispatch( { @@ -99,16 +86,14 @@ describe('attachMeshtasticNodeSideEffects', () => { ); expect(deps.touchLastData).toHaveBeenCalled(); expect(deps.rfHeardNodeIds.current.has(PEER)).toBe(true); - expect(nodeMirror.get(PEER)?.long_name).toBe('Peer Node'); - expect(nodeMirror.get(PEER)?.short_name).toBe('PEER'); + expect(getIdentityNode(IDENTITY, PEER)?.long_name).toBe('Peer Node'); + expect(getIdentityNode(IDENTITY, PEER)?.short_name).toBe('PEER'); expect(window.electronAPI.db.saveNode).toHaveBeenCalled(); detach(); }); it('patches valid position and requests NodeInfo for the sender', () => { - const { deps, nodeMirror } = makeDeps(); - nodeMirror.set(PEER, emptyNode(PEER)); - syncNodesMapToIdentityStore(IDENTITY, nodeMirror); + const { deps } = makeDeps(); const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); packetRouter.dispatch( { @@ -123,16 +108,14 @@ describe('attachMeshtasticNodeSideEffects', () => { }, IDENTITY, ); - expect(nodeMirror.get(PEER)?.latitude).toBeCloseTo(39.7392); - expect(nodeMirror.get(PEER)?.longitude).toBeCloseTo(-104.9903); + expect(getIdentityNode(IDENTITY, PEER)?.latitude).toBeCloseTo(39.7392); + expect(getIdentityNode(IDENTITY, PEER)?.longitude).toBeCloseTo(-104.9903); expect(deps.maybeRequestNodeInfoForNode).toHaveBeenCalledWith(PEER); detach(); }); it('charts deviceMetrics telemetry and updates battery on an existing node', () => { - const { deps, nodeMirror } = makeDeps(); - nodeMirror.set(PEER, emptyNode(PEER)); - syncNodesMapToIdentityStore(IDENTITY, nodeMirror); + const { deps } = makeDeps(); const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); packetRouter.dispatch( { @@ -153,6 +136,69 @@ describe('attachMeshtasticNodeSideEffects', () => { detach(); }); + it('passes the fully merged NodeDB row to diagnostics', () => { + const processNodeUpdate = vi + .spyOn(useDiagnosticsStore.getState(), 'processNodeUpdate') + .mockImplementation(() => undefined); + const { deps } = makeDeps(); + const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'node_info', + payload: { + nodeId: PEER, + longName: 'Peer Node', + shortName: 'PEER', + snr: 7.5, + hopsAway: 2, + latitude: 39.7, + longitude: -105, + lastHeardAt: Date.now(), + fromUserPacket: false, + }, + }, + IDENTITY, + ); + expect(processNodeUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + node_id: PEER, + snr: 7.5, + hops_away: 2, + latitude: 39.7, + longitude: -105, + }), + null, + MY_NODE, + expect.anything(), + ); + detach(); + }); + + it('creates an environment telemetry row from canonical nodeStore state', () => { + const { deps } = makeDeps(); + const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'telemetry', + payload: { + nodeId: PEER, + timestamp: Date.now(), + variantCase: 'environmentMetrics', + temperature: 21.5, + relativeHumidity: 45, + }, + }, + IDENTITY, + ); + expect(getIdentityNode(IDENTITY, PEER)).toEqual( + expect.objectContaining({ + env_temperature: 21.5, + env_humidity: 45, + }), + ); + detach(); + }); + it('ignores events routed for a different identity', () => { const { deps } = makeDeps(); const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); diff --git a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts index 9aa16b9e4..78614f8d3 100644 --- a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts @@ -6,14 +6,15 @@ * TelemetryPacket already, so the runtime no longer keeps a second * `device.events.on*` subscription for each of them. `PacketRouter` writes the * canonical `nodeStore` record before listeners run; everything here is the - * extra runtime work that record does not cover — hook-local node map patches, + * extra runtime work that record does not cover — enriched node patches, * diagnostics, position history, telemetry charts, and the BLE/serial display - * name caches. + * name caches. Enriched patches write back to `nodeStore` directly so React + * state never acts as a second node authority. * * Failure point: every branch is additive. A missing node row skips the patch, * localStorage name-cache writes are swallowed (non-critical), and SQLite - * `saveNode` is fire-and-forget — chat and node ingest have already been - * persisted by `PacketRouter`. + * `saveNode` is fire-and-forget with a rejection handler — chat and node + * ingest have already updated the canonical store before persistence begins. */ import type { Dispatch, RefObject, SetStateAction } from 'react'; @@ -21,10 +22,11 @@ import { meshtasticShortNameAfterClearingDefault, preferNonEmptyTrimmedString, } from '../../../shared/nodeNameUtils'; -import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; +import { upsertNodeRecord } from '../../stores/nodeStore'; import { usePositionHistoryStore } from '../../stores/positionHistoryStore'; import { validateCoords } from '../coordUtils'; -import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; +import { attachTypedPacketListeners } from '../drivers/attachTypedPacketListener'; +import { errLikeToLogString } from '../errLikeToLogString'; import { shouldPreserveStaticGpsForSelfNode } from '../gpsSource'; import { getIdentityNode } from '../identityStoreReads'; import { @@ -33,15 +35,11 @@ import { mergeMeshtasticUserPacketLastHeard, } from '../meshtasticLastHeard'; import { parseStoredJson } from '../parseStoredJson'; -import type { - DomainEvent, - NodeInfoEvent, - PositionEvent, - TelemetryEvent, -} from '../protocols/Protocol'; +import type { NodeInfoEvent, PositionEvent, TelemetryEvent } from '../protocols/Protocol'; import { MESHTASTIC_CAPABILITIES } from '../radio/BaseRadioProvider'; import { LAST_SERIAL_PORT_KEY } from '../serialPortSignature'; -import { getStoredMeshProtocol } from '../storedMeshProtocol'; +import { MAX_TELEMETRY_POINTS } from '../sessionMemoryCaps'; +import { meshNodeToNodeRecord } from '../storeRecordAdapters'; import type { ConnectionType, EnvironmentTelemetryPoint, @@ -49,9 +47,10 @@ import type { MeshNode, TelemetryPoint, } from '../types'; +import { processMeshtasticNodeDiagnostics } from './meshtasticProcessNodeDiagnostics'; -const MAX_TELEMETRY_POINTS = 50; const ROLE_CLIENT_MUTE = 1; +const MAX_TRANSPORT_DISPLAY_NAME_CACHE_ENTRIES = 64; const BLE_DEVICE_NAMES_KEY = 'mesh-client:bleDeviceNames'; const SERIAL_PORT_NODE_NAMES_KEY = 'mesh-client:serialPortNodeNames'; @@ -64,7 +63,6 @@ export interface MeshtasticNodeSideEffectsDeps { /** Web Bluetooth device id of the active BLE link, for the short-name cache. */ getBluetoothDeviceId: () => string | undefined; touchLastData: () => void; - updateNodes: (updater: (prev: Map) => Map) => void; emptyNode: (nodeId: number) => MeshNode; ensureNodeExists: (nodeNum: number, source: 'rf' | 'mqtt') => void; /** Ask an unknown sender for its NodeInfo (throttled by the caller). */ @@ -86,16 +84,11 @@ function meshtasticPublicKeyHex(bytes: Uint8Array | undefined): string | undefin .join(''); } -function processNodeDiagnostics( - identityId: IdentityId, - node: MeshNode, - myNodeNum: number, - homeNode: MeshNode | null, -): void { - if (getStoredMeshProtocol() !== 'meshtastic') return; - useDiagnosticsStore - .getState() - .processNodeUpdate(node, homeNode, myNodeNum, MESHTASTIC_CAPABILITIES); +function saveNode(identityId: IdentityId, node: MeshNode): void { + upsertNodeRecord(identityId, meshNodeToNodeRecord(node)); + void window.electronAPI.db.saveNode(node).catch((e: unknown) => { + console.debug('[meshtasticNodeSideEffects] saveNode failed ' + errLikeToLogString(e)); + }); } function cacheShortNameByKey(storageKey: string, cacheKey: string, shortName: string): void { @@ -105,50 +98,51 @@ function cacheShortNameByKey(storageKey: string, cacheKey: string, shortName: st localStorage.getItem(storageKey), `meshtasticNodeSideEffects ${storageKey}`, ) ?? {}; - cache[cacheKey] = shortName; - localStorage.setItem(storageKey, JSON.stringify(cache)); + const entries = Object.entries(cache).filter(([key]) => key !== cacheKey); + entries.push([cacheKey, shortName]); + const bounded = Object.fromEntries(entries.slice(-MAX_TRANSPORT_DISPLAY_NAME_CACHE_ENTRIES)); + localStorage.setItem(storageKey, JSON.stringify(bounded)); } catch { // catch-no-log-ok localStorage write for transport display-name cache — non-critical } } /** UserPacket identity: live long/short name, hardware, role, and public key. */ -function handleUserPacketNodeInfo(info: NodeInfoEvent, deps: MeshtasticNodeSideEffectsDeps): void { +function handleUserPacketNodeInfo( + identityId: IdentityId, + info: NodeInfoEvent, + deps: MeshtasticNodeSideEffectsDeps, +): void { const nodeNum = info.nodeId; deps.rfHeardNodeIds.current.add(nodeNum); - deps.updateNodes((prev) => { - const updated = new Map(prev); - const existing = updated.get(nodeNum) ?? deps.emptyNode(nodeNum); - const long_name = preferNonEmptyTrimmedString(info.longName, existing.long_name, { - nodeId: nodeNum, - }); - const short_name = meshtasticShortNameAfterClearingDefault( - long_name, - preferNonEmptyTrimmedString(info.shortName, existing.short_name), - nodeNum, - ); - const node: MeshNode = { - ...existing, - node_id: nodeNum, - long_name, - short_name, - hw_model: info.hwModel ?? existing.hw_model, - role: info.role ?? existing.role, - public_key_hex: meshtasticPublicKeyHex(info.publicKey) ?? existing.public_key_hex, - // During configure, skip rxTime bumps (NodeDB replay). After configure, use mesh rxTime. - last_heard: mergeMeshtasticUserPacketLastHeard( - existing.last_heard || 0, - info.lastHeardAt ?? 0, - deps.getIsConfiguring(), - ), - heard_via_mqtt_only: false, - via_mqtt: false, - source: 'rf', - }; - updated.set(nodeNum, node); - void window.electronAPI.db.saveNode(node); - return updated; + const existing = getIdentityNode(identityId, nodeNum) ?? deps.emptyNode(nodeNum); + const long_name = preferNonEmptyTrimmedString(info.longName, existing.long_name, { + nodeId: nodeNum, }); + const short_name = meshtasticShortNameAfterClearingDefault( + long_name, + preferNonEmptyTrimmedString(info.shortName, existing.short_name), + nodeNum, + ); + const node: MeshNode = { + ...existing, + node_id: nodeNum, + long_name, + short_name, + hw_model: info.hwModel ?? existing.hw_model, + role: info.role ?? existing.role, + public_key_hex: meshtasticPublicKeyHex(info.publicKey) ?? existing.public_key_hex, + // During configure, skip rxTime bumps (NodeDB replay). After configure, use mesh rxTime. + last_heard: mergeMeshtasticUserPacketLastHeard( + existing.last_heard || 0, + info.lastHeardAt ?? 0, + deps.getIsConfiguring(), + ), + heard_via_mqtt_only: false, + via_mqtt: false, + source: 'rf', + }; + saveNode(identityId, node); if (nodeNum === deps.getMyNodeNum()) { deps.setDeviceOwner({ longName: preferNonEmptyTrimmedString(info.longName, ''), @@ -193,96 +187,87 @@ function handleNodeDbNodeInfo( const myNodeNum = deps.getMyNodeNum(); const isSelf = nodeNum === myNodeNum; deps.rfHeardNodeIds.current.add(nodeNum); - const prevOwnRole = isSelf ? getIdentityNode(identityId, nodeNum)?.role : undefined; - const hasPosition = info.latitude != null && info.longitude != null; - - deps.updateNodes((prev) => { - const updated = new Map(prev); - const existing = updated.get(nodeNum) ?? deps.emptyNode(nodeNum); - - let newLat = existing.latitude; - let newLon = existing.longitude; - const newAlt = info.altitude ?? existing.altitude; - let posWarn: string | undefined = existing.lastPositionWarning; - - if (hasPosition && !shouldPreserveStaticGpsForSelfNode(nodeNum, myNodeNum)) { - const r = validateCoords(info.latitude!, info.longitude!); - if (r.valid) { - newLat = info.latitude!; - newLon = info.longitude!; - posWarn = undefined; - } else if (!isSelf || (existing.latitude === 0 && existing.longitude === 0)) { - posWarn = r.warning; - } + const storeExisting = getIdentityNode(identityId, nodeNum) ?? deps.emptyNode(nodeNum); + const prevOwnRole = isSelf ? storeExisting.role : undefined; + const positionCoords = + info.latitude != null && info.longitude != null + ? { latitude: info.latitude, longitude: info.longitude } + : null; + let newLat = storeExisting.latitude; + let newLon = storeExisting.longitude; + const newAlt = info.altitude ?? storeExisting.altitude; + let posWarn: string | undefined = storeExisting.lastPositionWarning; + if (positionCoords && !shouldPreserveStaticGpsForSelfNode(nodeNum, myNodeNum)) { + const { latitude, longitude } = positionCoords; + const r = validateCoords(latitude, longitude); + if (r.valid) { + newLat = latitude; + newLon = longitude; + posWarn = undefined; + } else if (!isSelf || (storeExisting.latitude === 0 && storeExisting.longitude === 0)) { + posWarn = r.warning; } - - const lastHeardMs = computeNodeInfoLastHeardMs(info.lastHeardAt, existing.last_heard, isSelf); - const lastHeardStale = - lastHeardMs > 0 && Date.now() - lastHeardMs > MESHTASTIC_CAPABILITIES.nodeStaleThresholdMs; - - const long_name = preferNonEmptyTrimmedString(info.longName, existing.long_name, { - nodeId: nodeNum, - }); - const short_name = meshtasticShortNameAfterClearingDefault( - long_name, - preferNonEmptyTrimmedString(info.shortName, existing.short_name), - nodeNum, - ); - const node: MeshNode = { - ...existing, - node_id: nodeNum, - long_name, - short_name, - hw_model: info.hwModel ?? existing.hw_model, - snr: info.snr ?? existing.snr, - battery: info.batteryLevel ?? existing.battery, - last_heard: lastHeardMs, - latitude: newLat, - longitude: newLon, - role: info.role ?? existing.role, - // Stale NodeInfo still carries cached hops; don't show hop count for ghosts. - hops_away: isSelf - ? (info.hopsAway ?? 0) - : lastHeardStale - ? undefined - : (info.hopsAway ?? existing.hops_away), - via_mqtt: info.viaMqtt ?? false, - voltage: info.voltage ?? existing.voltage, - channel_utilization: info.channelUtilization ?? existing.channel_utilization, - air_util_tx: info.airUtilTx ?? existing.air_util_tx, - altitude: newAlt, - heard_via_mqtt_only: false, - source: 'rf', - lastPositionWarning: posWarn, - }; - updated.set(nodeNum, node); - void window.electronAPI.db.saveNode(node); - return updated; + } + const lastHeardMs = computeNodeInfoLastHeardMs( + info.lastHeardAt, + storeExisting.last_heard, + isSelf, + ); + const lastHeardStale = + lastHeardMs > 0 && Date.now() - lastHeardMs > MESHTASTIC_CAPABILITIES.nodeStaleThresholdMs; + const long_name = preferNonEmptyTrimmedString(info.longName, storeExisting.long_name, { + nodeId: nodeNum, }); + const short_name = meshtasticShortNameAfterClearingDefault( + long_name, + preferNonEmptyTrimmedString(info.shortName, storeExisting.short_name), + nodeNum, + ); + const node: MeshNode = { + ...storeExisting, + node_id: nodeNum, + long_name, + short_name, + hw_model: info.hwModel ?? storeExisting.hw_model, + snr: info.snr ?? storeExisting.snr, + battery: info.batteryLevel ?? storeExisting.battery, + last_heard: lastHeardMs, + latitude: newLat, + longitude: newLon, + role: info.role ?? storeExisting.role, + hops_away: isSelf + ? (info.hopsAway ?? 0) + : lastHeardStale + ? undefined + : (info.hopsAway ?? storeExisting.hops_away), + via_mqtt: info.viaMqtt ?? false, + voltage: info.voltage ?? storeExisting.voltage, + channel_utilization: info.channelUtilization ?? storeExisting.channel_utilization, + air_util_tx: info.airUtilTx ?? storeExisting.air_util_tx, + altitude: newAlt, + heard_via_mqtt_only: false, + source: 'rf', + lastPositionWarning: posWarn, + }; + saveNode(identityId, node); if (isSelf && info.batteryLevel !== undefined) { deps.applyOwnNodeBatteryFromDeviceMetrics(info.batteryLevel); } - if ( - isSelf && - getIdentityNode(identityId, nodeNum)?.role === ROLE_CLIENT_MUTE && - prevOwnRole !== ROLE_CLIENT_MUTE - ) { + if (isSelf && node.role === ROLE_CLIENT_MUTE && prevOwnRole !== ROLE_CLIENT_MUTE) { console.info( '[meshtasticNodeSideEffects] Device role is Client Mute — position reports to device suppressed', ); } - const updatedRfNode = getIdentityNode(identityId, nodeNum); - if (updatedRfNode) { - processNodeDiagnostics( - identityId, - updatedRfNode, - myNodeNum, - getIdentityNode(identityId, myNodeNum) ?? null, - ); - } - if (hasPosition && validateCoords(info.latitude!, info.longitude!).valid) { - usePositionHistoryStore.getState().recordPosition(nodeNum, info.latitude!, info.longitude!); + processMeshtasticNodeDiagnostics( + node, + myNodeNum, + isSelf ? node : (getIdentityNode(identityId, myNodeNum) ?? null), + ); + if (positionCoords && validateCoords(positionCoords.latitude, positionCoords.longitude).valid) { + usePositionHistoryStore + .getState() + .recordPosition(nodeNum, positionCoords.latitude, positionCoords.longitude); } cacheSelfNodeTransportName(info, deps); } @@ -295,7 +280,7 @@ function handleNodeInfo( deps.touchLastData(); if (!info.nodeId) return; if (info.fromUserPacket) { - handleUserPacketNodeInfo(info, deps); + handleUserPacketNodeInfo(identityId, info, deps); return; } handleNodeDbNodeInfo(identityId, info, deps); @@ -308,6 +293,7 @@ function handlePosition( ): void { deps.touchLastData(); const nodeNum = position.nodeId; + if (position.latitude == null || position.longitude == null) return; const myNodeNum = deps.getMyNodeNum(); if (nodeNum !== 0) { deps.rfHeardNodeIds.current.add(nodeNum); @@ -315,23 +301,16 @@ function handlePosition( const r = validateCoords(position.latitude, position.longitude); if (!r.valid) { - deps.updateNodes((prev) => { - const updated = new Map(prev); - const existing = updated.get(nodeNum) ?? deps.emptyNode(nodeNum); - // Don't flag our own node if we have valid fallback coords - if (nodeNum === myNodeNum && (existing.latitude != null || existing.longitude != null)) { - return prev; - } - updated.set(nodeNum, { - ...existing, - lastPositionWarning: r.warning, - last_heard: mergeMeshtasticLivePacketLastHeard( - existing.last_heard || 0, - Date.now(), - deps.getIsConfiguring(), - ), - }); - return updated; + const existing = getIdentityNode(identityId, nodeNum) ?? deps.emptyNode(nodeNum); + if (nodeNum === myNodeNum && (existing.latitude != null || existing.longitude != null)) return; + saveNode(identityId, { + ...existing, + lastPositionWarning: r.warning, + last_heard: mergeMeshtasticLivePacketLastHeard( + existing.last_heard || 0, + Date.now(), + deps.getIsConfiguring(), + ), }); return; } @@ -356,19 +335,15 @@ function handlePosition( heard_via_mqtt_only: false, via_mqtt: false, }; - deps.updateNodes((prev) => { - const updated = new Map(prev); - updated.set(nodeNum, node); - void window.electronAPI.db.saveNode(node); - return updated; - }); - processNodeDiagnostics(identityId, node, myNodeNum, homeNode); + saveNode(identityId, node); + processMeshtasticNodeDiagnostics(node, myNodeNum, homeNode); usePositionHistoryStore.getState().recordPosition(nodeNum, position.latitude, position.longitude); deps.maybeRequestNodeInfoForNode(nodeNum); } /** Environment sensor variant: chart point plus the node row's `env_*` columns. */ function handleEnvironmentTelemetry( + identityId: IdentityId, telemetry: TelemetryEvent, deps: MeshtasticNodeSideEffectsDeps, ): void { @@ -391,29 +366,24 @@ function handleEnvironmentTelemetry( rainfall24h: telemetry.rainfall24h, }; deps.setEnvironmentTelemetry((prev) => [...prev, point].slice(-MAX_TELEMETRY_POINTS)); - deps.updateNodes((prev) => { - const existing = prev.get(nodeNum); - if (!existing) return prev; - const updated = new Map(prev); - updated.set(nodeNum, { - ...existing, - env_temperature: telemetry.temperature ?? existing.env_temperature, - env_humidity: telemetry.relativeHumidity ?? existing.env_humidity, - env_pressure: telemetry.barometricPressure ?? existing.env_pressure, - env_iaq: telemetry.iaq ?? existing.env_iaq, - env_lux: telemetry.lux ?? existing.env_lux, - env_wind_speed: telemetry.windSpeed ?? existing.env_wind_speed, - env_wind_direction: telemetry.windDirection ?? existing.env_wind_direction, - last_heard: mergeMeshtasticLivePacketLastHeard( - existing.last_heard || 0, - telemetry.timestamp, - deps.getIsConfiguring(), - ), - source: 'rf', - heard_via_mqtt_only: false, - via_mqtt: false, - }); - return updated; + const existing = getIdentityNode(identityId, nodeNum) ?? deps.emptyNode(nodeNum); + saveNode(identityId, { + ...existing, + env_temperature: telemetry.temperature ?? existing.env_temperature, + env_humidity: telemetry.relativeHumidity ?? existing.env_humidity, + env_pressure: telemetry.barometricPressure ?? existing.env_pressure, + env_iaq: telemetry.iaq ?? existing.env_iaq, + env_lux: telemetry.lux ?? existing.env_lux, + env_wind_speed: telemetry.windSpeed ?? existing.env_wind_speed, + env_wind_direction: telemetry.windDirection ?? existing.env_wind_direction, + last_heard: mergeMeshtasticLivePacketLastHeard( + existing.last_heard || 0, + telemetry.timestamp, + deps.getIsConfiguring(), + ), + source: 'rf', + heard_via_mqtt_only: false, + via_mqtt: false, }); } @@ -438,13 +408,8 @@ function handleLocalStatsTelemetry( heard_via_mqtt_only: false, via_mqtt: false, }; - deps.updateNodes((prev) => { - const updated = new Map(prev); - updated.set(myNodeNum, node); - void window.electronAPI.db.saveNode(node); - return updated; - }); - processNodeDiagnostics(identityId, node, myNodeNum, node); + saveNode(identityId, node); + processMeshtasticNodeDiagnostics(node, myNodeNum, node); } /** Device metrics (and any other variant): battery chart point plus node battery. */ @@ -464,32 +429,25 @@ function handleDeviceMetricsTelemetry( if (telemetry.batteryLevel == null || !nodeNum) return; deps.ensureNodeExists(nodeNum, 'rf'); - const existing = getIdentityNode(identityId, nodeNum); - if (existing) { - const node: MeshNode = { - ...existing, - battery: telemetry.batteryLevel, - last_heard: mergeMeshtasticLivePacketLastHeard( - existing.last_heard || 0, - telemetry.timestamp, - deps.getIsConfiguring(), - ), - source: 'rf', - heard_via_mqtt_only: false, - via_mqtt: false, - }; - deps.updateNodes((prev) => { - const updated = new Map(prev); - updated.set(nodeNum, node); - return updated; - }); - processNodeDiagnostics( - identityId, - node, - myNodeNum, - getIdentityNode(identityId, myNodeNum) ?? null, - ); - } + const existing = getIdentityNode(identityId, nodeNum) ?? deps.emptyNode(nodeNum); + const node: MeshNode = { + ...existing, + battery: telemetry.batteryLevel, + last_heard: mergeMeshtasticLivePacketLastHeard( + existing.last_heard || 0, + telemetry.timestamp, + deps.getIsConfiguring(), + ), + source: 'rf', + heard_via_mqtt_only: false, + via_mqtt: false, + }; + saveNode(identityId, node); + processMeshtasticNodeDiagnostics( + node, + myNodeNum, + nodeNum === myNodeNum ? node : (getIdentityNode(identityId, myNodeNum) ?? null), + ); deps.maybeRequestNodeInfoForNode(nodeNum); if (nodeNum === myNodeNum) { deps.applyOwnNodeBatteryFromDeviceMetrics(telemetry.batteryLevel); @@ -506,7 +464,7 @@ function handleTelemetry( // `deviceMetrics`, so there is nothing to chart or patch. if (!telemetry.variantCase) return; if (telemetry.variantCase === 'environmentMetrics') { - handleEnvironmentTelemetry(telemetry, deps); + handleEnvironmentTelemetry(identityId, telemetry, deps); return; } if (telemetry.variantCase === 'localStats' && telemetry.nodeId === deps.getMyNodeNum()) { @@ -521,21 +479,15 @@ export function attachMeshtasticNodeSideEffects( identityId: IdentityId, deps: MeshtasticNodeSideEffectsDeps, ): () => void { - const listener: PacketRouterListener = (event: DomainEvent, routedIdentityId) => { - if (routedIdentityId !== identityId) return; - switch (event.type) { - case 'node_info': - handleNodeInfo(identityId, event.payload, deps); - break; - case 'position': - handlePosition(identityId, event.payload, deps); - break; - case 'telemetry': - handleTelemetry(identityId, event.payload, deps); - break; - default: - break; - } - }; - return packetRouter.addListener(listener); + return attachTypedPacketListeners(identityId, { + node_info: (payload) => { + handleNodeInfo(identityId, payload, deps); + }, + position: (payload) => { + handlePosition(identityId, payload, deps); + }, + telemetry: (payload) => { + handleTelemetry(identityId, payload, deps); + }, + }); } diff --git a/src/renderer/lib/meshtastic/meshtasticProcessNodeDiagnostics.ts b/src/renderer/lib/meshtastic/meshtasticProcessNodeDiagnostics.ts new file mode 100644 index 000000000..b780a31ba --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticProcessNodeDiagnostics.ts @@ -0,0 +1,15 @@ +import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; +import { MESHTASTIC_CAPABILITIES } from '../radio/BaseRadioProvider'; +import { getStoredMeshProtocol } from '../storedMeshProtocol'; +import type { MeshNode } from '../types'; + +export function processMeshtasticNodeDiagnostics( + node: MeshNode, + myNodeNum: number, + homeNode: MeshNode | null, +): void { + if (getStoredMeshProtocol() !== 'meshtastic') return; + useDiagnosticsStore + .getState() + .processNodeUpdate(node, homeNode, myNodeNum, MESHTASTIC_CAPABILITIES); +} diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts index 9d9f797f9..106371b16 100644 --- a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts @@ -37,22 +37,15 @@ function emptyNode(nodeId: number): MeshNode { function makeDeps(overrides: Partial = {}) { const nodeMirror = new Map([[PEER, emptyNode(PEER)]]); syncNodesMapToIdentityStore(IDENTITY, nodeMirror); - const updateNodes = vi.fn((fn: (prev: Map) => Map) => { - const next = fn(new Map(nodeMirror)); - nodeMirror.clear(); - for (const [id, node] of next) nodeMirror.set(id, node); - syncNodesMapToIdentityStore(IDENTITY, nodeMirror); - }); const deps: MeshtasticRawPacketSideEffectsDeps = { getMyNodeNum: () => MY_NODE, getIsConfiguring: () => false, - updateNodes, setRawPackets: vi.fn(), setSignalTelemetry: vi.fn(), touchLastData: vi.fn(), ...overrides, }; - return { deps, nodeMirror }; + return { deps }; } describe('attachMeshtasticRawPacketSideEffects', () => { diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts index a1b0cc399..cf100b406 100644 --- a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts @@ -12,25 +12,26 @@ import type { Dispatch, SetStateAction } from 'react'; import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; -import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; +import { upsertNodeRecord } from '../../stores/nodeStore'; +import { attachTypedPacketListener } from '../drivers/attachTypedPacketListener'; import { errLikeToLogString } from '../errLikeToLogString'; import { getIdentityNode } from '../identityStoreReads'; import { mergeMeshtasticLivePacketLastHeard } from '../meshtasticLastHeard'; -import type { DomainEvent, RawPacketEntry } from '../protocols/Protocol'; +import type { RawPacketEntry } from '../protocols/Protocol'; import { MESHTASTIC_CAPABILITIES } from '../radio/BaseRadioProvider'; import { MAX_RAW_PACKET_LOG_ENTRIES, type MeshtasticRawPacketEntry, } from '../rawPacketLogConstants'; +import { MAX_TELEMETRY_POINTS } from '../sessionMemoryCaps'; import { getStoredMeshProtocol } from '../storedMeshProtocol'; +import { meshNodeToNodeRecord } from '../storeRecordAdapters'; import type { IdentityId, MeshNode, TelemetryPoint } from '../types'; - -const MAX_TELEMETRY_POINTS = 50; +import { processMeshtasticNodeDiagnostics } from './meshtasticProcessNodeDiagnostics'; export interface MeshtasticRawPacketSideEffectsDeps { getMyNodeNum: () => number; getIsConfiguring: () => boolean; - updateNodes: (updater: (prev: Map) => Map) => void; setRawPackets: Dispatch>; setSignalTelemetry: Dispatch>; touchLastData: () => void; @@ -104,22 +105,11 @@ function applySignalAndHops( heard_via_mqtt_only: false, via_mqtt: payload.viaMqtt, }; - deps.updateNodes((prev) => { - const updated = new Map(prev); - updated.set(from, node); - void window.electronAPI.db.saveNode(node); - return updated; + upsertNodeRecord(identityId, meshNodeToNodeRecord(node)); + void window.electronAPI.db.saveNode(node).catch((e: unknown) => { + console.debug('[meshtasticRawPacketSideEffects] saveNode failed ' + errLikeToLogString(e)); }); - if (getStoredMeshProtocol() === 'meshtastic') { - useDiagnosticsStore - .getState() - .processNodeUpdate( - node, - getIdentityNode(identityId, myNodeNum) ?? null, - myNodeNum, - MESHTASTIC_CAPABILITIES, - ); - } + processMeshtasticNodeDiagnostics(node, myNodeNum, getIdentityNode(identityId, myNodeNum) ?? null); } function handleRawPacket( @@ -131,8 +121,7 @@ function handleRawPacket( const from = payload.fromNodeId; if (!from) return; - const isMeshtasticTab = getStoredMeshProtocol() === 'meshtastic'; - if (isMeshtasticTab) { + if (getStoredMeshProtocol() === 'meshtastic') { appendRawPacketLog(payload, deps); if (typeof payload.portnum === 'number') { useDiagnosticsStore.getState().recordNoisePort(from, payload.portnum); @@ -164,9 +153,7 @@ export function attachMeshtasticRawPacketSideEffects( identityId: IdentityId, deps: MeshtasticRawPacketSideEffectsDeps, ): () => void { - const listener: PacketRouterListener = (event: DomainEvent, routedIdentityId) => { - if (routedIdentityId !== identityId || event.type !== 'raw_packet') return; - handleRawPacket(identityId, event.payload, deps); - }; - return packetRouter.addListener(listener); + return attachTypedPacketListener(identityId, 'raw_packet', (payload) => { + handleRawPacket(identityId, payload, deps); + }); } diff --git a/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.test.ts index d011bc465..68a681452 100644 --- a/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.test.ts @@ -1,4 +1,14 @@ // @vitest-environment jsdom +vi.mock('@/renderer/lib/i18n', () => ({ + default: { + t: (key: string, opts?: { sender?: string }) => { + if (key === 'chatPanel.notificationDmTitle') return `DM from ${opts?.sender ?? ''}`; + if (key === 'chatPanel.notificationMessageTitle') return `Message from ${opts?.sender ?? ''}`; + return key; + }, + }, +})); + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { packetRouter } from '../drivers/PacketRouter'; @@ -104,4 +114,63 @@ describe('attachMeshtasticRouterSideEffects', () => { expect(applyForeignLoraFromLog).toHaveBeenCalled(); detach(); }); + + it('uses i18n for hidden-window notification titles', () => { + const NotificationMock = vi.fn(); + vi.stubGlobal('Notification', NotificationMock); + Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); + + const deps: MeshtasticRouterSideEffectsDeps = { + getMyNodeNum: () => 1, + getMqttStatus: () => 'disconnected', + getChannelConfigs: () => [], + hasRfDevice: () => true, + getNodeName: () => 'Alpha', + registerMqttEchoPacketId: vi.fn(), + requestNodeInfoForNode: vi.fn(), + applyForeignLoraFromLog: vi.fn(), + applyRoutingErrorFromLog: vi.fn(), + setFirmwareVersion: vi.fn(), + }; + const detach = attachMeshtasticRouterSideEffects('mesh-id', deps); + packetRouter.dispatch( + { + type: 'text_message', + payload: { + id: '7:2', + from: 7, + to: 1, + payload: 'secret dm', + channelIndex: 0, + timestamp: Date.now(), + }, + }, + 'mesh-id', + ); + expect(NotificationMock).toHaveBeenCalledWith('DM from Alpha', { + body: 'secret dm', + silent: true, + }); + + NotificationMock.mockClear(); + packetRouter.dispatch( + { + type: 'text_message', + payload: { + id: '7:3', + from: 7, + to: 0xffffffff, + payload: 'hello channel', + channelIndex: 0, + timestamp: Date.now(), + }, + }, + 'mesh-id', + ); + expect(NotificationMock).toHaveBeenCalledWith('Message from Alpha', { + body: 'hello channel', + silent: true, + }); + detach(); + }); }); diff --git a/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.ts index b92cc251b..43954fec2 100644 --- a/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRouterSideEffects.ts @@ -10,18 +10,25 @@ * logged and never block chat ingest, which has already been persisted by * `PacketRouter` + `meshtasticIngest`. */ -import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; +import i18n from '@/renderer/lib/i18n'; + +import { attachTypedPacketListeners } from '../drivers/attachTypedPacketListener'; import { errLikeToLogString } from '../errLikeToLogString'; import { loadMeshtasticMqttManualChannelPsks, resolveMeshtasticMqttPublishFieldsForChannel, } from '../meshtasticMqttPublish'; +import { truncatePacketText } from '../packetPayload'; import type { DomainEvent } from '../protocols/Protocol'; import { stripControlCharacters } from '../stripControlCharacters'; import type { IdentityId, MQTTStatus } from '../types'; const BROADCAST_ADDR = 0xffffffff; +/** OS notification field caps (sender line / body preview). */ +const NOTIFICATION_SENDER_MAX_CHARS = 120; +const NOTIFICATION_BODY_MAX_CHARS = 100; + export interface MeshtasticRouterChannelConfig { index: number; name: string; @@ -49,28 +56,40 @@ export interface MeshtasticRouterSideEffectsDeps { setFirmwareVersion: (firmwareVersion: string) => void; } -type TextMessageEvent = Extract; -type WaypointDomainEvent = Extract; +type TextMessagePayload = Extract['payload']; +type WaypointPayload = Extract['payload']; -/** Gateway uplink: forward inbound RF broadcasts to MQTT when the channel allows it. */ -function uplinkTextToMqtt(event: TextMessageEvent, deps: MeshtasticRouterSideEffectsDeps): void { - if (deps.getMqttStatus() !== 'connected') return; - const channelIndex = event.payload.channelIndex; +function resolveMqttUplink( + channelIndex: number, + deps: MeshtasticRouterSideEffectsDeps, +): ReturnType | undefined { + if (deps.getMqttStatus() !== 'connected') return undefined; const channelConfigs = deps.getChannelConfigs(); - if (!channelConfigs.find((c) => c.index === channelIndex)?.uplinkEnabled) return; - + if (!channelConfigs.find((config) => config.index === channelIndex)?.uplinkEnabled) { + return undefined; + } const uplink = resolveMeshtasticMqttPublishFieldsForChannel( channelIndex, channelConfigs, loadMeshtasticMqttManualChannelPsks(), deps.hasRfDevice() ? undefined : { preferManualOverRadio: true }, ); - if (!uplink.channelName) return; + return uplink.channelName ? uplink : undefined; +} + +/** Gateway uplink: forward inbound RF broadcasts to MQTT when the channel allows it. */ +function uplinkTextToMqtt( + message: TextMessagePayload, + deps: MeshtasticRouterSideEffectsDeps, +): void { + const channelIndex = message.channelIndex; + const uplink = resolveMqttUplink(channelIndex, deps); + if (!uplink) return; window.electronAPI.mqtt .publish({ - text: event.payload.payload, - from: event.payload.from, + text: message.payload, + from: message.from, channel: channelIndex, destination: BROADCAST_ADDR, channelName: uplink.channelName, @@ -78,7 +97,7 @@ function uplinkTextToMqtt(event: TextMessageEvent, deps: MeshtasticRouterSideEff publishJsonMirror: uplink.publishJsonMirror, }) .then((packetId) => { - deps.registerMqttEchoPacketId(event.payload.from, packetId); + deps.registerMqttEchoPacketId(message.from, packetId); }) .catch((e: unknown) => { console.debug( @@ -92,13 +111,25 @@ function uplinkTextToMqtt(event: TextMessageEvent, deps: MeshtasticRouterSideEff * OS toast when the window is hidden. Silent on purpose: App.tsx owns typed Web * Audio via chatNotifications.ts, so sounding here would double-notify. */ -function notifyHiddenWindow(event: TextMessageEvent, deps: MeshtasticRouterSideEffectsDeps): void { +function notifyHiddenWindow( + message: TextMessagePayload, + deps: MeshtasticRouterSideEffectsDeps, +): void { if (!document.hidden) return; try { - const safeSender = stripControlCharacters(deps.getNodeName(event.payload.from)).slice(0, 120); - const isDirect = event.payload.to !== 0 && event.payload.to !== BROADCAST_ADDR; - new Notification(isDirect ? `DM from ${safeSender}` : `Message from ${safeSender}`, { - body: stripControlCharacters(event.payload.payload).slice(0, 100), + const safeSender = truncatePacketText( + stripControlCharacters(deps.getNodeName(message.from)), + NOTIFICATION_SENDER_MAX_CHARS, + ); + const isDirect = message.to !== 0 && message.to !== BROADCAST_ADDR; + const title = isDirect + ? i18n.t('chatPanel.notificationDmTitle', { sender: safeSender }) + : i18n.t('chatPanel.notificationMessageTitle', { sender: safeSender }); + new Notification(title, { + body: truncatePacketText( + stripControlCharacters(message.payload), + NOTIFICATION_BODY_MAX_CHARS, + ), silent: true, }); } catch (e) { @@ -108,17 +139,20 @@ function notifyHiddenWindow(event: TextMessageEvent, deps: MeshtasticRouterSideE } } -function handleTextMessage(event: TextMessageEvent, deps: MeshtasticRouterSideEffectsDeps): void { - const isEcho = event.payload.from === deps.getMyNodeNum(); +function handleTextMessage( + message: TextMessagePayload, + deps: MeshtasticRouterSideEffectsDeps, +): void { + const isEcho = message.from === deps.getMyNodeNum(); if (isEcho) return; - deps.requestNodeInfoForNode(event.payload.from); - if (event.payload.tapback) return; + deps.requestNodeInfoForNode(message.from); + if (message.tapback) return; - const isDirect = event.payload.to !== 0 && event.payload.to !== BROADCAST_ADDR; + const isDirect = message.to !== 0 && message.to !== BROADCAST_ADDR; // DMs are never uplinked (privacy); reactions would duplicate the parent row. - if (!isDirect) uplinkTextToMqtt(event, deps); - notifyHiddenWindow(event, deps); + if (!isDirect) uplinkTextToMqtt(message, deps); + notifyHiddenWindow(message, deps); } /** @@ -126,28 +160,18 @@ function handleTextMessage(event: TextMessageEvent, deps: MeshtasticRouterSideEf * stored by `PacketRouter`; only the MQTT relay lives here. */ function uplinkWaypointToMqtt( - event: WaypointDomainEvent, + waypoint: WaypointPayload, deps: MeshtasticRouterSideEffectsDeps, ): void { - const waypoint = event.payload; if (!waypoint.id) return; - if (deps.getMqttStatus() !== 'connected') return; const from = waypoint.from; if (!from || from === deps.getMyNodeNum()) return; const to = (waypoint.to ?? BROADCAST_ADDR) >>> 0; if (to !== BROADCAST_ADDR) return; const channelIndex = waypoint.channelIndex ?? 0; - const channelConfigs = deps.getChannelConfigs(); - if (!channelConfigs.find((c) => c.index === channelIndex)?.uplinkEnabled) return; - - const uplink = resolveMeshtasticMqttPublishFieldsForChannel( - channelIndex, - channelConfigs, - loadMeshtasticMqttManualChannelPsks(), - deps.hasRfDevice() ? undefined : { preferManualOverRadio: true }, - ); - if (!uplink.channelName) return; + const uplink = resolveMqttUplink(channelIndex, deps); + if (!uplink) return; void window.electronAPI.mqtt .publishWaypoint({ @@ -183,30 +207,20 @@ export function attachMeshtasticRouterSideEffects( identityId: IdentityId, deps: MeshtasticRouterSideEffectsDeps, ): () => void { - const listener: PacketRouterListener = (event, routedIdentityId) => { - if (routedIdentityId !== identityId) return; - switch (event.type) { - case 'text_message': - handleTextMessage(event, deps); - break; - case 'device_log': { - const message = event.payload.message; - if (!message) break; - deps.applyForeignLoraFromLog(message); - deps.applyRoutingErrorFromLog(message); - break; - } - case 'waypoint': - uplinkWaypointToMqtt(event, deps); - break; - case 'device_metadata': { - const firmwareVersion = event.payload.firmwareVersion; - if (firmwareVersion) deps.setFirmwareVersion(firmwareVersion); - break; - } - default: - break; - } - }; - return packetRouter.addListener(listener); + return attachTypedPacketListeners(identityId, { + text_message: (payload) => { + handleTextMessage(payload, deps); + }, + device_log: (payload) => { + if (!payload.message) return; + deps.applyForeignLoraFromLog(payload.message); + deps.applyRoutingErrorFromLog(payload.message); + }, + waypoint: (payload) => { + uplinkWaypointToMqtt(payload, deps); + }, + device_metadata: (payload) => { + if (payload.firmwareVersion) deps.setFirmwareVersion(payload.firmwareVersion); + }, + }); } diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.diagnostics.contract.test.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.diagnostics.contract.test.ts index 4ae4b0a63..3f08e17e0 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.diagnostics.contract.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.diagnostics.contract.test.ts @@ -8,6 +8,10 @@ import { describe, expect, it } from 'vitest'; const NODE_SOURCE = readFileSync(join(__dirname, 'meshtasticNodeSideEffects.ts'), 'utf-8'); const RAW_SOURCE = readFileSync(join(__dirname, 'meshtasticRawPacketSideEffects.ts'), 'utf-8'); +const DIAGNOSTICS_SOURCE = readFileSync( + join(__dirname, 'meshtasticProcessNodeDiagnostics.ts'), + 'utf-8', +); function sliceFromMarker(source: string, marker: string, length = 1400): string { const index = source.indexOf(marker); @@ -19,15 +23,16 @@ describe('meshtastic diagnostics ingest contract', () => { it('localStats handler calls processNodeUpdate when protocol is meshtastic', () => { const body = sliceFromMarker(NODE_SOURCE, 'handleLocalStatsTelemetry'); expect(body.length).toBeGreaterThan(0); - expect(body).toContain('processNodeDiagnostics'); - expect(NODE_SOURCE).toContain('processNodeUpdate'); - expect(NODE_SOURCE).toContain("getStoredMeshProtocol() !== 'meshtastic'"); + expect(body).toContain('processMeshtasticNodeDiagnostics'); + expect(DIAGNOSTICS_SOURCE).toContain('processNodeUpdate'); + expect(DIAGNOSTICS_SOURCE).toContain("getStoredMeshProtocol() !== 'meshtastic'"); }); it('RF mesh packet hop/signal handler calls processNodeUpdate when protocol is meshtastic', () => { const body = sliceFromMarker(RAW_SOURCE, 'if (!hasSignal && !hasHopUpdate)'); expect(body.length).toBeGreaterThan(0); - expect(body).toContain('processNodeUpdate'); - expect(body).toContain("getStoredMeshProtocol() === 'meshtastic'"); + expect(body).toContain('processMeshtasticNodeDiagnostics'); + expect(DIAGNOSTICS_SOURCE).toContain('processNodeUpdate'); + expect(DIAGNOSTICS_SOURCE).toContain("getStoredMeshProtocol() !== 'meshtastic'"); }); }); diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts index 95e97a451..a58498da7 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts @@ -25,7 +25,12 @@ function makeDeps() { }; const configureTimeoutRef = { current: null as ReturnType | null }; const meshtasticIngestSessionRef = { - current: { setConfiguring: vi.fn(), detach: vi.fn(), markPacketSeen: vi.fn() }, + current: { + setConfiguring: vi.fn(), + detach: vi.fn(), + markPacketSeen: vi.fn(), + isDuplicatePacket: vi.fn(), + }, }; const noopRef = { current: null }; diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts index cf9f2f992..9934ce0d3 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts @@ -68,7 +68,12 @@ function makeDeps() { localLoraConfigTimerRef: { current: undefined }, meshtasticIdentityIdRef: { current: IDENTITY }, meshtasticIngestSessionRef: { - current: { setConfiguring: vi.fn(), detach: vi.fn(), markPacketSeen: vi.fn() }, + current: { + setConfiguring: vi.fn(), + detach: vi.fn(), + markPacketSeen: vi.fn(), + isDuplicatePacket: vi.fn(), + }, }, meshtasticIngressDetachRef: { current: null }, mqttStatusRef: { current: 'disconnected' as const }, diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts index 2870c4241..2baa34eae 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts @@ -433,7 +433,12 @@ export function attachMeshtasticRuntimeWireEffects( meshtasticIngestSessionRef.current?.setConfiguring(false); lastDataReceivedRef.current = Date.now(); startWatchdog(); - void refreshOurPositionRef.current(); + void refreshOurPositionRef.current().catch((e: unknown) => { + console.debug( + '[meshtasticRuntimeWireEffects] refreshOurPosition after configure failed ' + + errLikeToLogString(e), + ); + }); startGpsInterval(); setQueueStatus({ free: 16, maxlen: 16, res: 0 }); deviceConfiguredRef.current = true; @@ -589,7 +594,11 @@ export function attachMeshtasticRuntimeWireEffects( heard_via_mqtt_only: false, }; updated.set(info.myNodeNum, selfNode); - void window.electronAPI.db.saveNode(selfNode); + void window.electronAPI.db.saveNode(selfNode).catch((e: unknown) => { + console.debug( + '[meshtasticRuntimeWireEffects] saveNode self failed ' + errLikeToLogString(e), + ); + }); } return updated; }); @@ -648,7 +657,6 @@ export function attachMeshtasticRuntimeWireEffects( attachMeshtasticRawPacketSideEffects(identityId, { getMyNodeNum: () => myNodeNumRef.current, getIsConfiguring: () => isConfiguringRef.current, - updateNodes, setRawPackets, setSignalTelemetry, touchLastData, @@ -679,7 +687,12 @@ export function attachMeshtasticRuntimeWireEffects( lastSfHeartbeatPeriodRef.current = period; }, requestStoreForwardHistory: (options) => { - void requestStoreForwardHistoryRef.current(options); + void requestStoreForwardHistoryRef.current(options).catch((e: unknown) => { + console.debug( + '[meshtasticRuntimeWireEffects] Store & Forward history request failed ' + + errLikeToLogString(e), + ); + }); }, setStoreForwardMessages, }), @@ -690,7 +703,6 @@ export function attachMeshtasticRuntimeWireEffects( getBluetoothDeviceId: () => (device.transport as { __bluetoothDevice?: { id?: string } })?.__bluetoothDevice?.id, touchLastData, - updateNodes, emptyNode, ensureNodeExists, maybeRequestNodeInfoForNode, diff --git a/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.test.ts index 47e313f57..a46c9dc49 100644 --- a/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.test.ts @@ -158,6 +158,68 @@ describe('attachMeshtasticStoreForwardSideEffects', () => { detach(); }); + it('appends a retransmitted frame to the ring buffer only once', () => { + const deps = makeDeps(); + const detach = attachMeshtasticStoreForwardSideEffects(IDENTITY, deps); + const data = sfPacket(StoreForward.StoreAndForward_RequestResponse.ROUTER_TEXT_BROADCAST, { + case: 'text', + value: new TextEncoder().encode('retransmit me'), + }); + const event = { + type: 'meshtastic_store_forward' as const, + payload: { from: SERVER, channel: 0, timestamp: Date.now(), raw: { data } }, + }; + packetRouter.dispatch(event, IDENTITY); + packetRouter.dispatch(event, IDENTITY); + expect(deps.setStoreForwardMessages).toHaveBeenCalledTimes(1); + detach(); + }); + + it('keeps recording heartbeats but rate-limits repeated auto history requests', () => { + const deps = makeDeps(); + const detach = attachMeshtasticStoreForwardSideEffects(IDENTITY, deps); + const data = sfPacket(StoreForward.StoreAndForward_RequestResponse.ROUTER_HEARTBEAT, { + case: 'heartbeat', + value: create(StoreForward.StoreAndForward_HeartbeatSchema, { period: 120, secondary: 0 }), + }); + const event = { + type: 'meshtastic_store_forward' as const, + payload: { from: SERVER, channel: 0, timestamp: Date.now(), raw: { data } }, + }; + packetRouter.dispatch(event, IDENTITY); + packetRouter.dispatch(event, IDENTITY); + packetRouter.dispatch(event, IDENTITY); + expect(deps.recordHeartbeat).toHaveBeenCalledTimes(3); + expect(deps.requestStoreForwardHistory).toHaveBeenCalledTimes(1); + detach(); + }); + + it('keeps distinct frames from the same router', () => { + const deps = makeDeps(); + const detach = attachMeshtasticStoreForwardSideEffects(IDENTITY, deps); + for (const text of ['first', 'second']) { + packetRouter.dispatch( + { + type: 'meshtastic_store_forward', + payload: { + from: SERVER, + channel: 0, + timestamp: Date.now(), + raw: { + data: sfPacket(StoreForward.StoreAndForward_RequestResponse.ROUTER_TEXT_BROADCAST, { + case: 'text', + value: new TextEncoder().encode(text), + }), + }, + }, + }, + IDENTITY, + ); + } + expect(deps.setStoreForwardMessages).toHaveBeenCalledTimes(2); + detach(); + }); + it('ignores events routed for a different identity', () => { const deps = makeDeps(); const detach = attachMeshtasticStoreForwardSideEffects(IDENTITY, deps); diff --git a/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.ts index cc67c1428..2a985420c 100644 --- a/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.ts @@ -14,7 +14,8 @@ import type { Dispatch, SetStateAction } from 'react'; import { addMessage } from '../../stores/messageStore'; -import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; +import { attachTypedPacketListener } from '../drivers/attachTypedPacketListener'; +import { createPacketDedupeRegistry } from '../drivers/packetDedupeRegistry'; import { errLikeToLogString } from '../errLikeToLogString'; import { getIdentityChatMessages } from '../identityStoreReads'; import { @@ -22,12 +23,29 @@ import { isDuplicateHistoryMessage, parseStoreForwardHeartbeat, } from '../meshtasticBacklogUtils'; +import { toPacketPayloadBytes } from '../packetPayload'; import type { DomainEvent } from '../protocols/Protocol'; +import { appendToRingMap } from '../sessionMemoryCaps'; import { chatMessageToMessageRecord } from '../storeRecordAdapters'; import type { ChatMessage, IdentityId } from '../types'; const MAX_STORE_FORWARD_MESSAGES_PER_NODE = 50; +/** + * Routers repeat heartbeats and can re-send the same history frame on retry. + * Identical bytes from the same node inside this window are dropped before they + * reach the ring buffer, the auto-history trigger, and the text replay path. + */ +const STORE_FORWARD_DEDUPE_TTL_MS = 60_000; +const STORE_FORWARD_DEDUPE_MAX_ENTRIES = 256; + +/** + * Local floor between automatic history requests per router. `requestStoreForwardHistory` + * applies the user-facing cooldown (see `meshtasticBacklogUtils`); this only + * stops a heartbeat burst from queueing several requests before that runs. + */ +const AUTO_HISTORY_MIN_INTERVAL_MS = 60_000; + export interface MeshtasticStoreForwardSideEffectsDeps { touchLastData: () => void; getNodeName: (nodeNum: number) => string; @@ -45,7 +63,27 @@ type StoreForwardDomainEvent = Extract 0 ? bytes : null; +} + +/** Content identity for one S&F frame: sender plus length-prefixed payload bytes. */ +function storeForwardDedupeKey(from: number, data: Uint8Array): string { + let hash = 2166136261; + for (const byte of data) { + hash = Math.imul(hash ^ byte, 16777619); + } + return `${from}:${data.length}:${(hash >>> 0).toString(16)}`; +} + +/** Per-router timestamps for the local auto-history floor. */ +type AutoHistoryGate = Map; + +function shouldRequestAutoHistory(gate: AutoHistoryGate, serverNodeId: number, now: number) { + const lastAt = gate.get(serverNodeId); + if (lastAt !== undefined && now - lastAt < AUTO_HISTORY_MIN_INTERVAL_MS) return false; + gate.set(serverNodeId, now); + return true; } function appendReplayedHistoryText(identityId: IdentityId, chat: ChatMessage): void { @@ -58,10 +96,16 @@ function appendReplayedHistoryText(identityId: IdentityId, chat: ChatMessage): v }); } +interface StoreForwardSessionState { + seenFrames: ReturnType; + autoHistoryGate: AutoHistoryGate; +} + function handleStoreForward( identityId: IdentityId, event: StoreForwardDomainEvent['payload'], deps: MeshtasticStoreForwardSideEffectsDeps, + session: StoreForwardSessionState, ): void { deps.touchLastData(); const data = storeForwardBytes(event.raw); @@ -69,25 +113,30 @@ function handleStoreForward( const from = event.from; const timestamp = event.timestamp; - deps.setStoreForwardMessages((prev) => { - const updated = new Map(prev); - const existing = updated.get(from) ?? []; - updated.set(from, [ - ...existing.slice(-MAX_STORE_FORWARD_MESSAGES_PER_NODE), - { from, data, timestamp }, - ]); - return updated; - }); + // Heartbeat bookkeeping stays outside the dedupe: identical periodic frames + // are exactly how the Chat catch-up control learns the router is still alive. + const isRetransmit = session.seenFrames.markSeen(storeForwardDedupeKey(from, data)); + + if (!isRetransmit) { + deps.setStoreForwardMessages((prev) => + appendToRingMap(prev, from, { from, data, timestamp }, MAX_STORE_FORWARD_MESSAGES_PER_NODE), + ); + } const heartbeat = parseStoreForwardHeartbeat(data); if (from && heartbeat) { deps.recordHeartbeat({ serverNodeId: from, channel: event.channel, period: heartbeat.period }); // secondary === 0 marks the primary router; only it replays chat history. - if (heartbeat.secondary === 0 && deps.getIsDeviceConfigured()) { + if ( + heartbeat.secondary === 0 && + deps.getIsDeviceConfigured() && + shouldRequestAutoHistory(session.autoHistoryGate, from, Date.now()) + ) { deps.requestStoreForwardHistory({ serverNodeId: from, manual: false }); } } + if (isRetransmit) return; const payloadText = decodeStoreForwardTextPayload(data); if (!from || !payloadText) return; appendReplayedHistoryText(identityId, { @@ -107,9 +156,14 @@ export function attachMeshtasticStoreForwardSideEffects( identityId: IdentityId, deps: MeshtasticStoreForwardSideEffectsDeps, ): () => void { - const listener: PacketRouterListener = (event, routedIdentityId) => { - if (routedIdentityId !== identityId || event.type !== 'meshtastic_store_forward') return; - handleStoreForward(identityId, event.payload, deps); + const session: StoreForwardSessionState = { + seenFrames: createPacketDedupeRegistry({ + ttlMs: STORE_FORWARD_DEDUPE_TTL_MS, + maxEntries: STORE_FORWARD_DEDUPE_MAX_ENTRIES, + }), + autoHistoryGate: new Map(), }; - return packetRouter.addListener(listener); + return attachTypedPacketListener(identityId, 'meshtastic_store_forward', (payload) => { + handleStoreForward(identityId, payload, deps, session); + }); } diff --git a/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.test.ts index 875a69f4e..8b6272596 100644 --- a/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.test.ts @@ -1,7 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { packetRouter } from '../drivers/PacketRouter'; -import { attachMeshtasticTraceSideEffects } from './meshtasticTraceSideEffects'; +import { + attachMeshtasticTraceSideEffects, + prunePendingTraceState, +} from './meshtasticTraceSideEffects'; describe('attachMeshtasticTraceSideEffects', () => { afterEach(() => { @@ -39,3 +42,53 @@ describe('attachMeshtasticTraceSideEffects', () => { detach(); }); }); + +describe('prunePendingTraceState', () => { + it('drops requests older than the 2 minute TTL', () => { + const now = 10_000_000; + const pendingTraceRequestsRef = { + current: new Map([ + [1, now - 130_000], + [2, now - 10_000], + ]), + }; + const pendingTracePacketIdToTargetRef = { current: new Map() }; + + prunePendingTraceState({ pendingTraceRequestsRef, pendingTracePacketIdToTargetRef }, now); + + expect([...pendingTraceRequestsRef.current.keys()]).toEqual([2]); + }); + + it('drops packet-id mappings whose target is no longer pending', () => { + const now = 10_000_000; + const pendingTraceRequestsRef = { current: new Map([[2, now]]) }; + const pendingTracePacketIdToTargetRef = { + current: new Map([ + [100, 1], + [200, 2], + ]), + }; + + prunePendingTraceState({ pendingTraceRequestsRef, pendingTracePacketIdToTargetRef }, now); + + expect([...pendingTracePacketIdToTargetRef.current.keys()]).toEqual([200]); + }); + + it('bounds unanswered requests so the maps cannot grow without limit', () => { + const now = 10_000_000; + const pendingTraceRequestsRef = { current: new Map() }; + const pendingTracePacketIdToTargetRef = { current: new Map() }; + for (let i = 0; i < 300; i++) { + pendingTraceRequestsRef.current.set(i, now); + pendingTracePacketIdToTargetRef.current.set(1000 + i, i); + } + + prunePendingTraceState({ pendingTraceRequestsRef, pendingTracePacketIdToTargetRef }, now); + + expect(pendingTraceRequestsRef.current.size).toBe(128); + expect(pendingTracePacketIdToTargetRef.current.size).toBe(128); + // Oldest insertions are evicted first; the newest target survives. + expect(pendingTraceRequestsRef.current.has(299)).toBe(true); + expect(pendingTraceRequestsRef.current.has(0)).toBe(false); + }); +}); diff --git a/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.ts index 35bd076a1..b9da8387f 100644 --- a/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.ts @@ -10,17 +10,25 @@ */ import type { Dispatch, RefObject, SetStateAction } from 'react'; -import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; +import { attachTypedPacketListener } from '../drivers/attachTypedPacketListener'; import { mergeMeshtasticTraceRouteIntoResultsMap, type MeshtasticTraceRouteEntry, meshtasticTraceRouteLookupKeys, } from '../meshtasticTraceRouteLookupKeys'; import type { DomainEvent } from '../protocols/Protocol'; +import { trimMapToMaxSize } from '../sessionMemoryCaps'; import type { IdentityId } from '../types'; const PENDING_TRACE_TTL_MS = 2 * 60_000; +/** + * Ceiling on outstanding trace correlations. Replies prune their own entries, + * but a session that fires traces at unreachable nodes never gets a reply, so + * the maps also need a hard bound. + */ +const MAX_PENDING_TRACE_ENTRIES = 128; + export interface MeshtasticTraceSideEffectsDeps { /** Outbound traceroute packet id → traced node, filled by the send path. */ pendingTracePacketIdToTargetRef: RefObject>; @@ -33,9 +41,36 @@ export interface MeshtasticTraceSideEffectsDeps { type TraceRouteEvent = Extract; function normalizedPacketId(value: number | undefined): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) return undefined; - const id = value >>> 0; - return id === 0 ? undefined : id; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 0xffffffff) { + return undefined; + } + return Math.trunc(value); +} + +/** Drop expired trace requests, orphaned packet-id mappings, and overflow entries. */ +export function prunePendingTraceState( + deps: Pick< + MeshtasticTraceSideEffectsDeps, + 'pendingTraceRequestsRef' | 'pendingTracePacketIdToTargetRef' + >, + now: number = Date.now(), +): void { + const requests = deps.pendingTraceRequestsRef.current; + const packetIds = deps.pendingTracePacketIdToTargetRef.current; + + const cutoff = now - PENDING_TRACE_TTL_MS; + for (const [target, startedAt] of requests) { + if (startedAt < cutoff) requests.delete(target); + } + if (requests.size > MAX_PENDING_TRACE_ENTRIES) { + const kept = trimMapToMaxSize(requests, MAX_PENDING_TRACE_ENTRIES); + for (const target of [...requests.keys()]) { + if (!kept.has(target)) requests.delete(target); + } + } + for (const [packetId, dest] of [...packetIds.entries()]) { + if (!requests.has(dest)) packetIds.delete(packetId); + } } /** @@ -70,15 +105,7 @@ export function applyMeshtasticTracerouteReply( deps.pendingTraceRequestsRef.current.delete(key); } - const cutoff = Date.now() - PENDING_TRACE_TTL_MS; - for (const [target, startedAt] of deps.pendingTraceRequestsRef.current) { - if (startedAt < cutoff) deps.pendingTraceRequestsRef.current.delete(target); - } - for (const [packetId, dest] of [...deps.pendingTracePacketIdToTargetRef.current.entries()]) { - if (!deps.pendingTraceRequestsRef.current.has(dest)) { - deps.pendingTracePacketIdToTargetRef.current.delete(packetId); - } - } + prunePendingTraceState(deps); deps.setTraceRouteResults((prev) => mergeMeshtasticTraceRouteIntoResultsMap( @@ -100,10 +127,8 @@ export function attachMeshtasticTraceSideEffects( identityId: IdentityId, deps: MeshtasticTraceSideEffectsDeps, ): () => void { - const listener: PacketRouterListener = (event, routedIdentityId) => { - if (routedIdentityId !== identityId || event.type !== 'trace_route') return; + return attachTypedPacketListener(identityId, 'trace_route', (payload) => { deps.touchLastData(); - applyMeshtasticTracerouteReply(event.payload, deps); - }; - return packetRouter.addListener(listener); + applyMeshtasticTracerouteReply(payload, deps); + }); } diff --git a/src/renderer/lib/meshtasticSourceIcons.test.ts b/src/renderer/lib/meshtasticSourceIcons.test.ts index 5c3ace288..dfba8c587 100644 --- a/src/renderer/lib/meshtasticSourceIcons.test.ts +++ b/src/renderer/lib/meshtasticSourceIcons.test.ts @@ -1,7 +1,9 @@ +import type { TFunction } from 'i18next'; import { describe, expect, it } from 'vitest'; import { isMeshtasticSelfHybridPath, + meshtasticHybridPathLabels, meshtasticNodeShowsHybridMqttPath, resolveMeshtasticPathBadge, } from './meshtasticSourceIcons'; @@ -86,3 +88,21 @@ describe('isMeshtasticSelfHybridPath', () => { expect(isMeshtasticSelfHybridPath(false, true, true)).toBe(false); }); }); + +describe('meshtasticHybridPathLabels', () => { + const t = ((key: string) => key) as TFunction; + + it('returns self hybrid keys when self hybrid', () => { + expect(meshtasticHybridPathLabels(t, true)).toEqual({ + title: 'nodeListPanel.connectedViaRfAndMqttTooltip', + ariaLabel: 'nodeListPanel.connectedViaRfAndMqttAria', + }); + }); + + it('returns packet hybrid keys otherwise', () => { + expect(meshtasticHybridPathLabels(t, false)).toEqual({ + title: 'nodeListPanel.hybridMqttPathTooltip', + ariaLabel: 'nodeListPanel.hybridMqttPathAria', + }); + }); +}); diff --git a/src/renderer/lib/meshtasticSourceIcons.tsx b/src/renderer/lib/meshtasticSourceIcons.tsx index 562c28d9f..7e936750f 100644 --- a/src/renderer/lib/meshtasticSourceIcons.tsx +++ b/src/renderer/lib/meshtasticSourceIcons.tsx @@ -1,3 +1,4 @@ +import type { TFunction } from 'i18next'; import { Globe, Wifi } from 'lucide-react-motion'; import { useIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; @@ -45,12 +46,22 @@ export function resolveMeshtasticPathBadge({ return 'none'; } -/** Tooltip for hybrid RF + MQTT path (list column + node detail). */ -export const MESHTASTIC_HYBRID_MQTT_PATH_TITLE = - 'Received via RF; some packets use an MQTT relay path'; - -/** Accessible name for the hybrid icon group in dense tables. */ -export const MESHTASTIC_HYBRID_MQTT_PATH_ARIA_LABEL = 'RF and MQTT path'; +/** Shared hybrid-path tooltip / aria strings for list + detail (requires caller `t`). */ +export function meshtasticHybridPathLabels( + t: TFunction, + isSelfHybrid: boolean, +): { title: string; ariaLabel: string } { + if (isSelfHybrid) { + return { + title: t('nodeListPanel.connectedViaRfAndMqttTooltip'), + ariaLabel: t('nodeListPanel.connectedViaRfAndMqttAria'), + }; + } + return { + title: t('nodeListPanel.hybridMqttPathTooltip'), + ariaLabel: t('nodeListPanel.hybridMqttPathAria'), + }; +} export function MeshtasticRfPathIcon({ className }: { className?: string }) { const trigger = useIconTrigger(); @@ -80,12 +91,12 @@ export function MeshtasticMqttPathIcon({ className }: { className?: string }) { } export function MeshtasticHybridPathIcons({ - title = MESHTASTIC_HYBRID_MQTT_PATH_TITLE, - ariaLabel = MESHTASTIC_HYBRID_MQTT_PATH_ARIA_LABEL, + title, + ariaLabel, className, }: { - title?: string; - ariaLabel?: string; + title: string; + ariaLabel: string; /** Optional wrapper class (e.g. justify-center for table cells). */ className?: string; }) { diff --git a/src/renderer/lib/packetPayload.test.ts b/src/renderer/lib/packetPayload.test.ts new file mode 100644 index 000000000..8c0f70272 --- /dev/null +++ b/src/renderer/lib/packetPayload.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { toPacketPayloadBytes, truncatePacketText } from './packetPayload'; + +describe('toPacketPayloadBytes', () => { + it('returns a Uint8Array unchanged', () => { + const bytes = Uint8Array.from([1, 2, 3]); + expect(toPacketPayloadBytes(bytes)).toBe(bytes); + }); + + it('unwraps raw / data / payload carriers', () => { + expect(toPacketPayloadBytes({ raw: Uint8Array.from([9]) })).toEqual(Uint8Array.from([9])); + expect(toPacketPayloadBytes({ data: Uint8Array.from([8]) })).toEqual(Uint8Array.from([8])); + expect(toPacketPayloadBytes({ payload: Uint8Array.from([7]) })).toEqual(Uint8Array.from([7])); + }); + + it('accepts ArrayBuffer, typed-array views, and byte arrays', () => { + expect(toPacketPayloadBytes(Uint8Array.from([1, 2]).buffer)).toEqual(Uint8Array.from([1, 2])); + expect(toPacketPayloadBytes(Int8Array.from([3]))).toEqual(Uint8Array.from([3])); + expect(toPacketPayloadBytes([4, 5])).toEqual(Uint8Array.from([4, 5])); + }); + + it('returns empty bytes for payloads it cannot interpret', () => { + for (const value of [undefined, null, 42, 'text', {}, { data: { nested: true } }]) { + expect(toPacketPayloadBytes(value)).toEqual(new Uint8Array()); + } + }); + + it('does not recurse infinitely on a self-referential wrapper', () => { + const wrapper: { data?: unknown } = {}; + wrapper.data = wrapper; + expect(toPacketPayloadBytes(wrapper)).toEqual(new Uint8Array()); + }); +}); + +describe('truncatePacketText', () => { + it('leaves text at or below the limit untouched', () => { + expect(truncatePacketText('hello', 5)).toBe('hello'); + expect(truncatePacketText('hi', 5)).toBe('hi'); + }); + + it('clamps longer text to the limit', () => { + expect(truncatePacketText('abcdef', 3)).toBe('abc'); + }); + + it('treats negative limits as zero', () => { + expect(truncatePacketText('abc', -1)).toBe(''); + }); +}); diff --git a/src/renderer/lib/packetPayload.ts b/src/renderer/lib/packetPayload.ts new file mode 100644 index 000000000..f750b2421 --- /dev/null +++ b/src/renderer/lib/packetPayload.ts @@ -0,0 +1,43 @@ +/** + * Normalizers for loosely-typed packet payloads that reach the UI. + * + * Module-port and Store & Forward events carry `unknown` data because the shape + * differs per firmware port; these helpers coerce the common carriers into the + * bytes/text the panels expect instead of each call site re-implementing the + * same `instanceof` ladder. + */ + +/** + * Coerce a decoded module payload into bytes. Accepts a `Uint8Array`, a wrapper + * carrying one under `raw` / `data` / `payload`, an `ArrayBuffer`, or a byte + * array. Anything else yields an empty array rather than throwing — these + * payloads feed display-only panels. + */ +export function toPacketPayloadBytes(value: unknown): Uint8Array { + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + if (Array.isArray(value) && value.every((b) => typeof b === 'number')) { + return Uint8Array.from(value); + } + if (value && typeof value === 'object') { + const wrapper = value as { raw?: unknown; data?: unknown; payload?: unknown }; + for (const nested of [wrapper.raw, wrapper.data, wrapper.payload]) { + if (nested === undefined || nested === value) continue; + const bytes = toPacketPayloadBytes(nested); + if (bytes.length > 0) return bytes; + } + } + return new Uint8Array(); +} + +/** + * Clamp untrusted text to `maxLength` code units for display surfaces (OS + * notifications, log lines). Callers must sanitize control characters first. + */ +export function truncatePacketText(text: string, maxLength: number): string { + const limit = Math.max(0, Math.floor(maxLength)); + return text.length > limit ? text.slice(0, limit) : text; +} diff --git a/src/renderer/lib/protocols/MeshCoreProtocol.test.ts b/src/renderer/lib/protocols/MeshCoreProtocol.test.ts index 94320faaa..4d9dc0787 100644 --- a/src/renderer/lib/protocols/MeshCoreProtocol.test.ts +++ b/src/renderer/lib/protocols/MeshCoreProtocol.test.ts @@ -13,6 +13,10 @@ const EVENT_ADVERT = 128; const EVENT_CHANNEL_MESSAGE = 8; const EVENT_DIRECT_MESSAGE = 7; const EVENT_PATH_UPDATED = 129; +const EVENT_DM_ACK = 130; +const EVENT_WAITING_MESSAGES = 131; +const EVENT_RF_RX = 136; +const EVENT_DISCONNECTED = 'disconnected'; function mockMeshCoreConnection() { const handlers = new Map void>>(); @@ -264,6 +268,60 @@ describe('MeshCoreProtocol.subscribe', () => { }); teardown(); }); + + it('emits meshcore_dm_ack on hop ACK (130)', () => { + const conn = mockMeshCoreConnection(); + const events: DomainEvent[] = []; + const teardown = meshcoreProtocol.subscribe(conn, (e) => events.push(e)); + conn.emit(EVENT_DM_ACK, { ackCode: 0x80, roundTrip: 120 }); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'meshcore_dm_ack', + payload: expect.objectContaining({ ackCode: 0x80, roundTrip: 120 }), + }), + ); + teardown(); + }); + + it('emits meshcore_waiting_messages on event 131', () => { + const conn = mockMeshCoreConnection(); + const events: DomainEvent[] = []; + const teardown = meshcoreProtocol.subscribe(conn, (e) => events.push(e)); + conn.emit(EVENT_WAITING_MESSAGES, {}); + expect(events.some((e) => e.type === 'meshcore_waiting_messages')).toBe(true); + teardown(); + }); + + it('emits meshcore_rf_rx on RF RX (136)', () => { + const conn = mockMeshCoreConnection(); + const events: DomainEvent[] = []; + const teardown = meshcoreProtocol.subscribe(conn, (e) => events.push(e)); + conn.emit(EVENT_RF_RX, { + lastSnr: 4.5, + lastRssi: -80, + raw: Uint8Array.from([1, 2, 3, 4]), + }); + const rf = events.find((e) => e.type === 'meshcore_rf_rx'); + expect(rf).toMatchObject({ + type: 'meshcore_rf_rx', + payload: expect.objectContaining({ lastSnr: 4.5, lastRssi: -80 }), + }); + teardown(); + }); + + it('emits device_status disconnected on disconnect', () => { + const conn = mockMeshCoreConnection(); + const events: DomainEvent[] = []; + const teardown = meshcoreProtocol.subscribe(conn, (e) => events.push(e)); + conn.emit(EVENT_DISCONNECTED, undefined); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'device_status', + payload: expect.objectContaining({ status: 'disconnected' }), + }), + ); + teardown(); + }); }); describe('MeshCoreProtocol capability-gated operations', () => { diff --git a/src/renderer/lib/protocols/MeshCoreProtocol.ts b/src/renderer/lib/protocols/MeshCoreProtocol.ts index bd01ddcce..0fd0fb0d7 100644 --- a/src/renderer/lib/protocols/MeshCoreProtocol.ts +++ b/src/renderer/lib/protocols/MeshCoreProtocol.ts @@ -2,6 +2,15 @@ import type { Connection } from '@liamcottle/meshcore.js'; import { meshcoreDmAckKeyU32 } from '../../hooks/meshcore/meshcoreHookPreamble'; +import type { + CayenneLppEntry, + MeshCoreContactRaw, + MeshCoreNeighborEntry, + MeshCoreNeighborResult, + MeshCoreNodeTelemetry, + MeshCoreRepeaterStatus, + RxPacketEntry, +} from '../meshcore/meshcoreHookTypes'; import { seedMeshcorePrefixLookupMaps } from '../meshcore/meshcorePubKeyRegistry'; import { meshcoreCoerceRadioRxFrame, parseAutoaddConfigResponse } from '../meshcoreContactAutoAdd'; import { decodeMeshcoreDirectMessageEvents } from '../meshcoreDirectMessageDecode'; @@ -46,89 +55,17 @@ const EVENT_RF_RX = 136; const EVENT_RX = 'rx'; const EVENT_DISCONNECTED = 'disconnected'; -// --- Re-exported types for components that previously imported from this module --- - -export interface RxPacketEntry { - ts: number; - snr: number; - rssi: number; - raw: Uint8Array; - fromNodeId: number | null; - routeTypeString: string | null; - payloadTypeString: string | null; - hopCount: number; - /** CRC-32 fingerprint (8 hex chars) — matches optional DB `rx_packet_fingerprint` on messages. */ - messageFingerprintHex: string | null; - transportScopeCode: number | null; - transportReturnCode: number | null; - advertName: string | null; - advertLat: number | null; - advertLon: number | null; - advertTimestampSec: number | null; - parseOk: boolean; -} - -export interface MeshCoreContactRaw { - publicKey: Uint8Array; - type: number; - advName: string; - lastAdvert: number; - advLat: number; - advLon: number; - flags: number; - outPathLen?: number; - outPath?: Uint8Array; -} - -export interface MeshCoreRepeaterStatus { - battMilliVolts: number; - noiseFloor: number; - lastRssi: number; - lastSnr: number; - nPacketsRecv: number; - nPacketsSent: number; - totalAirTimeSecs: number; - totalUpTimeSecs: number; - nSentFlood: number; - nSentDirect: number; - nRecvFlood: number; - nRecvDirect: number; - errEvents: number; - nDirectDups: number; - nFloodDups: number; - currTxQueueLen: number; -} - -export interface CayenneLppEntry { - channel: number; - type: number; - value: number | { latitude: number; longitude: number; altitude: number }; -} - -export interface MeshCoreNodeTelemetry { - fetchedAt: number; - entries: CayenneLppEntry[]; - temperature?: number; - mcuTemperature?: number; - relativeHumidity?: number; - barometricPressure?: number; - voltage?: number; - gps?: { latitude: number; longitude: number; altitude: number }; -} - -export interface MeshCoreNeighborEntry { - publicKeyPrefix: Uint8Array; - prefixHex: string; - resolvedNodeId: number; - heardSecondsAgo: number; - snr: number; -} - -export interface MeshCoreNeighborResult { - totalNeighboursCount: number; - neighbours: MeshCoreNeighborEntry[]; - fetchedAt: number; -} +// --- Canonical MeshCore types live in meshcoreHookTypes; re-export for legacy imports --- + +export type { + CayenneLppEntry, + MeshCoreContactRaw, + MeshCoreNeighborEntry, + MeshCoreNeighborResult, + MeshCoreNodeTelemetry, + MeshCoreRepeaterStatus, + RxPacketEntry, +}; interface MeshCoreEventBus { on(event: string | number, cb: (...args: unknown[]) => void): void; diff --git a/src/renderer/lib/protocols/MeshtasticProtocol.test.ts b/src/renderer/lib/protocols/MeshtasticProtocol.test.ts index ceabd56b0..9ccfa5b2a 100644 --- a/src/renderer/lib/protocols/MeshtasticProtocol.test.ts +++ b/src/renderer/lib/protocols/MeshtasticProtocol.test.ts @@ -1,5 +1,6 @@ -import { Portnums } from '@meshtastic/protobufs'; -import { describe, expect, it } from 'vitest'; +import { create, toBinary } from '@bufbuild/protobuf'; +import { Mesh, Portnums } from '@meshtastic/protobufs'; +import { describe, expect, it, vi } from 'vitest'; import { meshtasticProtocol } from './MeshtasticProtocol'; import type { DomainEvent } from './Protocol'; @@ -162,4 +163,270 @@ describe('MeshtasticProtocol.subscribe', () => { }); teardown(); }); + + it('emits live UserPacket identity through node_info', () => { + const { device, emit } = mockMeshDevice(); + const events: DomainEvent[] = []; + const teardown = meshtasticProtocol.subscribe(device, (event) => events.push(event)); + + emit('onUserPacket', { + from: 0x1234, + rxTime: 1_700_000_100, + data: { + longName: 'Live Node', + shortName: 'LIVE', + role: 2, + publicKey: new Uint8Array(32).fill(7), + }, + }); + + expect(events).toContainEqual({ + type: 'node_info', + payload: expect.objectContaining({ + nodeId: 0x1234, + longName: 'Live Node', + shortName: 'LIVE', + role: 2, + fromUserPacket: true, + }), + }); + teardown(); + }); + + it('emits Store & Forward and module-port events with normalized metadata', () => { + const { device, emit } = mockMeshDevice(); + const events: DomainEvent[] = []; + const teardown = meshtasticProtocol.subscribe(device, (event) => events.push(event)); + const bytes = Uint8Array.from([1, 2, 3]); + + emit('onStoreForwardPacket', { from: 42, channel: 3, data: bytes }); + emit('onAudioPacket', { from: 42, channel: 3, data: bytes }); + + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'meshtastic_store_forward', + payload: expect.objectContaining({ from: 42, channel: 3 }), + }), + expect.objectContaining({ + type: 'meshtastic_module_port', + payload: expect.objectContaining({ + portLabel: 'audio', + from: 42, + channel: 3, + data: bytes, + }), + }), + ]), + ); + teardown(); + }); + + it('rejects malformed sender numbers before emitting text', () => { + const { device, emit } = mockMeshDevice(); + const events: DomainEvent[] = []; + const teardown = meshtasticProtocol.subscribe(device, (event) => events.push(event)); + + emit('onMeshPacket', { + payloadVariant: { + case: 'decoded', + value: { + portnum: Portnums.PortNum.TEXT_MESSAGE_APP, + payload: new TextEncoder().encode('bad sender'), + }, + }, + from: -1, + to: 0xffffffff, + id: 9, + }); + + expect(events.some((event) => event.type === 'text_message')).toBe(false); + teardown(); + }); + + it('emits one trace_route per packet id when the SDK double-dispatches', () => { + const { device, emit } = mockMeshDevice(); + const events: DomainEvent[] = []; + const teardown = meshtasticProtocol.subscribe(device, (event) => events.push(event)); + const routeDiscovery = toBinary( + Mesh.RouteDiscoverySchema, + create(Mesh.RouteDiscoverySchema, { route: [11, 22] }), + ); + + // The SDK dispatches onMeshPacket for every packet and then the typed + // onTraceRoutePacket for the same wire packet. + emit('onMeshPacket', { + payloadVariant: { + case: 'decoded', + value: { portnum: Portnums.PortNum.TRACEROUTE_APP, payload: routeDiscovery, dest: 555 }, + }, + from: 1, + to: 2, + id: 4242, + }); + emit('onTraceRoutePacket', { id: 4242, from: 1, to: 2, data: { route: [11, 22] } }); + + const traces = events.filter((event) => event.type === 'trace_route'); + expect(traces).toHaveLength(1); + expect(traces[0]?.type === 'trace_route' && traces[0].payload.dataLayerDest).toBe(555); + teardown(); + }); + + it('still emits traceroutes for distinct packet ids and for id 0', () => { + const { device, emit } = mockMeshDevice(); + const events: DomainEvent[] = []; + const teardown = meshtasticProtocol.subscribe(device, (event) => events.push(event)); + + emit('onTraceRoutePacket', { id: 1, from: 1, to: 2, data: { route: [3] } }); + emit('onTraceRoutePacket', { id: 2, from: 1, to: 2, data: { route: [3] } }); + emit('onTraceRoutePacket', { id: 0, from: 1, to: 2, data: { route: [3] } }); + emit('onTraceRoutePacket', { id: 0, from: 1, to: 2, data: { route: [3] } }); + + expect(events.filter((event) => event.type === 'trace_route')).toHaveLength(4); + teardown(); + }); + + it('caps an implausibly long route and drops non-numeric hops', () => { + const { device, emit } = mockMeshDevice(); + const events: DomainEvent[] = []; + const teardown = meshtasticProtocol.subscribe(device, (event) => events.push(event)); + + emit('onTraceRoutePacket', { + id: 5, + from: 1, + to: 2, + data: { route: [...Array.from({ length: 100 }, (_, i) => i + 1), Number.NaN] }, + }); + + const trace = events.find((event) => event.type === 'trace_route'); + expect(trace?.type === 'trace_route' && trace.payload.route).toHaveLength(32); + teardown(); + }); + + it('drops malformed UTF-8 text payloads', () => { + const { device, emit } = mockMeshDevice(); + const events: DomainEvent[] = []; + const teardown = meshtasticProtocol.subscribe(device, (event) => events.push(event)); + emit('onMeshPacket', { + payloadVariant: { + case: 'decoded', + value: { + portnum: Portnums.PortNum.TEXT_MESSAGE_APP, + payload: Uint8Array.from([0xc3, 0x28]), + }, + }, + from: 1, + to: 0xffffffff, + id: 9, + }); + expect(events.some((event) => event.type === 'text_message')).toBe(false); + teardown(); + }); +}); + +describe('MeshtasticProtocol outbound guards', () => { + it('rejects unavailable handles before calling device APIs', async () => { + await expect( + meshtasticProtocol.sendMessage(null, { text: 'hello', channelIndex: 0 }), + ).rejects.toThrow('device handle is unavailable'); + await expect(meshtasticProtocol.setConfig(undefined, {})).rejects.toThrow( + 'device handle is unavailable', + ); + await expect(meshtasticProtocol.setModuleConfig(null, {})).rejects.toThrow( + 'device handle is unavailable', + ); + }); + + it('rejects invalid coordinates and oversized outbound text', async () => { + const device = { + sendText: vi.fn(), + setPosition: vi.fn(), + }; + await expect( + meshtasticProtocol.sendPosition(device, { latitude: Number.NaN, longitude: 1 }), + ).rejects.toThrow('finite'); + await expect( + meshtasticProtocol.sendPosition(device, { latitude: 91, longitude: 1 }), + ).rejects.toThrow('out of range'); + await expect( + meshtasticProtocol.sendMessage(device, { text: 'x'.repeat(1025), channelIndex: 0 }), + ).rejects.toThrow('1-1024'); + expect(device.sendText).not.toHaveBeenCalled(); + expect(device.setPosition).not.toHaveBeenCalled(); + }); +}); + +describe('MeshtasticProtocol outbound guards', () => { + function positionDevice() { + const calls: unknown[] = []; + return { + calls, + device: { + setPosition: (position: unknown) => { + calls.push(position); + return Promise.resolve(); + }, + }, + }; + } + + it('sendPosition and sendPositionToDevice build the same protobuf position', async () => { + const viaOptions = positionDevice(); + const viaArgs = positionDevice(); + + await meshtasticProtocol.sendPosition(viaOptions.device, { + latitude: 39.7392, + longitude: -104.9903, + altitude: 1609, + }); + await meshtasticProtocol.sendPositionToDevice(viaArgs.device, 39.7392, -104.9903, 1609); + + const [first] = viaOptions.calls as { latitudeI: number; longitudeI: number }[]; + const [second] = viaArgs.calls as { latitudeI: number; longitudeI: number }[]; + expect(first.latitudeI).toBe(second.latitudeI); + expect(first.longitudeI).toBe(second.longitudeI); + expect(first.latitudeI).toBe(Math.round(39.7392 * 1e7)); + }); + + it('rejects non-finite and out-of-range coordinates', async () => { + const { device, calls } = positionDevice(); + await expect( + meshtasticProtocol.sendPosition(device, { latitude: Number.NaN, longitude: 0 }), + ).rejects.toThrow(TypeError); + await expect( + meshtasticProtocol.sendPosition(device, { latitude: 0, longitude: 999 }), + ).rejects.toThrow(RangeError); + await expect( + meshtasticProtocol.sendPositionToDevice(device, 0, Number.POSITIVE_INFINITY), + ).rejects.toThrow(TypeError); + expect(calls).toHaveLength(0); + }); + + it('rejects empty text and invalid destinations before calling the SDK', async () => { + const sent: unknown[][] = []; + const device = { + sendText: (...args: unknown[]) => { + sent.push(args); + return Promise.resolve(1); + }, + }; + + await expect(meshtasticProtocol.sendMessage(device, { text: '' })).rejects.toThrow(TypeError); + await expect( + meshtasticProtocol.sendMessage(device, { text: 'hi', destination: Number.NaN }), + ).rejects.toThrow(TypeError); + expect(sent).toHaveLength(0); + + await meshtasticProtocol.sendMessage(device, { text: 'hi' }); + expect(sent[0]?.[1]).toBe('broadcast'); + }); + + it('rejects traceroute and position requests for invalid node numbers', async () => { + const device = { + traceRoute: () => Promise.resolve(), + requestPosition: () => Promise.resolve(), + }; + await expect(meshtasticProtocol.sendTraceRoute(device, 0)).rejects.toThrow(TypeError); + await expect(meshtasticProtocol.requestPosition(device, Number.NaN)).rejects.toThrow(TypeError); + }); }); diff --git a/src/renderer/lib/protocols/MeshtasticProtocol.ts b/src/renderer/lib/protocols/MeshtasticProtocol.ts index 96d137a94..d11e41f43 100644 --- a/src/renderer/lib/protocols/MeshtasticProtocol.ts +++ b/src/renderer/lib/protocols/MeshtasticProtocol.ts @@ -12,6 +12,7 @@ import { reconnectSerial, safeDisconnect, } from '../connection'; +import { createPacketDedupeRegistry } from '../drivers/packetDedupeRegistry'; import { meshtasticHwModelName } from '../hardwareModels'; import { meshtasticComputedRfHopsAway } from '../meshtasticRfHops'; import type { ProtocolCapabilities } from '../radio/BaseRadioProvider'; @@ -40,19 +41,6 @@ import type { } from './Protocol'; import { UnsupportedOperation } from './Protocol'; -// --- Re-exported types for callers that previously imported from this module --- - -export interface MeshtasticRawPacketEntry { - ts: number; - snr: number; - rssi: number; - raw: Uint8Array; - fromNodeId: number | null; - portLabel: string; - viaMqtt: boolean; - isLocal?: boolean; -} - const { DeviceStatusEnum } = Types; const { PortNum } = Portnums; @@ -67,6 +55,70 @@ const STATUS_CODE_MAP: Record = { 8: 'stale', }; +/** + * The SDK dispatches `onMeshPacket` for every packet and then a second typed + * event for the same payload, so a TRACEROUTE_APP packet reaches `subscribe` + * twice. Correlation state in `meshtasticTraceSideEffects` is consumed on the + * first reply, so the duplicate would land as an uncorrelated row. + */ +const TRACE_ROUTE_DEDUPE_TTL_MS = 60_000; +const TRACE_ROUTE_DEDUPE_MAX_ENTRIES = 256; + +/** Guard against a malformed RouteDiscovery advertising an implausible hop list. */ +const MAX_TRACE_ROUTE_HOPS = 32; + +/** NeighborInfo lists are firmware-capped well below this; bound the UI cost anyway. */ +const MAX_NEIGHBOR_ENTRIES = 256; + +/** Meshtastic text payloads are limited to ~237 bytes; allow slack for MQTT replays. */ +const MAX_TEXT_MESSAGE_CHARS = 1024; +const MAX_RAW_PACKET_BYTES = 64 * 1024; + +const MAX_LATITUDE = 90; +const MAX_LONGITUDE = 180; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +/** Node numbers are unsigned 32-bit; reject NaN / negative / out-of-range values. */ +function normalizedNodeNum(value: unknown): number | undefined { + if (!isFiniteNumber(value)) return undefined; + const num = Math.trunc(value); + if (num < 0 || num > 0xffffffff) return undefined; + return num >>> 0; +} + +// The type parameter is an explicit call-site assertion of the SDK surface the +// caller needs; the runtime check is the same for every handle shape. +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +function requireHandle(handle: unknown, operation: string): T { + if (handle == null || (typeof handle !== 'object' && typeof handle !== 'function')) { + throw new TypeError(`${operation}: device handle is unavailable`); + } + return handle as T; +} + +function assertFiniteCoordinates(operation: string, latitude: number, longitude: number): void { + if (!isFiniteNumber(latitude) || !isFiniteNumber(longitude)) { + throw new TypeError(`${operation}: latitude/longitude must be finite numbers`); + } + if (Math.abs(latitude) > MAX_LATITUDE || Math.abs(longitude) > MAX_LONGITUDE) { + throw new RangeError(`${operation}: latitude/longitude out of range`); + } +} + +function boundedRoute(route: readonly unknown[] | undefined): number[] { + if (!route) return []; + const hops: number[] = []; + for (const hop of route) { + if (hops.length >= MAX_TRACE_ROUTE_HOPS) break; + const num = normalizedNodeNum(hop); + if (num !== undefined) hops.push(num); + } + return hops; +} + /** * Meshtastic codec + SDK adapter. Stateless: every method that needs the SDK * takes the handle as a parameter. Lifecycle (watchdog, reconnect) lives in @@ -111,6 +163,18 @@ export class MeshtasticProtocol implements Protocol { const fire = (events: DomainEvent[]) => { for (const e of events) emit(e); }; + const seenTraceRoutes = createPacketDedupeRegistry({ + ttlMs: TRACE_ROUTE_DEDUPE_TTL_MS, + maxEntries: TRACE_ROUTE_DEDUPE_MAX_ENTRIES, + }); + /** Emits a traceroute at most once per wire packet id (id 0 is not unique). */ + const fireTraceRoute = (packet: unknown) => { + const events = this.decodeTraceRoute(packet); + if (events.length === 0) return; + const packetId = normalizedNodeNum((packet as { id?: unknown }).id); + if (packetId && seenTraceRoutes.markSeen(packetId)) return; + fire(events); + }; push( device.events.onDeviceStatus.subscribe((status) => { @@ -124,7 +188,7 @@ export class MeshtasticProtocol implements Protocol { if (portnum === PortNum.TEXT_MESSAGE_APP) { fire(this.decodeTextMessage(packet)); } else if (portnum === PortNum.TRACEROUTE_APP) { - fire(this.decodeTraceRoute(packet)); + fireTraceRoute(packet); } } fire(this.decodeRawPacket(packet)); @@ -152,7 +216,7 @@ export class MeshtasticProtocol implements Protocol { ); push( device.events.onTraceRoutePacket.subscribe((p) => { - fire(this.decodeTraceRoute(p)); + fireTraceRoute(p); }), ); push( @@ -192,8 +256,9 @@ export class MeshtasticProtocol implements Protocol { ); push( device.events.onMyNodeInfo.subscribe((info) => { - if (info.myNodeNum > 0) { - emit({ type: 'node_info', payload: { nodeId: info.myNodeNum } }); + const nodeId = normalizedNodeNum(info.myNodeNum); + if (nodeId) { + emit({ type: 'node_info', payload: { nodeId } }); } }), ); @@ -208,8 +273,8 @@ export class MeshtasticProtocol implements Protocol { emit({ type: 'meshtastic_store_forward', payload: { - from: packet.from ?? 0, - channel: packet.channel ?? 0, + from: normalizedNodeNum(packet.from) ?? 0, + channel: isFiniteNumber(packet.channel) ? Math.trunc(packet.channel) : 0, raw: p, timestamp: Date.now(), }, @@ -223,9 +288,9 @@ export class MeshtasticProtocol implements Protocol { type: 'meshtastic_module_port', payload: { portLabel, - from: p.from ?? 0, + from: normalizedNodeNum(p.from) ?? 0, data: p.data ?? packet, - channel: p.channel, + channel: isFiniteNumber(p.channel) ? Math.trunc(p.channel) : undefined, timestamp: Date.now(), }, }); @@ -333,35 +398,54 @@ export class MeshtasticProtocol implements Protocol { // --- Outbound --- async sendMessage(handle: unknown, opts: SendMessageOptions): Promise { - const device = handle as MeshDevice; - const dest: number | 'broadcast' = opts.destination ?? 'broadcast'; - const result = await device.sendText(opts.text, dest, true, opts.channelIndex ?? 0); + const device = requireHandle(handle, 'meshtastic sendMessage'); + const text = typeof opts.text === 'string' ? opts.text : ''; + if (!text || text.length > MAX_TEXT_MESSAGE_CHARS) { + throw new TypeError( + `meshtastic sendMessage: text must be 1-${MAX_TEXT_MESSAGE_CHARS} characters`, + ); + } + const destination = + opts.destination === undefined ? undefined : normalizedNodeNum(opts.destination); + if (opts.destination !== undefined && destination === undefined) { + throw new TypeError('meshtastic sendMessage: destination must be a valid node number'); + } + const dest: number | 'broadcast' = destination ?? 'broadcast'; + const channelIndex = isFiniteNumber(opts.channelIndex) ? Math.trunc(opts.channelIndex) : 0; + const result = await device.sendText(text, dest, true, channelIndex); const packetId = typeof result === 'number' ? result : undefined; return { packetId }; } async sendPosition(handle: unknown, opts: SendPositionOptions): Promise { - const device = handle as MeshDevice; - await device.setPosition( - create(Mesh.PositionSchema, { - latitudeI: Math.round(opts.latitude * 1e7), - longitudeI: Math.round(opts.longitude * 1e7), - altitude: opts.altitude ?? 0, - time: Math.floor(Date.now() / 1000), - }) as Parameters[0], + await this.setDevicePosition( + handle, + 'meshtastic sendPosition', + opts.latitude, + opts.longitude, + opts.altitude, ); } async sendTraceRoute(handle: unknown, nodeId: number): Promise { - const device = handle as MeshDevice; - await device.traceRoute(nodeId); + const device = requireHandle(handle, 'meshtastic sendTraceRoute'); + const target = normalizedNodeNum(nodeId); + if (target === undefined || target === 0) { + throw new TypeError('meshtastic sendTraceRoute: nodeId must be a valid node number'); + } + await device.traceRoute(target); } async sendWaypoint(handle: unknown, opts: SendWaypointOptions): Promise { const device = handle as MeshDevice; + assertFiniteCoordinates('meshtastic sendWaypoint', opts.latitude, opts.longitude); + const id = normalizedNodeNum(opts.id); + if (id === undefined) { + throw new TypeError('meshtastic sendWaypoint: id must be a valid unsigned integer'); + } await device.sendWaypoint( create(Mesh.WaypointSchema, { - id: opts.id, + id, name: opts.name, description: opts.description ?? '', latitudeI: Math.round(opts.latitude * 1e7), @@ -376,8 +460,14 @@ export class MeshtasticProtocol implements Protocol { async deleteWaypoint(handle: unknown, id: number): Promise { const device = handle as MeshDevice; + const waypointId = normalizedNodeNum(id); + if (waypointId === undefined) { + throw new TypeError('meshtastic deleteWaypoint: id must be a valid unsigned integer'); + } await device.sendWaypoint( - create(Mesh.WaypointSchema, { id, expire: 1 }) as Parameters[0], + create(Mesh.WaypointSchema, { id: waypointId, expire: 1 }) as Parameters< + MeshDevice['sendWaypoint'] + >[0], 0xffffffff, 0, ); @@ -393,12 +483,16 @@ export class MeshtasticProtocol implements Protocol { const device = handle as MeshDevice; const safeScalar = sanitizeUnicodeReactionScalar(emoji); if (safeScalar === undefined) return; + const parentId = normalizedNodeNum(replyId); + if (!parentId) { + throw new TypeError('meshtastic sendReaction: replyId must be a valid packet id'); + } await device.sendText( String.fromCodePoint(safeScalar), 'broadcast', true, - channelIndex, - replyId, + isFiniteNumber(channelIndex) ? Math.trunc(channelIndex) : 0, + parentId, MESHTASTIC_TAPBACK_DATA_EMOJI_FLAG, ); } @@ -440,7 +534,7 @@ export class MeshtasticProtocol implements Protocol { // --- Config --- async setConfig(handle: unknown, config: unknown): Promise { - await (handle as MeshDevice).setConfig(config as never); + await requireHandle(handle, 'meshtastic setConfig').setConfig(config as never); } async commitConfig(handle: unknown): Promise { @@ -478,7 +572,10 @@ export class MeshtasticProtocol implements Protocol { } async setModuleConfig(handle: unknown, config: unknown): Promise { - await (handle as { setModuleConfig: (c: unknown) => Promise }).setModuleConfig(config); + await requireHandle<{ setModuleConfig: (c: unknown) => Promise }>( + handle, + 'meshtastic setModuleConfig', + ).setModuleConfig(config); } async setCannedMessages(handle: unknown, messages: string[]): Promise { @@ -488,36 +585,57 @@ export class MeshtasticProtocol implements Protocol { } async setRingtone(handle: unknown, ringtone: string): Promise { + if (typeof ringtone !== 'string' || ringtone.length > 1024) { + throw new TypeError('meshtastic setRingtone: ringtone must be at most 1024 characters'); + } const msg = create(Admin.AdminMessageSchema, { payloadVariant: { case: 'setRingtoneMessage', value: ringtone }, }); - await ( - handle as { - sendPacket: (b: Uint8Array, p: number, t: string) => Promise; - } - ).sendPacket(toBinary(Admin.AdminMessageSchema, msg), Portnums.PortNum.ADMIN_APP, 'self'); + await requireHandle<{ + sendPacket: (b: Uint8Array, p: number, t: string) => Promise; + }>(handle, 'meshtastic setRingtone').sendPacket( + toBinary(Admin.AdminMessageSchema, msg), + Portnums.PortNum.ADMIN_APP, + 'self', + ); } // --- GPS / position --- - async sendPositionToDevice( + /** Shared body for `sendPosition` and `sendPositionToDevice` (same firmware call). */ + private async setDevicePosition( handle: unknown, - lat: number, - lon: number, - alt?: number, + operation: string, + latitude: number, + longitude: number, + altitude?: number, ): Promise { - await (handle as MeshDevice).setPosition( + assertFiniteCoordinates(operation, latitude, longitude); + await requireHandle(handle, operation).setPosition( create(Mesh.PositionSchema, { - latitudeI: Math.round(lat * 1e7), - longitudeI: Math.round(lon * 1e7), - altitude: alt ?? 0, + latitudeI: Math.round(latitude * 1e7), + longitudeI: Math.round(longitude * 1e7), + altitude: isFiniteNumber(altitude) ? Math.round(altitude) : 0, time: Math.floor(Date.now() / 1000), }) as Parameters[0], ); } + async sendPositionToDevice( + handle: unknown, + lat: number, + lon: number, + alt?: number, + ): Promise { + await this.setDevicePosition(handle, 'meshtastic sendPositionToDevice', lat, lon, alt); + } + async requestPosition(handle: unknown, nodeId: number): Promise { - await (handle as MeshDevice).requestPosition(nodeId); + const target = normalizedNodeNum(nodeId); + if (target === undefined || target === 0) { + throw new TypeError('meshtastic requestPosition: nodeId must be a valid node number'); + } + await (handle as MeshDevice).requestPosition(target); } deleteNode(): Promise { @@ -545,17 +663,29 @@ export class MeshtasticProtocol implements Protocol { }; if (p.payloadVariant?.case !== 'decoded') return []; const data = p.payloadVariant.value; + if (!(data?.payload instanceof Uint8Array)) return []; + const from = normalizedNodeNum(p.from); + const to = normalizedNodeNum(p.to); + if (from === undefined || to === undefined) return []; const hopCount = meshtasticComputedRfHopsAway(p); const rawReplyId = data.replyId ?? data.reply_id; + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(data.payload); + } catch { + // catch-no-log-ok malformed inbound text is dropped before reaching UI or persistence + return []; + } return [ { type: 'text_message', payload: { - id: String(p.id ?? 0), - from: p.from, - to: p.to, - payload: new TextDecoder().decode(data.payload), - channelIndex: p.channel ?? 0, + id: String(normalizedNodeNum(p.id) ?? 0), + from, + to, + payload: + text.length > MAX_TEXT_MESSAGE_CHARS ? text.slice(0, MAX_TEXT_MESSAGE_CHARS) : text, + channelIndex: isFiniteNumber(p.channel) ? Math.trunc(p.channel) : 0, timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(), rxSnr: p.rxSnr, rxRssi: p.rxRssi, @@ -580,13 +710,14 @@ export class MeshtasticProtocol implements Protocol { isLicensed?: boolean; }; }; - if (!p.from) return []; + const nodeId = normalizedNodeNum(p.from); + if (!nodeId) return []; const user = p.data ?? {}; return [ { type: 'node_info', payload: { - nodeId: p.from, + nodeId, longName: user.longName, shortName: user.shortName, hwModel: user.hwModel != null ? meshtasticHwModelName(user.hwModel) : undefined, @@ -622,12 +753,13 @@ export class MeshtasticProtocol implements Protocol { airUtilTx?: number; }; }; - if (!p.num) return []; + const nodeId = normalizedNodeNum(p.num); + if (!nodeId) return []; return [ { type: 'node_info', payload: { - nodeId: p.num, + nodeId, longName: p.user?.longName, shortName: p.user?.shortName, hwModel: p.user?.hwModel != null ? meshtasticHwModelName(p.user.hwModel) : undefined, @@ -655,14 +787,19 @@ export class MeshtasticProtocol implements Protocol { rxTime?: number; data: { latitudeI?: number; longitudeI?: number; altitude?: number }; }; + const nodeId = normalizedNodeNum(p.from); + if (nodeId === undefined) return []; + const latitude = (isFiniteNumber(p.data?.latitudeI) ? p.data.latitudeI : 0) / 1e7; + const longitude = (isFiniteNumber(p.data?.longitudeI) ? p.data.longitudeI : 0) / 1e7; + if (Math.abs(latitude) > MAX_LATITUDE || Math.abs(longitude) > MAX_LONGITUDE) return []; return [ { type: 'position', payload: { - nodeId: p.from, - latitude: (p.data?.latitudeI ?? 0) / 1e7, - longitude: (p.data?.longitudeI ?? 0) / 1e7, - altitude: p.data?.altitude, + nodeId, + latitude, + longitude, + altitude: isFiniteNumber(p.data?.altitude) ? p.data.altitude : undefined, timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(), }, }, @@ -731,14 +868,15 @@ export class MeshtasticProtocol implements Protocol { expire?: number; }; }; - if (!p.data?.id) return []; - const latitudeI = p.data.latitudeI ?? 0; - const longitudeI = p.data.longitudeI ?? 0; + const waypointId = normalizedNodeNum(p.data?.id); + if (!waypointId) return []; + const latitudeI = isFiniteNumber(p.data.latitudeI) ? p.data.latitudeI : 0; + const longitudeI = isFiniteNumber(p.data.longitudeI) ? p.data.longitudeI : 0; return [ { type: 'waypoint', payload: { - id: p.data.id, + id: waypointId, name: p.data.name ?? '', description: p.data.description, latitude: latitudeI / 1e7, @@ -748,9 +886,9 @@ export class MeshtasticProtocol implements Protocol { icon: p.data.icon, lockedTo: p.data.lockedTo, expire: p.data.expire, - from: p.from, - to: p.to, - channelIndex: p.channel, + from: normalizedNodeNum(p.from) ?? 0, + to: normalizedNodeNum(p.to), + channelIndex: isFiniteNumber(p.channel) ? Math.trunc(p.channel) : undefined, timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(), }, }, @@ -792,8 +930,8 @@ export class MeshtasticProtocol implements Protocol { route: readonly number[]; routeBack: readonly number[]; }; - route = Array.from(rd.route ?? []); - routeBack = rd.routeBack ? Array.from(rd.routeBack) : undefined; + route = boundedRoute(rd.route); + routeBack = rd.routeBack ? boundedRoute(rd.routeBack) : undefined; } catch { // catch-no-log-ok non-traceroute payload on TRACEROUTE_APP return []; @@ -809,18 +947,21 @@ export class MeshtasticProtocol implements Protocol { const rawReq = dp.requestId; requestId = typeof rawReq === 'number' && Number.isFinite(rawReq) ? rawReq : undefined; } else if (p.data) { - route = Array.from(p.data.route ?? []); - routeBack = p.data.routeBack ? Array.from(p.data.routeBack) : undefined; + route = boundedRoute(p.data.route); + routeBack = p.data.routeBack ? boundedRoute(p.data.routeBack) : undefined; } else { return []; } + const from = normalizedNodeNum(p.from); + if (from === undefined) return []; + return [ { type: 'trace_route', payload: { - from: p.from, - to: p.to ?? dataLayerDest ?? 0, + from, + to: normalizedNodeNum(p.to) ?? dataLayerDest ?? 0, route, routeBack, timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(), @@ -845,9 +986,10 @@ export class MeshtasticProtocol implements Protocol { }; role?: number; }; - if (ch.index === undefined) return []; + const index = ch.index; + if (!isFiniteNumber(index) || index < 0 || !Number.isInteger(index)) return []; const payload: ChannelEvent = { - index: ch.index, + index, role: ch.role ?? 0, name: ch.settings?.name ?? '', psk: ch.settings?.psk ?? new Uint8Array([1]), @@ -958,9 +1100,11 @@ export class MeshtasticProtocol implements Protocol { viaMqtt?: boolean; payloadVariant?: { case?: string; value?: { portnum?: number } }; }; - if (!mp.from) return []; + const from = normalizedNodeNum(mp.from); + if (!from) return []; try { const serialized = toBinary(Mesh.MeshPacketSchema, raw as never); + if (serialized.byteLength > MAX_RAW_PACKET_BYTES) return []; const portLabel = this.rawPacketPortLabel(raw); const portnum = mp.payloadVariant?.case === 'decoded' && @@ -973,12 +1117,12 @@ export class MeshtasticProtocol implements Protocol { snr: mp.rxSnr ?? 0, rssi: mp.rxRssi ?? 0, raw: serialized, - fromNodeId: mp.from, + fromNodeId: from, portLabel, viaMqtt: mp.viaMqtt === true, isLocal: false, hopsAway, - packetId: typeof mp.id === 'number' ? mp.id >>> 0 : undefined, + packetId: normalizedNodeNum(mp.id), hopLimit: mp.hopLimit, hopStart: mp.hopStart, portnum, @@ -1031,13 +1175,14 @@ export class MeshtasticProtocol implements Protocol { }; }; const data = packet.data; - if (!data?.nodeId) return []; + const nodeId = normalizedNodeNum(data?.nodeId); + if (!nodeId) return []; const payload: NeighborInfoEvent = { - nodeId: data.nodeId, - neighbors: (data.neighbors ?? []).map((n) => ({ - nodeId: n.nodeId ?? 0, - snr: n.snr ?? 0, - lastRxTime: n.lastRxTime ?? 0, + nodeId, + neighbors: (data?.neighbors ?? []).slice(0, MAX_NEIGHBOR_ENTRIES).map((n) => ({ + nodeId: normalizedNodeNum(n.nodeId) ?? 0, + snr: isFiniteNumber(n.snr) ? n.snr : 0, + lastRxTime: isFiniteNumber(n.lastRxTime) ? n.lastRxTime : 0, })), timestamp: Date.now(), }; diff --git a/src/renderer/lib/protocols/Protocol.ts b/src/renderer/lib/protocols/Protocol.ts index 6a2ed976a..5a85e77e4 100644 --- a/src/renderer/lib/protocols/Protocol.ts +++ b/src/renderer/lib/protocols/Protocol.ts @@ -372,6 +372,17 @@ export type DomainEvent = | { type: 'meshcore_cli_response'; payload: MeshcoreCliResponseEvent } | { type: 'meshcore_rf_rx'; payload: MeshcoreRfRxEvent }; +export type DomainEventType = DomainEvent['type']; + +/** + * Typed registry of `DomainEvent.type` → payload, so PacketRouter consumers can + * subscribe to one event kind without re-deriving the discriminated union at + * every call site (see `attachTypedPacketListener`). + */ +export type DomainEventPayloadMap = { + [E in DomainEvent as E['type']]: E['payload']; +}; + // --- Raw packet log --- export interface RawPacketEntry { diff --git a/src/renderer/lib/sessionMemoryCaps.ts b/src/renderer/lib/sessionMemoryCaps.ts index a371d4a89..92fd9f8a6 100644 --- a/src/renderer/lib/sessionMemoryCaps.ts +++ b/src/renderer/lib/sessionMemoryCaps.ts @@ -11,6 +11,7 @@ export const MAX_MESH_ENTITY_CAP = 100_000; export const MAX_TRACE_ROUTES_PER_IDENTITY = 100; export const MAX_MESHCORE_CLI_HISTORY_ENTRIES = 50; export const MAX_MESHTASTIC_TRACE_ROUTE_RESULTS = 100; +export const MAX_TELEMETRY_POINTS = 50; export const MAX_DIAGNOSTICS_TRACKED_NODES = MAX_MESH_ENTITY_CAP; export const MAX_RETICULUM_IDENTITY_DESTINATIONS = MAX_MESH_ENTITY_CAP; /** In-memory cap for RMAP discovery rows mirrored from the sidecar DiscoveryStore. */ @@ -28,6 +29,21 @@ export function trimArrayTail(items: readonly T[], max: number): T[] { return items.slice(items.length - max); } +/** + * Append `entry` to the per-key ring at `key`, keeping at most `max` entries. + * Returns a new Map so it can be used directly as a React state updater. + */ +export function appendToRingMap( + prev: Map, + key: K, + entry: V, + max: number, +): Map { + const updated = new Map(prev); + updated.set(key, trimArrayTail([...(prev.get(key) ?? []), entry], max)); + return updated; +} + /** Evict oldest Map keys when size exceeds max ( insertion order ). */ export function trimMapToMaxSize(map: Map, max: number): Map { if (map.size <= max) return map; diff --git a/src/renderer/lib/storeRecordAdapters.test.ts b/src/renderer/lib/storeRecordAdapters.test.ts index ab9dec631..6513253c9 100644 --- a/src/renderer/lib/storeRecordAdapters.test.ts +++ b/src/renderer/lib/storeRecordAdapters.test.ts @@ -129,16 +129,48 @@ describe('store record adapters (merge precedence)', () => { expect(messageRecordToChatMessage(record).rxHops).toBe(3); }); - it('round-trips channel utilization and air util between NodeRecord and MeshNode', () => { + it('round-trips Meshtastic node metrics between NodeRecord and MeshNode', () => { const record: NodeRecord = { nodeId: 1, longName: 'Alpha', channelUtilization: 42.5, airUtilTx: 3.1, + altitude: 1600, + lastPositionWarning: 'bad fix', + numPacketsRxBad: 4, + numRxDupe: 5, + numPacketsRx: 6, + numPacketsTx: 7, + publicKeyHex: 'ab'.repeat(32), + temperature: 20, + relativeHumidity: 40, + barometricPressure: 850, + iaq: 12, + lux: 30, + windSpeed: 5, + windDirection: 180, }; const node = nodeRecordToMeshNode(record); expect(node.channel_utilization).toBe(42.5); expect(node.air_util_tx).toBe(3.1); + expect(node).toEqual( + expect.objectContaining({ + altitude: 1600, + lastPositionWarning: 'bad fix', + num_packets_rx_bad: 4, + num_rx_dupe: 5, + num_packets_rx: 6, + num_packets_tx: 7, + public_key_hex: 'ab'.repeat(32), + env_temperature: 20, + env_humidity: 40, + env_pressure: 850, + env_iaq: 12, + env_lux: 30, + env_wind_speed: 5, + env_wind_direction: 180, + }), + ); const back = meshNodeToNodeRecord({ ...node, node_id: 1, @@ -154,6 +186,24 @@ describe('store record adapters (merge precedence)', () => { }); expect(back.channelUtilization).toBe(42.5); expect(back.airUtilTx).toBe(3.1); + expect(back).toEqual( + expect.objectContaining({ + altitude: 1600, + lastPositionWarning: 'bad fix', + numPacketsRxBad: 4, + numRxDupe: 5, + numPacketsRx: 6, + numPacketsTx: 7, + publicKeyHex: 'ab'.repeat(32), + temperature: 20, + relativeHumidity: 40, + barometricPressure: 850, + iaq: 12, + lux: 30, + windSpeed: 5, + windDirection: 180, + }), + ); }); it('nodeRecordsToMeshNodeMap merges legacy fields when spread under hook merge pattern', () => { diff --git a/src/renderer/lib/storeRecordAdapters.ts b/src/renderer/lib/storeRecordAdapters.ts index 76cec7839..68bfcabbd 100644 --- a/src/renderer/lib/storeRecordAdapters.ts +++ b/src/renderer/lib/storeRecordAdapters.ts @@ -164,6 +164,13 @@ export function nodeRecordToMeshNode(record: NodeRecord): MeshNode { voltage: record.voltage, channel_utilization: record.channelUtilization, air_util_tx: record.airUtilTx, + env_temperature: record.temperature, + env_humidity: record.relativeHumidity, + env_pressure: record.barometricPressure, + env_iaq: record.iaq, + env_lux: record.lux, + env_wind_speed: record.windSpeed, + env_wind_direction: record.windDirection, lastPositionWarning: record.lastPositionWarning, num_packets_rx_bad: record.numPacketsRxBad, num_rx_dupe: record.numRxDupe, @@ -207,6 +214,14 @@ function nodeRecordsShallowEqual(a: NodeRecord, b: NodeRecord): boolean { a.voltage === b.voltage && a.channelUtilization === b.channelUtilization && a.airUtilTx === b.airUtilTx && + a.temperature === b.temperature && + a.relativeHumidity === b.relativeHumidity && + a.barometricPressure === b.barometricPressure && + a.iaq === b.iaq && + a.gasResistance === b.gasResistance && + a.lux === b.lux && + a.windSpeed === b.windSpeed && + a.windDirection === b.windDirection && a.lastPositionWarning === b.lastPositionWarning && a.numPacketsRxBad === b.numPacketsRxBad && a.numRxDupe === b.numRxDupe && @@ -288,6 +303,13 @@ export function meshNodeToNodeRecord(node: MeshNode): NodeRecord { voltage: node.voltage, channelUtilization: node.channel_utilization, airUtilTx: node.air_util_tx, + temperature: node.env_temperature, + relativeHumidity: node.env_humidity, + barometricPressure: node.env_pressure, + iaq: node.env_iaq, + lux: node.env_lux, + windSpeed: node.env_wind_speed, + windDirection: node.env_wind_direction, lastPositionWarning: node.lastPositionWarning, numPacketsRxBad: node.num_packets_rx_bad, numRxDupe: node.num_rx_dupe, diff --git a/src/renderer/lib/timeConstants.ts b/src/renderer/lib/timeConstants.ts index 1560348d0..753dadc32 100644 --- a/src/renderer/lib/timeConstants.ts +++ b/src/renderer/lib/timeConstants.ts @@ -172,6 +172,11 @@ export const MESHCORE_WAITING_MESSAGES_SERIAL_SILENT_TIMEOUT_MS = 15 * MS_PER_SE export const MESHCORE_SYNC_NEXT_MESSAGE_TIMEOUT_MS = 12 * MS_PER_SECOND; /** Cap silent incremental drains per event-131 trigger (safety valve). */ export const MESHCORE_SYNC_NEXT_MESSAGE_MAX_PER_DRAIN = 200; +/** + * Cap silent follow-up drains chained after an in-flight 131 drain (safety valve). + * Manual Sync now is not counted against this limit. + */ +export const MESHCORE_WAITING_MESSAGES_SILENT_FOLLOW_UP_CHAIN_MAX = 40; /** Coalesce rapid MsgWaiting (131) pushes into one drain. */ export const MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS = 1_500; /** Defer auto-drain after companion TX so syncNextMessage is not issued mid-send. */ diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 03b93050f..7728e354b 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -428,7 +428,6 @@ "jumpToQuotedMessage": "Přejít na citovanou zprávu od {{sender}}", "replyToMessage": "Odpovědět na zprávu", "addReaction": "Přidejte reakci", - "emojiButton": "Emoji", "sendButton": "Poslat", "sendButtonDm": "DM", "sendButtonSending": "...", @@ -576,7 +575,6 @@ "floodScopeOverrideLabel": "Rozsah Flood pro tuto zprávu", "floodScopeOverrideAria": "Přepsání rozsahu Flood podle zprávy", "floodScopeOverrideDefault": "Výchozí rozsah", - "floodScopeOverrideUnscoped": "Unscoped", "rncp": { "sendFile": "Odeslat soubor", "sendFileAria": "Odeslat soubor na {{name}} pomocí rncp", @@ -607,7 +605,12 @@ "floodScopeOverrideCustomInvalid": "Zadejte platný hashtag regionu (například #myregion).", "dmNodeHops_one": "{{count}} skok", "dmNodeHops_few": "{{count}} skoky", - "dmNodeHops_other": "{{count}} skoků" + "dmNodeHops_many": "{{count}} skoků", + "dmNodeHops_other": "{{count}} skoků", + "notificationDmTitle": "DM od {{sender}}", + "notificationMessageTitle": "Zpráva od {{sender}}", + "floodScopeOverrideUnscoped": "Unscoped", + "emojiButton": "Emoji" }, "chatPayload": { "mention": "Zmínit {{label}}", @@ -707,7 +710,6 @@ "submit": "Předložit", "rePairDevice": "Odebrat a znovu spárovat zařízení", "mqttConnection": "Připojení MQTT", - "server": "Server", "from": "Z", "topic": "Téma", "disconnectMqtt": "Odpojit", @@ -1199,20 +1201,21 @@ "coloradoRegionGateStay": "Jsem v Coloradu", "coloradoRegionGateSwitch": "Přepnout na LetsMesh", "topicPrefixInvalidIata": "Předpona tématu musí být meshcore/test nebo meshcore/{IATA} (3 písmena), např. meshcore/DEN.", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "Pokus o opětovné připojení před vzdáním se (1–{{max}}). Uloženo s nastavením; po změně znovu stiskněte tlačítko Připojit, aby jej hlavní proces zvedl.", "meshtastic": "Oba protokoly umožňují 1–{{max}}. Uloženo s nastavením; odpojte a znovu připojte, aby běžící relace používala novou hodnotu." }, "reticulumStackRunningAs": "Běží — {{name}}", - "reticulumSidecarUnhealthyRestarting": "Zásobník Reticulum nereaguje – restartování…" + "reticulumSidecarUnhealthyRestarting": "Zásobník Reticulum nereaguje – restartování…", + "server": "Server", + "reticulumSharedInstance": { + "title": "Používání sdílené instance jiné aplikace", + "body": "Tato aplikace je připojena jako klient sdílené instance Reticulum jiné aplikace. Místní TCP rozbočovače z konfigurace této aplikace nejsou v tomto režimu spuštěny — provoz může používat pouze zásobník druhé aplikace.", + "networkHint": "Nebo otevřete instanci Network → Share Reticulum a vypněte Sdílení ručně a poté restartujte zásobník. Můžete také zcela ukončit MeshChat, Ratspeak nebo samostatný rnsd a restartovat.", + "disableShare": "Vypnout sdílení instance a restartovat", + "disableShareAria": "Vypněte možnost Sdílet instanci a restartujte zásobník", + "disableFailed": "Sdílení instance se nezdařilo vypnout." + } }, "contactGroupsModal": { "failedLoadMembers": "Nepodařilo se načíst členy: {{message}}", @@ -2787,7 +2790,8 @@ "hybridMqttPathTooltip": "Přijato prostřednictvím RF; některé pakety používají reléovou cestu MQTT", "hybridMqttPathAria": "Cesta RF a MQTT", "connectedViaRfAndMqttTooltip": "Připojeno přes RF a MQTT", - "connectedViaRfAndMqttAria": "Připojeno přes RF a MQTT" + "connectedViaRfAndMqttAria": "Připojeno přes RF a MQTT", + "echoesConnectionHealthTooltip": "{{score}}% zdraví připojení" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 473553af1..5990314a7 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -428,7 +428,6 @@ "jumpToQuotedMessage": "Zur zitierten Nachricht von {{sender}} springen", "replyToMessage": "Nachricht beantworten", "addReaction": "Reaktion hinzufügen", - "emojiButton": "Emojis", "sendButton": "Senden", "sendButtonDm": "DM", "sendButtonSending": "...", @@ -606,7 +605,10 @@ "floodScopeOverrideCustomApply": "Nutzen Sie den Geltungsbereich", "floodScopeOverrideCustomInvalid": "Geben Sie einen gültigen Regions-Hashtag ein (zum Beispiel #myregion).", "dmNodeHops_one": "{{count}} Hop", - "dmNodeHops_other": "{{count}} Hops" + "dmNodeHops_other": "{{count}} Hops", + "notificationDmTitle": "DM von {{sender}}", + "notificationMessageTitle": "Nachricht von {{sender}}", + "emojiButton": "Emojis" }, "chatPayload": { "mention": "Erwähne {{label}}", @@ -706,14 +708,12 @@ "submit": "Einreichen", "rePairDevice": "Gerät entfernen und erneut koppeln", "mqttConnection": "MQTT-Verbindung", - "server": "Server", "from": "Aus", "topic": "Thema", "disconnectMqtt": "Trennen", "disconnectAndQuit": "Trennen und beenden", "quit": "Aufhören", "networkPreset": "Netzwerkvoreinstellung", - "region": "Region", "port": "Hafen", "useWebSocket": "Verwenden Sie den WebSocket-Transport", "wsRequired": "(erforderlich für Port 443)", @@ -1198,20 +1198,22 @@ "coloradoRegionGateStay": "Ich bin in Colorado", "coloradoRegionGateSwitch": "Zu LetsMesh wechseln", "topicPrefixInvalidIata": "Das Themenpräfix muss meshcore/test oder meshcore/{IATA} (3 Buchstaben) sein, z. B. meshcore/DEN.", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "Versuche erneut verbinden, bevor du aufgibst (1–{{max}}). Mit Einstellungen gespeichert; drücken Sie nach dem Ändern erneut auf Verbinden, damit der Hauptprozess ihn aufnimmt.", "meshtastic": "Beide Protokolle erlauben 1–{{max}}. Mit Einstellungen gespeichert; trennen und erneut verbinden, damit die laufende Sitzung den neuen Wert verwendet." }, "reticulumStackRunningAs": "Läuft — {{name}}", - "reticulumSidecarUnhealthyRestarting": "Reticulum-Stack reagiert nicht – Neustart…" + "reticulumSidecarUnhealthyRestarting": "Reticulum-Stack reagiert nicht – Neustart…", + "server": "Server", + "region": "Bundesland", + "reticulumSharedInstance": { + "title": "Verwenden der gemeinsam genutzten Instanz einer anderen App", + "body": "Diese App wurde als Client der gemeinsam genutzten Reticulum-Instanz einer anderen App angehängt. Lokale TCP-Hubs aus der Konfiguration dieser App werden in diesem Modus nicht gestartet — der Datenverkehr darf nur den Stack der anderen App verwenden.", + "networkHint": "Oder öffnen Sie die Network → Share Reticulum-Instanz, um die Freigabe manuell zu deaktivieren, und starten Sie dann den Stack neu. Sie können auch MeshChat, Ratspeak oder eigenständiges RNSD vollständig beenden und neu starten.", + "disableShare": "Share-Instanz deaktivieren und neu starten", + "disableShareAria": "Deaktivieren Sie die Share-Instanz und starten Sie den Stack neu", + "disableFailed": "Fehler beim Deaktivieren der Share-Instanz." + } }, "contactGroupsModal": { "failedLoadMembers": "Mitglieder konnten nicht geladen werden: {{message}}", @@ -2786,7 +2788,8 @@ "hybridMqttPathTooltip": "Über RF empfangen; einige Pakete verwenden einen MQTT-Relay-Pfad", "hybridMqttPathAria": "RF- und MQTT-Pfad", "connectedViaRfAndMqttTooltip": "Verbunden über RF und MQTT", - "connectedViaRfAndMqttAria": "Verbunden über RF und MQTT" + "connectedViaRfAndMqttAria": "Verbunden über RF und MQTT", + "echoesConnectionHealthTooltip": "{{score}}% Verbindungszustand" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 33b56fb68..d70694326 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -568,6 +568,8 @@ "dmNodeSignal": "SNR {{snr}} dB", "dmNodeHops_one": "{{count}} hop", "dmNodeHops_other": "{{count}} hops", + "notificationDmTitle": "DM from {{sender}}", + "notificationMessageTitle": "Message from {{sender}}", "muteConversation": "Mute this conversation", "unmuteConversation": "Unmute this conversation", "starMessage": "Star message", @@ -2792,7 +2794,8 @@ "removeFromFavorites": "Remove from favorites", "addToFavorites": "Add to favorites", "healthTooltip": "Health score: {{total}}/100 (signal {{signal}}, recency {{recency}}, load {{load}}, battery {{battery}})", - "healthAriaLabel": "Node health score {{total}} out of 100" + "healthAriaLabel": "Node health score {{total}} out of 100", + "echoesConnectionHealthTooltip": "{{score}}% connection health" }, "rrc": { "hubsTitle": "RRC Hubs", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 9e76ff7d4..0e0afb629 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -428,7 +428,6 @@ "jumpToQuotedMessage": "Ir al mensaje cotizado de {{sender}}", "replyToMessage": "Responder al mensaje", "addReaction": "Añadir reacción:", - "emojiButton": "Emoji", "sendButton": "Enviar", "sendButtonDm": "DM", "sendButtonSending": "...", @@ -606,7 +605,10 @@ "floodScopeOverrideCustomApply": "Usar alcance", "floodScopeOverrideCustomInvalid": "Ingrese un hashtag de región válido (por ejemplo, #miregión).", "dmNodeHops_one": "{{count}} salto", - "dmNodeHops_other": "{{count}} saltos" + "dmNodeHops_other": "{{count}} saltos", + "notificationDmTitle": "DM desde {{sender}}", + "notificationMessageTitle": "Mensaje de {{sender}}", + "emojiButton": "Emoji" }, "chatPayload": { "mention": "Mencionar {{label}}", @@ -1198,20 +1200,20 @@ "coloradoRegionGateStay": "Estoy en Colorado", "coloradoRegionGateSwitch": "Cambiar a LetsMesh", "topicPrefixInvalidIata": "El prefijo del tema debe ser meshcore/test o meshcore/{IATA} (3 letras), por ejemplo, meshcore/DEN.", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "Vuelve a conectar los intentos antes de darte por vencido (1–{{max}}). Guardado con la configuración; pulse Conectar de nuevo después de cambiar para que el proceso principal lo recoja.", "meshtastic": "Ambos protocolos permiten 1–{{max}}. Guardado con la configuración; desconéctese y conéctese de nuevo para que la sesión en ejecución use el nuevo valor." }, "reticulumStackRunningAs": "En ejecución — {{name}}", - "reticulumSidecarUnhealthyRestarting": "La pila de Reticulum no responde: se reinicia…" + "reticulumSidecarUnhealthyRestarting": "La pila de Reticulum no responde: se reinicia…", + "reticulumSharedInstance": { + "title": "Usar la instancia compartida de otra aplicación", + "body": "Esta aplicación se adjuntó como cliente de la instancia de Reticulum compartida de otra aplicación. Los concentradores TCP locales de la configuración de esta aplicación no se inician en este modo; el tráfico solo puede usar la pila de la otra aplicación.", + "networkHint": "O abra la instancia de Network → Share Reticulum para desactivar Compartir manualmente y, a continuación, reinicie la pila. También puedes salir completamente de MeshChat, Ratspeak o RNSD independiente y reiniciar.", + "disableShare": "Desactivar la instancia Compartir y reiniciar", + "disableShareAria": "Desactivar la instancia Compartir y reiniciar la pila", + "disableFailed": "No se ha podido desactivar la instancia Compartir." + } }, "contactGroupsModal": { "failedLoadMembers": "No se han podido cargar los miembros: {{message}}", @@ -2786,7 +2788,8 @@ "hybridMqttPathTooltip": "Recibido a través de RF; algunos paquetes utilizan una ruta de retransmisión MQTT", "hybridMqttPathAria": "Ruta RF y MQTT", "connectedViaRfAndMqttTooltip": "Conectado a través de RF y MQTT", - "connectedViaRfAndMqttAria": "Conectado a través de RF y MQTT" + "connectedViaRfAndMqttAria": "Conectado a través de RF y MQTT", + "echoesConnectionHealthTooltip": "{{score}}% estado DE conexión" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 0f7e3c1d1..d4801736c 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -428,7 +428,6 @@ "jumpToQuotedMessage": "Aller au message cité de {{sender}}", "replyToMessage": "Répondre au message", "addReaction": "Ajoute une réaction :", - "emojiButton": "Emoji", "sendButton": "Envoyer", "sendButtonDm": "DM", "sendButtonSending": "...", @@ -606,7 +605,10 @@ "floodScopeOverrideCustomApply": "Utiliser la portée", "floodScopeOverrideCustomInvalid": "Saisissez un hashtag de région valide (par exemple #myregion).", "dmNodeHops_one": "{{count}} hop", - "dmNodeHops_other": "{{count}} sauts" + "dmNodeHops_other": "{{count}} sauts", + "notificationDmTitle": "DM de {{sender}}", + "notificationMessageTitle": "Message de {{sender}}", + "emojiButton": "Emoji" }, "chatPayload": { "mention": "Mention {{label}}", @@ -714,7 +716,6 @@ "quit": "Quitter", "networkPreset": "Préréglage réseau", "region": "Région", - "port": "Port", "useWebSocket": "Utiliser le transport WebSocket", "wsRequired": "(obligatoire pour le port 443)", "username": "Nom d'utilisateur", @@ -1198,20 +1199,21 @@ "coloradoRegionGateStay": "Je suis au Colorado", "coloradoRegionGateSwitch": "Passer à LetsMesh", "topicPrefixInvalidIata": "Le préfixe du sujet doit être meshcore/test ou meshcore/{IATA} (3 lettres), par exemple meshcore/DEN.", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "Reconnectez-vous avant d'abandonner (1–{{max}}). Enregistré avec les paramètres ; appuyez à nouveau sur Connecter après la modification pour que le processus principal le récupère.", "meshtastic": "Les deux protocoles permettent 1–{{max}}. Enregistré avec les paramètres ; déconnectez-vous et reconnectez-vous afin que la session en cours d'exécution utilise la nouvelle valeur." }, "reticulumStackRunningAs": "En cours — {{name}}", - "reticulumSidecarUnhealthyRestarting": "La pile Reticulum ne répond pas – redémarrage…" + "reticulumSidecarUnhealthyRestarting": "La pile Reticulum ne répond pas – redémarrage…", + "port": "Port", + "reticulumSharedInstance": { + "title": "Utilisation de l'instance partagée d'une autre application", + "body": "Cette application est jointe en tant que client de l'instance Reticulum partagée d'une autre application. Les concentrateurs TCP locaux de la configuration de cette application ne sont pas démarrés dans ce mode — le trafic ne peut utiliser que la pile de l'autre application.", + "networkHint": "Ou ouvrez l'instance Network → Share Reticulum pour désactiver le partage manuellement, puis redémarrez la pile. Vous pouvez également quitter complètement MeshChat, Ratspeak ou rnsd autonome et redémarrer.", + "disableShare": "Désactiver l'instance de partage et redémarrer", + "disableShareAria": "Désactiver l'instance de partage et redémarrer la pile", + "disableFailed": "Impossible de désactiver l'instance de partage." + } }, "contactGroupsModal": { "failedLoadMembers": "Échec du chargement des membres : {{message}}", @@ -2786,7 +2788,8 @@ "hybridMqttPathTooltip": "Reçu via RF ; certains paquets utilisent un chemin de relais MQTT", "hybridMqttPathAria": "Chemin RF et MQTT", "connectedViaRfAndMqttTooltip": "Connecté via RF et MQTT", - "connectedViaRfAndMqttAria": "Connecté via RF et MQTT" + "connectedViaRfAndMqttAria": "Connecté via RF et MQTT", + "echoesConnectionHealthTooltip": "{{score}}% santé de la connexion" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 6525e44c4..4a1f1b754 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -606,7 +606,9 @@ "floodScopeOverrideCustomApply": "Gunakan ruang lingkup", "floodScopeOverrideCustomInvalid": "Masukkan hashtag wilayah yang valid (misalnya #wilayahsaya).", "dmNodeHops_one": "{{count}} hop", - "dmNodeHops_other": "{{count}} hop" + "dmNodeHops_other": "{{count}} hop", + "notificationDmTitle": "DM dari {{sender}}", + "notificationMessageTitle": "Pesan dari {{sender}}" }, "chatPayload": { "mention": "Sebutkan {{label}}", @@ -1198,20 +1200,20 @@ "coloradoRegionGateStay": "Saya di Colorado", "coloradoRegionGateSwitch": "Beralih ke LetsMesh", "topicPrefixInvalidIata": "Awalan topik harus berupa meshcore/test atau meshcore/{IATA} (3 huruf), misalnya meshcore/DEN.", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "Sambungkan kembali percobaan sebelum menyerah (1 -{{max}}). Disimpan dengan pengaturan; tekan Hubungkan lagi setelah berubah sehingga proses utama mengambilnya.", "meshtastic": "Kedua protokol mengizinkan 1 -{{max}}. Disimpan dengan pengaturan; putuskan dan Hubungkan lagi sehingga sesi yang sedang berjalan menggunakan nilai baru." }, "reticulumStackRunningAs": "Berjalan — {{name}}", - "reticulumSidecarUnhealthyRestarting": "Stack Reticulum tidak responsif — memulai ulang…" + "reticulumSidecarUnhealthyRestarting": "Stack Reticulum tidak responsif — memulai ulang…", + "reticulumSharedInstance": { + "title": "Menggunakan sesi berbagi aplikasi lain", + "body": "Aplikasi ini dilampirkan sebagai klien dari instance Reticulum bersama aplikasi lain. Hub TCP lokal dari konfigurasi aplikasi ini tidak dimulai dalam mode ini — lalu lintas hanya dapat menggunakan tumpukan aplikasi lain.", + "networkHint": "Atau buka contoh Network → Share Reticulum untuk menonaktifkan Share secara manual, lalu mulai ulang tumpukan. Anda juga dapat sepenuhnya keluar dari MeshChat, Ratspeak, atau rnsd mandiri dan memulai kembali.", + "disableShare": "Nonaktifkan Bagikan sesi dan mulai ulang", + "disableShareAria": "Nonaktifkan Bagikan instance dan mulai ulang tumpukan", + "disableFailed": "Gagal menonaktifkan Bagikan instance." + } }, "contactGroupsModal": { "failedLoadMembers": "Gagal memuat anggota: {{message}}", @@ -2786,7 +2788,8 @@ "hybridMqttPathTooltip": "Diterima melalui RF; beberapa paket menggunakan jalur relai MQTT", "hybridMqttPathAria": "Jalur RF dan MQTT", "connectedViaRfAndMqttTooltip": "Terhubung melalui RF dan MQTT", - "connectedViaRfAndMqttAria": "Terhubung melalui RF dan MQTT" + "connectedViaRfAndMqttAria": "Terhubung melalui RF dan MQTT", + "echoesConnectionHealthTooltip": "{{score}}% kesehatan koneksi" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 0cd1f8f2a..957e7f73d 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -428,7 +428,6 @@ "jumpToQuotedMessage": "Vai al messaggio citato da {{sender}}", "replyToMessage": "Rispondi al messaggio", "addReaction": "Aggiungi reazione", - "emojiButton": "Emoji", "sendButton": "Inviare", "sendButtonDm": "DM", "sendButtonSending": "...", @@ -606,7 +605,10 @@ "floodScopeOverrideCustomApply": "Usa l'ambito", "floodScopeOverrideCustomInvalid": "Inserisci un hashtag regionale valido (ad esempio #myregion).", "dmNodeHops_one": "{{count}} salto", - "dmNodeHops_other": "{{count}} salti" + "dmNodeHops_other": "{{count}} salti", + "notificationDmTitle": "DM da {{sender}}", + "notificationMessageTitle": "Messaggio da {{sender}}", + "emojiButton": "Emoji" }, "chatPayload": { "mention": "Menziona {{label}}", @@ -706,7 +708,6 @@ "submit": "Invia", "rePairDevice": "Rimuovere e riassociare il dispositivo", "mqttConnection": "Connessione MQTT", - "server": "Server", "from": "Da", "topic": "Argomento", "disconnectMqtt": "Disconnetti", @@ -718,7 +719,6 @@ "useWebSocket": "Utilizzare il trasporto WebSocket", "wsRequired": "(richiesto per la porta 443)", "username": "Nome utente", - "password": "Password", "topicPrefix": "Prefisso argomento", "maxRetries": "Numero massimo di tentativi", "channelPsks": "PSK del canale", @@ -1198,20 +1198,22 @@ "coloradoRegionGateStay": "Sono in Colorado", "coloradoRegionGateSwitch": "Passa a LetsMesh", "topicPrefixInvalidIata": "Il prefisso dell'argomento deve essere meshcore/test o meshcore/{IATA} (3 lettere), ad esempio meshcore/DEN.", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "Riconnetti i tentativi prima di rinunciare (1–{{max}}). Salvato con le impostazioni; premere nuovamente Connetti dopo aver modificato in modo che il processo principale lo raccolga.", "meshtastic": "Entrambi i protocolli consentono 1–{{max}}. Salvato con le impostazioni; disconnettersi e connettersi di nuovo in modo che la sessione in esecuzione utilizzi il nuovo valore." }, "reticulumStackRunningAs": "In esecuzione — {{name}}", - "reticulumSidecarUnhealthyRestarting": "Lo stack Reticulum non risponde: riavvio in corso…" + "reticulumSidecarUnhealthyRestarting": "Lo stack Reticulum non risponde: riavvio in corso…", + "server": "Server", + "password": "Password", + "reticulumSharedInstance": { + "title": "Utilizzo dell'istanza condivisa di un'altra app", + "body": "Questa app è allegata come client dell'istanza Reticulum condivisa di un'altra app. Gli hub TCP locali dalla configurazione di questa app non vengono avviati in questa modalità: il traffico può utilizzare solo lo stack dell'altra app.", + "networkHint": "Oppure aprire l'istanza Network → Share Reticulum per disattivare manualmente Share, quindi riavviare lo stack. Puoi anche uscire completamente da MeshChat, Ratspeak o rnsd autonomo e riavviare.", + "disableShare": "Disattiva istanza di condivisione e riavvia", + "disableShareAria": "Disattiva l'istanza di condivisione e riavvia lo stack", + "disableFailed": "Impossibile disattivare l'istanza di condivisione." + } }, "contactGroupsModal": { "failedLoadMembers": "Impossibile caricare i membri: {{message}}", @@ -2786,7 +2788,8 @@ "hybridMqttPathTooltip": "Ricevuti via RF; alcuni pacchetti utilizzano un percorso di inoltro MQTT", "hybridMqttPathAria": "Percorso RF e MQTT", "connectedViaRfAndMqttTooltip": "Connesso via RF e MQTT", - "connectedViaRfAndMqttAria": "Connesso via RF e MQTT" + "connectedViaRfAndMqttAria": "Connesso via RF e MQTT", + "echoesConnectionHealthTooltip": "{{score}}% integrità della connessione" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index ab3444cd5..806e80684 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -606,7 +606,9 @@ "floodScopeOverrideCustomApply": "使用範囲", "floodScopeOverrideCustomInvalid": "有効な地域ハッシュタグ (#myregion など) を入力します。", "dmNodeHops_one": "{{count}} ホップ", - "dmNodeHops_other": "{{count}} ホップ" + "dmNodeHops_other": "{{count}} ホップ", + "notificationDmTitle": "{{sender}}からのDM", + "notificationMessageTitle": "{{sender}}からのメッセージ" }, "chatPayload": { "mention": "{{label}} について言及してください", @@ -1198,20 +1200,20 @@ "coloradoRegionGateStay": "私はコロラド州にいます", "coloradoRegionGateSwitch": "LetsMeshに切り替える", "topicPrefixInvalidIata": "トピックの接頭辞は、meshcore/testまたはmeshcore/{IATA}(3文字)でなければなりません(例:meshcore/DEN)。", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "諦める前に再接続を試みます( 1 - {{max}} )。設定が保存されました。変更後にもう一度接続を押して、メインプロセスがそれをピックアップします。", "meshtastic": "どちらのプロトコルも1 - {{max}}を許可します。設定とともに保存されました。接続を解除してもう一度接続すると、実行中のセッションが新しい値を使用します。" }, "reticulumStackRunningAs": "実行中— {{name}}", - "reticulumSidecarUnhealthyRestarting": "Reticulum スタックが応答なし — 再起動中…" + "reticulumSidecarUnhealthyRestarting": "Reticulum スタックが応答なし — 再起動中…", + "reticulumSharedInstance": { + "title": "別のアプリの共有インスタンスを使用する", + "body": "このアプリは、別のアプリの共有Reticulumインスタンスのクライアントとして接続されています。このアプリの設定からのローカルTCPハブは、このモードでは開始されません。トラフィックは、他のアプリのスタックのみを使用できます。", + "networkHint": "または、Network → Share Reticulumインスタンスを開いて手動で共有をオフにし、スタックを再起動します。MeshChat、Ratspeak、またはスタンドアロンRNSDを完全に終了して再起動することもできます。", + "disableShare": "インスタンスの共有をオフにして再起動する", + "disableShareAria": "共有インスタンスをオフにして、スタックを再起動します", + "disableFailed": "共有インスタンスをオフにできませんでした。" + } }, "contactGroupsModal": { "failedLoadMembers": "メンバーのロードに失敗しました: {{message}}", @@ -2786,7 +2788,8 @@ "hybridMqttPathTooltip": "RFを介して受信。一部のパケットはMQTTリレーパスを使用", "hybridMqttPathAria": "RFおよびMQTTパス", "connectedViaRfAndMqttTooltip": "RFおよびMQTT経由で接続", - "connectedViaRfAndMqttAria": "RFおよびMQTT経由で接続" + "connectedViaRfAndMqttAria": "RFおよびMQTT経由で接続", + "echoesConnectionHealthTooltip": "{{score}} %接続状態" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 02afe0a77..7ef3abded 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -606,7 +606,9 @@ "floodScopeOverrideCustomApply": "사용 범위", "floodScopeOverrideCustomInvalid": "유효한 지역 해시태그(예: #myregion)를 입력하세요.", "dmNodeHops_one": "{{count}} 홉", - "dmNodeHops_other": "{{count}} 홉" + "dmNodeHops_other": "{{count}} 홉", + "notificationDmTitle": "{{sender}} 님의 DM", + "notificationMessageTitle": "{{sender}} 님의 메시지" }, "chatPayload": { "mention": "{{label}}을(를) 언급하세요", @@ -1198,20 +1200,20 @@ "coloradoRegionGateStay": "콜로라도에 있습니다", "coloradoRegionGateSwitch": "LetsMesh로 전환", "topicPrefixInvalidIata": "주제 접두사는 meshcore/test 또는 meshcore/{IATA}(3글자)여야 합니다(예: meshcore/DEN).", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "포기하기 전에 다시 연결해 보세요 (1– {{max}}). 설정으로 저장되었습니다. 변경 후 Connect (연결) 을 다시 눌러 메인 프로세스가 픽업할 수 있도록 합니다.", "meshtastic": "두 프로토콜 모두 1– {{max}} 을 허용합니다. 설정과 함께 저장되었습니다. 연결을 끊고 다시 연결하여 실행 중인 세션이 새 값을 사용하도록 합니다." }, "reticulumStackRunningAs": "실행 중 — {{name}}", - "reticulumSidecarUnhealthyRestarting": "Reticulum 스택이 응답하지 않음 — 다시 시작 중…" + "reticulumSidecarUnhealthyRestarting": "Reticulum 스택이 응답하지 않음 — 다시 시작 중…", + "reticulumSharedInstance": { + "title": "다른 앱의 공유 인스턴스 사용", + "body": "이 앱은 다른 앱의 공유 Reticulum 인스턴스의 클라이언트로 연결되었습니다. 이 앱의 구성에서 로컬 TCP 허브는 이 모드에서 시작되지 않습니다. 트래픽은 다른 앱의 스택만 사용할 수 있습니다.", + "networkHint": "또는 Network → Share Reticulum 인스턴스를 열어 Share를 수동으로 끄고 스택을 다시 시작합니다. MeshChat, Ratspeak 또는 독립 실행형 rnsd를 완전히 종료하고 다시 시작할 수도 있습니다.", + "disableShare": "인스턴스 공유 끄고 다시 시작", + "disableShareAria": "인스턴스 공유 끄고 스택 다시 시작", + "disableFailed": "회차 공유 기능을 끄지 못했습니다." + } }, "contactGroupsModal": { "failedLoadMembers": "멤버를 로드하지 못했습니다: {{message}}", @@ -2786,7 +2788,8 @@ "hybridMqttPathTooltip": "RF를 통해 수신; 일부 패킷은 MQTT 릴레이 경로를 사용합니다", "hybridMqttPathAria": "RF 및 MQTT 경로", "connectedViaRfAndMqttTooltip": "RF 및 MQTT를 통해 연결됨", - "connectedViaRfAndMqttAria": "RF 및 MQTT를 통해 연결됨" + "connectedViaRfAndMqttAria": "RF 및 MQTT를 통해 연결됨", + "echoesConnectionHealthTooltip": "{{score}} % 연결 상태" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index c5fd3fa4c..1037277a7 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -428,7 +428,6 @@ "jumpToQuotedMessage": "Ga naar het geciteerde bericht van {{sender}}", "replyToMessage": "Beantwoord bericht", "addReaction": "Reactie toevoegen", - "emojiButton": "Emoji", "sendButton": "Versturen", "sendButtonDm": "DM", "sendButtonSending": "...", @@ -540,7 +539,6 @@ "starredDm": "DM: {{name}}", "starredChannel": "ch{{channel}}", "mentionSuggestionsAria": "Noem suggesties", - "openDmByAddress": "Open", "dmAddressPlaceholder": "Bestemmingshash of lxmf://…", "dmAddressAria": "Bestemmingsadres van Reticulum", "dmAddressInvalid": "Voer een geldig bestemmingshash- of lxmf://-adres van 32 tekens in.", @@ -582,7 +580,6 @@ "sendFileAria": "Bestand verzenden naar {{name}} via rncp", "pendingOffersBadgeAria_one": "{{count}} in afwachting van aanbieding voor inkomende bestanden van deze collega", "pendingOffersBadgeAria_other": "{{count}} lopende aanbiedingen voor inkomende bestanden van deze collega", - "destinationLabel": "rncp destination hash", "rememberAddress": "Dit adres onthouden", "rememberAddressAria": "Onthoud deze rncp-bestemming voor deze collega", "chooseAndSend": "Bestand kiezen en verzenden...", @@ -591,7 +588,8 @@ "sendFailed": "Verzenden mislukt: {{error}}", "newOfferToast": "{{peer}} wil je een bestand sturen: {{file}}", "requestEnable": "Aanvraag inschakelen", - "requestEnableAria": "Vraag deze collega om bestandsontvangst in te schakelen" + "requestEnableAria": "Vraag deze collega om bestandsontvangst in te schakelen", + "destinationLabel": "rncp destination hash" }, "shareLocation": "Deel locatie", "shareLocationLabel": "📍 Gedeelde locatie", @@ -606,7 +604,11 @@ "floodScopeOverrideCustomApply": "Gebruik bereik", "floodScopeOverrideCustomInvalid": "Voer een geldige regio-hashtag in (bijvoorbeeld #mijnregio).", "dmNodeHops_one": "{{count}} hop", - "dmNodeHops_other": "{{count}} hops" + "dmNodeHops_other": "{{count}} hops", + "notificationDmTitle": "DM vanaf {{sender}}", + "notificationMessageTitle": "Bericht van {{sender}}", + "emojiButton": "Emoji", + "openDmByAddress": "Open" }, "chatPayload": { "mention": "Vermeld {{label}}", @@ -706,7 +708,6 @@ "submit": "Indienen", "rePairDevice": "Apparaat verwijderen en opnieuw koppelen", "mqttConnection": "MQTT-verbinding", - "server": "Server", "from": "Van", "topic": "Onderwerp", "disconnectMqtt": "Verbreek de verbinding", @@ -1198,20 +1199,21 @@ "coloradoRegionGateStay": "Ik ben in Colorado", "coloradoRegionGateSwitch": "Overschakelen naar LetsMesh", "topicPrefixInvalidIata": "Onderwerpvoorvoegsel moet meshcore/test of meshcore/{IATA} zijn (3 letters), bijv. meshcore/DEN.", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "Probeer het opnieuw voordat je het opgeeft (1–{{max}}). Opgeslagen met instellingen; druk opnieuw op Verbinden na het wijzigen, zodat het hoofdproces het oppikt.", "meshtastic": "Beide protocollen staan 1–{{max}} toe. Opgeslagen met instellingen; verbreek de verbinding en maak opnieuw verbinding, zodat de lopende sessie de nieuwe waarde gebruikt." }, "reticulumStackRunningAs": "Actief — {{name}}", - "reticulumSidecarUnhealthyRestarting": "Reticulum-stack reageert niet — opnieuw opstarten…" + "reticulumSidecarUnhealthyRestarting": "Reticulum-stack reageert niet — opnieuw opstarten…", + "server": "Server", + "reticulumSharedInstance": { + "title": "Gedeelde instantie van een andere app gebruiken", + "body": "Deze app is bijgevoegd als een client van het gedeelde Reticulum-exemplaar van een andere app. Lokale TCP-hubs van de configuratie van deze app worden niet gestart in deze modus — verkeer mag alleen de stack van de andere app gebruiken.", + "networkHint": "Of open Network → Share Reticulum-instantie om delen handmatig uit te schakelen en start vervolgens de stapel opnieuw. Je kunt MeshChat, Ratspeak of standalone rnsd ook volledig afsluiten en opnieuw starten.", + "disableShare": "Deel instantie uitschakelen en opnieuw opstarten", + "disableShareAria": "Schakel de instantie Delen uit en start de stapel opnieuw", + "disableFailed": "Kan de instantie Delen niet uitschakelen." + } }, "contactGroupsModal": { "failedLoadMembers": "Kan leden niet laden: {{message}}", @@ -2786,7 +2788,8 @@ "hybridMqttPathTooltip": "Ontvangen via RF; sommige pakketten gebruiken een MQTT-relaispad", "hybridMqttPathAria": "RF- en MQTT-pad", "connectedViaRfAndMqttTooltip": "Verbonden via RF en MQTT", - "connectedViaRfAndMqttAria": "Verbonden via RF en MQTT" + "connectedViaRfAndMqttAria": "Verbonden via RF en MQTT", + "echoesConnectionHealthTooltip": "{{score}}% verbindingsstatus" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 769973cf9..967f9f016 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -428,7 +428,6 @@ "jumpToQuotedMessage": "Przejdź do cytowanej wiadomości od {{sender}}", "replyToMessage": "Odpowiedz na wiadomość", "addReaction": "Dodaj reakcję", - "emojiButton": "Emoji", "sendButton": "Wysłać", "sendButtonDm": "DM", "sendButtonSending": "...", @@ -608,7 +607,10 @@ "dmNodeHops_one": "{{count}} przeskok", "dmNodeHops_few": "{{count}} przeskoki", "dmNodeHops_many": "{{count}} przeskoków", - "dmNodeHops_other": "{{count}} przeskoku" + "dmNodeHops_other": "{{count}} przeskoku", + "notificationDmTitle": "DM from {{sender}}", + "notificationMessageTitle": "Wiadomość od {{sender}}", + "emojiButton": "Emoji" }, "chatPayload": { "mention": "Wspomnij o {{label}}", @@ -715,8 +717,6 @@ "disconnectAndQuit": "Rozłącz i zakończ", "quit": "Zrezygnować", "networkPreset": "Ustawienia sieciowe", - "region": "Region", - "port": "Port", "useWebSocket": "Użyj transportu WebSocket", "wsRequired": "(wymagane dla portu 443)", "username": "Nazwa użytkownika", @@ -1200,20 +1200,22 @@ "coloradoRegionGateStay": "Jestem w Kolorado", "coloradoRegionGateSwitch": "Przełącz na LetsMesh", "topicPrefixInvalidIata": "Prefiks tematu musi być meshcore/test lub meshcore/{IATA} (3 litery), np. meshcore/DEN.", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "Ponownie połącz próby przed rezygnacją (1–{{max}}). Zapisano z ustawieniami; naciśnij ponownie Połącz po zmianie, aby główny proces go podniósł.", "meshtastic": "Oba protokoły umożliwiają 1–{{max}}. Zapisano z ustawieniami; odłącz i połącz ponownie, aby uruchomiona sesja używała nowej wartości." }, "reticulumStackRunningAs": "Działa — {{name}}", - "reticulumSidecarUnhealthyRestarting": "Stos Reticulum nie odpowiada — uruchamiam ponownie…" + "reticulumSidecarUnhealthyRestarting": "Stos Reticulum nie odpowiada — uruchamiam ponownie…", + "region": "Region", + "port": "Port", + "reticulumSharedInstance": { + "title": "Korzystanie z udostępnionej instancji innej aplikacji", + "body": "Ta aplikacja została dołączona jako klient udostępnionej instancji Reticulum innej aplikacji. W tym trybie nie są uruchamiane lokalne koncentratory TCP z konfiguracji tej aplikacji — ruch może korzystać tylko ze stosu innej aplikacji.", + "networkHint": "Lub otwórz instancję Network → Share Reticulum, aby ręcznie wyłączyć Udostępnianie, a następnie ponownie uruchom stos. Możesz również całkowicie wyjść z MeshChat, Ratspeak lub samodzielnego rnsd i uruchomić ponownie.", + "disableShare": "Wyłącz udostępnianie instancji i uruchom ponownie", + "disableShareAria": "Wyłącz opcję Udostępnij instancję i uruchom ponownie stos", + "disableFailed": "Nie udało się wyłączyć instancji udostępniania." + } }, "contactGroupsModal": { "failedLoadMembers": "Nie udało się załadować członków: {{message}}", @@ -2788,7 +2790,8 @@ "hybridMqttPathTooltip": "Odebrane przez RF; niektóre pakiety używają ścieżki przekaźnika MQTT", "hybridMqttPathAria": "Ścieżka RF i MQTT", "connectedViaRfAndMqttTooltip": "Połączono przez RF i MQTT", - "connectedViaRfAndMqttAria": "Połączono przez RF i MQTT" + "connectedViaRfAndMqttAria": "Połączono przez RF i MQTT", + "echoesConnectionHealthTooltip": "{{score}}% stan połączenia" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 81e9e846e..99ed9af65 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -606,7 +606,9 @@ "floodScopeOverrideCustomApply": "Usar escopo", "floodScopeOverrideCustomInvalid": "Insira uma hashtag de região válida (por exemplo #myregion).", "dmNodeHops_one": "{{count}} hop", - "dmNodeHops_other": "{{count}} saltos" + "dmNodeHops_other": "{{count}} saltos", + "notificationDmTitle": "DM de {{sender}}", + "notificationMessageTitle": "Mensagem de {{sender}}" }, "chatPayload": { "mention": "Mencionar {{label}}", @@ -1198,20 +1200,20 @@ "coloradoRegionGateStay": "Estou no Colorado", "coloradoRegionGateSwitch": "Mudar para LetsMesh", "topicPrefixInvalidIata": "O prefixo do tópico deve ser meshcore/test ou meshcore/{IATA} (3 letras), por exemplo, meshcore/DEN.", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "Reconecte as tentativas antes de desistir (1–{{max}}). Salvo com as configurações; pressione Conectar novamente depois de alterar para que o processo principal o pegue.", "meshtastic": "Ambos os protocolos permitem 1–{{max}}. Salvo com configurações; desconecte e conecte novamente para que a sessão em execução use o novo valor." }, "reticulumStackRunningAs": "Em execução — {{name}}", - "reticulumSidecarUnhealthyRestarting": "Pilha Reticulum sem resposta – reiniciando…" + "reticulumSidecarUnhealthyRestarting": "Pilha Reticulum sem resposta – reiniciando…", + "reticulumSharedInstance": { + "title": "Usar a instância compartilhada de outro aplicativo", + "body": "Este aplicativo é anexado como um cliente da instância Reticulum compartilhada de outro aplicativo. Os hubs TCP locais da configuração deste aplicativo não são iniciados neste modo — o tráfego só pode usar a pilha do outro aplicativo.", + "networkHint": "Ou abra Network → Share Reticulum instance para desativar o Share manualmente e reinicie a pilha. Você também pode sair totalmente do MeshChat, Ratspeak ou rnsd autônomo e reiniciar.", + "disableShare": "Desativar a instância Compartilhar e reiniciar", + "disableShareAria": "Desativar a instância Compartilhar e reiniciar a pilha", + "disableFailed": "Falha ao desativar a instância Compartilhar." + } }, "contactGroupsModal": { "failedLoadMembers": "Falha ao carregar membros: {{message}}", @@ -2786,7 +2788,8 @@ "hybridMqttPathTooltip": "Recebido via RF; alguns pacotes usam um caminho de retransmissão MQTT", "hybridMqttPathAria": "Caminho de RF e MQTT", "connectedViaRfAndMqttTooltip": "Conectado via RF e MQTT", - "connectedViaRfAndMqttAria": "Conectado via RF e MQTT" + "connectedViaRfAndMqttAria": "Conectado via RF e MQTT", + "echoesConnectionHealthTooltip": "{{score}}% integridade da ligação" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 61e20d363..4fd70842d 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -608,7 +608,9 @@ "dmNodeHops_one": "{{count}} переход", "dmNodeHops_few": "{{count}} перехода", "dmNodeHops_many": "{{count}} переходов", - "dmNodeHops_other": "{{count}} перехода" + "dmNodeHops_other": "{{count}} перехода", + "notificationDmTitle": "ДМ от {{sender}}", + "notificationMessageTitle": "Сообщение от {{sender}}" }, "chatPayload": { "mention": "Упоминание {{label}}", @@ -1200,20 +1202,20 @@ "coloradoRegionGateStay": "Я в Колорадо", "coloradoRegionGateSwitch": "Переключиться на LetsMesh", "topicPrefixInvalidIata": "Префикс темы должен быть meshcore/test или meshcore/{IATA} (3 буквы), например meshcore/DEN.", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "Повторно подключитесь, прежде чем сдаваться (1–{{max}}). Сохранено с настройками; нажмите «Подключиться» еще раз после изменения, чтобы основной процесс забрал его.", "meshtastic": "Оба протокола допускают 1–{{max}}. Сохранено с настройками; отключите и снова подключитесь, чтобы сеанс работы использовал новое значение." }, "reticulumStackRunningAs": "Работает — {{name}}", - "reticulumSidecarUnhealthyRestarting": "Стек Reticulum не отвечает — перезапуск…" + "reticulumSidecarUnhealthyRestarting": "Стек Reticulum не отвечает — перезапуск…", + "reticulumSharedInstance": { + "title": "Использование общего экземпляра другого приложения", + "body": "Это приложение прикреплено в качестве клиента общего экземпляра Reticulum другого приложения. Локальные концентраторы TCP из конфигурации этого приложения не запускаются в этом режиме — трафик может использовать только стек другого приложения.", + "networkHint": "Или откройте экземпляр Network → Share Reticulum, чтобы отключить общий доступ вручную, а затем перезапустите стек. Вы также можете полностью выйти из MeshChat, Ratspeak или автономного rnsd и перезапустить.", + "disableShare": "Отключите «Поделиться экземпляром» и перезапустите", + "disableShareAria": "Отключите «Поделиться экземпляром» и перезапустите стек", + "disableFailed": "Не удалось отключить экземпляр Share." + } }, "contactGroupsModal": { "failedLoadMembers": "Не удалось загрузить участников: {{message}}.", @@ -2788,7 +2790,8 @@ "hybridMqttPathTooltip": "Получено через RF; некоторые пакеты используют путь ретрансляции MQTT", "hybridMqttPathAria": "Путь RF и MQTT", "connectedViaRfAndMqttTooltip": "Подключено через RF и MQTT", - "connectedViaRfAndMqttAria": "Подключено через RF и MQTT" + "connectedViaRfAndMqttAria": "Подключено через RF и MQTT", + "echoesConnectionHealthTooltip": "{{score}}% исправности соединения" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index f4199b2a1..d8d2e9fab 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -606,7 +606,9 @@ "floodScopeOverrideCustomApply": "Kapsamı kullan", "floodScopeOverrideCustomInvalid": "Geçerli bir bölge hashtag'i girin (örneğin #myregion).", "dmNodeHops_one": "{{count}} atlama", - "dmNodeHops_other": "{{count}} atlama" + "dmNodeHops_other": "{{count}} atlama", + "notificationDmTitle": "{{sender}} 'dan DM", + "notificationMessageTitle": "{{sender}} adlı kişiden mesaj" }, "chatPayload": { "mention": "{{label}}'dan bahsedin", @@ -1198,20 +1200,20 @@ "coloradoRegionGateStay": "Colorado'dayım", "coloradoRegionGateSwitch": "LetsMesh'e geç", "topicPrefixInvalidIata": "Konu ön eki meshcore/test veya meshcore/{IATA} (3 harf), ör. meshcore/DEN olmalıdır.", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "Vazgeçmeden önce denemeleri yeniden bağlayın (1 -{{max}}). Ayarlar ile kaydedildi; değiştirdikten sonra Bağlan'a tekrar basın, böylece ana işlem onu alır.", "meshtastic": "Her iki protokol de 1 -{{max}} 'a izin verir. Ayarlar ile kaydedildi; bağlantıyı kesin ve tekrar bağlanın, böylece çalışan oturum yeni değeri kullanır." }, "reticulumStackRunningAs": "Çalışıyor — {{name}}", - "reticulumSidecarUnhealthyRestarting": "Reticulum yığını yanıt vermiyor — yeniden başlatılıyor…" + "reticulumSidecarUnhealthyRestarting": "Reticulum yığını yanıt vermiyor — yeniden başlatılıyor…", + "reticulumSharedInstance": { + "title": "Başka bir uygulamanın paylaşılan örneğini kullanma", + "body": "Bu uygulama, başka bir uygulamanın paylaşılan Reticulum örneğinin istemcisi olarak eklenmiştir. Bu uygulamanın yapılandırmasındaki yerel TCP hub'ları bu modda başlatılmaz; trafik yalnızca diğer uygulamanın yığınını kullanabilir.", + "networkHint": "Veya → Paylaşımı manuel olarak kapatmak için Ağ Paylaşımı Reticulum örneğini açın, ardından yığını yeniden başlatın. Ayrıca MeshChat, Ratspeak veya bağımsız rnsd'den tamamen çıkıp yeniden başlatabilirsiniz.", + "disableShare": "Paylaşım örneğini kapatın ve yeniden başlatın", + "disableShareAria": "Paylaşım örneğini kapat ve yığını yeniden başlat", + "disableFailed": "Paylaşım örneği kapatılamadı." + } }, "contactGroupsModal": { "failedLoadMembers": "Üyeler yüklenemedi: {{message}}", @@ -2786,7 +2788,8 @@ "hybridMqttPathTooltip": "RF aracılığıyla alınır; bazı paketler bir MQTT röle yolu kullanır", "hybridMqttPathAria": "RF ve MQTT yolu", "connectedViaRfAndMqttTooltip": "RF ve MQTT aracılığıyla bağlanıldı", - "connectedViaRfAndMqttAria": "RF ve MQTT aracılığıyla bağlanıldı" + "connectedViaRfAndMqttAria": "RF ve MQTT aracılığıyla bağlanıldı", + "echoesConnectionHealthTooltip": "{{score}}% bağlantı sağlığı" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 5d2cd0e2c..520381a39 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -608,7 +608,9 @@ "dmNodeHops_one": "{{count}} перехід", "dmNodeHops_few": "{{count}} переходи", "dmNodeHops_many": "{{count}} переходів", - "dmNodeHops_other": "{{count}} переходу" + "dmNodeHops_other": "{{count}} переходу", + "notificationDmTitle": "ДМ від {{sender}}", + "notificationMessageTitle": "Повідомлення від {{sender}}" }, "chatPayload": { "mention": "Згадайте {{label}}", @@ -1200,20 +1202,20 @@ "coloradoRegionGateStay": "Я в Колорадо", "coloradoRegionGateSwitch": "Перемкнутися на LetsMesh", "topicPrefixInvalidIata": "Префікс теми повинен бути meshcore/test або meshcore/{IATA} (3 літери), наприклад meshcore/DEN.", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "Перш ніж відмовитися, повторіть спробу підключення (1–{{max}}). Збережено з налаштуваннями; натисніть Connect ще раз після зміни, щоб основний процес забрав його.", "meshtastic": "Обидва протоколи дозволяють 1–{{max}}. Збережено з налаштуваннями; від'єднайтеся та підключіться знову, щоб сеанс роботи використовував нове значення." }, "reticulumStackRunningAs": "Триває — {{name}}", - "reticulumSidecarUnhealthyRestarting": "Стек Reticulum не відповідає — перезапуск…" + "reticulumSidecarUnhealthyRestarting": "Стек Reticulum не відповідає — перезапуск…", + "reticulumSharedInstance": { + "title": "Використання спільного екземпляра іншої програми", + "body": "Ця програма прикріплена як клієнт спільного екземпляра Reticulum іншої програми. Локальні концентратори TCP з конфігурації цієї програми не запускаються в цьому режимі — трафік може використовувати лише стек іншої програми.", + "networkHint": "Або відкрийте екземпляр Network → Share Reticulum, щоб вимкнути спільний доступ вручну, а потім перезапустіть стек. Ви також можете повністю вийти з MeshChat, Ratspeak або автономного rnsd і перезапустити.", + "disableShare": "Вимкнути спільний екземпляр і перезапустити", + "disableShareAria": "Вимкніть функцію Поділитися екземпляром і перезапустіть стек", + "disableFailed": "Не вдалося вимкнути екземпляр спільного доступу." + } }, "contactGroupsModal": { "failedLoadMembers": "Не вдалося завантажити учасників: {{message}}", @@ -2788,7 +2790,8 @@ "hybridMqttPathTooltip": "Отримано через RF; деякі пакети використовують шлях ретрансляції MQTT", "hybridMqttPathAria": "Шлях RF та MQTT", "connectedViaRfAndMqttTooltip": "Підключено через RF та MQTT", - "connectedViaRfAndMqttAria": "Підключено через RF та MQTT" + "connectedViaRfAndMqttAria": "Підключено через RF та MQTT", + "echoesConnectionHealthTooltip": "{{score}}% здоров'я з'єднання" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 9c721f565..2ae28445f 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -606,7 +606,9 @@ "floodScopeOverrideCustomApply": "使用范围", "floodScopeOverrideCustomInvalid": "输入有效的区域标签(例如#myregion)。", "dmNodeHops_one": "{{count}}跳", - "dmNodeHops_other": "{{count}}跳" + "dmNodeHops_other": "{{count}}跳", + "notificationDmTitle": "来自{{sender}}的DM", + "notificationMessageTitle": "来自{{sender}}的消息" }, "chatPayload": { "mention": "提及{{label}}", @@ -1198,20 +1200,20 @@ "coloradoRegionGateStay": "我在科罗拉多州", "coloradoRegionGateSwitch": "切换到LetsMesh", "topicPrefixInvalidIata": "主题前缀必须是meshcore/test或meshcore/{IATA} ( 3个字母) ,例如meshcore/DEN。", - "reticulumSharedInstance": { - "title": "Using another app’s shared instance", - "body": "This app attached as a client of another app’s shared Reticulum instance. Local TCP hubs from this app’s config are not started in this mode — traffic may only use the other app’s stack.", - "networkHint": "Or open Network → Share Reticulum instance to turn Share off manually, then restart the stack. You can also fully quit MeshChat, Ratspeak, or standalone rnsd and restart.", - "disableShare": "Turn off Share instance and restart", - "disableShareAria": "Turn off Share instance and restart the stack", - "disableFailed": "Failed to turn off Share instance." - }, "maxRetriesHelpConnected": { "meshcore": "在放弃前尝试重新连接( 1– {{max}} )。已保存设置;更改后再次按“连接” ,以便主进程将其拾取。", "meshtastic": "两种方案均允许1– {{max}}。已使用设置保存;断开连接并再次连接,以便正在运行的会话使用新值。" }, "reticulumStackRunningAs": "运行中 — {{name}}", - "reticulumSidecarUnhealthyRestarting": "Reticulum 堆栈无响应 — 正在重新启动..." + "reticulumSidecarUnhealthyRestarting": "Reticulum 堆栈无响应 — 正在重新启动...", + "reticulumSharedInstance": { + "title": "使用其他应用的共享实例", + "body": "此应用程序附加为另一个应用程序的共享Reticulum实例的客户端。来自此应用程序配置的本地TCP集线器未在此模式下启动—流量只能使用其他应用程序的堆栈。", + "networkHint": "或者打开Network → Share Reticulum实例手动关闭Share ,然后重新启动堆栈。您还可以完全退出MeshChat、Ratspeak或独立RNSD并重新启动。", + "disableShare": "关闭共享实例并重新启动", + "disableShareAria": "关闭共享实例并重新启动堆栈", + "disableFailed": "无法关闭共享实例。" + } }, "contactGroupsModal": { "failedLoadMembers": "无法加载会员: {{message}}", @@ -2786,7 +2788,8 @@ "hybridMqttPathTooltip": "通过射频接收;某些数据包使用MQTT中继路径", "hybridMqttPathAria": "射频和MQTT路径", "connectedViaRfAndMqttTooltip": "通过射频和MQTT连接", - "connectedViaRfAndMqttAria": "通过射频和MQTT连接" + "connectedViaRfAndMqttAria": "通过射频和MQTT连接", + "echoesConnectionHealthTooltip": "{{score}} %连接健康" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 979369f98..7c19e0dfd 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -1932,7 +1932,9 @@ export function useMeshcoreRuntime() { const observeMeshcoreSetupAbort = useCallback((promise: Promise): void => { void promise.catch((e: unknown) => { if (isMeshcoreSetupAbortError(e)) return; - throw e; + console.warn( + '[useMeshcoreRuntime] observed parallel setup rejection ' + errLikeToLogString(e), + ); }); }, []); @@ -5584,7 +5586,11 @@ export function useMeshcoreRuntime() { ); }); } - void setMeshcoreRoomLastPostAt(nodeId, sentAt); + void setMeshcoreRoomLastPostAt(nodeId, sentAt).catch((e: unknown) => { + console.warn( + '[useMeshcoreRuntime] setMeshcoreRoomLastPostAt failed ' + errLikeToLogString(e), + ); + }); } catch (e: unknown) { const errMsg = meshcoreRoomPostSendErrorStored(e); const failed: ChatMessage = { ...tempMsg, status: 'failed', error: errMsg }; diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index c4bebd5e1..6f1187d5c 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -110,6 +110,15 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { /attachRfSession[\s\S]{0,3500}reconnectGenerationRef\.current !== generation[\s\S]{0,200}Attach superseded during configure/, ); }); + + it('uses nodeStore as the merge base and synchronizes runtime patches immediately', () => { + const updateNodesBody = extractUseCallbackBody(SOURCE, 'updateNodes'); + expect(updateNodesBody).toContain('getIdentityNodeMap(identityId)'); + expect(updateNodesBody).toContain('syncNodesMapToIdentityStore(identityId, next)'); + expect(SOURCE).not.toMatch( + /useEffect\(\(\) => \{[\s\S]{0,250}syncNodesMapToIdentityStore\(storeId, nodes\)/, + ); + }); }); describe('useMeshtasticRuntime manual disconnect must not auto-reconnect', () => { diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 84bffb101..09f1bd349 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -97,6 +97,7 @@ import { withNobleBleConnectMutex, } from '../lib/meshcoreDualNobleBleInit'; import { meshtasticTransportParams } from '../lib/meshIdentityBridge'; +import { setMeshtasticRemoteConfigTarget } from '../lib/meshtastic/meshtasticConfigIngressGuard'; import { configureMeshtasticDeviceWithRetry } from '../lib/meshtastic/meshtasticConfigureRetry'; import type { ModulePortEvent, PaxCounterPoint } from '../lib/meshtastic/meshtasticModuleEvents'; import { normalizeMeshtasticMqttChatMessage } from '../lib/meshtastic/meshtasticMqttChatNormalize'; @@ -191,6 +192,7 @@ import { import { getStoredMeshProtocol } from '../lib/storedMeshProtocol'; import { chatMessageToMessageRecord, + meshNodeToNodeRecord, messageRecordsToChatMessages, messageRecordToChatMessage, neighborInfoEventsToRecordMap, @@ -235,7 +237,7 @@ import { upsertMessage, useMessageStore, } from '../stores/messageStore'; -import { patchNodeFavorited, useNodeStore } from '../stores/nodeStore'; +import { patchNodeFavorited, upsertNodeRecord, useNodeStore } from '../stores/nodeStore'; import { usePositionHistoryStore } from '../stores/positionHistoryStore'; type ChannelType = Parameters[0]; @@ -256,6 +258,18 @@ const HTTP_STALE_THRESHOLD_MS = 90_000; // 90s — show warning const HTTP_DEAD_THRESHOLD_MS = 180_000; // 3min — trigger reconnect const WATCHDOG_INTERVAL_MS = 15_000; // Check every 15s +function persistMeshtasticNode(node: MeshNode): void { + void window.electronAPI.db.saveNode(node).catch((e: unknown) => { + console.debug('[useMeshtasticRuntime] saveNode failed ' + errLikeToLogString(e)); + }); +} + +function persistMeshtasticMessage(message: ChatMessage): void { + void window.electronAPI.db.saveMessage(message).catch((e: unknown) => { + console.warn('[useMeshtasticRuntime] saveMessage failed ' + errLikeToLogString(e)); + }); +} + function getOrCreateVirtualNodeId(): number { const key = 'mesh-client:mqttVirtualNodeId'; const existing = localStorage.getItem(key); @@ -391,8 +405,8 @@ export function useMeshtasticRuntime() { const rfHeardNodeIds = useRef>(new Set()); const lastRfSelfNodeIdRef = useRef(loadPersistedLastRfSelfNodeId()); const virtualNodeIdRef = useRef(getOrCreateVirtualNodeId()); - // Dedup map shared between RF and MQTT handlers - const seenPacketIds = useRef>(new Map()); + // MQTT-only fallback; RF sessions use the ingest session's shared RF/MQTT registry. + const mqttOnlySeenPacketIdsRef = useRef>(new Map()); /** Last time we sent a proactive NODEINFO_APP request for each node (debounce). */ const lastNodeInfoRequestAtRef = useRef>(new Map()); @@ -556,7 +570,14 @@ export function useMeshtasticRuntime() { const updateNodes = useCallback( (updater: (prev: Map) => Map) => { - setNodes((prev) => updater(prev)); + // The identity-scoped store is the merge base and is updated immediately; + // `nodes` remains only a pre-identity/fallback render snapshot. + const identityId = + meshtasticIdentityIdRef.current ?? meshtasticPendingDriverIdentityRef.current; + const base = identityId ? getIdentityNodeMap(identityId) : new Map(); + const next = updater(base); + setNodes(next); + if (identityId) syncNodesMapToIdentityStore(identityId, next); }, [], ); @@ -573,29 +594,15 @@ export function useMeshtasticRuntime() { [], ); - // Push runtime node map into identity-scoped Zustand after commit — never inside setState updaters. - useEffect(() => { - const storeId = meshtasticIdentityIdRef.current ?? meshtasticPendingDriverIdentityRef.current; - if (!storeId) return; - syncNodesMapToIdentityStore(storeId, nodes); - }, [nodes, meshtasticIdentityId]); - - const ensureNodeExists = useCallback( - (nodeNum: number, source: 'rf' | 'mqtt') => { - if (nodeNum === 0 || getIdentityNode(meshtasticIdentityIdRef.current, nodeNum) != null) { - return; - } - updateNodes((prev) => { - if (prev.has(nodeNum)) return prev; - const created = createChatStubNode(nodeNum, source); - const next = new Map(prev); - next.set(nodeNum, created); - void window.electronAPI.db.saveNode(created); - return next; - }); - }, - [updateNodes], - ); + const ensureNodeExists = useCallback((nodeNum: number, source: 'rf' | 'mqtt') => { + const identityId = meshtasticIdentityIdRef.current; + if (!identityId || nodeNum === 0 || getIdentityNode(identityId, nodeNum) != null) { + return; + } + const created = createChatStubNode(nodeNum, source); + upsertNodeRecord(identityId, meshNodeToNodeRecord(created)); + persistMeshtasticNode(created); + }, []); const pushMqttChannelKeys = useCallback(() => { if (mqttStatusRef.current !== 'connected') return; @@ -651,18 +658,30 @@ export function useMeshtasticRuntime() { // ─── Packet dedup helper (shared by RF and MQTT handlers) ────── const isDuplicate = useCallback((senderId: number, packetId: number): boolean => { + const session = meshtasticIngestSessionRef.current; + if (session) return session.isDuplicatePacket(senderId, packetId); const now = Date.now(); const key = meshtasticPacketDedupKey(senderId, packetId); - const expiry = seenPacketIds.current.get(key); - if (expiry !== undefined && expiry > now) return true; - seenPacketIds.current.set(key, now + 10 * 60 * 1000); - // Periodic cleanup to prevent unbounded growth - if (seenPacketIds.current.size > 5_000) { - for (const [id, exp] of seenPacketIds.current) { - if (exp < now) seenPacketIds.current.delete(id); + const expiry = mqttOnlySeenPacketIdsRef.current.get(key); + mqttOnlySeenPacketIdsRef.current.set(key, now + 10 * 60 * 1000); + if (mqttOnlySeenPacketIdsRef.current.size > 5_000) { + for (const [id, expiresAt] of mqttOnlySeenPacketIdsRef.current) { + if (expiresAt <= now) mqttOnlySeenPacketIdsRef.current.delete(id); } } - return false; + return expiry !== undefined && expiry > now; + }, []); + + const markPacketSeen = useCallback((senderId: number, packetId: number): void => { + const session = meshtasticIngestSessionRef.current; + if (session) { + session.markPacketSeen(senderId, packetId); + return; + } + mqttOnlySeenPacketIdsRef.current.set( + meshtasticPacketDedupKey(senderId, packetId), + Date.now() + 10 * 60 * 1000, + ); }, []); // Compact display name: short_name, truncated long_name, or hex ID @@ -933,6 +952,12 @@ export function useMeshtasticRuntime() { configureTargetNodeNumRef.current = configureTargetNodeNum; }, [configureTargetNodeNum]); + useEffect(() => { + if (!meshtasticIdentityId) return; + setMeshtasticRemoteConfigTarget(meshtasticIdentityId, configureTargetNodeNum); + return () => setMeshtasticRemoteConfigTarget(meshtasticIdentityId, null); + }, [configureTargetNodeNum, meshtasticIdentityId]); + useEffect(() => { remoteAdminStatusRef.current = remoteAdminStatus; }, [remoteAdminStatus]); @@ -1088,7 +1113,7 @@ export function useMeshtasticRuntime() { heard_via_mqtt_only: true, }; updated.set(mqttOnlyId, rfNode); - void window.electronAPI.db.saveNode(rfNode); + persistMeshtasticNode(rfNode); return updated; } const existing = updated.get(virtualId) ?? emptyNode(virtualId); @@ -1104,7 +1129,7 @@ export function useMeshtasticRuntime() { heard_via_mqtt_only: true, }; updated.set(virtualId, virtualNode); - void window.electronAPI.db.saveNode(virtualNode); + persistMeshtasticNode(virtualNode); return updated; }); // Periodic NodeInfo broadcast so other nodes see this client (every 5 min) @@ -1266,7 +1291,7 @@ export function useMeshtasticRuntime() { node.node_id, ); updated.set(nodeUpdate.node_id, node); - void window.electronAPI.db.saveNode(node); + persistMeshtasticNode(node); return updated; }); const updatedMqttNode = getIdentityNode(meshtasticIdentityIdRef.current, nodeUpdate.node_id); @@ -1437,7 +1462,7 @@ export function useMeshtasticRuntime() { if (storeId) { upsertMessage(storeId, chatMessageToMessageRecord(mqttWithPreviews)); } - void window.electronAPI.db.saveMessage(mqttWithPreviews); + persistMeshtasticMessage(mqttWithPreviews); }); const unsubBrokerRaw = window.electronAPI.mqtt.onBrokerRaw((payload) => { @@ -2280,10 +2305,7 @@ export function useMeshtasticRuntime() { const fromDb = await loadMeshtasticMessagesFromDb(); for (const m of fromDb) { if (m.packetId && m.sender_id) { - seenPacketIds.current.set( - meshtasticPacketDedupKey(m.sender_id, m.packetId), - Date.now() + 10 * 60 * 1000, - ); + markPacketSeen(m.sender_id, m.packetId); } } setMessages((prev) => mergeMeshtasticDbHydrationWithLive(prev, fromDb)); @@ -2304,7 +2326,7 @@ export function useMeshtasticRuntime() { throw new Error('Attach superseded during configure'); } }, - [applyMeshtasticNodesToUi, wireSubscriptions], + [applyMeshtasticNodesToUi, markPacketSeen, wireSubscriptions], ); const handleRfConnectFailure = useCallback( @@ -2693,7 +2715,7 @@ export function useMeshtasticRuntime() { channel, }); setMessages((prev) => trimChatMessagesToMax([...prev, msg], MAX_IN_MEMORY_CHAT_MESSAGES)); - void window.electronAPI.db.saveMessage(msg); + persistMeshtasticMessage(msg); const identityId = meshtasticIdentityIdRef.current; if (identityId) { trackMeshtasticOutboundTempId(tempId, String(tempId)); @@ -3610,33 +3632,36 @@ export function useMeshtasticRuntime() { }); }, []); - const refreshMessagesFromDb = useCallback((opts?: { replaceFromDb?: boolean }) => { - void loadMeshtasticMessagesFromDb() - .then((fromDb) => { - console.debug( - `[useMeshtasticRuntime] refreshMessagesFromDb: loaded ${fromDb.length} messages`, - ); - for (const m of fromDb) { - if (m.packetId && m.sender_id) { - seenPacketIds.current.set( - meshtasticPacketDedupKey(m.sender_id, m.packetId), - Date.now() + 10 * 60 * 1000, + const refreshMessagesFromDb = useCallback( + (opts?: { replaceFromDb?: boolean }) => { + void loadMeshtasticMessagesFromDb() + .then((fromDb) => { + console.debug( + `[useMeshtasticRuntime] refreshMessagesFromDb: loaded ${fromDb.length} messages`, + ); + for (const m of fromDb) { + if (m.packetId && m.sender_id) { + markPacketSeen(m.sender_id, m.packetId); + } + } + setMessages((prev) => mergeMeshtasticDbHydrationWithLive(prev, fromDb, opts)); + const storeId = + meshtasticIdentityIdRef.current ?? meshtasticPendingDriverIdentityRef.current; + if (storeId) { + void hydrateMeshtasticMessagesFromDb( + storeId, + opts?.replaceFromDb ? 'replace' : 'upsert', ); } - } - setMessages((prev) => mergeMeshtasticDbHydrationWithLive(prev, fromDb, opts)); - const storeId = - meshtasticIdentityIdRef.current ?? meshtasticPendingDriverIdentityRef.current; - if (storeId) { - void hydrateMeshtasticMessagesFromDb(storeId, opts?.replaceFromDb ? 'replace' : 'upsert'); - } - }) - .catch((err: unknown) => { - console.error( - '[useMeshtasticRuntime] Failed to refresh messages: ' + errLikeToLogString(err), - ); - }); - }, []); + }) + .catch((err: unknown) => { + console.error( + '[useMeshtasticRuntime] Failed to refresh messages: ' + errLikeToLogString(err), + ); + }); + }, + [markPacketSeen], + ); const setNodeFavorited = useCallback( async (nodeId: number, favorited: boolean) => { @@ -3702,7 +3727,7 @@ export function useMeshtasticRuntime() { : {}), }; updated.set(selfNodeId, node); - if (!isVirtualNode) void window.electronAPI.db.saveNode(node); + if (!isVirtualNode) persistMeshtasticNode(node); return updated; }); } @@ -3742,7 +3767,11 @@ export function useMeshtasticRuntime() { // Resolve position on app startup regardless of device connection useEffect(() => { - void refreshOurPositionRef.current(); + void refreshOurPositionRef.current().catch((e: unknown) => { + console.debug( + '[useMeshtasticRuntime] initial refreshOurPosition failed ' + errLikeToLogString(e), + ); + }); }, []); const sendPositionToDevice = useCallback(async (lat: number, lon: number, alt?: number) => { @@ -3874,7 +3903,7 @@ export function useMeshtasticRuntime() { if (isDup) return prev; return trimChatMessagesToMax([...prev, msg], MAX_IN_MEMORY_CHAT_MESSAGES); }); - void window.electronAPI.db.saveMessage(msg); + persistMeshtasticMessage(msg); } // Device transport — mesh.proto: `emoji` is a boolean flag; the glyph is UTF-8 in `payload`. diff --git a/src/renderer/stores/nodeStore.test.ts b/src/renderer/stores/nodeStore.test.ts index 89c7fd6db..74f722ecf 100644 --- a/src/renderer/stores/nodeStore.test.ts +++ b/src/renderer/stores/nodeStore.test.ts @@ -1,12 +1,19 @@ import { afterEach, describe, expect, it } from 'vitest'; import { + addTraceRoute, appendMeshcoreCliEntry, clearMeshcoreCliHistory, + clearNodeIdentity, patchNodeFavorited, updateMeshcoreOp, updatePosition, + updateTelemetry, + upsertNeighborInfo, upsertNode, + upsertNodeRecord, + upsertNodeRecordsForIdentity, + upsertWaypoint, useNodeStore, } from './nodeStore'; @@ -111,3 +118,112 @@ describe('mergeNode nodeId guard', () => { expect(useNodeStore.getState().nodes[ID][999]).toBeUndefined(); }); }); + +describe('nodeStore Meshtastic packet operations', () => { + afterEach(() => { + useNodeStore.setState({ nodes: {}, traceRoutes: {}, waypoints: {}, neighborInfo: {} }); + }); + + it('merges identity, position, and telemetry without clobbering prior fields', () => { + upsertNode(ID, { + nodeId: NODE, + longName: 'Alpha', + shortName: 'ALP', + role: 2, + }); + updatePosition(ID, { + nodeId: NODE, + latitude: 39.7, + longitude: -105, + altitude: 1600, + timestamp: 1000, + }); + updateTelemetry(ID, { + nodeId: NODE, + timestamp: 2000, + variantCase: 'environmentMetrics', + batteryLevel: 88, + voltage: 4.1, + temperature: 20, + relativeHumidity: 40, + barometricPressure: 850, + iaq: 12, + }); + + expect(useNodeStore.getState().nodes[ID][NODE]).toEqual( + expect.objectContaining({ + longName: 'Alpha', + shortName: 'ALP', + role: 2, + latitude: 39.7, + longitude: -105, + altitude: 1600, + batteryLevel: 88, + voltage: 4.1, + temperature: 20, + relativeHumidity: 40, + barometricPressure: 850, + iaq: 12, + }), + ); + }); + + it('upserts full and batched records without replacing unrelated nodes', () => { + upsertNodeRecord(ID, { nodeId: NODE, longName: 'Alpha', snr: 7 }); + upsertNodeRecordsForIdentity(ID, [ + { nodeId: NODE, rssi: -90 }, + { nodeId: 43, longName: 'Beta' }, + ]); + + expect(useNodeStore.getState().nodes[ID][NODE]).toEqual( + expect.objectContaining({ longName: 'Alpha', snr: 7, rssi: -90 }), + ); + expect(useNodeStore.getState().nodes[ID][43].longName).toBe('Beta'); + }); + + it('stores route, waypoint, and neighbor event collections by identity', () => { + addTraceRoute(ID, { + from: NODE, + to: 43, + route: [7, 8], + timestamp: 1000, + }); + upsertWaypoint(ID, { + id: 5, + from: NODE, + name: 'Trailhead', + latitude: 39, + longitude: -104, + timestamp: 1000, + }); + upsertNeighborInfo(ID, { + nodeId: NODE, + neighbors: [{ nodeId: 43, snr: 4, lastRxTime: 99 }], + timestamp: 1000, + }); + + const state = useNodeStore.getState(); + expect(state.traceRoutes[ID][0].route).toEqual([7, 8]); + expect(state.waypoints[ID][5].from).toBe(NODE); + expect(state.neighborInfo[ID][NODE].neighbors[0].nodeId).toBe(43); + }); + + it('clears every identity-scoped node collection together', () => { + upsertNodeRecord(ID, { nodeId: NODE }); + upsertWaypoint(ID, { + id: 5, + from: NODE, + name: 'Trailhead', + latitude: 1, + longitude: 2, + timestamp: 1, + }); + clearNodeIdentity(ID); + + const state = useNodeStore.getState(); + expect(state.nodes[ID]).toBeUndefined(); + expect(state.traceRoutes[ID]).toBeUndefined(); + expect(state.waypoints[ID]).toBeUndefined(); + expect(state.neighborInfo[ID]).toBeUndefined(); + }); +}); diff --git a/src/renderer/stores/nodeStore.ts b/src/renderer/stores/nodeStore.ts index af9445580..6aacc329e 100644 --- a/src/renderer/stores/nodeStore.ts +++ b/src/renderer/stores/nodeStore.ts @@ -57,6 +57,10 @@ export interface NodeRecord { relativeHumidity?: number; barometricPressure?: number; iaq?: number; + gasResistance?: number; + lux?: number; + windSpeed?: number; + windDirection?: number; telemetryTimestamp?: number; snr?: number; rssi?: number; @@ -76,9 +80,6 @@ export interface NodeRecord { numRxDupe?: number; numPacketsRx?: number; numPacketsTx?: number; - lux?: number; - windSpeed?: number; - windDirection?: number; paxCount?: number; detectionText?: string; neighbors?: MeshNeighbor[]; diff --git a/vitest.config.ts b/vitest.config.ts index 0adc32d93..35f707aa3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,7 @@ -import os from 'node:os'; import { readdirSync } from 'node:fs'; -import { dirname, join, relative, resolve } from 'path'; -import { fileURLToPath } from 'url'; +import os from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vitest/config'; @@ -80,6 +80,7 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/controlledEditableValue.test.ts', 'src/renderer/lib/debugSnapshot.test.ts', 'src/renderer/lib/devElectronApiStub.test.ts', + 'src/renderer/lib/drivers/attachTypedPacketListener.test.ts', 'src/renderer/lib/drivers/PacketRouter.test.ts', 'src/renderer/lib/encryptedKeyBackupStorage.test.ts', 'src/renderer/lib/gpsSource.test.ts', From c78246229c324caa29a37b52226c30f5d3444980 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 10:58:22 -0600 Subject: [PATCH 06/13] fix: collapse dual-write and finish audit hygiene Make identity nodeStore the canonical Meshtastic/MeshCore RF write path, add bounded DB persist retry, share device-status and transport-name helpers, and close remaining i18n, bounds, Protocol, and attach-contract gaps. --- ARCHITECTURE.md | 2 +- src/renderer/components/NodeInfoBody.tsx | 15 +- .../meshcore/meshcoreConnSideEffects.test.ts | 22 ++- .../hooks/meshcore/meshcoreConnSideEffects.ts | 129 ++++++++++-------- .../meshcore/meshcoreConnSideEffectsCtx.ts | 1 - src/renderer/lib/dbPersistRetry.test.ts | 44 ++++++ src/renderer/lib/dbPersistRetry.ts | 70 ++++++++++ src/renderer/lib/drivers/ConnectionDriver.ts | 4 +- src/renderer/lib/ingest/meshtasticIngest.ts | 15 +- .../meshcoreProcessWaitingMessageItem.test.ts | 3 + .../lib/meshcoreProcessWaitingMessageItem.ts | 6 +- .../meshtastic/meshtasticDeviceStatus.test.ts | 22 +++ .../lib/meshtastic/meshtasticDeviceStatus.ts | 21 +++ .../meshtasticModulePortSideEffects.ts | 27 ++-- .../meshtastic/meshtasticNodeSideEffects.ts | 29 +--- .../meshtasticRawPacketSideEffects.test.ts | 11 +- .../meshtasticRawPacketSideEffects.ts | 5 +- .../meshtasticRuntimeWireEffects.ts | 21 +-- .../meshtasticSdkRoutingErrorLog.test.ts | 8 ++ .../meshtasticSdkRoutingErrorLog.ts | 12 ++ .../meshtasticStoreForwardSideEffects.ts | 14 +- .../transportDisplayNameCache.test.ts | 28 ++++ .../meshtastic/transportDisplayNameCache.ts | 26 ++++ .../lib/protocols/MeshCoreProtocol.test.ts | 36 +++++ .../lib/protocols/MeshCoreProtocol.ts | 11 +- .../lib/protocols/MeshtasticProtocol.ts | 17 +-- .../runtimeSideEffectAttach.contract.test.ts | 36 +++++ .../lib/transport/TransportManager.test.ts | 6 +- src/renderer/locales/cs/translation.json | 10 +- src/renderer/locales/de/translation.json | 10 +- src/renderer/locales/en/translation.json | 8 +- src/renderer/locales/es/translation.json | 10 +- src/renderer/locales/fr/translation.json | 10 +- src/renderer/locales/id/translation.json | 10 +- src/renderer/locales/it/translation.json | 10 +- src/renderer/locales/ja/translation.json | 10 +- src/renderer/locales/ko/translation.json | 10 +- src/renderer/locales/nl/translation.json | 10 +- src/renderer/locales/pl/translation.json | 10 +- src/renderer/locales/pt-BR/translation.json | 10 +- src/renderer/locales/ru/translation.json | 10 +- src/renderer/locales/tr/translation.json | 10 +- src/renderer/locales/uk/translation.json | 10 +- src/renderer/locales/zh/translation.json | 10 +- .../runtimeAttachDetach.contract.test.ts | 31 +++++ src/renderer/runtime/useMeshcoreRuntime.ts | 1 - src/renderer/runtime/useMeshtasticRuntime.ts | 107 ++++++++------- src/renderer/stores/nodeStore.test.ts | 21 +++ src/renderer/stores/nodeStore.ts | 20 +++ vitest.config.ts | 5 + 50 files changed, 750 insertions(+), 234 deletions(-) create mode 100644 src/renderer/lib/dbPersistRetry.test.ts create mode 100644 src/renderer/lib/dbPersistRetry.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticDeviceStatus.test.ts create mode 100644 src/renderer/lib/meshtastic/meshtasticDeviceStatus.ts create mode 100644 src/renderer/lib/meshtastic/transportDisplayNameCache.test.ts create mode 100644 src/renderer/lib/meshtastic/transportDisplayNameCache.ts create mode 100644 src/renderer/lib/runtimeSideEffectAttach.contract.test.ts create mode 100644 src/renderer/runtime/runtimeAttachDetach.contract.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b0d078d06..b5b0e9822 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -76,7 +76,7 @@ Sanitize user-controlled strings before logs and IPC per [AGENTS.md](AGENTS.md). **Renderer layers:** `runtime/` (single-mount protocol runtimes), `hooks/` (facades and store selectors), `lib/` (drivers, sessions, types), `stores/` (identity-scoped UI: `identityStore`, `nodeStore`, `messageStore`, `connectionStore`; Reticulum also uses session-global `reticulumIdentityStore` for sidecar identity status). Prefer `useProtocolFacade(protocol)` in App for new wiring. Hook/runtime boundaries: [AGENTS.md](AGENTS.md#renderer-hook-architecture-multi-protocol) ([#375](https://github.com/Colorado-Mesh/mesh-client/issues/375), [#377](https://github.com/Colorado-Mesh/mesh-client/issues/377)). -**Drivers / identity bridge:** `lib/drivers/ConnectionDriver.ts` owns RF/MQTT session lifecycle and dispatches Protocol events into `lib/drivers/PacketRouter.ts` (store ingest first, then side-effect listeners). `lib/meshIdentityBridge.ts` builds transport params and attaches Meshtastic Protocol ingress; `lib/identityStoreReads.ts` is the canonical read path for identity-scoped nodes/messages (`getIdentityNode` / `getIdentityChatMessages`). +**Drivers / identity bridge:** `lib/drivers/ConnectionDriver.ts` owns RF/MQTT session lifecycle and dispatches Protocol events into `lib/drivers/PacketRouter.ts` (store ingest first, then side-effect listeners). `PacketRouter` invokes generic listeners before event-type listeners, in registration order within each group; attach persistence/ingest before dependent UI side effects. `lib/meshIdentityBridge.ts` builds transport params and attaches Meshtastic Protocol ingress; `lib/identityStoreReads.ts` is the canonical read path for identity-scoped nodes/messages (`getIdentityNode` / `getIdentityChatMessages`). ### Protocols diff --git a/src/renderer/components/NodeInfoBody.tsx b/src/renderer/components/NodeInfoBody.tsx index d7f9314dd..789566e45 100644 --- a/src/renderer/components/NodeInfoBody.tsx +++ b/src/renderer/components/NodeInfoBody.tsx @@ -40,7 +40,6 @@ import { meshcoreTracePathLenToHops } from '../lib/meshcoreUtils'; import { isMeshtasticSelfHybridPath, MeshtasticHybridPathIcons, - meshtasticHybridPathLabels, MeshtasticMqttOnlyPathIcons, MeshtasticRfPathIcon, resolveMeshtasticPathBadge, @@ -123,10 +122,16 @@ function NodeSourceBadge({ const displayBadge = pathBadge === 'none' ? 'rfOnly' : pathBadge; if (displayBadge === 'hybrid') { - const labels = meshtasticHybridPathLabels( - t, - isMeshtasticSelfHybridPath(isSelf, mqttConnected, radioConnected), - ); + const selfHybrid = isMeshtasticSelfHybridPath(isSelf, mqttConnected, radioConnected); + const labels = selfHybrid + ? { + title: t('nodeInfoBody.connectedViaRfAndMqttTooltip'), + ariaLabel: t('nodeInfoBody.connectedViaRfAndMqttAria'), + } + : { + title: t('nodeInfoBody.hybridMqttPathTooltip'), + ariaLabel: t('nodeInfoBody.hybridMqttPathAria'), + }; return ; } if (displayBadge === 'mqttOnly') { diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts index a045a2ba4..bd16fcba2 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts @@ -98,7 +98,6 @@ function makeHarness(overrides?: { handleResponseResult?: boolean }): Harness { }, setMeshcorePingRouteReadyEpoch: vi.fn(), setMessages: vi.fn(), - setNodes: vi.fn(), setQueueStatus: vi.fn(), setRawPackets: (updater) => { const next = typeof updater === 'function' ? updater(rawPackets) : updater; @@ -352,6 +351,27 @@ describe('attachMeshcoreConnSideEffects', () => { expect(h.conn.getWaitingMessages).not.toHaveBeenCalled(); }); + it('flushes waiting-message node changes to nodeStore without updating the runtime node mirror', async () => { + const h = makeHarness(); + vi.mocked(h.conn.getWaitingMessages).mockResolvedValue([ + { + channelMessage: { + channelIdx: 0, + text: 'QueuePeer: queued channel message', + senderTimestamp: 1_700_000_000, + }, + }, + ]); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + await h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: true }); + + const records = useNodeStore.getState().nodes[ID]; + const node = Object.values(records ?? {})[0]; + expect(node).toMatchObject({ lastHeardAt: 1_700_000_000, source: 'rf' }); + expect(h.ctx.addMessagesBatch).toHaveBeenCalledTimes(1); + }); + it('tears down the session and requests reconnect on disconnect', async () => { const h = makeHarness(); detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index c6bdee96c..a6f429159 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -19,7 +19,6 @@ import { extractMeshtasticSenderId, meshtasticSenderIdForRawLogFallback, } from '../../lib/foreignLoraDetection'; -import { syncNodesMapToIdentityStore } from '../../lib/hydrateIdentityStoresFromDb'; import { applyMeshcoreDmAckToPending, syncMeshcoreDmAckToMessageStore, @@ -62,6 +61,7 @@ import { getMeshtasticConnectedMyNodeNum } from '../../lib/meshtasticConnectedNo import type { DomainEvent } from '../../lib/protocols/Protocol'; import { MAX_RAW_PACKET_LOG_ENTRIES } from '../../lib/rawPacketLogConstants'; import { getStoredMeshProtocol } from '../../lib/storedMeshProtocol'; +import { meshNodeToNodeRecord } from '../../lib/storeRecordAdapters'; import { MESHCORE_RAW_SELF_FLOOD_ADVERT_COALESCE_MS, MESHCORE_SYNC_NEXT_MESSAGE_MAX_PER_DRAIN, @@ -70,6 +70,7 @@ import { } from '../../lib/timeConstants'; import type { ChatMessage, MeshNode, TelemetryPoint } from '../../lib/types'; import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; +import { upsertNodeRecord, upsertNodeRecordsForIdentity } from '../../stores/nodeStore'; import { usePathHistoryStore } from '../../stores/pathHistoryStore'; import type { MeshcoreConnSideEffectsCtx, @@ -123,7 +124,6 @@ export function attachMeshcoreConnSideEffects( setDeviceLogs, setMeshcorePingRouteReadyEpoch, setMessages, - setNodes, setQueueStatus, setRawPackets, setSignalTelemetry, @@ -301,19 +301,25 @@ export function attachMeshcoreConnSideEffects( let processed = 0; let pendingMessages: ChatMessage[] = []; const workingNodes = new Map(readNodes()); - let nodesDirty = false; + const dirtyNodeIds = new Set(); const flushBatch = () => { if (pendingMessages.length > 0) { addMessagesBatch(pendingMessages); pendingMessages = []; } - if (nodesDirty) { - setNodes(workingNodes); - // Publish before React commits so the next drain iteration reads merged rows. + if (dirtyNodeIds.size > 0) { const storeId = meshcoreIdentityIdRef.current; - if (storeId) syncNodesMapToIdentityStore(storeId, workingNodes); - nodesDirty = false; + if (storeId) { + const dirtyRecords = Array.from(dirtyNodeIds) + .map((nodeId) => workingNodes.get(nodeId)) + .filter((node): node is MeshNode => node != null) + .map((node) => meshNodeToNodeRecord(node)); + if (dirtyRecords.length > 0) { + upsertNodeRecordsForIdentity(storeId, dirtyRecords); + } + } + dirtyNodeIds.clear(); } }; @@ -327,12 +333,13 @@ export function attachMeshcoreConnSideEffects( item, buildWaitingMessageItemDeps(workingNodes), ); - if (result.nodesDirty) nodesDirty = true; + if (result.nodesDirty) { + for (const nodeId of result.updatedNodeIds) dirtyNodeIds.add(nodeId); + } if (result.pendingMessages.length > 0) { pendingMessages.push(...result.pendingMessages); } processed += 1; - flushBatch(); if (bannerActive) { setWaitingMessagesSyncProgress({ processed, total: syncTotal }); } @@ -521,18 +528,19 @@ export function attachMeshcoreConnSideEffects( // If we know this node (and it's not ourselves), update last_heard + SNR/RSSI if (senderId !== myNodeNumRef.current && readNodes().has(senderId)) { const nowSec = Math.floor(now / 1000); - setNodes((prev) => { - const existing = prev.get(senderId); - if (!existing) return prev; - const next = new Map(prev); - next.set(senderId, { - ...existing, - last_heard: Math.max(existing.last_heard ?? 0, nowSec), - snr: snr, - rssi: rssi, - }); - return next; - }); + const existing = readNodes().get(senderId); + const storeId = meshcoreIdentityIdRef.current; + if (existing && storeId) { + upsertNodeRecord( + storeId, + meshNodeToNodeRecord({ + ...existing, + last_heard: Math.max(existing.last_heard ?? 0, nowSec), + snr, + rssi, + }), + ); + } } } } else if (loraPacketClass === 'meshcore') { @@ -633,9 +641,9 @@ export function attachMeshcoreConnSideEffects( if (fromNodeId !== null && fromNodeId !== myNodeNumRef.current) { const resolvedFromNodeId = fromNodeId; const nowSec = Math.floor(now / 1000); - setNodes((prev) => { - const existing = prev.get(resolvedFromNodeId); - if (!existing) return prev; + const existing = readNodes().get(resolvedFromNodeId); + const storeId = meshcoreIdentityIdRef.current; + if (existing && storeId) { const mergedHopsAway = meshcoreMergeContactHopsAwayFromPrevious( hopCount, existing.hops_away, @@ -652,44 +660,45 @@ export function attachMeshcoreConnSideEffects( via_mqtt: false, }; - // Optimization: skip identical updates + // Skip store/DB writes when metrics and liveness have not changed. if ( - existing.hops_away === updated.hops_away && - existing.snr === snr && - existing.rssi === rssi && - existing.last_heard === updated.last_heard + existing.hops_away !== updated.hops_away || + existing.snr !== snr || + existing.rssi !== rssi || + existing.last_heard !== updated.last_heard ) { - return prev; - } + upsertNodeRecord(storeId, meshNodeToNodeRecord(updated)); - const next = new Map(prev); - next.set(resolvedFromNodeId, updated); - - void window.electronAPI.db - .updateMeshcoreContactLastRf( - resolvedFromNodeId, - snr, - rssi, - mergedHopsAway ?? hopCount, - nowSec, - ) - .catch((e: unknown) => { - console.warn( - '[meshcoreConnSideEffects] updateMeshcoreContactLastRf error ' + - errLikeToLogString(e), - ); - }); - void useDiagnosticsStore - .getState() - .saveMeshcoreHopHistory(resolvedFromNodeId, now, mergedHopsAway ?? hopCount, snr, rssi) - .catch((e: unknown) => { - console.warn( - '[meshcoreConnSideEffects] saveMeshcoreHopHistory error ' + errLikeToLogString(e), - ); - }); - - return next; - }); + void window.electronAPI.db + .updateMeshcoreContactLastRf( + resolvedFromNodeId, + snr, + rssi, + mergedHopsAway ?? hopCount, + nowSec, + ) + .catch((e: unknown) => { + console.warn( + '[meshcoreConnSideEffects] updateMeshcoreContactLastRf error ' + + errLikeToLogString(e), + ); + }); + void useDiagnosticsStore + .getState() + .saveMeshcoreHopHistory( + resolvedFromNodeId, + now, + mergedHopsAway ?? hopCount, + snr, + rssi, + ) + .catch((e: unknown) => { + console.warn( + '[meshcoreConnSideEffects] saveMeshcoreHopHistory error ' + errLikeToLogString(e), + ); + }); + } + } } if (fromNodeId == null) { diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts index abf3a821d..8f726168b 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts @@ -56,7 +56,6 @@ export interface MeshcoreConnSideEffectsCtx { setDeviceLogs: Dispatch>; setMeshcorePingRouteReadyEpoch: Dispatch>; setMessages: Dispatch>; - setNodes: Dispatch>>; setQueueStatus: Dispatch>; setRawPackets: Dispatch>; setSignalTelemetry: Dispatch>; diff --git a/src/renderer/lib/dbPersistRetry.test.ts b/src/renderer/lib/dbPersistRetry.test.ts new file mode 100644 index 000000000..8a8c30f2f --- /dev/null +++ b/src/renderer/lib/dbPersistRetry.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { persistDbWrite } from './dbPersistRetry'; + +describe('persistDbWrite', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('does not queue when the write succeeds', async () => { + const write = vi.fn().mockResolvedValue(undefined); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + persistDbWrite('ok write', write); + await Promise.resolve(); + expect(write).toHaveBeenCalledTimes(1); + expect(warn).not.toHaveBeenCalled(); + }); + + it('retries a failed write then warns when exhausted', async () => { + const write = vi.fn().mockRejectedValue(new Error('disk full')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + persistDbWrite('fail write', write); + await Promise.resolve(); + expect(write).toHaveBeenCalledTimes(1); + + // attempt 0 + await vi.advanceTimersByTimeAsync(1_000); + expect(write).toHaveBeenCalledTimes(2); + + // attempt 1 + await vi.advanceTimersByTimeAsync(1_000); + expect(write).toHaveBeenCalledTimes(3); + + // attempt 2 exhausted → warn + await vi.advanceTimersByTimeAsync(1_000); + expect(write).toHaveBeenCalledTimes(4); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('degraded persistence')); + }); +}); diff --git a/src/renderer/lib/dbPersistRetry.ts b/src/renderer/lib/dbPersistRetry.ts new file mode 100644 index 000000000..b1cc8c5c3 --- /dev/null +++ b/src/renderer/lib/dbPersistRetry.ts @@ -0,0 +1,70 @@ +import { errLikeToLogString } from './errLikeToLogString'; + +const MAX_PENDING_PERSIST_WRITES = 200; +const MAX_RETRY_ATTEMPTS = 2; +const RETRY_DELAY_MS = 1_000; + +interface PendingPersistWrite { + label: string; + attempt: number; + write: () => Promise; +} + +const pendingWrites: PendingPersistWrite[] = []; +let retryTimer: ReturnType | undefined; +let flushing = false; + +function scheduleRetry(): void { + if (retryTimer || pendingWrites.length === 0) return; + retryTimer = setTimeout(() => { + retryTimer = undefined; + void flushRetries(); + }, RETRY_DELAY_MS); +} + +async function flushRetries(): Promise { + if (flushing) return; + flushing = true; + try { + // Snapshot one batch so an individual write is attempted at most once per + // delay. This prevents a persistent failure from spinning in one flush. + const batch = pendingWrites.splice(0); + for (const pending of batch) { + try { + await pending.write(); + } catch (error) { + if (pending.attempt >= MAX_RETRY_ATTEMPTS) { + console.warn( + `[dbPersistRetry] degraded persistence: ${pending.label} failed after retries: ` + + errLikeToLogString(error), + ); + } else { + pending.attempt++; + pendingWrites.push(pending); + } + } + } + } finally { + flushing = false; + scheduleRetry(); + } +} + +/** + * Persist an already-applied UI update. Failed writes enter a bounded, + * batched retry queue; after the final retry the in-memory state remains + * authoritative and a degraded-persistence warning is emitted. + */ +export function persistDbWrite(label: string, write: () => Promise): void { + void write().catch((error: unknown) => { + if (pendingWrites.length >= MAX_PENDING_PERSIST_WRITES) { + console.warn( + `[dbPersistRetry] degraded persistence: queue full; dropping ${label}: ` + + errLikeToLogString(error), + ); + return; + } + pendingWrites.push({ label, attempt: 0, write }); + scheduleRetry(); + }); +} diff --git a/src/renderer/lib/drivers/ConnectionDriver.ts b/src/renderer/lib/drivers/ConnectionDriver.ts index 37ba857dc..5a5b27027 100644 --- a/src/renderer/lib/drivers/ConnectionDriver.ts +++ b/src/renderer/lib/drivers/ConnectionDriver.ts @@ -255,7 +255,7 @@ export class ConnectionDriver { try { slot.teardown(); } catch (e) { - console.debug('[ConnectionDriver] teardown error ' + errLikeToLogString(e)); + console.warn('[ConnectionDriver] teardown error ' + errLikeToLogString(e)); } await slot.protocol.destroyDevice(slot.handle).catch((e: unknown) => { console.debug('[ConnectionDriver] destroy error ' + errLikeToLogString(e)); @@ -316,7 +316,7 @@ export class ConnectionDriver { try { teardown(); } catch (e) { - console.debug('[ConnectionDriver] external teardown error ' + errLikeToLogString(e)); + console.warn('[ConnectionDriver] external teardown error ' + errLikeToLogString(e)); } this.slots.delete(transportId); removeTransport(identityId, transportId); diff --git a/src/renderer/lib/ingest/meshtasticIngest.ts b/src/renderer/lib/ingest/meshtasticIngest.ts index e1f48565f..f8cbedfcb 100644 --- a/src/renderer/lib/ingest/meshtasticIngest.ts +++ b/src/renderer/lib/ingest/meshtasticIngest.ts @@ -7,6 +7,7 @@ import { getConnection } from '../../stores/connectionStore'; import { upsertMessage, useMessageStore } from '../../stores/messageStore'; import { useNodeStore } from '../../stores/nodeStore'; +import { persistDbWrite } from '../dbPersistRetry'; import { packetRouter, type PacketRouterListener } from '../drivers/PacketRouter'; import { errLikeToLogString } from '../errLikeToLogString'; import { meshcoreHwModelIsContactTypeLabel } from '../meshcoreUtils'; @@ -70,9 +71,7 @@ function persistNode(identityId: IdentityId, nodeId: number): void { if (!record) return; const meshNode = nodeRecordToMeshNode(record); if (meshcoreHwModelIsContactTypeLabel(meshNode.hw_model)) return; - void window.electronAPI.db.saveNode(meshNode).catch((e: unknown) => { - console.debug('[meshtasticIngest] saveNode failed ' + errLikeToLogString(e)); - }); + persistDbWrite('meshtastic ingest node', () => window.electronAPI.db.saveNode(meshNode)); } function handleTextMessage( @@ -102,9 +101,9 @@ function handleTextMessage( if (record.status === 'sending') { return; } - void window.electronAPI.db.saveMessage(incoming).catch((e: unknown) => { - console.debug('[meshtasticIngest] saveMessage echo failed ' + errLikeToLogString(e)); - }); + persistDbWrite('meshtastic ingest echo message', () => + window.electronAPI.db.saveMessage(incoming), + ); return; } @@ -170,9 +169,7 @@ function handleTextMessage( } } - void window.electronAPI.db.saveMessage(incoming).catch((e: unknown) => { - console.debug('[meshtasticIngest] saveMessage failed ' + errLikeToLogString(e)); - }); + persistDbWrite('meshtastic ingest message', () => window.electronAPI.db.saveMessage(incoming)); } function createListener( diff --git a/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts b/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts index 2bd676282..2edad7617 100644 --- a/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts +++ b/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts @@ -110,6 +110,8 @@ describe('processMeshcoreWaitingMessageItem', () => { expect(result.pendingMessages[0]?.payload).toBe('hello dm'); expect(result.pendingMessages[0]?.isHistory).toBe(true); expect(result.nodesDirty).toBe(true); + expect(result.updatedNodeIds).toEqual([senderId]); + expect(deps.workingNodes.get(senderId)?.last_heard).toBe(1_700_000_100); }); it('warns and skips ingest for unknown pubKeyPrefix (senderId 0)', () => { @@ -131,6 +133,7 @@ describe('processMeshcoreWaitingMessageItem', () => { expect(warnSpy).toHaveBeenCalled(); expect(result.pendingMessages).toHaveLength(0); expect(result.nodesDirty).toBe(false); + expect(result.updatedNodeIds).toEqual([]); warnSpy.mockRestore(); }); diff --git a/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts b/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts index 8f4c5ad37..fcac090b8 100644 --- a/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts +++ b/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts @@ -20,6 +20,7 @@ import type { MeshcoreWaitingMessageItem } from './meshcoreWaitingMessageItem'; export interface ProcessWaitingMessageItemResult { nodesDirty: boolean; + updatedNodeIds: number[]; pendingMessages: ChatMessage[]; roomDispatched: boolean; } @@ -38,6 +39,7 @@ export function processMeshcoreWaitingMessageItem( deps: ProcessWaitingMessageItemDeps, ): ProcessWaitingMessageItemResult { const pendingMessages: ChatMessage[] = []; + const updatedNodeIds: number[] = []; let nodesDirty = false; let roomDispatched = false; @@ -64,6 +66,7 @@ export function processMeshcoreWaitingMessageItem( last_heard: Math.max(existing.last_heard ?? 0, d.senderTimestamp), }); nodesDirty = true; + updatedNodeIds.push(senderId); } if (isMeshcoreTransportStatusChatLine(d.text)) { deps.logTransportLineAsDevice(d.text); @@ -154,6 +157,7 @@ export function processMeshcoreWaitingMessageItem( ), ); nodesDirty = true; + updatedNodeIds.push(resolved.senderId); } const channelRxHops = meshcoreCompanionRxPathLenToHopCount(d.pathLen); pendingMessages.push({ @@ -171,5 +175,5 @@ export function processMeshcoreWaitingMessageItem( } } - return { nodesDirty, pendingMessages, roomDispatched }; + return { nodesDirty, updatedNodeIds, pendingMessages, roomDispatched }; } diff --git a/src/renderer/lib/meshtastic/meshtasticDeviceStatus.test.ts b/src/renderer/lib/meshtastic/meshtasticDeviceStatus.test.ts new file mode 100644 index 000000000..5c9b1d063 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticDeviceStatus.test.ts @@ -0,0 +1,22 @@ +import { Types } from '@meshtastic/core'; +import { describe, expect, it } from 'vitest'; + +import { meshtasticDeviceStatusForCode } from './meshtasticDeviceStatus'; + +const { DeviceStatusEnum } = Types; + +describe('meshtasticDeviceStatusForCode', () => { + it.each([ + [DeviceStatusEnum.DeviceDisconnected, 'disconnected'], + [DeviceStatusEnum.DeviceConnecting, 'connecting'], + [DeviceStatusEnum.DeviceConnected, 'connected'], + [DeviceStatusEnum.DeviceConfigured, 'configured'], + [8, 'stale'], + ] as const)('maps %s → %s', (code, status) => { + expect(meshtasticDeviceStatusForCode(code)).toBe(status); + }); + + it('defaults unknown codes to connected', () => { + expect(meshtasticDeviceStatusForCode(999)).toBe('connected'); + }); +}); diff --git a/src/renderer/lib/meshtastic/meshtasticDeviceStatus.ts b/src/renderer/lib/meshtastic/meshtasticDeviceStatus.ts new file mode 100644 index 000000000..62bbb69f2 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticDeviceStatus.ts @@ -0,0 +1,21 @@ +import { Types } from '@meshtastic/core'; + +const { DeviceStatusEnum } = Types; + +export type MeshtasticDeviceStatus = + 'connecting' | 'disconnected' | 'connected' | 'configured' | 'stale'; + +const STATUS_CODE_MAP: Record = { + [DeviceStatusEnum.DeviceRestarting]: 'connecting', + [DeviceStatusEnum.DeviceDisconnected]: 'disconnected', + [DeviceStatusEnum.DeviceConnecting]: 'connecting', + [DeviceStatusEnum.DeviceReconnecting]: 'connecting', + [DeviceStatusEnum.DeviceConnected]: 'connected', + [DeviceStatusEnum.DeviceConfiguring]: 'connecting', + [DeviceStatusEnum.DeviceConfigured]: 'configured', + 8: 'stale', +}; + +export function meshtasticDeviceStatusForCode(status: number): MeshtasticDeviceStatus { + return STATUS_CODE_MAP[status] ?? 'connected'; +} diff --git a/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.ts index bf72a0d3c..9d9dd37d0 100644 --- a/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.ts @@ -30,6 +30,19 @@ interface RawModuleMessage { } type RawMessageMapSetter = Dispatch>>; +const MAX_SINGLE_ENTRY_MAP_NODES = 128; + +function setBoundedMapEntry(previous: Map, key: number, value: T): Map { + const updated = new Map(previous); + updated.delete(key); + updated.set(key, value); + while (updated.size > MAX_SINGLE_ENTRY_MAP_NODES) { + const oldestKey = updated.keys().next().value; + if (oldestKey === undefined) break; + updated.delete(oldestKey); + } + return updated; +} export interface MeshtasticModulePortSideEffectsDeps { touchLastData: () => void; @@ -97,11 +110,7 @@ function handleModulePort( deps.setDetectionSensorEvents((prev) => appendModulePortEvent(prev, entry)); break; case 'ping': - deps.setPingResponses((prev) => { - const updated = new Map(prev); - updated.set(from, entry); - return updated; - }); + deps.setPingResponses((prev) => setBoundedMapEntry(prev, from, entry)); break; case 'ipTunnel': appendRawMessage(deps.setIpTunnelMessages, entry, 100); @@ -128,11 +137,9 @@ function handleModulePort( appendRawMessage(deps.setAtakMessages, entry, 100); break; case 'mapReport': - deps.setMapReports((prev) => { - const updated = new Map(prev); - updated.set(from, { from, data: payload.data, timestamp }); - return updated; - }); + deps.setMapReports((prev) => + setBoundedMapEntry(prev, from, { from, data: payload.data, timestamp }), + ); break; case 'private': appendRawMessage(deps.setPrivateMessages, entry, 50); diff --git a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts index 78614f8d3..df54a8369 100644 --- a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts @@ -25,8 +25,8 @@ import { import { upsertNodeRecord } from '../../stores/nodeStore'; import { usePositionHistoryStore } from '../../stores/positionHistoryStore'; import { validateCoords } from '../coordUtils'; +import { persistDbWrite } from '../dbPersistRetry'; import { attachTypedPacketListeners } from '../drivers/attachTypedPacketListener'; -import { errLikeToLogString } from '../errLikeToLogString'; import { shouldPreserveStaticGpsForSelfNode } from '../gpsSource'; import { getIdentityNode } from '../identityStoreReads'; import { @@ -34,7 +34,6 @@ import { mergeMeshtasticLivePacketLastHeard, mergeMeshtasticUserPacketLastHeard, } from '../meshtasticLastHeard'; -import { parseStoredJson } from '../parseStoredJson'; import type { NodeInfoEvent, PositionEvent, TelemetryEvent } from '../protocols/Protocol'; import { MESHTASTIC_CAPABILITIES } from '../radio/BaseRadioProvider'; import { LAST_SERIAL_PORT_KEY } from '../serialPortSignature'; @@ -48,9 +47,9 @@ import type { TelemetryPoint, } from '../types'; import { processMeshtasticNodeDiagnostics } from './meshtasticProcessNodeDiagnostics'; +import { cacheTransportDisplayName } from './transportDisplayNameCache'; const ROLE_CLIENT_MUTE = 1; -const MAX_TRANSPORT_DISPLAY_NAME_CACHE_ENTRIES = 64; const BLE_DEVICE_NAMES_KEY = 'mesh-client:bleDeviceNames'; const SERIAL_PORT_NODE_NAMES_KEY = 'mesh-client:serialPortNodeNames'; @@ -86,25 +85,7 @@ function meshtasticPublicKeyHex(bytes: Uint8Array | undefined): string | undefin function saveNode(identityId: IdentityId, node: MeshNode): void { upsertNodeRecord(identityId, meshNodeToNodeRecord(node)); - void window.electronAPI.db.saveNode(node).catch((e: unknown) => { - console.debug('[meshtasticNodeSideEffects] saveNode failed ' + errLikeToLogString(e)); - }); -} - -function cacheShortNameByKey(storageKey: string, cacheKey: string, shortName: string): void { - try { - const cache = - parseStoredJson>( - localStorage.getItem(storageKey), - `meshtasticNodeSideEffects ${storageKey}`, - ) ?? {}; - const entries = Object.entries(cache).filter(([key]) => key !== cacheKey); - entries.push([cacheKey, shortName]); - const bounded = Object.fromEntries(entries.slice(-MAX_TRANSPORT_DISPLAY_NAME_CACHE_ENTRIES)); - localStorage.setItem(storageKey, JSON.stringify(bounded)); - } catch { - // catch-no-log-ok localStorage write for transport display-name cache — non-critical - } + persistDbWrite('meshtastic node', () => window.electronAPI.db.saveNode(node)); } /** UserPacket identity: live long/short name, hardware, role, and public key. */ @@ -162,7 +143,7 @@ function cacheSelfNodeTransportName( const deviceId = deps.getBluetoothDeviceId(); const shortName = preferNonEmptyTrimmedString(info.shortName, '') || null; if (deviceId && shortName) { - cacheShortNameByKey(BLE_DEVICE_NAMES_KEY, deviceId, shortName); + cacheTransportDisplayName(BLE_DEVICE_NAMES_KEY, deviceId, shortName); } return; } @@ -172,7 +153,7 @@ function cacheSelfNodeTransportName( preferNonEmptyTrimmedString(info.shortName, preferNonEmptyTrimmedString(info.longName, '')) || null; if (portId && shortName) { - cacheShortNameByKey(SERIAL_PORT_NODE_NAMES_KEY, portId, shortName); + cacheTransportDisplayName(SERIAL_PORT_NODE_NAMES_KEY, portId, shortName); } } } diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts index 106371b16..5551ebac6 100644 --- a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts @@ -97,8 +97,11 @@ describe('attachMeshtasticRawPacketSideEffects', () => { detach(); }); - it('patches SNR and hops onto an existing store node', () => { + it('patches SNR/hops and invokes processNodeUpdate for RF mesh packets', () => { const { deps } = makeDeps(); + const processNodeUpdate = vi + .spyOn(useDiagnosticsStore.getState(), 'processNodeUpdate') + .mockImplementation(() => {}); const detach = attachMeshtasticRawPacketSideEffects(IDENTITY, deps); packetRouter.dispatch( { @@ -122,6 +125,12 @@ describe('attachMeshtasticRawPacketSideEffects', () => { expect(node?.rssi).toBe(-80); expect(node?.hops_away).toBe(1); expect(window.electronAPI.db.saveNode).toHaveBeenCalled(); + expect(processNodeUpdate).toHaveBeenCalledWith( + expect.objectContaining({ node_id: PEER, snr: 9.25, hops_away: 1 }), + null, + MY_NODE, + expect.anything(), + ); detach(); }); diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts index cf100b406..74196065f 100644 --- a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts @@ -13,6 +13,7 @@ import type { Dispatch, SetStateAction } from 'react'; import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; import { upsertNodeRecord } from '../../stores/nodeStore'; +import { persistDbWrite } from '../dbPersistRetry'; import { attachTypedPacketListener } from '../drivers/attachTypedPacketListener'; import { errLikeToLogString } from '../errLikeToLogString'; import { getIdentityNode } from '../identityStoreReads'; @@ -106,9 +107,7 @@ function applySignalAndHops( via_mqtt: payload.viaMqtt, }; upsertNodeRecord(identityId, meshNodeToNodeRecord(node)); - void window.electronAPI.db.saveNode(node).catch((e: unknown) => { - console.debug('[meshtasticRawPacketSideEffects] saveNode failed ' + errLikeToLogString(e)); - }); + persistDbWrite('meshtastic raw packet node', () => window.electronAPI.db.saveNode(node)); processMeshtasticNodeDiagnostics(node, myNodeNum, getIdentityNode(identityId, myNodeNum) ?? null); } diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts index 2baa34eae..aa3af5b88 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts @@ -13,6 +13,7 @@ import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; import { updateIdentity } from '../../stores/identityStore'; import { usePositionHistoryStore } from '../../stores/positionHistoryStore'; import { safeDisconnect } from '../connection'; +import { persistDbWrite } from '../dbPersistRetry'; import { connectionDriver } from '../drivers/ConnectionDriver'; import { isForeignLoraLogCandidate } from '../foreignLoraDetection'; import type { OurPosition } from '../gpsSource'; @@ -40,6 +41,7 @@ import type { TelemetryPoint, } from '../types'; import { recordMeshtasticClientNotification } from './meshtasticClientNotification'; +import { meshtasticDeviceStatusForCode } from './meshtasticDeviceStatus'; import { shouldFetchLocalLoraConfigAfterConfigure } from './meshtasticLocalLoraConfig'; import type { ModulePortEvent, PaxCounterPoint } from './meshtasticModuleEvents'; import { attachMeshtasticModulePortSideEffects } from './meshtasticModulePortSideEffects'; @@ -363,16 +365,7 @@ export function attachMeshtasticRuntimeWireEffects( if (status !== DeviceStatusEnum.DeviceRestarting) { touchLastData(); } - const statusMap: Record = { - [DeviceStatusEnum.DeviceRestarting]: 'connecting', - [DeviceStatusEnum.DeviceDisconnected]: 'disconnected', - [DeviceStatusEnum.DeviceConnecting]: 'connecting', - [DeviceStatusEnum.DeviceReconnecting]: 'connecting', - [DeviceStatusEnum.DeviceConnected]: 'connected', - [DeviceStatusEnum.DeviceConfiguring]: 'connecting', - [DeviceStatusEnum.DeviceConfigured]: 'configured', - }; - const mapped = statusMap[status] ?? 'connected'; + const mapped: DeviceState['status'] = meshtasticDeviceStatusForCode(status); setState((s) => ({ ...s, status: mapped, @@ -594,11 +587,9 @@ export function attachMeshtasticRuntimeWireEffects( heard_via_mqtt_only: false, }; updated.set(info.myNodeNum, selfNode); - void window.electronAPI.db.saveNode(selfNode).catch((e: unknown) => { - console.debug( - '[meshtasticRuntimeWireEffects] saveNode self failed ' + errLikeToLogString(e), - ); - }); + persistDbWrite('meshtastic runtime self node', () => + window.electronAPI.db.saveNode(selfNode), + ); } return updated; }); diff --git a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts index 87102fa43..11e6cdcbb 100644 --- a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts @@ -119,6 +119,14 @@ describe('meshtasticSdkRoutingErrorLog', () => { ); }); + it.each([ + ['RATE_LIMIT_EXCEEDED', 'chatPanel.routingErrors.rateLimited'], + ['NO_INTERFACE', 'chatPanel.routingErrors.noInterface'], + ['NO_INTERFACE_AVAILABLE', 'chatPanel.routingErrors.noInterface'], + ])('maps %s to a chat routing i18n key', (errorName, key) => { + expect(chatRoutingErrorKeyForSdkErrorName(errorName)).toBe(key); + }); + it('marks matching outbound message failed', () => { seedOutbound([{ packetId: 669520633 }]); const applied = applyMeshtasticOutboundRoutingErrorFromLog( diff --git a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts index 76f86d2af..c94b900df 100644 --- a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts +++ b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts @@ -14,6 +14,7 @@ import { const SDK_ROUTING_ERROR_RE = /Error received for packet (\d+): ([A-Z0-9_]+)/; const SDK_PACKET_TIMEOUT_RE = /Packet (\d+) of type \w+ timed out/; const FALLBACK_SENDING_WINDOW_MS = 90_000; +const routingErrorScansInFlight = new Set(); export interface MeshtasticSdkRoutingErrorLog { packetId: number; @@ -77,6 +78,11 @@ export function chatRoutingErrorKeyForSdkErrorName(errorName: string): string | return 'chatPanel.routingErrors.pkiFailed'; case 'NO_CHANNEL': return 'chatPanel.routingErrors.noChannel'; + case 'RATE_LIMIT_EXCEEDED': + return 'chatPanel.routingErrors.rateLimited'; + case 'NO_INTERFACE': + case 'NO_INTERFACE_AVAILABLE': + return 'chatPanel.routingErrors.noInterface'; case 'TIMEOUT': case 'NO_RESPONSE': case 'MAX_RETRANSMIT': @@ -208,6 +214,12 @@ export function applyMeshtasticOutboundRoutingErrorFromLog( if (!parsed) { return false; } + const key = `${ctx.identityId ?? 'none'}:${parsed.packetId}:${parsed.errorName}`; + if (routingErrorScansInFlight.has(key)) { + return false; + } + routingErrorScansInFlight.add(key); + queueMicrotask(() => routingErrorScansInFlight.delete(key)); return applyMeshtasticOutboundRoutingError(parsed, ctx); } diff --git a/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.ts index 2a985420c..05db7a319 100644 --- a/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.ts @@ -14,6 +14,7 @@ import type { Dispatch, SetStateAction } from 'react'; import { addMessage } from '../../stores/messageStore'; +import { persistDbWrite } from '../dbPersistRetry'; import { attachTypedPacketListener } from '../drivers/attachTypedPacketListener'; import { createPacketDedupeRegistry } from '../drivers/packetDedupeRegistry'; import { errLikeToLogString } from '../errLikeToLogString'; @@ -89,10 +90,15 @@ function shouldRequestAutoHistory(gate: AutoHistoryGate, serverNodeId: number, n function appendReplayedHistoryText(identityId: IdentityId, chat: ChatMessage): void { if (isDuplicateHistoryMessage(getIdentityChatMessages(identityId), chat)) return; addMessage(identityId, chatMessageToMessageRecord(chat)); - void window.electronAPI.db.saveMessage(chat).catch((e: unknown) => { - console.debug( - '[meshtasticStoreForwardSideEffects] saveMessage failed ' + errLikeToLogString(e), - ); + persistDbWrite('Meshtastic Store & Forward message', async () => { + try { + await window.electronAPI.db.saveMessage(chat); + } catch (error) { + console.warn( + '[meshtasticStoreForwardSideEffects] saveMessage failed ' + errLikeToLogString(error), + ); + throw error; + } }); } diff --git a/src/renderer/lib/meshtastic/transportDisplayNameCache.test.ts b/src/renderer/lib/meshtastic/transportDisplayNameCache.test.ts new file mode 100644 index 000000000..7eb4c3277 --- /dev/null +++ b/src/renderer/lib/meshtastic/transportDisplayNameCache.test.ts @@ -0,0 +1,28 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; + +import { cacheTransportDisplayName } from './transportDisplayNameCache'; + +const KEY = 'mesh-client:testTransportNames'; + +describe('cacheTransportDisplayName', () => { + afterEach(() => { + localStorage.clear(); + }); + + it('stores and overwrites a short name for a cache key', () => { + cacheTransportDisplayName(KEY, 'ble-1', 'ABC'); + cacheTransportDisplayName(KEY, 'ble-1', 'XYZ'); + expect(JSON.parse(localStorage.getItem(KEY) ?? '{}')).toEqual({ 'ble-1': 'XYZ' }); + }); + + it('bounds the cache to the most recent entries', () => { + for (let i = 0; i < 70; i++) { + cacheTransportDisplayName(KEY, `k${i}`, `n${i}`); + } + const cache = JSON.parse(localStorage.getItem(KEY) ?? '{}') as Record; + expect(Object.keys(cache)).toHaveLength(64); + expect(cache.k0).toBeUndefined(); + expect(cache.k69).toBe('n69'); + }); +}); diff --git a/src/renderer/lib/meshtastic/transportDisplayNameCache.ts b/src/renderer/lib/meshtastic/transportDisplayNameCache.ts new file mode 100644 index 000000000..ddee901a8 --- /dev/null +++ b/src/renderer/lib/meshtastic/transportDisplayNameCache.ts @@ -0,0 +1,26 @@ +import { parseStoredJson } from '../parseStoredJson'; + +const MAX_TRANSPORT_DISPLAY_NAME_CACHE_ENTRIES = 64; + +/** Store the most-recent short name for a BLE peripheral or serial port. */ +export function cacheTransportDisplayName( + storageKey: string, + cacheKey: string, + shortName: string, +): void { + try { + const cache = + parseStoredJson>( + localStorage.getItem(storageKey), + `transportDisplayNameCache ${storageKey}`, + ) ?? {}; + const entries = Object.entries(cache).filter(([key]) => key !== cacheKey); + entries.push([cacheKey, shortName]); + localStorage.setItem( + storageKey, + JSON.stringify(Object.fromEntries(entries.slice(-MAX_TRANSPORT_DISPLAY_NAME_CACHE_ENTRIES))), + ); + } catch { + // catch-no-log-ok localStorage display-name cache is non-critical + } +} diff --git a/src/renderer/lib/protocols/MeshCoreProtocol.test.ts b/src/renderer/lib/protocols/MeshCoreProtocol.test.ts index 4d9dc0787..b8a96dac1 100644 --- a/src/renderer/lib/protocols/MeshCoreProtocol.test.ts +++ b/src/renderer/lib/protocols/MeshCoreProtocol.test.ts @@ -325,6 +325,42 @@ describe('MeshCoreProtocol.subscribe', () => { }); describe('MeshCoreProtocol capability-gated operations', () => { + it('rejects null or empty outbound text before calling the connection', async () => { + const conn = { + sendTextMessage: vi.fn(), + sendChannelTextMessage: vi.fn(), + } as unknown as Connection; + + await expect( + meshcoreProtocol.sendMessage(null, { text: 'hello', channelIndex: 0 }), + ).rejects.toThrow(/handle is required/); + await expect( + meshcoreProtocol.sendMessage(conn, { text: null as unknown as string, channelIndex: 0 }), + ).rejects.toThrow(/contain text/); + await expect(meshcoreProtocol.sendMessage(conn, { text: '', channelIndex: 0 })).rejects.toThrow( + /contain text/, + ); + expect( + (conn as unknown as { sendChannelTextMessage: ReturnType }) + .sendChannelTextMessage, + ).not.toHaveBeenCalled(); + }); + + it('rejects an empty direct-message public key', async () => { + const conn = { sendTextMessage: vi.fn() } as unknown as Connection; + + await expect( + meshcoreProtocol.sendMessage(conn, { + text: 'hello', + destination: 1, + destinationPubKey: new Uint8Array(), + }), + ).rejects.toThrow(/destinationPubKey/); + expect( + (conn as unknown as { sendTextMessage: ReturnType }).sendTextMessage, + ).not.toHaveBeenCalled(); + }); + it('setConfig remains on legacy companion until Protocol JSON config lands', async () => { await expect(meshcoreProtocol.setConfig({}, {})).rejects.toThrow(/setConfig/); }); diff --git a/src/renderer/lib/protocols/MeshCoreProtocol.ts b/src/renderer/lib/protocols/MeshCoreProtocol.ts index 0fd0fb0d7..9495a0e5b 100644 --- a/src/renderer/lib/protocols/MeshCoreProtocol.ts +++ b/src/renderer/lib/protocols/MeshCoreProtocol.ts @@ -223,9 +223,18 @@ export class MeshCoreProtocol implements Protocol { // --- Outbound --- async sendMessage(handle: unknown, opts: SendMessageOptions): Promise { + if (handle == null) { + throw new TypeError('meshcore sendMessage: connection handle is required'); + } const conn = handle as Connection; + if (typeof opts.text !== 'string' || opts.text.length === 0) { + throw new TypeError('MeshCore messages must contain text.'); + } if (opts.destination != null) { - if (!opts.destinationPubKey) { + if ( + !(opts.destinationPubKey instanceof Uint8Array) || + opts.destinationPubKey.byteLength === 0 + ) { throw new Error( 'MeshCore direct messages require destinationPubKey to be provided in SendMessageOptions.', ); diff --git a/src/renderer/lib/protocols/MeshtasticProtocol.ts b/src/renderer/lib/protocols/MeshtasticProtocol.ts index d11e41f43..36db16baf 100644 --- a/src/renderer/lib/protocols/MeshtasticProtocol.ts +++ b/src/renderer/lib/protocols/MeshtasticProtocol.ts @@ -1,5 +1,5 @@ import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; -import { type MeshDevice, Types } from '@meshtastic/core'; +import { type MeshDevice } from '@meshtastic/core'; import { Admin, Channel as ProtobufChannel, Mesh, Portnums } from '@meshtastic/protobufs'; import { @@ -14,6 +14,7 @@ import { } from '../connection'; import { createPacketDedupeRegistry } from '../drivers/packetDedupeRegistry'; import { meshtasticHwModelName } from '../hardwareModels'; +import { meshtasticDeviceStatusForCode } from '../meshtastic/meshtasticDeviceStatus'; import { meshtasticComputedRfHopsAway } from '../meshtasticRfHops'; import type { ProtocolCapabilities } from '../radio/BaseRadioProvider'; import { MESHTASTIC_CAPABILITIES } from '../radio/BaseRadioProvider'; @@ -41,20 +42,8 @@ import type { } from './Protocol'; import { UnsupportedOperation } from './Protocol'; -const { DeviceStatusEnum } = Types; const { PortNum } = Portnums; -const STATUS_CODE_MAP: Record = { - [DeviceStatusEnum.DeviceRestarting]: 'connecting', - [DeviceStatusEnum.DeviceDisconnected]: 'disconnected', - [DeviceStatusEnum.DeviceConnecting]: 'connecting', - [DeviceStatusEnum.DeviceReconnecting]: 'connecting', - [DeviceStatusEnum.DeviceConnected]: 'connected', - [DeviceStatusEnum.DeviceConfiguring]: 'connecting', - [DeviceStatusEnum.DeviceConfigured]: 'configured', - 8: 'stale', -}; - /** * The SDK dispatches `onMeshPacket` for every packet and then a second typed * event for the same payload, so a TRACEROUTE_APP packet reaches `subscribe` @@ -1150,7 +1139,7 @@ export class MeshtasticProtocol implements Protocol { } private decodeDeviceStatus(raw: unknown): DomainEvent[] { - const status = STATUS_CODE_MAP[raw as number] ?? 'connected'; + const status = meshtasticDeviceStatusForCode(raw as number); return [{ type: 'device_status', payload: { status } }]; } diff --git a/src/renderer/lib/runtimeSideEffectAttach.contract.test.ts b/src/renderer/lib/runtimeSideEffectAttach.contract.test.ts new file mode 100644 index 000000000..97233e9ee --- /dev/null +++ b/src/renderer/lib/runtimeSideEffectAttach.contract.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const rendererRoot = join(__dirname, '..'); +const MESHTASTIC_WIRE_SOURCE = readFileSync( + join(rendererRoot, 'lib/meshtastic/meshtasticRuntimeWireEffects.ts'), + 'utf-8', +); +const MESHCORE_CONN_SOURCE = readFileSync( + join(rendererRoot, 'hooks/meshcore/meshcoreConnSideEffects.ts'), + 'utf-8', +); +const MESHCORE_INGEST_SOURCE = readFileSync( + join(rendererRoot, 'lib/ingest/meshcoreIngest.ts'), + 'utf-8', +); + +describe('runtime side-effect attachment contracts', () => { + it('replaces Meshtastic ingress and ingest listeners before reattaching', () => { + expect(MESHTASTIC_WIRE_SOURCE).toContain('meshtasticIngressDetachRef.current()'); + expect(MESHTASTIC_WIRE_SOURCE).toContain('meshtasticIngestSessionRef.current.detach()'); + expect(MESHTASTIC_WIRE_SOURCE).toContain('attachMeshtasticIngest(identityId'); + }); + + it('returns a MeshCore PacketRouter detach function', () => { + expect(MESHCORE_CONN_SOURCE).toContain('const detachListener = packetRouter.addListener'); + expect(MESHCORE_CONN_SOURCE).toContain('detachListener();'); + }); + + it('returns a MeshCore ingest detach function', () => { + expect(MESHCORE_INGEST_SOURCE).toContain('return packetRouter.addListener(createListener'); + expect(MESHCORE_INGEST_SOURCE).toContain('): () => void'); + }); +}); diff --git a/src/renderer/lib/transport/TransportManager.test.ts b/src/renderer/lib/transport/TransportManager.test.ts index aa0dec74b..480646a5c 100644 --- a/src/renderer/lib/transport/TransportManager.test.ts +++ b/src/renderer/lib/transport/TransportManager.test.ts @@ -249,14 +249,14 @@ describe('TransportManager', () => { warnSpy.mockRestore(); }); - it('falls back to routing error name for unmapped numeric codes', async () => { + it('uses the chat routing error key for rate-limited sends', async () => { window.electronAPI = { mqtt: { publish: vi.fn() }, } as unknown as typeof window.electronAPI; const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const status = vi.fn(); - const sdkErr = { id: 42, error: 38 }; // RATE_LIMIT_EXCEEDED — no chat i18n mapping + const sdkErr = { id: 42, error: 38 }; // RATE_LIMIT_EXCEEDED const manager = new TransportManager({ deviceRef: { current: { @@ -279,7 +279,7 @@ describe('TransportManager', () => { expect.objectContaining({ status: 'failed', finalPacketId: 42, - error: 'RATE_LIMIT_EXCEEDED', + error: 'chatPanel.routingErrors.rateLimited', }), ); warnSpy.mockRestore(); diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 7728e354b..b9c363d9f 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -547,7 +547,9 @@ "pkiMissingRecipientKey": "Nelze odeslat šifrovanou zprávu — v NodeDB chybí veřejný klíč příjemce.", "pkiFailed": "Šifrování PKI pro tuto zprávu selhalo.", "noChannel": "Pro směrování této zprávy není k dispozici žádný kanál.", - "timeout": "Časový limit zprávy v rádiu vypršel." + "timeout": "Časový limit zprávy v rádiu vypršel.", + "rateLimited": "Rádio posílá zprávy omezující frekvenci. Zkus to znovu za chvíli.", + "noInterface": "Pro odeslání této zprávy není k dispozici žádné rádiové rozhraní." }, "waitingMessagesQueued": "{{count}} zprávy ve frontě v rádiu", "waitingMessagesSyncProgress": "Synchronizace {{processed}} / {{total}} z rádia…", @@ -2702,7 +2704,11 @@ "stableStability": "Stabilní", "moderateStability": "Mírný", "unstableStability": "Nestabilní", - "unknownStability": "Neznámý" + "unknownStability": "Neznámý", + "hybridMqttPathTooltip": "Přijato prostřednictvím RF; některé pakety používají reléovou cestu MQTT", + "hybridMqttPathAria": "Cesta RF a MQTT", + "connectedViaRfAndMqttTooltip": "Připojeno přes RF a MQTT", + "connectedViaRfAndMqttAria": "Připojeno přes RF a MQTT" }, "nodeListPanel": { "contactsRefreshed": "Kontakty byly obnoveny.", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 5990314a7..eaf006318 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -547,7 +547,9 @@ "pkiMissingRecipientKey": "Verschlüsselte Nachricht konnte nicht gesendet werden — öffentlicher Schlüssel des Empfängers fehlt in NodeDB.", "pkiFailed": "PKI-Verschlüsselung für diese Nachricht fehlgeschlagen.", "noChannel": "Kein Kanal verfügbar, um diese Nachricht weiterzuleiten.", - "timeout": "Zeitüberschreitung der Nachricht im Funkgerät." + "timeout": "Zeitüberschreitung der Nachricht im Funkgerät.", + "rateLimited": "Das Radio ist geschwindigkeitsbegrenzende Nachrichten. Versuchen Sie es in Kürze erneut.", + "noInterface": "Es ist keine Funkschnittstelle zum Senden dieser Nachricht verfügbar." }, "waitingMessagesQueued": "{{count}} in der Warteschlange stehende Nachricht(en) im Funk", "waitingMessagesSyncProgress": "{{processed}}/{{total}} wird vom Funkgerät synchronisiert...", @@ -2700,7 +2702,11 @@ "stableStability": "Stabil", "moderateStability": "Mäßig", "unstableStability": "Instabil", - "unknownStability": "Unbekannt" + "unknownStability": "Unbekannt", + "hybridMqttPathTooltip": "Über RF empfangen; einige Pakete verwenden einen MQTT-Relay-Pfad", + "hybridMqttPathAria": "RF- und MQTT-Pfad", + "connectedViaRfAndMqttTooltip": "Anbindung über RF und MQTT", + "connectedViaRfAndMqttAria": "Anbindung über RF und MQTT" }, "nodeListPanel": { "contactsRefreshed": "Kontakte aktualisiert.", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index d70694326..5e2c308ce 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -529,6 +529,8 @@ "pkiMissingRecipientKey": "Could not send encrypted message — recipient public key is missing from NodeDB.", "pkiFailed": "PKI encryption failed for this message.", "noChannel": "No channel available to route this message.", + "rateLimited": "The radio is rate-limiting messages. Try again shortly.", + "noInterface": "No radio interface is available to send this message.", "timeout": "Message timed out on the radio." }, "queueButton": "Queue", @@ -2706,7 +2708,11 @@ "stableStability": "Stable", "moderateStability": "Moderate", "unstableStability": "Unstable", - "unknownStability": "Unknown" + "unknownStability": "Unknown", + "hybridMqttPathTooltip": "Received via RF; some packets use an MQTT relay path", + "hybridMqttPathAria": "RF and MQTT path", + "connectedViaRfAndMqttTooltip": "Connected via RF and MQTT", + "connectedViaRfAndMqttAria": "Connected via RF and MQTT" }, "nodeListPanel": { "contactsRefreshed": "Contacts refreshed.", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 0e0afb629..a708187b8 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -547,7 +547,9 @@ "pkiMissingRecipientKey": "No se ha podido enviar el mensaje cifrado: falta la clave pública del destinatario de NodeDB.", "pkiFailed": "El cifrado PKI ha fallado para este mensaje.", "noChannel": "No hay ningún canal disponible para enrutar este mensaje.", - "timeout": "El mensaje ha caducado en la radio." + "timeout": "El mensaje ha caducado en la radio.", + "rateLimited": "La radio está limitando la velocidad de los mensajes. Inténtalo de nuevo en breve.", + "noInterface": "No hay ninguna interfaz de radio disponible para enviar este mensaje." }, "waitingMessagesQueued": "{{count}} mensajes en cola en la radio", "waitingMessagesSyncProgress": "Sincronizando {{processed}} / {{total}} desde la radio...", @@ -2700,7 +2702,11 @@ "stableStability": "Estable", "moderateStability": "Moderado", "unstableStability": "Inestable", - "unknownStability": "Desconocido" + "unknownStability": "Desconocido", + "hybridMqttPathTooltip": "Recibido a través de RF; algunos paquetes utilizan una ruta de retransmisión MQTT", + "hybridMqttPathAria": "Ruta RF y MQTT", + "connectedViaRfAndMqttTooltip": "Conectado a través de RF y MQTT", + "connectedViaRfAndMqttAria": "Conectado a través de RF y MQTT" }, "nodeListPanel": { "contactsRefreshed": "Contactos actualizados.", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index d4801736c..d2a705848 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -547,7 +547,9 @@ "pkiMissingRecipientKey": "Impossible d'envoyer un message chiffré — la clé publique du destinataire est manquante dans NodeDB.", "pkiFailed": "Le cryptage PKI a échoué pour ce message.", "noChannel": "Aucun canal disponible pour acheminer ce message.", - "timeout": "Le message a expiré à la radio." + "timeout": "Le message a expiré à la radio.", + "rateLimited": "La radio émet des messages de limitation de débit. Réessayez sous peu.", + "noInterface": "Aucune interface radio n'est disponible pour envoyer ce message." }, "waitingMessagesQueued": "{{count}} message(s) en file d'attente à la radio", "waitingMessagesSyncProgress": "Synchronisation de {{processed}} / {{total}} à partir de la radio…", @@ -2700,7 +2702,11 @@ "stableStability": "Écurie", "moderateStability": "Modéré", "unstableStability": "Instable", - "unknownStability": "Inconnu" + "unknownStability": "Inconnu", + "hybridMqttPathTooltip": "Reçu via RF ; certains paquets utilisent un chemin de relais MQTT", + "hybridMqttPathAria": "Chemin RF et MQTT", + "connectedViaRfAndMqttTooltip": "Connecté via RF et MQTT", + "connectedViaRfAndMqttAria": "Connecté via RF et MQTT" }, "nodeListPanel": { "contactsRefreshed": "Contacts actualisés.", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 4a1f1b754..2f077461c 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -548,7 +548,9 @@ "pkiMissingRecipientKey": "Tidak dapat mengirim pesan terenkripsi — kunci publik penerima hilang dari NodeDB.", "pkiFailed": "Enkripsi PKI gagal untuk pesan ini.", "noChannel": "Tidak ada saluran yang tersedia untuk merutekan pesan ini.", - "timeout": "Waktu pesan habis di radio." + "timeout": "Waktu pesan habis di radio.", + "rateLimited": "Radio adalah pesan yang membatasi tingkat. Coba lagi sebentar lagi.", + "noInterface": "Tidak ada antarmuka radio yang tersedia untuk mengirim pesan ini." }, "waitingMessagesQueued": "{{count}} antrean pesan di radio", "waitingMessagesSyncProgress": "Menyinkronkan {{processed}} / {{total}} dari radio…", @@ -2700,7 +2702,11 @@ "stableStability": "Stabil", "moderateStability": "Sedang", "unstableStability": "Tidak stabil", - "unknownStability": "Tidak dikenal" + "unknownStability": "Tidak dikenal", + "hybridMqttPathTooltip": "Diterima melalui RF; beberapa paket menggunakan jalur relai MQTT", + "hybridMqttPathAria": "Jalur RF dan MQTT", + "connectedViaRfAndMqttTooltip": "Terhubung melalui RF dan MQTT", + "connectedViaRfAndMqttAria": "Terhubung melalui RF dan MQTT" }, "nodeListPanel": { "contactsRefreshed": "Kontak disegarkan.", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 957e7f73d..34f010355 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -547,7 +547,9 @@ "pkiMissingRecipientKey": "Impossibile inviare un messaggio crittografato — la chiave pubblica del destinatario manca da NodeDB.", "pkiFailed": "Crittografia PKI non riuscita per questo messaggio.", "noChannel": "Nessun canale disponibile per instradare questo messaggio.", - "timeout": "Messaggio scaduto alla radio." + "timeout": "Messaggio scaduto alla radio.", + "rateLimited": "La radio è messaggi limitanti la velocità. Riprova a breve.", + "noInterface": "Nessuna interfaccia radio è disponibile per inviare questo messaggio." }, "waitingMessagesQueued": "{{count}} messaggio/i in coda alla radio", "waitingMessagesSyncProgress": "Sincronizzazione di {{processed}} / {{total}} dalla radio...", @@ -2700,7 +2702,11 @@ "stableStability": "Stabile", "moderateStability": "Moderare", "unstableStability": "Instabile", - "unknownStability": "Sconosciuto" + "unknownStability": "Sconosciuto", + "hybridMqttPathTooltip": "Ricevuti via RF; alcuni pacchetti utilizzano un percorso di inoltro MQTT", + "hybridMqttPathAria": "Percorso RF e MQTT", + "connectedViaRfAndMqttTooltip": "Connesso via RF e MQTT", + "connectedViaRfAndMqttAria": "Connesso via RF e MQTT" }, "nodeListPanel": { "contactsRefreshed": "Contatti aggiornati.", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 806e80684..c4bc5669d 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -548,7 +548,9 @@ "pkiMissingRecipientKey": "暗号化されたメッセージを送信できませんでした— NodeDBに受信者の公開鍵がありません。", "pkiFailed": "このメッセージのPKI暗号化に失敗しました。", "noChannel": "このメッセージをルーティングするチャンネルはありません。", - "timeout": "ラジオでメッセージがタイムアウトしました。" + "timeout": "ラジオでメッセージがタイムアウトしました。", + "rateLimited": "無線はレート制限メッセージです。しばらくしてから再度お試しください。", + "noInterface": "このメッセージを送信するための無線インターフェースはありません。" }, "waitingMessagesQueued": "{{count}}は無線でメッセージをキューに入れました", "waitingMessagesSyncProgress": "ラジオから{{processed}}/{{total}}を同期しています…", @@ -2700,7 +2702,11 @@ "stableStability": "安定した", "moderateStability": "適度", "unstableStability": "不安定", - "unknownStability": "未知" + "unknownStability": "未知", + "hybridMqttPathTooltip": "RFを介して受信。一部のパケットはMQTTリレーパスを使用", + "hybridMqttPathAria": "RFおよびMQTTパス", + "connectedViaRfAndMqttTooltip": "RFおよびMQTT経由で接続", + "connectedViaRfAndMqttAria": "RFおよびMQTT経由で接続" }, "nodeListPanel": { "contactsRefreshed": "連絡先が更新されました。", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 7ef3abded..5fc478e35 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -548,7 +548,9 @@ "pkiMissingRecipientKey": "암호화된 메시지를 보낼 수 없습니다 — 수신자 공개 키가 NodeDB에서 누락되었습니다.", "pkiFailed": "이 메시지에 대한 PKI 암호화에 실패했습니다.", "noChannel": "이 메시지를 라우팅할 수 있는 채널이 없습니다.", - "timeout": "라디오에서 메시지가 시간 초과되었습니다." + "timeout": "라디오에서 메시지가 시간 초과되었습니다.", + "rateLimited": "라디오는 속도 제한 메시지입니다. 잠시 후 다시 시도하세요.", + "noInterface": "이 메시지를 보낼 수 있는 라디오 인터페이스가 없습니다." }, "waitingMessagesQueued": "{{count}} 라디오에서 대기 중인 메시지", "waitingMessagesSyncProgress": "라디오에서 {{processed}}/{{total}} 동기화 중…", @@ -2700,7 +2702,11 @@ "stableStability": "안정적인", "moderateStability": "보통의", "unstableStability": "불안정한", - "unknownStability": "알려지지 않은" + "unknownStability": "알려지지 않은", + "hybridMqttPathTooltip": "RF를 통해 수신; 일부 패킷은 MQTT 릴레이 경로를 사용합니다", + "hybridMqttPathAria": "RF 및 MQTT 경로", + "connectedViaRfAndMqttTooltip": "RF 및 MQTT를 통해 연결됨", + "connectedViaRfAndMqttAria": "RF 및 MQTT를 통해 연결됨" }, "nodeListPanel": { "contactsRefreshed": "연락처가 새로 고쳐졌습니다.", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 1037277a7..de85d3f64 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -546,7 +546,9 @@ "pkiMissingRecipientKey": "Kan gecodeerd bericht niet verzenden — de openbare sleutel van de ontvanger ontbreekt in NodeDB.", "pkiFailed": "PKI-codering mislukt voor dit bericht.", "noChannel": "Geen kanaal beschikbaar om dit bericht te routeren.", - "timeout": "Er is een time-out opgetreden op de radio." + "timeout": "Er is een time-out opgetreden op de radio.", + "rateLimited": "De radio is tariefbeperkende berichten. Probeer het binnenkort opnieuw.", + "noInterface": "Er is geen radio-interface beschikbaar om dit bericht te verzenden." }, "waitingMessagesQueued": "{{count}} bericht(en) in de wachtrij op de radio", "waitingMessagesSyncProgress": "{{processed}} / {{total}} synchroniseren vanaf radio...", @@ -2700,7 +2702,11 @@ "stableStability": "Stabiel", "moderateStability": "Gematigd", "unstableStability": "Onstabiel", - "unknownStability": "Onbekend" + "unknownStability": "Onbekend", + "hybridMqttPathTooltip": "Ontvangen via RF; sommige pakketten gebruiken een MQTT-relaispad", + "hybridMqttPathAria": "RF- en MQTT-pad", + "connectedViaRfAndMqttTooltip": "Verbonden via RF en MQTT", + "connectedViaRfAndMqttAria": "Verbonden via RF en MQTT" }, "nodeListPanel": { "contactsRefreshed": "Contacten vernieuwd.", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 967f9f016..e23c75c24 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -547,7 +547,9 @@ "pkiMissingRecipientKey": "Nie można wysłać zaszyfrowanej wiadomości — brak klucza publicznego odbiorcy w NodeDB.", "pkiFailed": "Szyfrowanie PKI dla tej wiadomości nie powiodło się.", "noChannel": "Brak dostępnego kanału do przekierowania tej wiadomości.", - "timeout": "Upłynął limit czasu wiadomości w radiu." + "timeout": "Upłynął limit czasu wiadomości w radiu.", + "rateLimited": "Radio jest komunikatem ograniczającym szybkość. Spróbuj ponownie za chwilę.", + "noInterface": "Brak dostępnego interfejsu radiowego do wysłania tej wiadomości." }, "waitingMessagesQueued": "{{count}} wiadomościw kolejce w radiu", "waitingMessagesSyncProgress": "Synchronizowanie {{processed}} / {{total}} z radiem…", @@ -2702,7 +2704,11 @@ "stableStability": "Stabilny", "moderateStability": "Umiarkowany", "unstableStability": "Nietrwały", - "unknownStability": "Nieznany" + "unknownStability": "Nieznany", + "hybridMqttPathTooltip": "Odebrane przez RF; niektóre pakiety używają ścieżki przekaźnika MQTT", + "hybridMqttPathAria": "Ścieżka RF i MQTT", + "connectedViaRfAndMqttTooltip": "Połączony przez RF i MQTT", + "connectedViaRfAndMqttAria": "Połączony przez RF i MQTT" }, "nodeListPanel": { "refreshFailed": "Odświeżanie nie powiodło się: {{message}}", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 99ed9af65..3ac9e009f 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -548,7 +548,9 @@ "pkiMissingRecipientKey": "Não foi possível enviar a mensagem criptografada — a chave pública do destinatário está ausente do NodeDB.", "pkiFailed": "A criptografia PKI falhou para esta mensagem.", "noChannel": "Nenhum canal disponível para encaminhar esta mensagem.", - "timeout": "Mensagem expirou no rádio." + "timeout": "Mensagem expirou no rádio.", + "rateLimited": "O rádio é mensagens de limitação de taxa. Tente novamente em breve.", + "noInterface": "Nenhuma interface de rádio está disponível para enviar esta mensagem." }, "waitingMessagesQueued": "{{count}} mensagem(ões) na fila no rádio", "waitingMessagesSyncProgress": "Sincronizando {{processed}} / {{total}} do rádio...", @@ -2700,7 +2702,11 @@ "stableStability": "Estável", "moderateStability": "Moderado", "unstableStability": "Instável", - "unknownStability": "Desconhecido" + "unknownStability": "Desconhecido", + "hybridMqttPathTooltip": "Recebido via RF; alguns pacotes usam um caminho de retransmissão MQTT", + "hybridMqttPathAria": "Caminho de RF e MQTT", + "connectedViaRfAndMqttTooltip": "Conectado via RF e MQTT", + "connectedViaRfAndMqttAria": "Conectado via RF e MQTT" }, "nodeListPanel": { "contactsRefreshed": "Contatos atualizados.", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 4fd70842d..f025371ca 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -548,7 +548,9 @@ "pkiMissingRecipientKey": "Не удалось отправить зашифрованное сообщение — в NodeDB отсутствует открытый ключ получателя.", "pkiFailed": "Ошибка шифрования PKI для этого сообщения.", "noChannel": "Нет канала для маршрутизации этого сообщения.", - "timeout": "Время ожидания сообщения по радио истекло." + "timeout": "Время ожидания сообщения по радио истекло.", + "rateLimited": "Радиостанция предназначена для передачи сообщений с ограничением скорости. Повторите попытку позже.", + "noInterface": "Нет радиоинтерфейса для отправки этого сообщения." }, "waitingMessagesQueued": "{{count}} сообщенийв очереди по радио", "waitingMessagesSyncProgress": "Синхронизация {{processed}} / {{total}} с радио…", @@ -2702,7 +2704,11 @@ "stableStability": "Стабильный", "moderateStability": "Умеренный", "unstableStability": "Нестабильный", - "unknownStability": "Неизвестный" + "unknownStability": "Неизвестный", + "hybridMqttPathTooltip": "Получено через RF; некоторые пакеты используют путь ретрансляции MQTT", + "hybridMqttPathAria": "Путь RF и MQTT", + "connectedViaRfAndMqttTooltip": "Подключено через RF и MQTT", + "connectedViaRfAndMqttAria": "Подключено через RF и MQTT" }, "nodeListPanel": { "contactsRefreshed": "Контакты обновлены.", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index d8d2e9fab..31db26bcb 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -548,7 +548,9 @@ "pkiMissingRecipientKey": "Şifrelenmiş mesaj gönderilemedi — NodeDB'de alıcı genel anahtarı eksik.", "pkiFailed": "Bu mesaj için PKI şifrelemesi başarısız oldu.", "noChannel": "Bu mesajı yönlendirmek için kullanılabilir kanal yok.", - "timeout": "Mesaj radyoda zaman aşımına uğradı." + "timeout": "Mesaj radyoda zaman aşımına uğradı.", + "rateLimited": "Radyo hız sınırlayıcı mesajlardır. Kısa süre içinde yeniden deneyin.", + "noInterface": "Bu mesajı göndermek için radyo arayüzü mevcut değil." }, "waitingMessagesQueued": "{{count}} radyoda kuyrukta bekleyen mesaj(lar)", "waitingMessagesSyncProgress": "{{processed}} / {{total}} radyodan senkronize ediliyor…", @@ -2700,7 +2702,11 @@ "moderateStability": "Ilıman", "unstableStability": "Dengesiz", "unknownStability": "Bilinmiyor", - "flagged": "{{flagged}}/{{total}} işaretlendi" + "flagged": "{{flagged}}/{{total}} işaretlendi", + "hybridMqttPathTooltip": "RF aracılığıyla alınır; bazı paketler bir MQTT röle yolu kullanır", + "hybridMqttPathAria": "RF ve MQTT yolu", + "connectedViaRfAndMqttTooltip": "RF ve MQTT ile bağlanır", + "connectedViaRfAndMqttAria": "RF ve MQTT ile bağlanır" }, "nodeListPanel": { "contactsRefreshed": "Kişiler yenilendi.", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 520381a39..883848a94 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -548,7 +548,9 @@ "pkiMissingRecipientKey": "Не вдалося надіслати зашифроване повідомлення — у NodeDB відсутній відкритий ключ одержувача.", "pkiFailed": "Не вдалося зашифрувати PKI для цього повідомлення.", "noChannel": "Немає каналу для маршрутизації цього повідомлення.", - "timeout": "Час очікування повідомлення на радіо закінчився." + "timeout": "Час очікування повідомлення на радіо закінчився.", + "rateLimited": "Радіоприймач обмежує швидкість передачі повідомлень. Повторіть спробу найближчим часом.", + "noInterface": "Немає радіоінтерфейсу для надсилання цього повідомлення." }, "waitingMessagesQueued": "{{count}} повідомлення в черзі на радіо", "waitingMessagesSyncProgress": "Синхронізація {{processed}} / {{total}} з радіо…", @@ -2702,7 +2704,11 @@ "stableStability": "Стабільний", "moderateStability": "Помірний", "unstableStability": "Нестабільний", - "unknownStability": "Невідомий" + "unknownStability": "Невідомий", + "hybridMqttPathTooltip": "Отримано через RF; деякі пакети використовують шлях ретрансляції MQTT", + "hybridMqttPathAria": "Шлях RF та MQTT", + "connectedViaRfAndMqttTooltip": "Підключено через RF та MQTT", + "connectedViaRfAndMqttAria": "Підключено через RF та MQTT" }, "nodeListPanel": { "contactsRefreshed": "Контакти оновлено.", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 2ae28445f..fe811023f 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -548,7 +548,9 @@ "pkiMissingRecipientKey": "无法发送加密邮件— NodeDB中缺少收件人公钥。", "pkiFailed": "此邮件的PKI加密失败。", "noChannel": "没有可用于路由此消息的渠道。", - "timeout": "收音机消息超时。" + "timeout": "收音机消息超时。", + "rateLimited": "无线电是速率限制消息。请稍后重试。", + "noInterface": "没有无线电接口可用于发送此消息。" }, "waitingMessagesQueued": "{{count}}在无线电上排队的消息", "waitingMessagesSyncProgress": "正在从收音机同步{{processed}}/{{total}} …", @@ -2700,7 +2702,11 @@ "stableStability": "稳定的", "moderateStability": "缓和", "unstableStability": "不稳定", - "unknownStability": "未知" + "unknownStability": "未知", + "hybridMqttPathTooltip": "通过射频接收;某些数据包使用MQTT中继路径", + "hybridMqttPathAria": "射频和MQTT路径", + "connectedViaRfAndMqttTooltip": "通过射频和MQTT连接", + "connectedViaRfAndMqttAria": "通过射频和MQTT连接" }, "nodeListPanel": { "contactsRefreshed": "联系人已刷新。", diff --git a/src/renderer/runtime/runtimeAttachDetach.contract.test.ts b/src/renderer/runtime/runtimeAttachDetach.contract.test.ts new file mode 100644 index 000000000..7c26feea1 --- /dev/null +++ b/src/renderer/runtime/runtimeAttachDetach.contract.test.ts @@ -0,0 +1,31 @@ +/** + * Source-contract: Meshtastic RF wire attach stores a detach handle that + * cleanupSubscriptions invokes; MeshCore conn attach similarly tears down via + * meshcoreIngressDetachRef / teardownMeshcoreConnEventListeners. + */ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; + +import { extractUseCallbackBody } from '../lib/sourceContractTestHelpers'; + +const MESHTASTIC_RUNTIME = readFileSync(join(__dirname, 'useMeshtasticRuntime.ts'), 'utf-8'); +const MESHCORE_RUNTIME = readFileSync(join(__dirname, 'useMeshcoreRuntime.ts'), 'utf-8'); + +describe('runtime attach/detach source contracts', () => { + it('Meshtastic cleanupSubscriptions invokes meshtasticIngressDetachRef', () => { + const body = extractUseCallbackBody(MESHTASTIC_RUNTIME, 'cleanupSubscriptions'); + expect(body).toContain('meshtasticIngressDetachRef.current'); + expect(body).toContain('meshtasticIngressDetachRef.current()'); + expect(MESHTASTIC_RUNTIME).toContain('attachMeshtasticRuntimeWireEffects('); + }); + + it('MeshCore teardown invokes meshcoreIngressDetachRef and attaches conn side effects', () => { + const body = extractUseCallbackBody(MESHCORE_RUNTIME, 'teardownMeshcoreConnEventListeners'); + expect(body).toContain('meshcoreIngressDetachRef.current'); + expect(body).toContain('meshcoreIngressDetachRef.current()'); + expect(MESHCORE_RUNTIME).toContain( + 'attachMeshcoreConnSideEffects(conn, meshcoreConnSideEffectsCtx)', + ); + }); +}); diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 7c19e0dfd..d65f0f2be 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -1769,7 +1769,6 @@ export function useMeshcoreRuntime() { setDeviceLogs, setMeshcorePingRouteReadyEpoch, setMessages, - setNodes, setQueueStatus, setRawPackets, setSignalTelemetry, diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 09f1bd349..9fadc30bc 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -237,7 +237,12 @@ import { upsertMessage, useMessageStore, } from '../stores/messageStore'; -import { patchNodeFavorited, upsertNodeRecord, useNodeStore } from '../stores/nodeStore'; +import { + patchNodeFavorited, + removeNode, + upsertNodeRecord, + useNodeStore, +} from '../stores/nodeStore'; import { usePositionHistoryStore } from '../stores/positionHistoryStore'; type ChannelType = Parameters[0]; @@ -570,14 +575,20 @@ export function useMeshtasticRuntime() { const updateNodes = useCallback( (updater: (prev: Map) => Map) => { - // The identity-scoped store is the merge base and is updated immediately; - // `nodes` remains only a pre-identity/fallback render snapshot. + // Identity-scoped nodeStore is canonical once an identity exists. Hook-local + // `nodes` is only a pre-identity snapshot (MQTT-only before hydrate / no RF id). const identityId = meshtasticIdentityIdRef.current ?? meshtasticPendingDriverIdentityRef.current; - const base = identityId ? getIdentityNodeMap(identityId) : new Map(); - const next = updater(base); - setNodes(next); - if (identityId) syncNodesMapToIdentityStore(identityId, next); + if (identityId) { + const base = getIdentityNodeMap(identityId); + const next = updater(base); + for (const nodeId of base.keys()) { + if (!next.has(nodeId)) removeNode(identityId, nodeId); + } + syncNodesMapToIdentityStore(identityId, next); + return; + } + setNodes((prev) => updater(prev)); }, [], ); @@ -2255,7 +2266,6 @@ export function useMeshtasticRuntime() { const applyMeshtasticNodesToUi = useCallback( (driverIdentityId: string, nodeMap: Map) => { - setNodes(nodeMap); syncNodesMapToIdentityStore(driverIdentityId, nodeMap); }, [], @@ -3593,39 +3603,44 @@ export function useMeshtasticRuntime() { pendingTracePacketIdToTargetRef.current.set(packetId >>> 0, nodeNum); }, []); - const deleteNode = useCallback( - async (nodeId: number) => { - const activeVirtualNodeId = virtualNodeIdRef.current; - if (nodeId === activeVirtualNodeId && mqttStatusRef.current === 'connected') { - throw markDeleteActiveMqttIdentityError( - 'Cannot delete active MQTT identity while MQTT is connected', - ); - } - if (nodeId === activeVirtualNodeId) { - clearVirtualNodeId(); - virtualNodeIdRef.current = getOrCreateVirtualNodeId(); - } - await window.electronAPI.db.deleteNode(nodeId); - console.debug( - `[useMeshtasticRuntime] deleteNode: removed 0x${nodeId.toString(16).toUpperCase()} from memory`, + const deleteNode = useCallback(async (nodeId: number) => { + const activeVirtualNodeId = virtualNodeIdRef.current; + if (nodeId === activeVirtualNodeId && mqttStatusRef.current === 'connected') { + throw markDeleteActiveMqttIdentityError( + 'Cannot delete active MQTT identity while MQTT is connected', ); - updateNodes((prev) => { + } + if (nodeId === activeVirtualNodeId) { + clearVirtualNodeId(); + virtualNodeIdRef.current = getOrCreateVirtualNodeId(); + } + await window.electronAPI.db.deleteNode(nodeId); + console.debug( + `[useMeshtasticRuntime] deleteNode: removed 0x${nodeId.toString(16).toUpperCase()} from memory`, + ); + const storeId = meshtasticIdentityIdRef.current ?? meshtasticPendingDriverIdentityRef.current; + if (storeId) { + removeNode(storeId, nodeId); + } else { + setNodes((prev) => { const updated = new Map(prev); updated.delete(nodeId); return updated; }); - }, - [updateNodes], - ); + } + }, []); const refreshNodesFromDb = useCallback(() => { void loadMeshtasticNodeMapFromDb() .then((nodeMap) => { console.debug(`[useMeshtasticRuntime] refreshNodesFromDb: loaded ${nodeMap.size} nodes`); - setNodes(nodeMap); const storeId = meshtasticIdentityIdRef.current ?? meshtasticPendingDriverIdentityRef.current; - if (storeId) syncNodesMapToIdentityStore(storeId, nodeMap); + if (storeId) { + syncNodesMapToIdentityStore(storeId, nodeMap); + } else { + setNodes(nodeMap); + } }) .catch((err: unknown) => { console.error('[useMeshtasticRuntime] Failed to refresh nodes: ' + errLikeToLogString(err)); @@ -3663,22 +3678,20 @@ export function useMeshtasticRuntime() { [markPacketSeen], ); - const setNodeFavorited = useCallback( - async (nodeId: number, favorited: boolean) => { - await window.electronAPI.db.setNodeFavorited(nodeId, favorited); - const storeId = getIdentityIdForProtocol('meshtastic'); - if (storeId) { - patchNodeFavorited(storeId, nodeId, favorited); - } - updateNodes((prev) => { - const updated = new Map(prev); - const existing = updated.get(nodeId); - if (existing) updated.set(nodeId, { ...existing, favorited }); - return updated; - }); - }, - [updateNodes], - ); + const setNodeFavorited = useCallback(async (nodeId: number, favorited: boolean) => { + await window.electronAPI.db.setNodeFavorited(nodeId, favorited); + const storeId = getIdentityIdForProtocol('meshtastic'); + if (storeId) { + patchNodeFavorited(storeId, nodeId, favorited); + return; + } + setNodes((prev) => { + const updated = new Map(prev); + const existing = updated.get(nodeId); + if (existing) updated.set(nodeId, { ...existing, favorited }); + return updated; + }); + }, []); const refreshOurPosition = useCallback(async (): Promise => { // Dual-protocol: Meshtastic hook stays mounted when user switches to MeshCore; skip GPS work @@ -4025,8 +4038,8 @@ export function useMeshtasticRuntime() { const resolvedNodes = useMemo(() => { if (!meshtasticIdentityId) return nodes; if (!meshtasticNodesFromStore) return nodes; - const fromStore = nodeRecordsToMeshNodeMap(Object.values(meshtasticNodesFromStore)); - return fromStore.size > 0 ? fromStore : nodes; + // Empty store is valid (e.g. after delete); do not fall back to stale hook-local Map. + return nodeRecordsToMeshNodeMap(Object.values(meshtasticNodesFromStore)); }, [meshtasticIdentityId, nodes, meshtasticNodesFromStore]); const resolvedTraceRouteResults = useMemo(() => { diff --git a/src/renderer/stores/nodeStore.test.ts b/src/renderer/stores/nodeStore.test.ts index 74f722ecf..d2db4cb8a 100644 --- a/src/renderer/stores/nodeStore.test.ts +++ b/src/renderer/stores/nodeStore.test.ts @@ -6,6 +6,7 @@ import { clearMeshcoreCliHistory, clearNodeIdentity, patchNodeFavorited, + removeNode, updateMeshcoreOp, updatePosition, updateTelemetry, @@ -106,6 +107,26 @@ describe('patchNodeFavorited', () => { }); }); +describe('removeNode', () => { + afterEach(() => { + useNodeStore.setState({ nodes: {}, traceRoutes: {}, waypoints: {}, neighborInfo: {} }); + }); + + it('removes a single node without clearing sibling nodes', () => { + upsertNode(ID, { nodeId: NODE, longName: 'Alpha' }); + upsertNode(ID, { nodeId: NODE + 1, longName: 'Beta' }); + removeNode(ID, NODE); + expect(useNodeStore.getState().nodes[ID]?.[NODE]).toBeUndefined(); + expect(useNodeStore.getState().nodes[ID]?.[NODE + 1]?.longName).toBe('Beta'); + }); + + it('is a no-op when the node is absent', () => { + upsertNode(ID, { nodeId: NODE, longName: 'Alpha' }); + removeNode(ID, 999); + expect(useNodeStore.getState().nodes[ID]?.[NODE]?.longName).toBe('Alpha'); + }); +}); + describe('mergeNode nodeId guard', () => { afterEach(() => { useNodeStore.setState({ nodes: {}, traceRoutes: {}, waypoints: {}, neighborInfo: {} }); diff --git a/src/renderer/stores/nodeStore.ts b/src/renderer/stores/nodeStore.ts index 6aacc329e..0702051b9 100644 --- a/src/renderer/stores/nodeStore.ts +++ b/src/renderer/stores/nodeStore.ts @@ -525,3 +525,23 @@ export function clearNodeIdentity(identityId: IdentityId): void { neighborInfo: omitRecordKey(s.neighborInfo, identityId), })); } + +/** Remove a single node from an identity bucket (deleteNode UI / MQTT identity cleanup). */ +export function removeNode(identityId: IdentityId, nodeId: number): void { + useNodeStore.setState((s) => { + const byId = s.nodes[identityId]; + if (!byId || !(nodeId in byId)) return s; + const nextById: Record = {}; + for (const [key, value] of Object.entries(byId)) { + if (Number(key) !== nodeId) { + nextById[Number(key)] = value; + } + } + return { + nodes: { + ...s.nodes, + [identityId]: nextById, + }, + }; + }); +} diff --git a/vitest.config.ts b/vitest.config.ts index 35f707aa3..3461407e4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -105,6 +105,11 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts', 'src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts', 'src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts', + 'src/renderer/lib/meshtastic/meshtasticRouterSideEffects.test.ts', + 'src/renderer/lib/meshtastic/meshtasticModulePortSideEffects.test.ts', + 'src/renderer/lib/meshtastic/meshtasticStoreForwardSideEffects.test.ts', + 'src/renderer/lib/meshtastic/meshtasticTraceSideEffects.test.ts', + 'src/renderer/lib/meshtastic/transportDisplayNameCache.test.ts', 'src/renderer/lib/meshcoreDualNobleBleInit.test.ts', 'src/renderer/lib/meshcoreKeyBackupStorage.test.ts', 'src/renderer/lib/meshcoreMqttSettingsStorage.test.ts', From a7d7112a676500f7a693e9a6dc32b8bb623057cc Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 11:40:45 -0600 Subject: [PATCH 07/13] fix: clear SonarCloud new-code findings on PacketRouter ingress Extract MeshCore RF RX and waiting-message drain helpers to cut nesting and cognitive complexity, and apply the remaining readonly/export/ternary/push cleanups flagged on PR #714. --- .../hooks/meshcore/meshcoreConnSideEffects.ts | 848 ++++++------------ .../hooks/meshcore/meshcoreHookPreamble.ts | 3 +- src/renderer/lib/drivers/PacketRouter.ts | 8 +- .../lib/meshcore/meshcoreRfRxRuntime.test.ts | 172 ++++ .../lib/meshcore/meshcoreRfRxRuntime.ts | 633 +++++++++++++ .../meshtastic/meshtasticNodeSideEffects.ts | 84 +- .../meshtasticRuntimeWireEffects.ts | 35 +- .../meshtastic/meshtasticTraceSideEffects.ts | 4 +- .../lib/protocols/MeshCoreProtocol.ts | 12 +- .../lib/protocols/MeshtasticProtocol.ts | 151 ++-- .../useMeshcoreRuntime.reconnect.test.ts | 8 +- src/renderer/runtime/useMeshcoreRuntime.ts | 9 +- vitest.config.ts | 1 + 13 files changed, 1269 insertions(+), 699 deletions(-) create mode 100644 src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts create mode 100644 src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index a6f429159..4c3ca4a76 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -7,42 +7,22 @@ * Failure point: DB / MQTT IPC rejections are logged; Zustand stores and hook state stay * authoritative for the UI. */ +import type { Dispatch, RefObject, SetStateAction } from 'react'; + import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { getIdentityChatMessages } from '@/renderer/lib/identityStoreReads'; import { withTimeout } from '@/shared/withTimeout'; -import { parseMeshCoreRfPacket } from '../../../shared/meshcoreRfPacketParse'; import { packetRouter } from '../../lib/drivers/PacketRouter'; -import { - classifyPayload, - classifyProximity, - extractMeshtasticSenderId, - meshtasticSenderIdForRawLogFallback, -} from '../../lib/foreignLoraDetection'; import { applyMeshcoreDmAckToPending, syncMeshcoreDmAckToMessageStore, } from '../../lib/meshcore/meshcoreDmAckRuntime'; -import type { - DeviceLogEntry, - MeshCoreConnection, - RxPacketEntry, -} from '../../lib/meshcore/meshcoreHookTypes'; -import { - createMeshcoreMqttPacketLogBucket, - tryTakeMeshcoreMqttPacketLogToken, -} from '../../lib/meshcore/meshcoreMqttPacketLogThrottle'; +import type { DeviceLogEntry, MeshCoreConnection } from '../../lib/meshcore/meshcoreHookTypes'; +import { createMeshcoreMqttPacketLogBucket } from '../../lib/meshcore/meshcoreMqttPacketLogThrottle'; +import { handleMeshcoreRfRx, type MeshcoreRfRxDeps } from '../../lib/meshcore/meshcoreRfRxRuntime'; import { processMeshcoreWaitingMessageItem } from '../../lib/meshcoreProcessWaitingMessageItem'; -import { - meshcoreRawPacketLogFromBytesFallback, - meshcoreRawPacketResolveFromParsed, - meshcoreRfIsSelfOriginated, - meshcoreRfNodeHashCandidates, - meshcoreRfResolvePathSender, -} from '../../lib/meshcoreRawPacketSender'; -import { shouldCoalesceSelfFloodAdvert } from '../../lib/meshcoreRawSelfFloodAdvertCoalesce'; import { meshcoreSortedStorePrior } from '../../lib/meshcoreStoreDedup'; -import { meshcoreMergeContactHopsAwayFromPrevious, pubkeyToNodeId } from '../../lib/meshcoreUtils'; import { normalizeMeshcoreWaitingMessageBatch, normalizeMeshcoreWaitingMessageItem, @@ -57,26 +37,22 @@ import { shouldActivateWaitingMessagesBanner, waitingMessagesDrainTimeoutMs, } from '../../lib/meshcoreWaitingMessagesDrain'; -import { getMeshtasticConnectedMyNodeNum } from '../../lib/meshtasticConnectedNodeRef'; import type { DomainEvent } from '../../lib/protocols/Protocol'; -import { MAX_RAW_PACKET_LOG_ENTRIES } from '../../lib/rawPacketLogConstants'; -import { getStoredMeshProtocol } from '../../lib/storedMeshProtocol'; import { meshNodeToNodeRecord } from '../../lib/storeRecordAdapters'; import { - MESHCORE_RAW_SELF_FLOOD_ADVERT_COALESCE_MS, MESHCORE_SYNC_NEXT_MESSAGE_MAX_PER_DRAIN, MESHCORE_SYNC_NEXT_MESSAGE_TIMEOUT_MS, MESHCORE_WAITING_MESSAGES_BATCH_YIELD, } from '../../lib/timeConstants'; -import type { ChatMessage, MeshNode, TelemetryPoint } from '../../lib/types'; -import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; -import { upsertNodeRecord, upsertNodeRecordsForIdentity } from '../../stores/nodeStore'; +import type { ChatMessage, MeshNode } from '../../lib/types'; +import type { NodeRecord } from '../../stores/nodeStore'; +import { upsertNodeRecordsForIdentity } from '../../stores/nodeStore'; import { usePathHistoryStore } from '../../stores/pathHistoryStore'; import type { MeshcoreConnSideEffectsCtx, ProcessWaitingMessagesOptions, } from './meshcoreConnSideEffectsCtx'; -import { MAX_DEVICE_LOGS, MAX_TELEMETRY_POINTS, meshcoreDmAckKeyU32 } from './meshcoreHookPreamble'; +import { MAX_DEVICE_LOGS, meshcoreDmAckKeyU32 } from './meshcoreHookPreamble'; import { getMeshcoreProcessWaitingMessagesInFlight, requestMeshcoreWaitingMessagesFollowUp, @@ -94,6 +70,233 @@ function yieldToEventLoop(): Promise { }); } +// --- Waiting messages (event 131) drain — module-level so the flush/ingest steps stay +// shallow closures instead of nesting inside attach → processWaitingMessages → async IIFE. --- + +type WaitingMessageItemDeps = Parameters[1]; + +interface MeshcoreWaitingMessagesDrainDeps { + meshcoreHookMountedRef: RefObject; + meshcoreIdentityIdRef: RefObject; + connectionType: 'ble' | 'serial' | 'tcp'; + readNodes: () => Map; + addMessagesBatch: (msgs: ChatMessage[]) => void; + buildItemDeps: (workingNodes: Map) => WaitingMessageItemDeps; + setWaitingMessagesSyncActive: Dispatch>; + setWaitingMessagesSyncProgress: Dispatch< + SetStateAction<{ processed: number; total: number } | null> + >; + setWaitingMessagesCount: Dispatch>; + setWaitingMessagesSilentDrainActive: Dispatch>; +} + +interface MeshcoreWaitingMessagesDrainState { + processed: number; + bannerActive: boolean; + syncTotal: number; + /** Mutated in place (pushed/cleared) rather than reassigned so helpers can share the reference. */ + pendingMessages: ChatMessage[]; + dirtyNodeIds: Set; + workingNodes: Map; +} + +function collectDirtyWaitingNodeRecords( + dirtyNodeIds: Set, + workingNodes: Map, +): NodeRecord[] { + return Array.from(dirtyNodeIds) + .map((nodeId) => workingNodes.get(nodeId)) + .filter((node): node is MeshNode => node != null) + .map((node) => meshNodeToNodeRecord(node)); +} + +interface FlushMeshcoreWaitingBatchOptions { + pendingMessages: ChatMessage[]; + dirtyNodeIds: Set; + workingNodes: Map; + identityId: string | null; + addMessagesBatch: (msgs: ChatMessage[]) => void; +} + +function flushMeshcoreWaitingBatch(opts: FlushMeshcoreWaitingBatchOptions): void { + if (opts.pendingMessages.length > 0) { + opts.addMessagesBatch(opts.pendingMessages); + opts.pendingMessages.length = 0; + } + if (opts.dirtyNodeIds.size === 0) return; + if (opts.identityId) { + const dirtyRecords = collectDirtyWaitingNodeRecords(opts.dirtyNodeIds, opts.workingNodes); + if (dirtyRecords.length > 0) { + upsertNodeRecordsForIdentity(opts.identityId, dirtyRecords); + } + } + opts.dirtyNodeIds.clear(); +} + +function flushMeshcoreWaitingState( + state: MeshcoreWaitingMessagesDrainState, + deps: Pick, +): void { + flushMeshcoreWaitingBatch({ + pendingMessages: state.pendingMessages, + dirtyNodeIds: state.dirtyNodeIds, + workingNodes: state.workingNodes, + identityId: deps.meshcoreIdentityIdRef.current, + addMessagesBatch: deps.addMessagesBatch, + }); +} + +async function ingestMeshcoreWaitingMessageItem( + item: ReturnType, + state: MeshcoreWaitingMessagesDrainState, + deps: Pick, +): Promise { + if (!item) return; + try { + const result = processMeshcoreWaitingMessageItem(item, deps.buildItemDeps(state.workingNodes)); + if (result.nodesDirty) { + for (const nodeId of result.updatedNodeIds) state.dirtyNodeIds.add(nodeId); + } + if (result.pendingMessages.length > 0) { + state.pendingMessages.push(...result.pendingMessages); + } + state.processed += 1; + if (state.bannerActive) { + deps.setWaitingMessagesSyncProgress({ processed: state.processed, total: state.syncTotal }); + } + } catch (e: unknown) { + console.warn( + '[meshcoreConnSideEffects] processWaitingMessages ingest error ' + errLikeToLogString(e), + ); + } + await yieldToEventLoop(); +} + +/** Manual sync (Chat "Sync now") — fetches the full queue and shows the sync-progress banner. */ +async function drainWaitingMessagesManual( + conn: MeshCoreConnection, + state: MeshcoreWaitingMessagesDrainState, + deps: MeshcoreWaitingMessagesDrainDeps, +): Promise { + const msgs = await withTimeout( + conn.getWaitingMessages(), + waitingMessagesDrainTimeoutMs(true, deps.connectionType), + 'MeshCore getWaitingMessages', + ); + if (!deps.meshcoreHookMountedRef.current) return; + const arr = normalizeMeshcoreWaitingMessageBatch(msgs); + const total = arr.length; + state.syncTotal = total; + if (shouldActivateWaitingMessagesBanner(true, total)) { + state.bannerActive = true; + deps.setWaitingMessagesSyncActive(true); + deps.setWaitingMessagesSyncProgress(null); + deps.setWaitingMessagesCount(total); + deps.setWaitingMessagesSyncProgress({ processed: 0, total }); + } else if (total === 0) { + console.debug('[meshcoreConnSideEffects] processWaitingMessages empty queue (manual sync)'); + return; + } + console.debug('[meshcoreConnSideEffects] processWaitingMessages start', { + count: total, + showSyncBanner: true, + }); + for (const m of arr) { + if (!deps.meshcoreHookMountedRef.current) break; + await ingestMeshcoreWaitingMessageItem(m, state, deps); + if ( + state.processed % MESHCORE_WAITING_MESSAGES_BATCH_YIELD === 0 || + state.pendingMessages.length >= MESHCORE_WAITING_MESSAGES_BATCH_YIELD + ) { + flushMeshcoreWaitingState(state, deps); + } + } + flushMeshcoreWaitingState(state, deps); +} + +/** Silent/incremental drain (message-waiting push 131) — no banner, capped per-drain. */ +async function drainWaitingMessagesSilent( + conn: MeshCoreConnection, + state: MeshcoreWaitingMessagesDrainState, + deps: MeshcoreWaitingMessagesDrainDeps, +): Promise { + console.debug('[meshcoreConnSideEffects] processWaitingMessages start (incremental)', { + showSyncBanner: false, + }); + let silentDrainExhaustedCap = false; + for (let i = 0; i < MESHCORE_SYNC_NEXT_MESSAGE_MAX_PER_DRAIN; i += 1) { + if (!deps.meshcoreHookMountedRef.current) break; + let raw: unknown; + try { + raw = await withTimeout( + conn.syncNextMessage(), + MESHCORE_SYNC_NEXT_MESSAGE_TIMEOUT_MS, + 'MeshCore syncNextMessage', + ); + } catch (e: unknown) { + if (isMeshcoreSyncNextMessageTimeoutError(e)) break; + throw e; + } + const item = normalizeMeshcoreWaitingMessageItem(raw); + if (!item) break; + await ingestMeshcoreWaitingMessageItem(item, state, deps); + if (i === MESHCORE_SYNC_NEXT_MESSAGE_MAX_PER_DRAIN - 1) { + silentDrainExhaustedCap = true; + } + } + if (silentDrainExhaustedCap) { + requestMeshcoreWaitingMessagesFollowUp(); + } + flushMeshcoreWaitingState(state, deps); +} + +/** + * Runs one waiting-messages drain (manual full sync or silent incremental) and manages the + * sync-progress banner / silent-drain UI flag around it. Mirrors the original inline async IIFE + * inside `processWaitingMessages`, minus the in-flight bookkeeping (owned by the caller). + */ +async function runMeshcoreWaitingMessagesDrain( + conn: MeshCoreConnection, + options: { showSyncBanner: boolean }, + deps: MeshcoreWaitingMessagesDrainDeps, +): Promise { + const startedAt = Date.now(); + const state: MeshcoreWaitingMessagesDrainState = { + processed: 0, + bannerActive: false, + syncTotal: 0, + pendingMessages: [], + dirtyNodeIds: new Set(), + workingNodes: new Map(deps.readNodes()), + }; + let silentDrainUiActive = false; + if (!options.showSyncBanner) { + deps.setWaitingMessagesSilentDrainActive(true); + silentDrainUiActive = true; + } + try { + if (options.showSyncBanner) { + await drainWaitingMessagesManual(conn, state, deps); + } else { + await drainWaitingMessagesSilent(conn, state, deps); + } + console.debug('[meshcoreConnSideEffects] processWaitingMessages done', { + count: state.processed, + durationMs: Date.now() - startedAt, + showSyncBanner: options.showSyncBanner, + }); + } finally { + if (silentDrainUiActive) { + deps.setWaitingMessagesSilentDrainActive(false); + } + if (state.bannerActive) { + deps.setWaitingMessagesCount(0); + deps.setWaitingMessagesSyncActive(false); + deps.setWaitingMessagesSyncProgress(null); + } + } +} + export function attachMeshcoreConnSideEffects( conn: MeshCoreConnection, ctx: MeshcoreConnSideEffectsCtx, @@ -275,7 +478,7 @@ export function attachMeshcoreConnSideEffects( logTransportLineAsDevice, }); - const processWaitingMessages = async (options?: ProcessWaitingMessagesOptions) => { + const processWaitingMessages = async (options?: ProcessWaitingMessagesOptions): Promise => { if (getMeshcoreProcessWaitingMessagesInFlight()) { if (options?.showSyncBanner !== false) { requestMeshcoreWaitingMessagesManualFollowUp(); @@ -292,158 +495,26 @@ export function attachMeshcoreConnSideEffects( logMeshcoreWaitingMessagesDrainError('getWaitingMessages error', e, false); }), ); - return Promise.resolve(); + return; } - const connectionType = meshcoreConnectTypeRef.current; - const inFlight = (async () => { - const startedAt = Date.now(); - let bannerActive = false; - let processed = 0; - let pendingMessages: ChatMessage[] = []; - const workingNodes = new Map(readNodes()); - const dirtyNodeIds = new Set(); - - const flushBatch = () => { - if (pendingMessages.length > 0) { - addMessagesBatch(pendingMessages); - pendingMessages = []; - } - if (dirtyNodeIds.size > 0) { - const storeId = meshcoreIdentityIdRef.current; - if (storeId) { - const dirtyRecords = Array.from(dirtyNodeIds) - .map((nodeId) => workingNodes.get(nodeId)) - .filter((node): node is MeshNode => node != null) - .map((node) => meshNodeToNodeRecord(node)); - if (dirtyRecords.length > 0) { - upsertNodeRecordsForIdentity(storeId, dirtyRecords); - } - } - dirtyNodeIds.clear(); - } - }; - - let syncTotal = 0; - let silentDrainUiActive = false; - - const ingestItem = async (item: ReturnType) => { - if (!item) return; - try { - const result = processMeshcoreWaitingMessageItem( - item, - buildWaitingMessageItemDeps(workingNodes), - ); - if (result.nodesDirty) { - for (const nodeId of result.updatedNodeIds) dirtyNodeIds.add(nodeId); - } - if (result.pendingMessages.length > 0) { - pendingMessages.push(...result.pendingMessages); - } - processed += 1; - if (bannerActive) { - setWaitingMessagesSyncProgress({ processed, total: syncTotal }); - } - } catch (e: unknown) { - console.warn( - '[meshcoreConnSideEffects] processWaitingMessages ingest error ' + - errLikeToLogString(e), - ); - } - await yieldToEventLoop(); - }; - - if (!showSyncBanner) { - setWaitingMessagesSilentDrainActive(true); - silentDrainUiActive = true; - } - try { - if (showSyncBanner) { - const msgs = await withTimeout( - conn.getWaitingMessages(), - waitingMessagesDrainTimeoutMs(true, connectionType), - 'MeshCore getWaitingMessages', - ); - if (!meshcoreHookMountedRef.current) return; - const arr = normalizeMeshcoreWaitingMessageBatch(msgs); - const total = arr.length; - syncTotal = total; - if (shouldActivateWaitingMessagesBanner(showSyncBanner, total)) { - bannerActive = true; - setWaitingMessagesSyncActive(true); - setWaitingMessagesSyncProgress(null); - setWaitingMessagesCount(total); - setWaitingMessagesSyncProgress({ processed: 0, total }); - } else if (total === 0) { - console.debug( - '[meshcoreConnSideEffects] processWaitingMessages empty queue (manual sync)', - ); - return; - } - console.debug('[meshcoreConnSideEffects] processWaitingMessages start', { - count: total, - showSyncBanner, - }); - for (const m of arr) { - if (!meshcoreHookMountedRef.current) break; - await ingestItem(m); - if ( - processed % MESHCORE_WAITING_MESSAGES_BATCH_YIELD === 0 || - pendingMessages.length >= MESHCORE_WAITING_MESSAGES_BATCH_YIELD - ) { - flushBatch(); - } - } - flushBatch(); - } else { - console.debug('[meshcoreConnSideEffects] processWaitingMessages start (incremental)', { - showSyncBanner, - }); - let silentDrainExhaustedCap = false; - for (let i = 0; i < MESHCORE_SYNC_NEXT_MESSAGE_MAX_PER_DRAIN; i += 1) { - if (!meshcoreHookMountedRef.current) break; - let raw: unknown; - try { - raw = await withTimeout( - conn.syncNextMessage(), - MESHCORE_SYNC_NEXT_MESSAGE_TIMEOUT_MS, - 'MeshCore syncNextMessage', - ); - } catch (e: unknown) { - if (isMeshcoreSyncNextMessageTimeoutError(e)) { - break; - } - throw e; - } - const item = normalizeMeshcoreWaitingMessageItem(raw); - if (!item) break; - await ingestItem(item); - if (i === MESHCORE_SYNC_NEXT_MESSAGE_MAX_PER_DRAIN - 1) { - silentDrainExhaustedCap = true; - } - } - if (silentDrainExhaustedCap) { - requestMeshcoreWaitingMessagesFollowUp(); - } - flushBatch(); - } - console.debug('[meshcoreConnSideEffects] processWaitingMessages done', { - count: processed, - durationMs: Date.now() - startedAt, - showSyncBanner, - }); - } finally { - if (silentDrainUiActive) { - setWaitingMessagesSilentDrainActive(false); - } - if (bannerActive) { - setWaitingMessagesCount(0); - setWaitingMessagesSyncActive(false); - setWaitingMessagesSyncProgress(null); - } + const drainDeps: MeshcoreWaitingMessagesDrainDeps = { + meshcoreHookMountedRef, + meshcoreIdentityIdRef, + connectionType: meshcoreConnectTypeRef.current, + readNodes, + addMessagesBatch, + buildItemDeps: buildWaitingMessageItemDeps, + setWaitingMessagesSyncActive, + setWaitingMessagesSyncProgress, + setWaitingMessagesCount, + setWaitingMessagesSilentDrainActive, + }; + const inFlight = runMeshcoreWaitingMessagesDrain(conn, { showSyncBanner }, drainDeps).finally( + () => { setMeshcoreProcessWaitingMessagesInFlight(null); maybeChainWaitingMessageFollowUp(); - } - })(); + }, + ); setMeshcoreProcessWaitingMessagesInFlight(inFlight); return inFlight; }; @@ -512,401 +583,24 @@ export function attachMeshcoreConnSideEffects( // --- RF RX (event 136) --- const handleRfRx = (payload: Extract['payload']) => { - const snr = payload.lastSnr; - const rssi = payload.lastRssi; - const now = Date.now(); - const rawU8 = payload.raw; - const loraPacketClass = rawU8 ? classifyPayload(rawU8) : null; - - // Extract sender ID and update known node's last_heard + signal metrics - let senderInfo = ''; - if (rawU8 && rawU8.length >= 8 && loraPacketClass != null) { - if (loraPacketClass === 'meshtastic') { - const senderId = extractMeshtasticSenderId(rawU8); - if (senderId !== null) { - senderInfo = ` from=0x${senderId.toString(16)}`; - // If we know this node (and it's not ourselves), update last_heard + SNR/RSSI - if (senderId !== myNodeNumRef.current && readNodes().has(senderId)) { - const nowSec = Math.floor(now / 1000); - const existing = readNodes().get(senderId); - const storeId = meshcoreIdentityIdRef.current; - if (existing && storeId) { - upsertNodeRecord( - storeId, - meshNodeToNodeRecord({ - ...existing, - last_heard: Math.max(existing.last_heard ?? 0, nowSec), - snr, - rssi, - }), - ); - } - } - } - } else if (loraPacketClass === 'meshcore') { - senderInfo = ' [meshcore]'; - } - } - - const entry: DeviceLogEntry = { - ts: now, - level: 'debug', - source: 'meshcore', - message: `RX${senderInfo} SNR=${snr.toFixed(2)}dB RSSI=${rssi}dBm`, + const deps: MeshcoreRfRxDeps = { + myNodeNumRef, + meshcoreIdentityIdRef, + readNodes, + pubKeyMapRef, + pubKeyPrefixMapRef, + nicknameMapRef, + selfInfoRef, + rawPacketsRef, + mqttStatusRef, + lastPacketLogAtRef, + lastPacketLogPublishFailureLogAtRef, + mqttPacketLogBucket, + setDeviceLogs, + setSignalTelemetry, + setRawPackets, }; - setDeviceLogs((prev) => { - const next = [...prev, entry]; - return next.length > MAX_DEVICE_LOGS ? next.slice(next.length - MAX_DEVICE_LOGS) : next; - }); - const sigPoint: TelemetryPoint = { timestamp: now, snr, rssi }; - setSignalTelemetry((prev) => [...prev, sigPoint].slice(-MAX_TELEMETRY_POINTS)); - - // Packet-log metadata hoisted for the MQTT publish below (set inside the rawU8 block). - let mqttRawHex: string | undefined; - let mqttLen: number | undefined; - let mqttPacketType: number | undefined; - let mqttRoute: string | undefined; - let mqttPayloadLen: number | undefined; - let mqttHash: string | undefined; - - // Raw packet log: always run MeshCore in-house parse on this path (LOG_RX is MeshCore RF only). - // Do not gate on classifyPayload — Meshtastic-shaped heuristics can mis-label MeshCore frames. - if (rawU8) { - let routeTypeString: string | null = null; - let payloadTypeString: string | null = null; - let hopCount = 0; - let pathBytes: number[] = []; - let pathHashSizeBytes: 1 | 2 | 3 = 1; - let fromNodeId: number | null = null; - let messageFingerprintHex: string | null = null; - let transportScopeCode: number | null = null; - let transportReturnCode: number | null = null; - let advertName: string | null = null; - let advertLat: number | null = null; - let advertLon: number | null = null; - let advertTimestampSec: number | null = null; - let parseOk = false; - - const parsed = parseMeshCoreRfPacket(rawU8); - if (parsed.ok) { - parseOk = true; - routeTypeString = parsed.routeTypeString; - payloadTypeString = parsed.payloadTypeString; - hopCount = parsed.hopCount; - pathBytes = parsed.pathBytes; - pathHashSizeBytes = parsed.pathHashSizeBytes; - messageFingerprintHex = parsed.messageFingerprintHex; - if (parsed.transportCodes) { - transportScopeCode = parsed.transportCodes[0]; - transportReturnCode = parsed.transportCodes[1]; - } - if (parsed.advert) { - advertName = parsed.advert.name.length > 0 ? parsed.advert.name : null; - advertLat = parsed.advert.latitudeDeg; - advertLon = parsed.advert.longitudeDeg; - advertTimestampSec = parsed.advert.timestampSec; - } - const id = meshcoreRawPacketResolveFromParsed(parsed, pubKeyPrefixMapRef.current); - if (id != null) { - fromNodeId = id; - if (parsed.transportCodes) { - void window.electronAPI.db - .updateMeshcoreContactRfTransport( - id, - parsed.transportCodes[0], - parsed.transportCodes[1], - ) - .catch((e: unknown) => { - console.warn( - '[meshcoreConnSideEffects] updateMeshcoreContactRfTransport error ' + - errLikeToLogString(e), - ); - }); - } - } - } else { - const fb = meshcoreRawPacketLogFromBytesFallback(rawU8, pubKeyPrefixMapRef.current); - if (fb) { - routeTypeString = fb.routeTypeString; - payloadTypeString = fb.payloadTypeString; - hopCount = fb.hopCount; - pathBytes = fb.pathBytes; - pathHashSizeBytes = fb.pathHashSizeBytes; - if (fb.fromNodeId != null) fromNodeId = fb.fromNodeId; - } - } - - // Update hops_away on known MeshCore nodes from RF packet hop count. - // Only use fromNodeId resolved by MeshCore parsing (before the Meshtastic fallback). - if (fromNodeId !== null && fromNodeId !== myNodeNumRef.current) { - const resolvedFromNodeId = fromNodeId; - const nowSec = Math.floor(now / 1000); - const existing = readNodes().get(resolvedFromNodeId); - const storeId = meshcoreIdentityIdRef.current; - if (existing && storeId) { - const mergedHopsAway = meshcoreMergeContactHopsAwayFromPrevious( - hopCount, - existing.hops_away, - 0, - ); - const updated: MeshNode = { - ...existing, - hops_away: mergedHopsAway ?? hopCount, - snr: snr, - rssi: rssi, - last_heard: Math.max(existing.last_heard ?? 0, nowSec), - source: 'rf', - heard_via_mqtt_only: false, - via_mqtt: false, - }; - - // Skip store/DB writes when metrics and liveness have not changed. - if ( - existing.hops_away !== updated.hops_away || - existing.snr !== snr || - existing.rssi !== rssi || - existing.last_heard !== updated.last_heard - ) { - upsertNodeRecord(storeId, meshNodeToNodeRecord(updated)); - - void window.electronAPI.db - .updateMeshcoreContactLastRf( - resolvedFromNodeId, - snr, - rssi, - mergedHopsAway ?? hopCount, - nowSec, - ) - .catch((e: unknown) => { - console.warn( - '[meshcoreConnSideEffects] updateMeshcoreContactLastRf error ' + - errLikeToLogString(e), - ); - }); - void useDiagnosticsStore - .getState() - .saveMeshcoreHopHistory( - resolvedFromNodeId, - now, - mergedHopsAway ?? hopCount, - snr, - rssi, - ) - .catch((e: unknown) => { - console.warn( - '[meshcoreConnSideEffects] saveMeshcoreHopHistory error ' + errLikeToLogString(e), - ); - }); - } - } - } - - if (fromNodeId == null) { - const mtId = meshtasticSenderIdForRawLogFallback(parseOk, rawU8); - if (mtId != null) fromNodeId = mtId; - } - const rxEntry: RxPacketEntry = { - ts: now, - snr, - rssi, - raw: rawU8, - routeTypeString, - payloadTypeString, - hopCount, - pathBytes, - pathHashSizeBytes, - fromNodeId, - messageFingerprintHex, - transportScopeCode, - transportReturnCode, - advertName, - advertLat, - advertLon, - advertTimestampSec, - parseOk, - }; - setRawPackets((prev) => { - const myId = myNodeNumRef.current; - const last = prev[prev.length - 1]; - if ( - myId !== 0 && - shouldCoalesceSelfFloodAdvert( - last, - rxEntry, - myId, - MESHCORE_RAW_SELF_FLOOD_ADVERT_COALESCE_MS, - ) - ) { - const next = [...prev.slice(0, -1), rxEntry]; - const trimmed = - next.length > MAX_RAW_PACKET_LOG_ENTRIES - ? next.slice(next.length - MAX_RAW_PACKET_LOG_ENTRIES) - : next; - // Sync before React commit so same-tick chat ingest sees this row. - rawPacketsRef.current = trimmed; - return trimmed; - } - const next = [...prev, rxEntry]; - const trimmed = - next.length > MAX_RAW_PACKET_LOG_ENTRIES - ? next.slice(next.length - MAX_RAW_PACKET_LOG_ENTRIES) - : next; - // Sync before React commit so same-tick chat ingest sees this row. - rawPacketsRef.current = trimmed; - return trimmed; - }); - - // Populate hoisted MQTT packet-log fields from the parsed result. - mqttRawHex = Array.from(rawU8, (b) => b.toString(16).padStart(2, '0')).join(''); - mqttLen = rawU8.length; - mqttPayloadLen = rawU8.length; - if (parsed.ok) { - mqttPacketType = parsed.payloadTypeNibble; - } - mqttRoute = routeTypeString ?? undefined; - mqttHash = messageFingerprintHex ?? undefined; - - // Record noisy payload types for MeshCore - // FLOOD (1001): Discovery Floods indicate routing loops or lost paths - // FLOOD + ADVERT (1002): Flood-routed advertisements (room or device) - if (fromNodeId != null && routeTypeString === 'FLOOD') { - if (parsed.ok && parsed.advert != null) { - useDiagnosticsStore.getState().recordNoisePort(fromNodeId, 1002); - } else { - useDiagnosticsStore.getState().recordNoisePort(fromNodeId, 1001); - } - } - - // MeshCore radio RF RX → Meshtastic Foreign LoRa (local overhear, not contact-list sync). - const selfPubKey = - myNodeNumRef.current !== 0 - ? (pubKeyMapRef.current.get(myNodeNumRef.current) ?? selfInfoRef.current?.publicKey) - : undefined; - const isSelfRf = - myNodeNumRef.current !== 0 && - meshcoreRfIsSelfOriginated(rawU8, selfPubKey, myNodeNumRef.current); - if (loraPacketClass === 'meshcore') { - const mtNode = getMeshtasticConnectedMyNodeNum(); - if (mtNode > 0) { - let rfSenderId = fromNodeId ?? undefined; - let rfDisplayName: string | undefined; - const meshcoreNodes = readNodes(); - if (parsed.ok) { - if (rfSenderId == null && parsed.advert) { - const advertId = pubkeyToNodeId(parsed.advert.publicKey); - if (advertId !== 0) rfSenderId = advertId; - if (parsed.advert.name.length > 0) rfDisplayName = parsed.advert.name; - } else if (advertName) { - rfDisplayName = advertName; - } - if (rfSenderId == null && parsed.pathBytes.length > 0) { - const useAllContacts = hopCount <= 2 && rssi > -80 && parsed.pathBytes.length > 0; - const pathCandidates = meshcoreRfNodeHashCandidates( - meshcoreNodes, - myNodeNumRef.current, - useAllContacts ? { rssi: undefined } : { rssi }, - ); - const pathId = meshcoreRfResolvePathSender(parsed.pathBytes, pathCandidates); - if (pathId != null) rfSenderId = pathId; - } - } - const isOwnMeshcoreTx = - isSelfRf || - (rfSenderId != null && rfSenderId === myNodeNumRef.current) || - (fromNodeId != null && fromNodeId === myNodeNumRef.current); - if (isOwnMeshcoreTx && myNodeNumRef.current !== 0) { - rfSenderId = myNodeNumRef.current; - rfDisplayName = - rfDisplayName ?? - selfInfoRef.current?.name?.trim() ?? - meshcoreNodes.get(myNodeNumRef.current)?.long_name ?? - meshcoreNodes.get(myNodeNumRef.current)?.short_name ?? - nicknameMapRef.current.get(myNodeNumRef.current); - } - if (rfSenderId != null && rfDisplayName == null) { - const known = meshcoreNodes.get(rfSenderId); - rfDisplayName = - known?.long_name ?? known?.short_name ?? nicknameMapRef.current.get(rfSenderId); - } - const proximity = classifyProximity(rssi || undefined, snr || undefined); - let rfFingerprint = - rfSenderId == null && messageFingerprintHex ? messageFingerprintHex : undefined; - if (isOwnMeshcoreTx) { - rfFingerprint = undefined; - } - // Local RF only — skip distant mesh floods (identified or not). - if (proximity !== 'very-close' && proximity !== 'nearby') { - return; - } - if (rfSenderId == null && rfFingerprint == null) { - return; - } - useDiagnosticsStore - .getState() - .recordForeignLora( - mtNode, - 'meshcore', - rssi || undefined, - snr || undefined, - rfSenderId, - readNodes, - 'meshcore-radio-rf', - rfFingerprint, - rfDisplayName, - ); - } - } - } - - // Foreign LoRa fingerprinting: only flag non-MeshCore packets as foreign (requires known self node ID) - if ( - getStoredMeshProtocol() === 'meshcore' && - myNodeNumRef.current !== 0 && - rawU8 && - loraPacketClass != null - ) { - if (loraPacketClass !== 'meshcore') { - const senderId = loraPacketClass === 'meshtastic' ? extractMeshtasticSenderId(rawU8) : null; - useDiagnosticsStore - .getState() - .recordForeignLora( - myNodeNumRef.current, - loraPacketClass, - rssi || undefined, - snr || undefined, - senderId ?? undefined, - readNodes, - ); - } - } - - if (mqttStatusRef.current === 'connected') { - const nowMs = Date.now(); - if (tryTakeMeshcoreMqttPacketLogToken(mqttPacketLogBucket, nowMs)) { - lastPacketLogAtRef.current = nowMs; - void window.electronAPI.mqtt - .publishMeshcorePacketLog({ - origin: selfInfoRef.current?.name ?? 'mesh-client', - snr, - rssi, - rawHex: mqttRawHex, - len: mqttLen, - packetType: mqttPacketType, - route: mqttRoute, - payloadLen: mqttPayloadLen, - hash: mqttHash, - }) - .catch((e: unknown) => { - const t = Date.now(); - if (t - lastPacketLogPublishFailureLogAtRef.current >= 30_000) { - lastPacketLogPublishFailureLogAtRef.current = t; - console.warn( - '[meshcoreConnSideEffects] MQTT packet-log publish failed ' + errLikeToLogString(e), - ); - } - }); - } - } + handleMeshcoreRfRx(payload, deps); }; // --- Disconnect --- diff --git a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts index 0e10b58c1..97a0c0501 100644 --- a/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts +++ b/src/renderer/hooks/meshcore/meshcoreHookPreamble.ts @@ -37,7 +37,6 @@ import { mergeMeshcoreLastHeardFromAdvert, } from '../../lib/nodeStatus'; import { normalizeReactionEmoji } from '../../lib/reactions'; -import { MAX_TELEMETRY_POINTS as SHARED_MAX_TELEMETRY_POINTS } from '../../lib/sessionMemoryCaps'; import { MESHCORE_CHANNEL_RF_DEDUP_WINDOW_MS, MESHCORE_CROSS_TRANSPORT_DEDUP_WINDOW_MS, @@ -310,7 +309,7 @@ export const MESHCORE_TRACE_TIMEOUT_MS = MESHCORE_REPEATER_RPC_TIMEOUT_MS; export const MESHCORE_NEIGHBORS_MAX_RECOMMENDED_HOPS = 8; /** Request page size for GetNeighbours; firmware reply buffers often return fewer rows (~11 at 6-byte prefixes). */ export const MESHCORE_NEIGHBORS_PAGE_SIZE = 50; -export const MAX_TELEMETRY_POINTS = SHARED_MAX_TELEMETRY_POINTS; +export { MAX_TELEMETRY_POINTS } from '../../lib/sessionMemoryCaps'; export const MAX_ENV_TELEMETRY_POINTS = 50; diff --git a/src/renderer/lib/drivers/PacketRouter.ts b/src/renderer/lib/drivers/PacketRouter.ts index e7c255f5d..9cdad8229 100644 --- a/src/renderer/lib/drivers/PacketRouter.ts +++ b/src/renderer/lib/drivers/PacketRouter.ts @@ -116,8 +116,8 @@ function findRoomPostOptimistic( } class PacketRouter { - private listeners: PacketRouterListenerEntry[] = []; - private typedListeners = new Map(); + private readonly listeners: PacketRouterListenerEntry[] = []; + private readonly typedListeners = new Map(); private nextListenerId = 1; /** Registers a listener at the end of the dispatch order. Returns a detach fn. */ @@ -148,7 +148,9 @@ class PacketRouter { } private removeListener(id: number): void { - this.listeners = this.listeners.filter((entry) => entry.id !== id); + const remaining = this.listeners.filter((entry) => entry.id !== id); + this.listeners.length = 0; + this.listeners.push(...remaining); for (const [type, entries] of this.typedListeners) { const next = entries.filter((entry) => entry.id !== id); if (next.length === 0) this.typedListeners.delete(type); diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts new file mode 100644 index 000000000..ea1c97da8 --- /dev/null +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; +import { useNodeStore } from '../../stores/nodeStore'; +import { setMeshtasticConnectedMyNodeNum } from '../meshtasticConnectedNodeRef'; +import type { MeshNode, TelemetryPoint } from '../types'; +import type { DeviceLogEntry, MeshCoreSelfInfo, RxPacketEntry } from './meshcoreHookTypes'; +import { createMeshcoreMqttPacketLogBucket } from './meshcoreMqttPacketLogThrottle'; +import { handleMeshcoreRfRx, type MeshcoreRfRxDeps } from './meshcoreRfRxRuntime'; + +const ID = 'meshcore-rf-rx-runtime-test'; + +function ref(current: T) { + return { current }; +} + +function makeNode(nodeId: number, overrides?: Partial): MeshNode { + return { + node_id: nodeId, + long_name: `Node-${nodeId}`, + short_name: `N${nodeId}`, + hw_model: 'Companion', + snr: 0, + battery: 100, + last_heard: 0, + latitude: null, + longitude: null, + ...overrides, + }; +} + +function makeDeps(overrides?: Partial): { + deps: MeshcoreRfRxDeps; + deviceLogs: DeviceLogEntry[]; + rawPackets: RxPacketEntry[]; +} { + const deviceLogs: DeviceLogEntry[] = []; + const rawPackets: RxPacketEntry[] = []; + const signal: TelemetryPoint[] = []; + + const deps: MeshcoreRfRxDeps = { + myNodeNumRef: ref(1), + meshcoreIdentityIdRef: ref(ID), + readNodes: () => new Map(), + pubKeyMapRef: ref(new Map()), + pubKeyPrefixMapRef: ref(new Map()), + nicknameMapRef: ref(new Map()), + selfInfoRef: ref(null), + rawPacketsRef: ref(rawPackets), + mqttStatusRef: ref('disconnected' as const), + lastPacketLogAtRef: ref(0), + lastPacketLogPublishFailureLogAtRef: ref(0), + mqttPacketLogBucket: createMeshcoreMqttPacketLogBucket(), + setDeviceLogs: (updater) => { + const next = typeof updater === 'function' ? updater(deviceLogs) : updater; + deviceLogs.splice(0, deviceLogs.length, ...next); + }, + setSignalTelemetry: (updater) => { + const next = typeof updater === 'function' ? updater(signal) : updater; + signal.splice(0, signal.length, ...next); + }, + setRawPackets: (updater) => { + const next = typeof updater === 'function' ? updater(rawPackets) : updater; + rawPackets.splice(0, rawPackets.length, ...next); + }, + ...overrides, + }; + + return { deps, deviceLogs, rawPackets }; +} + +describe('handleMeshcoreRfRx', () => { + afterEach(() => { + useNodeStore.setState({ nodes: {} }); + setMeshtasticConnectedMyNodeNum(0); + vi.restoreAllMocks(); + }); + + it('updates a known Meshtastic-class node last_heard/snr/rssi from the node map', () => { + const nodes = new Map([[2, makeNode(2, { last_heard: 100 })]]); + const { deps } = makeDeps({ + myNodeNumRef: ref(1), + readNodes: () => nodes, + }); + + // Meshtastic wire shape: dest=16129 (bytes 0-3, byte1=0x3F forces the MeshCore path-length + // field to overrun the buffer so parseMeshCoreRfPacket fails), sender=2 (bytes 4-7); an + // 8-byte buffer with no MeshCore parse and non-zero/non-broadcast dest+sender always + // classifies as Meshtastic (no hop-flags byte to check). + const raw = Uint8Array.from([1, 0x3f, 0, 0, 2, 0, 0, 0]); + + handleMeshcoreRfRx({ lastSnr: 5.5, lastRssi: -55, raw }, deps); + + const record = useNodeStore.getState().nodes[ID]?.[2]; + expect(record).toMatchObject({ snr: 5.5, rssi: -55 }); + expect(record?.lastHeardAt).toBeGreaterThanOrEqual(100); + }); + + it('does not update the sending node itself (senderId === myNodeNum)', () => { + const nodes = new Map([[2, makeNode(2, { last_heard: 100 })]]); + const { deps } = makeDeps({ + myNodeNumRef: ref(2), + readNodes: () => nodes, + }); + // Same Meshtastic-classifying shape as above (byte1=0x3F forces MeshCore parse failure); + // sender (bytes 4-7) equals myNodeNum, so the sender-is-self guard should skip the update. + const raw = Uint8Array.from([1, 0x3f, 0, 0, 2, 0, 0, 0]); + + handleMeshcoreRfRx({ lastSnr: 5.5, lastRssi: -55, raw }, deps); + + expect(useNodeStore.getState().nodes[ID]?.[2]).toBeUndefined(); + }); + + it('skips foreign-LoRa recording when the MeshCore RF bridge proximity gate fails', () => { + setMeshtasticConnectedMyNodeNum(99); + const recordSpy = vi.spyOn(useDiagnosticsStore.getState(), 'recordForeignLora'); + const { deps } = makeDeps(); + + // raw[0] = 0x3c is the legacy MeshCore marker, so classifyPayload always returns + // 'meshcore' regardless of full-packet parse success. + const raw = Uint8Array.from([0x3c, 1, 2, 3, 4, 5, 6, 7, 8]); + + handleMeshcoreRfRx({ lastSnr: 1, lastRssi: -100, raw }, deps); + + expect(recordSpy).not.toHaveBeenCalled(); + }); + + it('records foreign-LoRa when the MeshCore RF bridge proximity is nearby', () => { + setMeshtasticConnectedMyNodeNum(99); + const recordSpy = vi.spyOn(useDiagnosticsStore.getState(), 'recordForeignLora'); + const { deps } = makeDeps(); + + const raw = Uint8Array.from([0x3c, 1, 2, 3, 4, 5, 6, 7, 8]); + + handleMeshcoreRfRx({ lastSnr: 5, lastRssi: -60, raw }, deps); + + // rfSenderId (arg 5) resolves from the packet's path/pubkey hash; rfFingerprint and + // rfDisplayName (args 8-9) stay undefined once a concrete sender id is resolved. + expect(recordSpy).toHaveBeenCalledWith( + 99, + 'meshcore', + -60, + 5, + expect.anything(), + deps.readNodes, + 'meshcore-radio-rf', + undefined, + undefined, + ); + }); + + it('publishes an MQTT packet log when MQTT is connected and the throttle allows it', () => { + const { deps } = makeDeps({ mqttStatusRef: ref('connected' as const) }); + const publish = vi.mocked(window.electronAPI.mqtt.publishMeshcorePacketLog); + publish.mockClear(); + + handleMeshcoreRfRx({ lastSnr: 3, lastRssi: -70, raw: null }, deps); + + expect(publish).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledWith(expect.objectContaining({ snr: 3, rssi: -70 })); + }); + + it('does not publish an MQTT packet log when MQTT is disconnected', () => { + const { deps } = makeDeps({ mqttStatusRef: ref('disconnected' as const) }); + const publish = vi.mocked(window.electronAPI.mqtt.publishMeshcorePacketLog); + publish.mockClear(); + + handleMeshcoreRfRx({ lastSnr: 3, lastRssi: -70, raw: null }, deps); + + expect(publish).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts new file mode 100644 index 000000000..1e1cdd9a0 --- /dev/null +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts @@ -0,0 +1,633 @@ +/** + * MeshCore RF RX (event 136) handling, extracted from the runtime side-effect listener so each + * step (sender resolution, raw-packet parse, hop tracking, foreign-LoRa bridging, MQTT packet + * log) stays independently testable and under the cognitive-complexity limit. + * + * Failure point: DB / MQTT IPC rejections are logged; Zustand stores stay authoritative for UI. + */ +import type { Dispatch, RefObject, SetStateAction } from 'react'; + +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; + +import { parseMeshCoreRfPacket } from '../../../shared/meshcoreRfPacketParse'; +import { MAX_DEVICE_LOGS, MAX_TELEMETRY_POINTS } from '../../hooks/meshcore/meshcoreHookPreamble'; +import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; +import { upsertNodeRecord } from '../../stores/nodeStore'; +import { + classifyPayload, + classifyProximity, + extractMeshtasticSenderId, + meshtasticSenderIdForRawLogFallback, + type PacketClass, +} from '../foreignLoraDetection'; +import { + meshcoreRawPacketLogFromBytesFallback, + meshcoreRawPacketResolveFromParsed, + meshcoreRfIsSelfOriginated, + meshcoreRfNodeHashCandidates, + meshcoreRfResolvePathSender, +} from '../meshcoreRawPacketSender'; +import { shouldCoalesceSelfFloodAdvert } from '../meshcoreRawSelfFloodAdvertCoalesce'; +import { meshcoreMergeContactHopsAwayFromPrevious, pubkeyToNodeId } from '../meshcoreUtils'; +import { getMeshtasticConnectedMyNodeNum } from '../meshtasticConnectedNodeRef'; +import type { DomainEvent } from '../protocols/Protocol'; +import { MAX_RAW_PACKET_LOG_ENTRIES } from '../rawPacketLogConstants'; +import { getStoredMeshProtocol } from '../storedMeshProtocol'; +import { meshNodeToNodeRecord } from '../storeRecordAdapters'; +import { MESHCORE_RAW_SELF_FLOOD_ADVERT_COALESCE_MS } from '../timeConstants'; +import type { MeshNode, MQTTStatus, TelemetryPoint } from '../types'; +import type { DeviceLogEntry, MeshCoreSelfInfo, RxPacketEntry } from './meshcoreHookTypes'; +import { + type MeshcoreMqttPacketLogBucket, + tryTakeMeshcoreMqttPacketLogToken, +} from './meshcoreMqttPacketLogThrottle'; + +export type MeshcoreRfRxPayload = Extract['payload']; + +/** Runtime state RF RX needs — owned by `useMeshcoreRuntime`, wired from `attachMeshcoreConnSideEffects`. */ +export interface MeshcoreRfRxDeps { + myNodeNumRef: RefObject; + meshcoreIdentityIdRef: RefObject; + readNodes: () => Map; + pubKeyMapRef: RefObject>; + pubKeyPrefixMapRef: RefObject>; + nicknameMapRef: RefObject>; + selfInfoRef: RefObject; + rawPacketsRef: RefObject; + mqttStatusRef: RefObject; + lastPacketLogAtRef: RefObject; + lastPacketLogPublishFailureLogAtRef: RefObject; + /** Token bucket created once at attach time; shared across every RF RX in the session. */ + mqttPacketLogBucket: MeshcoreMqttPacketLogBucket; + setDeviceLogs: Dispatch>; + setSignalTelemetry: Dispatch>; + setRawPackets: Dispatch>; +} + +interface MeshcoreRfParseContext { + parsed: ReturnType; + routeTypeString: string | null; + payloadTypeString: string | null; + hopCount: number; + pathBytes: number[]; + pathHashSizeBytes: 1 | 2 | 3; + fromNodeId: number | null; + messageFingerprintHex: string | null; + transportScopeCode: number | null; + transportReturnCode: number | null; + advertName: string | null; + advertLat: number | null; + advertLon: number | null; + advertTimestampSec: number | null; + parseOk: boolean; +} + +interface MeshcoreRfMqttPacketLogFields { + rawHex: string; + len: number; + packetType?: number; + route?: string; + payloadLen: number; + hash?: string; +} + +function persistMeshcoreContactRfTransport( + nodeId: number, + transportCodes: readonly [number, number], +): void { + void window.electronAPI.db + .updateMeshcoreContactRfTransport(nodeId, transportCodes[0], transportCodes[1]) + .catch((e: unknown) => { + console.warn( + '[meshcoreRfRxRuntime] updateMeshcoreContactRfTransport error ' + errLikeToLogString(e), + ); + }); +} + +function persistMeshcoreContactLastRf( + nodeId: number, + snr: number, + rssi: number, + hopsAway: number, + nowSec: number, +): void { + void window.electronAPI.db + .updateMeshcoreContactLastRf(nodeId, snr, rssi, hopsAway, nowSec) + .catch((e: unknown) => { + console.warn( + '[meshcoreRfRxRuntime] updateMeshcoreContactLastRf error ' + errLikeToLogString(e), + ); + }); +} + +function persistMeshcoreRfHopHistory( + nodeId: number, + now: number, + hopsAway: number, + snr: number, + rssi: number, +): void { + void useDiagnosticsStore + .getState() + .saveMeshcoreHopHistory(nodeId, now, hopsAway, snr, rssi) + .catch((e: unknown) => { + console.warn('[meshcoreRfRxRuntime] saveMeshcoreHopHistory error ' + errLikeToLogString(e)); + }); +} + +function updateKnownMeshtasticSenderNode( + senderId: number, + now: number, + snr: number, + rssi: number, + deps: Pick, +): void { + if (senderId === deps.myNodeNumRef.current) return; + const existing = deps.readNodes().get(senderId); + const storeId = deps.meshcoreIdentityIdRef.current; + if (!existing || !storeId) return; + const nowSec = Math.floor(now / 1000); + upsertNodeRecord( + storeId, + meshNodeToNodeRecord({ + ...existing, + last_heard: Math.max(existing.last_heard ?? 0, nowSec), + snr, + rssi, + }), + ); +} + +/** Builds the RX device-log suffix and, for known Meshtastic-class senders, bumps last_heard. */ +export function resolveMeshcoreRfSenderInfo( + rawU8: Uint8Array | null, + loraPacketClass: PacketClass | null, + now: number, + snr: number, + rssi: number, + deps: Pick, +): string { + if (!rawU8 || rawU8.length < 8 || loraPacketClass == null) return ''; + if (loraPacketClass === 'meshcore') return ' [meshcore]'; + if (loraPacketClass !== 'meshtastic') return ''; + + const senderId = extractMeshtasticSenderId(rawU8); + if (senderId === null) return ''; + updateKnownMeshtasticSenderNode(senderId, now, snr, rssi, deps); + return ` from=0x${senderId.toString(16)}`; +} + +function appendMeshcoreRfDeviceLog( + deps: Pick, + now: number, + senderInfo: string, + snr: number, + rssi: number, +): void { + const entry: DeviceLogEntry = { + ts: now, + level: 'debug', + source: 'meshcore', + message: `RX${senderInfo} SNR=${snr.toFixed(2)}dB RSSI=${rssi}dBm`, + }; + deps.setDeviceLogs((prev) => { + const next = [...prev, entry]; + return next.length > MAX_DEVICE_LOGS ? next.slice(next.length - MAX_DEVICE_LOGS) : next; + }); +} + +function appendMeshcoreRfSignalTelemetry( + deps: Pick, + now: number, + snr: number, + rssi: number, +): void { + const sigPoint: TelemetryPoint = { timestamp: now, snr, rssi }; + deps.setSignalTelemetry((prev) => [...prev, sigPoint].slice(-MAX_TELEMETRY_POINTS)); +} + +/** + * Raw packet log: always run the in-house MeshCore parse (LOG_RX is MeshCore RF only). Do not + * gate on `classifyPayload` — Meshtastic-shaped heuristics can mis-label MeshCore frames. + */ +function buildMeshcoreRfParseContext( + rawU8: Uint8Array, + pubKeyPrefixMap: Map, +): MeshcoreRfParseContext { + const parsed = parseMeshCoreRfPacket(rawU8); + if (parsed.ok) { + const fromNodeId = meshcoreRawPacketResolveFromParsed(parsed, pubKeyPrefixMap); + if (fromNodeId != null && parsed.transportCodes) { + persistMeshcoreContactRfTransport(fromNodeId, parsed.transportCodes); + } + return { + parsed, + routeTypeString: parsed.routeTypeString, + payloadTypeString: parsed.payloadTypeString, + hopCount: parsed.hopCount, + pathBytes: parsed.pathBytes, + pathHashSizeBytes: parsed.pathHashSizeBytes, + fromNodeId, + messageFingerprintHex: parsed.messageFingerprintHex, + transportScopeCode: parsed.transportCodes?.[0] ?? null, + transportReturnCode: parsed.transportCodes?.[1] ?? null, + advertName: parsed.advert && parsed.advert.name.length > 0 ? parsed.advert.name : null, + advertLat: parsed.advert?.latitudeDeg ?? null, + advertLon: parsed.advert?.longitudeDeg ?? null, + advertTimestampSec: parsed.advert?.timestampSec ?? null, + parseOk: true, + }; + } + + const fb = meshcoreRawPacketLogFromBytesFallback(rawU8, pubKeyPrefixMap); + return { + parsed, + routeTypeString: fb?.routeTypeString ?? null, + payloadTypeString: fb?.payloadTypeString ?? null, + hopCount: fb?.hopCount ?? 0, + pathBytes: fb?.pathBytes ?? [], + pathHashSizeBytes: fb?.pathHashSizeBytes ?? 1, + fromNodeId: fb?.fromNodeId ?? null, + messageFingerprintHex: null, + transportScopeCode: null, + transportReturnCode: null, + advertName: null, + advertLat: null, + advertLon: null, + advertTimestampSec: null, + parseOk: false, + }; +} + +/** Update hops_away/SNR/RSSI on a known MeshCore node from the RF packet's own hop count. */ +function applyMeshcoreRfHopsAwayUpdate( + fromNodeId: number | null, + hopCount: number, + now: number, + snr: number, + rssi: number, + deps: Pick, +): void { + if (fromNodeId === null || fromNodeId === deps.myNodeNumRef.current) return; + const existing = deps.readNodes().get(fromNodeId); + const storeId = deps.meshcoreIdentityIdRef.current; + if (!existing || !storeId) return; + + const nowSec = Math.floor(now / 1000); + const mergedHopsAway = meshcoreMergeContactHopsAwayFromPrevious(hopCount, existing.hops_away, 0); + const updated: MeshNode = { + ...existing, + hops_away: mergedHopsAway ?? hopCount, + snr, + rssi, + last_heard: Math.max(existing.last_heard ?? 0, nowSec), + source: 'rf', + heard_via_mqtt_only: false, + via_mqtt: false, + }; + + const unchanged = + existing.hops_away === updated.hops_away && + existing.snr === snr && + existing.rssi === rssi && + existing.last_heard === updated.last_heard; + if (unchanged) return; + + upsertNodeRecord(storeId, meshNodeToNodeRecord(updated)); + persistMeshcoreContactLastRf(fromNodeId, snr, rssi, mergedHopsAway ?? hopCount, nowSec); + persistMeshcoreRfHopHistory(fromNodeId, now, mergedHopsAway ?? hopCount, snr, rssi); +} + +function buildMeshcoreRfRawPacketEntry( + ctx: MeshcoreRfParseContext, + effectiveFromNodeId: number | null, + now: number, + snr: number, + rssi: number, + rawU8: Uint8Array, +): RxPacketEntry { + return { + ts: now, + snr, + rssi, + raw: rawU8, + routeTypeString: ctx.routeTypeString, + payloadTypeString: ctx.payloadTypeString, + hopCount: ctx.hopCount, + pathBytes: ctx.pathBytes, + pathHashSizeBytes: ctx.pathHashSizeBytes, + fromNodeId: effectiveFromNodeId, + messageFingerprintHex: ctx.messageFingerprintHex, + transportScopeCode: ctx.transportScopeCode, + transportReturnCode: ctx.transportReturnCode, + advertName: ctx.advertName, + advertLat: ctx.advertLat, + advertLon: ctx.advertLon, + advertTimestampSec: ctx.advertTimestampSec, + parseOk: ctx.parseOk, + }; +} + +function computeNextRawPacketLog( + prev: RxPacketEntry[], + rxEntry: RxPacketEntry, + myId: number, +): RxPacketEntry[] { + const last = prev[prev.length - 1]; + const shouldCoalesce = + myId !== 0 && + shouldCoalesceSelfFloodAdvert(last, rxEntry, myId, MESHCORE_RAW_SELF_FLOOD_ADVERT_COALESCE_MS); + const next = shouldCoalesce ? [...prev.slice(0, -1), rxEntry] : [...prev, rxEntry]; + return next.length > MAX_RAW_PACKET_LOG_ENTRIES + ? next.slice(next.length - MAX_RAW_PACKET_LOG_ENTRIES) + : next; +} + +function pushMeshcoreRfRawPacketLog( + deps: Pick, + rxEntry: RxPacketEntry, +): void { + deps.setRawPackets((prev) => { + const trimmed = computeNextRawPacketLog(prev, rxEntry, deps.myNodeNumRef.current); + // Sync before React commit so same-tick chat ingest sees this row. + deps.rawPacketsRef.current = trimmed; + return trimmed; + }); +} + +function buildMeshcoreRfMqttPacketLogFields( + ctx: MeshcoreRfParseContext, + rawU8: Uint8Array, +): MeshcoreRfMqttPacketLogFields { + return { + rawHex: Array.from(rawU8, (b) => b.toString(16).padStart(2, '0')).join(''), + len: rawU8.length, + payloadLen: rawU8.length, + packetType: ctx.parsed.ok ? ctx.parsed.payloadTypeNibble : undefined, + route: ctx.routeTypeString ?? undefined, + hash: ctx.messageFingerprintHex ?? undefined, + }; +} + +/** + * Noisy payload types for MeshCore: + * FLOOD (1001) discovery floods indicate routing loops or lost paths; + * FLOOD + ADVERT (1002) are flood-routed advertisements (room or device). + */ +function recordMeshcoreRfNoisePorts( + ctx: MeshcoreRfParseContext, + effectiveFromNodeId: number | null, +): void { + if (effectiveFromNodeId == null || ctx.routeTypeString !== 'FLOOD') return; + const port = ctx.parsed.ok && ctx.parsed.advert != null ? 1002 : 1001; + useDiagnosticsStore.getState().recordNoisePort(effectiveFromNodeId, port); +} + +function resolveMeshcoreRfBridgeSender( + ctx: MeshcoreRfParseContext, + effectiveFromNodeId: number | null, + rssi: number, + myNodeNum: number, + meshcoreNodes: Map, +): { rfSenderId: number | undefined; rfDisplayName: string | undefined } { + let rfSenderId = effectiveFromNodeId ?? undefined; + let rfDisplayName: string | undefined; + if (!ctx.parsed.ok) return { rfSenderId, rfDisplayName }; + + const parsed = ctx.parsed; + if (rfSenderId == null && parsed.advert) { + const advertId = pubkeyToNodeId(parsed.advert.publicKey); + if (advertId !== 0) rfSenderId = advertId; + if (parsed.advert.name.length > 0) rfDisplayName = parsed.advert.name; + } else if (ctx.advertName) { + rfDisplayName = ctx.advertName; + } + if (rfSenderId == null && parsed.pathBytes.length > 0) { + const useAllContacts = ctx.hopCount <= 2 && rssi > -80 && parsed.pathBytes.length > 0; + const pathCandidates = meshcoreRfNodeHashCandidates( + meshcoreNodes, + myNodeNum, + useAllContacts ? { rssi: undefined } : { rssi }, + ); + const pathId = meshcoreRfResolvePathSender(parsed.pathBytes, pathCandidates); + if (pathId != null) rfSenderId = pathId; + } + return { rfSenderId, rfDisplayName }; +} + +interface MeshcoreRfBridgeIdentity { + rfSenderId: number | undefined; + rfDisplayName: string | undefined; + isOwnMeshcoreTx: boolean; +} + +function finalizeMeshcoreRfBridgeIdentity( + sender: { rfSenderId: number | undefined; rfDisplayName: string | undefined }, + isSelfRf: boolean, + effectiveFromNodeId: number | null, + myNodeNum: number, + meshcoreNodes: Map, + deps: Pick, +): MeshcoreRfBridgeIdentity { + let { rfSenderId, rfDisplayName } = sender; + const isOwnMeshcoreTx = + isSelfRf || + (rfSenderId != null && rfSenderId === myNodeNum) || + (effectiveFromNodeId != null && effectiveFromNodeId === myNodeNum); + + if (isOwnMeshcoreTx && myNodeNum !== 0) { + rfSenderId = myNodeNum; + rfDisplayName = + rfDisplayName ?? + deps.selfInfoRef.current?.name?.trim() ?? + meshcoreNodes.get(myNodeNum)?.long_name ?? + meshcoreNodes.get(myNodeNum)?.short_name ?? + deps.nicknameMapRef.current.get(myNodeNum); + } + if (rfSenderId != null && rfDisplayName == null) { + const known = meshcoreNodes.get(rfSenderId); + rfDisplayName = + known?.long_name ?? known?.short_name ?? deps.nicknameMapRef.current.get(rfSenderId); + } + return { rfSenderId, rfDisplayName, isOwnMeshcoreTx }; +} + +/** + * MeshCore radio RF RX → Meshtastic Foreign LoRa bridge (local overhear only, not contact-list + * sync). Returns `skip: true` when the caller's original `handleRfRx` would have returned early + * (proximity gate failed, or no identified sender/fingerprint) — the remaining RF RX steps + * (generic foreign-LoRa fingerprinting, MQTT packet log) must then be skipped too. + */ +function bridgeMeshcoreRfToForeignLora( + ctx: MeshcoreRfParseContext, + loraPacketClass: PacketClass | null, + effectiveFromNodeId: number | null, + rawU8: Uint8Array, + snr: number, + rssi: number, + deps: MeshcoreRfRxDeps, +): { skip: boolean } { + if (loraPacketClass !== 'meshcore') return { skip: false }; + const mtNode = getMeshtasticConnectedMyNodeNum(); + if (mtNode <= 0) return { skip: false }; + + const myNodeNum = deps.myNodeNumRef.current; + const selfPubKey = + myNodeNum !== 0 + ? (deps.pubKeyMapRef.current.get(myNodeNum) ?? deps.selfInfoRef.current?.publicKey) + : undefined; + const isSelfRf = myNodeNum !== 0 && meshcoreRfIsSelfOriginated(rawU8, selfPubKey, myNodeNum); + + const meshcoreNodes = deps.readNodes(); + const sender = resolveMeshcoreRfBridgeSender( + ctx, + effectiveFromNodeId, + rssi, + myNodeNum, + meshcoreNodes, + ); + const identity = finalizeMeshcoreRfBridgeIdentity( + sender, + isSelfRf, + effectiveFromNodeId, + myNodeNum, + meshcoreNodes, + deps, + ); + + const proximity = classifyProximity(rssi || undefined, snr || undefined); + let rfFingerprint = + identity.rfSenderId == null && ctx.messageFingerprintHex + ? ctx.messageFingerprintHex + : undefined; + if (identity.isOwnMeshcoreTx) rfFingerprint = undefined; + + // Local RF only — skip distant mesh floods (identified or not). + if (proximity !== 'very-close' && proximity !== 'nearby') return { skip: true }; + if (identity.rfSenderId == null && rfFingerprint == null) return { skip: true }; + + useDiagnosticsStore + .getState() + .recordForeignLora( + mtNode, + 'meshcore', + rssi || undefined, + snr || undefined, + identity.rfSenderId, + deps.readNodes, + 'meshcore-radio-rf', + rfFingerprint, + identity.rfDisplayName, + ); + return { skip: false }; +} + +/** Foreign LoRa fingerprinting: only flag non-MeshCore packets (requires known self node ID). */ +function recordMeshcoreForeignLoraFingerprint( + rawU8: Uint8Array | null, + loraPacketClass: PacketClass | null, + snr: number, + rssi: number, + deps: Pick, +): void { + if (getStoredMeshProtocol() !== 'meshcore') return; + if (deps.myNodeNumRef.current === 0) return; + if (!rawU8 || loraPacketClass == null || loraPacketClass === 'meshcore') return; + + const senderId = loraPacketClass === 'meshtastic' ? extractMeshtasticSenderId(rawU8) : null; + useDiagnosticsStore + .getState() + .recordForeignLora( + deps.myNodeNumRef.current, + loraPacketClass, + rssi || undefined, + snr || undefined, + senderId ?? undefined, + deps.readNodes, + ); +} + +function publishMeshcoreRfMqttPacketLog( + fields: MeshcoreRfMqttPacketLogFields | null, + snr: number, + rssi: number, + deps: Pick< + MeshcoreRfRxDeps, + | 'mqttStatusRef' + | 'lastPacketLogAtRef' + | 'lastPacketLogPublishFailureLogAtRef' + | 'selfInfoRef' + | 'mqttPacketLogBucket' + >, +): void { + if (deps.mqttStatusRef.current !== 'connected') return; + const nowMs = Date.now(); + if (!tryTakeMeshcoreMqttPacketLogToken(deps.mqttPacketLogBucket, nowMs)) return; + deps.lastPacketLogAtRef.current = nowMs; + void window.electronAPI.mqtt + .publishMeshcorePacketLog({ + origin: deps.selfInfoRef.current?.name ?? 'mesh-client', + snr, + rssi, + rawHex: fields?.rawHex, + len: fields?.len, + packetType: fields?.packetType, + route: fields?.route, + payloadLen: fields?.payloadLen, + hash: fields?.hash, + }) + .catch((e: unknown) => { + const t = Date.now(); + if (t - deps.lastPacketLogPublishFailureLogAtRef.current >= 30_000) { + deps.lastPacketLogPublishFailureLogAtRef.current = t; + console.warn( + '[meshcoreRfRxRuntime] MQTT packet-log publish failed ' + errLikeToLogString(e), + ); + } + }); +} + +/** + * Full RF RX (event 136) handling: device log + signal telemetry, raw packet log with hop/foreign + * LoRa bridging, and throttled MQTT packet-log publish. Mirrors the original inline + * `handleRfRx` — including its early return once the foreign-LoRa proximity gate fails, which + * skips the generic fingerprinting pass and the MQTT publish below it. + */ +export function handleMeshcoreRfRx(payload: MeshcoreRfRxPayload, deps: MeshcoreRfRxDeps): void { + const { lastSnr: snr, lastRssi: rssi, raw: rawU8 } = payload; + const now = Date.now(); + const loraPacketClass = rawU8 ? classifyPayload(rawU8) : null; + + const senderInfo = resolveMeshcoreRfSenderInfo(rawU8, loraPacketClass, now, snr, rssi, deps); + appendMeshcoreRfDeviceLog(deps, now, senderInfo, snr, rssi); + appendMeshcoreRfSignalTelemetry(deps, now, snr, rssi); + + let mqttFields: MeshcoreRfMqttPacketLogFields | null = null; + + if (rawU8) { + const ctx = buildMeshcoreRfParseContext(rawU8, deps.pubKeyPrefixMapRef.current); + applyMeshcoreRfHopsAwayUpdate(ctx.fromNodeId, ctx.hopCount, now, snr, rssi, deps); + + const effectiveFromNodeId = + ctx.fromNodeId ?? meshtasticSenderIdForRawLogFallback(ctx.parseOk, rawU8); + const rxEntry = buildMeshcoreRfRawPacketEntry(ctx, effectiveFromNodeId, now, snr, rssi, rawU8); + pushMeshcoreRfRawPacketLog(deps, rxEntry); + + mqttFields = buildMeshcoreRfMqttPacketLogFields(ctx, rawU8); + recordMeshcoreRfNoisePorts(ctx, effectiveFromNodeId); + + const bridgeResult = bridgeMeshcoreRfToForeignLora( + ctx, + loraPacketClass, + effectiveFromNodeId, + rawU8, + snr, + rssi, + deps, + ); + if (bridgeResult.skip) return; + } + + recordMeshcoreForeignLoraFingerprint(rawU8, loraPacketClass, snr, rssi, deps); + publishMeshcoreRfMqttPacketLog(mqttFields, snr, rssi, deps); +} diff --git a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts index df54a8369..b58faee27 100644 --- a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts @@ -88,6 +88,51 @@ function saveNode(identityId: IdentityId, node: MeshNode): void { persistDbWrite('meshtastic node', () => window.electronAPI.db.saveNode(node)); } +/** Self stays at hopsAway (default 0); stale peers clear hops; live peers keep packet or prior. */ +function resolveNodeDbHopsAway( + isSelf: boolean, + lastHeardStale: boolean, + hopsAway: number | undefined, + previousHopsAway: number | undefined, +): number | undefined { + if (isSelf) return hopsAway ?? 0; + if (lastHeardStale) return undefined; + return hopsAway ?? previousHopsAway; +} + +interface NodeDbPositionPatch { + latitude: number | null; + longitude: number | null; + lastPositionWarning: string | undefined; +} + +function applyNodeDbPositionCoords( + positionCoords: { latitude: number; longitude: number } | null, + nodeNum: number, + myNodeNum: number, + isSelf: boolean, + storeExisting: MeshNode, +): NodeDbPositionPatch { + const latitude = storeExisting.latitude; + const longitude = storeExisting.longitude; + let lastPositionWarning: string | undefined = storeExisting.lastPositionWarning; + if (!positionCoords || shouldPreserveStaticGpsForSelfNode(nodeNum, myNodeNum)) { + return { latitude, longitude, lastPositionWarning }; + } + const r = validateCoords(positionCoords.latitude, positionCoords.longitude); + if (r.valid) { + return { + latitude: positionCoords.latitude, + longitude: positionCoords.longitude, + lastPositionWarning: undefined, + }; + } + if (!isSelf || (storeExisting.latitude === 0 && storeExisting.longitude === 0)) { + lastPositionWarning = r.warning; + } + return { latitude, longitude, lastPositionWarning }; +} + /** UserPacket identity: live long/short name, hardware, role, and public key. */ function handleUserPacketNodeInfo( identityId: IdentityId, @@ -174,21 +219,14 @@ function handleNodeDbNodeInfo( info.latitude != null && info.longitude != null ? { latitude: info.latitude, longitude: info.longitude } : null; - let newLat = storeExisting.latitude; - let newLon = storeExisting.longitude; + const positionPatch = applyNodeDbPositionCoords( + positionCoords, + nodeNum, + myNodeNum, + isSelf, + storeExisting, + ); const newAlt = info.altitude ?? storeExisting.altitude; - let posWarn: string | undefined = storeExisting.lastPositionWarning; - if (positionCoords && !shouldPreserveStaticGpsForSelfNode(nodeNum, myNodeNum)) { - const { latitude, longitude } = positionCoords; - const r = validateCoords(latitude, longitude); - if (r.valid) { - newLat = latitude; - newLon = longitude; - posWarn = undefined; - } else if (!isSelf || (storeExisting.latitude === 0 && storeExisting.longitude === 0)) { - posWarn = r.warning; - } - } const lastHeardMs = computeNodeInfoLastHeardMs( info.lastHeardAt, storeExisting.last_heard, @@ -204,6 +242,12 @@ function handleNodeDbNodeInfo( preferNonEmptyTrimmedString(info.shortName, storeExisting.short_name), nodeNum, ); + const hops_away = resolveNodeDbHopsAway( + isSelf, + lastHeardStale, + info.hopsAway, + storeExisting.hops_away, + ); const node: MeshNode = { ...storeExisting, node_id: nodeNum, @@ -213,14 +257,10 @@ function handleNodeDbNodeInfo( snr: info.snr ?? storeExisting.snr, battery: info.batteryLevel ?? storeExisting.battery, last_heard: lastHeardMs, - latitude: newLat, - longitude: newLon, + latitude: positionPatch.latitude, + longitude: positionPatch.longitude, role: info.role ?? storeExisting.role, - hops_away: isSelf - ? (info.hopsAway ?? 0) - : lastHeardStale - ? undefined - : (info.hopsAway ?? storeExisting.hops_away), + hops_away, via_mqtt: info.viaMqtt ?? false, voltage: info.voltage ?? storeExisting.voltage, channel_utilization: info.channelUtilization ?? storeExisting.channel_utilization, @@ -228,7 +268,7 @@ function handleNodeDbNodeInfo( altitude: newAlt, heard_via_mqtt_only: false, source: 'rf', - lastPositionWarning: posWarn, + lastPositionWarning: positionPatch.lastPositionWarning, }; saveNode(identityId, node); diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts index aa3af5b88..9b70344cf 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts @@ -64,6 +64,22 @@ import { pushMeshtasticTransportSideEffectUnsubs } from './meshtasticTransportSi const REQUEST_NODEINFO_MIN_INTERVAL_MS = 120_000; const { DeviceStatusEnum } = Types; +/** After configure, request NodeDB metadata once; retry once if the first call fails. */ +function requestMetadataAfterConfigure(device: MeshDevice, myNode: number, attempt: 1 | 2): void { + void device.getMetadata(myNode).catch((e: unknown) => { + console.debug( + '[useMeshtasticRuntime] getMetadata after configure failed ' + + errLikeToLogString(e) + + (attempt === 2 ? ' (retry)' : ''), + ); + if (attempt === 1) { + setTimeout(() => { + requestMetadataAfterConfigure(device, myNode, 2); + }, MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS); + } + }); +} + export type RequestStoreForwardHistoryResult = | { ok: true } | { @@ -438,21 +454,7 @@ export function attachMeshtasticRuntimeWireEffects( mqttClientProxyBridgeRef.current?.flushPendingToDevice(); const myNode = myNodeNumRef.current; if (myNode > 0) { - const requestMetadataAfterConfigure = (attempt: 1 | 2): void => { - void device.getMetadata(myNode).catch((e: unknown) => { - console.debug( - '[useMeshtasticRuntime] getMetadata after configure failed ' + - errLikeToLogString(e) + - (attempt === 2 ? ' (retry)' : ''), - ); - if (attempt === 1) { - setTimeout(() => { - requestMetadataAfterConfigure(2); - }, MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS); - } - }); - }; - requestMetadataAfterConfigure(1); + requestMetadataAfterConfigure(device, myNode, 1); } if (localLoraConfigTimerRef.current != null) { clearTimeout(localLoraConfigTimerRef.current); @@ -828,9 +830,8 @@ export function attachMeshtasticRuntimeWireEffects( } applySdkRoutingErrorFromLog(entry.message); }); - unsubscribesRef.current.push(unsubForeignLoraLogLine); - unsubscribesRef.current.push( + unsubForeignLoraLogLine, installMeshtasticSdkRoutingErrorConsoleHook(applySdkRoutingErrorFromLog), installMeshtasticSdkRoutingErrorUnhandledRejectionHandler(applySdkRoutingErrorFromRejection), ); diff --git a/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.ts index b9da8387f..825ae8664 100644 --- a/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticTraceSideEffects.ts @@ -64,11 +64,11 @@ export function prunePendingTraceState( } if (requests.size > MAX_PENDING_TRACE_ENTRIES) { const kept = trimMapToMaxSize(requests, MAX_PENDING_TRACE_ENTRIES); - for (const target of [...requests.keys()]) { + for (const target of requests.keys()) { if (!kept.has(target)) requests.delete(target); } } - for (const [packetId, dest] of [...packetIds.entries()]) { + for (const [packetId, dest] of packetIds) { if (!requests.has(dest)) packetIds.delete(packetId); } } diff --git a/src/renderer/lib/protocols/MeshCoreProtocol.ts b/src/renderer/lib/protocols/MeshCoreProtocol.ts index 9495a0e5b..828317f39 100644 --- a/src/renderer/lib/protocols/MeshCoreProtocol.ts +++ b/src/renderer/lib/protocols/MeshCoreProtocol.ts @@ -2,15 +2,7 @@ import type { Connection } from '@liamcottle/meshcore.js'; import { meshcoreDmAckKeyU32 } from '../../hooks/meshcore/meshcoreHookPreamble'; -import type { - CayenneLppEntry, - MeshCoreContactRaw, - MeshCoreNeighborEntry, - MeshCoreNeighborResult, - MeshCoreNodeTelemetry, - MeshCoreRepeaterStatus, - RxPacketEntry, -} from '../meshcore/meshcoreHookTypes'; +import type { MeshCoreContactRaw } from '../meshcore/meshcoreHookTypes'; import { seedMeshcorePrefixLookupMaps } from '../meshcore/meshcorePubKeyRegistry'; import { meshcoreCoerceRadioRxFrame, parseAutoaddConfigResponse } from '../meshcoreContactAutoAdd'; import { decodeMeshcoreDirectMessageEvents } from '../meshcoreDirectMessageDecode'; @@ -65,7 +57,7 @@ export type { MeshCoreNodeTelemetry, MeshCoreRepeaterStatus, RxPacketEntry, -}; +} from '../meshcore/meshcoreHookTypes'; interface MeshCoreEventBus { on(event: string | number, cb: (...args: unknown[]) => void): void; diff --git a/src/renderer/lib/protocols/MeshtasticProtocol.ts b/src/renderer/lib/protocols/MeshtasticProtocol.ts index 36db16baf..dfb5b1688 100644 --- a/src/renderer/lib/protocols/MeshtasticProtocol.ts +++ b/src/renderer/lib/protocols/MeshtasticProtocol.ts @@ -108,6 +108,84 @@ function boundedRoute(route: readonly unknown[] | undefined): number[] { return hops; } +/** Finite number or undefined — used for optional MeshPacket data-layer fields. */ +function finiteOptionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +interface TraceRouteMeshPacket { + from: number; + to?: number; + rxTime?: number; + data?: { route?: readonly number[]; routeBack?: readonly number[] }; + payloadVariant?: { + case?: string; + value?: { + portnum?: number; + payload?: Uint8Array; + dest?: number; + source?: number; + replyId?: number; + requestId?: number; + }; + }; +} + +interface DecodedTraceRouteFields { + route: number[]; + routeBack: number[] | undefined; + dataLayerDest: number | undefined; + dataLayerSource: number | undefined; + replyId: number | undefined; + requestId: number | undefined; +} + +function decodeTraceRouteFromDecodedPayload( + value: NonNullable['value']>, +): DecodedTraceRouteFields | null { + if (!value.payload) return null; + try { + const rd = fromBinary(Mesh.RouteDiscoverySchema, value.payload) as unknown as { + route: readonly number[]; + routeBack: readonly number[]; + }; + return { + route: boundedRoute(rd.route), + routeBack: rd.routeBack ? boundedRoute(rd.routeBack) : undefined, + dataLayerDest: finiteOptionalNumber(value.dest), + dataLayerSource: finiteOptionalNumber(value.source), + replyId: finiteOptionalNumber(value.replyId), + requestId: finiteOptionalNumber(value.requestId), + }; + } catch { + // catch-no-log-ok non-traceroute payload on TRACEROUTE_APP + return null; + } +} + +function decodeTraceRouteFromDataLayer( + data: NonNullable, +): DecodedTraceRouteFields { + return { + route: boundedRoute(data.route), + routeBack: data.routeBack ? boundedRoute(data.routeBack) : undefined, + dataLayerDest: undefined, + dataLayerSource: undefined, + replyId: undefined, + requestId: undefined, + }; +} + +function resolveTraceRouteFields(p: TraceRouteMeshPacket): DecodedTraceRouteFields | null { + if (p.payloadVariant?.case === 'decoded' && p.payloadVariant.value) { + return decodeTraceRouteFromDecodedPayload(p.payloadVariant.value); + } + if (p.data) { + return decodeTraceRouteFromDataLayer(p.data); + } + return null; +} + /** * Meshtastic codec + SDK adapter. Stateless: every method that needs the SDK * takes the handle as a parameter. Lifecycle (watchdog, reconnect) lives in @@ -885,62 +963,9 @@ export class MeshtasticProtocol implements Protocol { } private decodeTraceRoute(raw: unknown): DomainEvent[] { - const p = raw as { - from: number; - to?: number; - rxTime?: number; - data?: { route?: readonly number[]; routeBack?: readonly number[] }; - payloadVariant?: { - case?: string; - value?: { - portnum?: number; - payload?: Uint8Array; - dest?: number; - source?: number; - replyId?: number; - requestId?: number; - }; - }; - }; - - let route: number[]; - let routeBack: number[] | undefined; - let dataLayerDest: number | undefined; - let dataLayerSource: number | undefined; - let replyId: number | undefined; - let requestId: number | undefined; - - if (p.payloadVariant?.case === 'decoded' && p.payloadVariant.value?.payload) { - try { - const rd = fromBinary( - Mesh.RouteDiscoverySchema, - p.payloadVariant.value.payload, - ) as unknown as { - route: readonly number[]; - routeBack: readonly number[]; - }; - route = boundedRoute(rd.route); - routeBack = rd.routeBack ? boundedRoute(rd.routeBack) : undefined; - } catch { - // catch-no-log-ok non-traceroute payload on TRACEROUTE_APP - return []; - } - const dp = p.payloadVariant.value; - const rawDest = dp.dest; - dataLayerDest = typeof rawDest === 'number' && Number.isFinite(rawDest) ? rawDest : undefined; - const rawSource = dp.source; - dataLayerSource = - typeof rawSource === 'number' && Number.isFinite(rawSource) ? rawSource : undefined; - const rawReply = dp.replyId; - replyId = typeof rawReply === 'number' && Number.isFinite(rawReply) ? rawReply : undefined; - const rawReq = dp.requestId; - requestId = typeof rawReq === 'number' && Number.isFinite(rawReq) ? rawReq : undefined; - } else if (p.data) { - route = boundedRoute(p.data.route); - routeBack = p.data.routeBack ? boundedRoute(p.data.routeBack) : undefined; - } else { - return []; - } + const p = raw as TraceRouteMeshPacket; + const fields = resolveTraceRouteFields(p); + if (!fields) return []; const from = normalizedNodeNum(p.from); if (from === undefined) return []; @@ -950,14 +975,14 @@ export class MeshtasticProtocol implements Protocol { type: 'trace_route', payload: { from, - to: normalizedNodeNum(p.to) ?? dataLayerDest ?? 0, - route, - routeBack, + to: normalizedNodeNum(p.to) ?? fields.dataLayerDest ?? 0, + route: fields.route, + routeBack: fields.routeBack, timestamp: p.rxTime ? p.rxTime * 1000 : Date.now(), - dataLayerDest, - dataLayerSource, - replyId, - requestId, + dataLayerDest: fields.dataLayerDest, + dataLayerSource: fields.dataLayerSource, + replyId: fields.replyId, + requestId: fields.requestId, }, }, ]; diff --git a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts index 7355a9514..1a14f3f83 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts @@ -11,6 +11,10 @@ const CONN_EVENTS_SOURCE = readFileSync( join(__dirname, '../hooks/meshcore/meshcoreConnSideEffects.ts'), 'utf-8', ); +const RF_RX_RUNTIME_SOURCE = readFileSync( + join(__dirname, '../lib/meshcore/meshcoreRfRxRuntime.ts'), + 'utf-8', +); describe('useMeshcoreRuntime auto-reconnect (regression)', () => { it('implements exponential backoff reconnect with max attempts', () => { @@ -232,7 +236,7 @@ describe('meshcoreConnSideEffects disconnected handler (regression)', () => { }); it('logs rate-limited MQTT packet-log publish failures', () => { - expect(CONN_EVENTS_SOURCE).toMatch( + expect(RF_RX_RUNTIME_SOURCE).toMatch( /publishMeshcorePacketLog[\s\S]{0,800}MQTT packet-log publish failed/, ); }); @@ -243,7 +247,7 @@ describe('meshcoreConnSideEffects disconnected handler (regression)', () => { }); it('syncs rawPacketsRef inside event 136 setRawPackets updater (same-tick hop correlation)', () => { - expect(CONN_EVENTS_SOURCE).toMatch( + expect(RF_RX_RUNTIME_SOURCE).toMatch( /setRawPackets\(\(prev\) => \{[\s\S]*?rawPacketsRef\.current = trimmed;[\s\S]*?return trimmed;/, ); }); diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index d65f0f2be..94b04c0e6 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -460,6 +460,13 @@ async function awaitMeshcoreCompanionConfigAck( }); } +/** Path-updated rebuild merges chat stubs without nesting a setState updater in the debounce. */ +function meshcorePathUpdatedNodesMergeUpdater( + newNodes: Map, +): (prev: Map) => Map { + return (prev) => mergeMeshcoreChatStubNodes(prev, newNodes); +} + export function useMeshcoreRuntime() { const [state, setState] = useState(INITIAL_STATE); const [queueStatus, setQueueStatus] = useState<{ @@ -1622,7 +1629,7 @@ export function useMeshcoreRuntime() { pendingPathUpdateNodeIds: pendingIds, onContacts: setMeshcoreContactsForTelemetry, onNodes: (newNodes) => { - setNodes((prev) => mergeMeshcoreChatStubNodes(prev, newNodes)); + setNodes(meshcorePathUpdatedNodesMergeUpdater(newNodes)); }, }); }, MESHCORE_PATH_UPDATED_CONTACTS_REBUILD_DEBOUNCE_MS); diff --git a/vitest.config.ts b/vitest.config.ts index 3461407e4..f18264799 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -116,6 +116,7 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/meshcoreContactAutoAdd.test.ts', 'src/renderer/lib/meshcoreDbCacheHydration.repair.test.ts', 'src/renderer/lib/meshcoreOwnNodeIds.test.ts', + 'src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts', 'src/renderer/lib/meshcoreRoomCredentialStorage.test.ts', 'src/renderer/lib/meshcoreRepeaterCredentialStorage.test.ts', 'src/renderer/lib/meshcorePerNodeCredentialStorage.test.ts', From e4d5c10c4b4484c24acf03932901b69f834103b7 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 11:50:46 -0600 Subject: [PATCH 08/13] fix: address CodeRabbit findings on PR #714 - Hoist a single node-map snapshot per MeshCore RF packet so handleMeshcoreRfRx no longer rebuilds the identity Map several times on the hot path. - Re-queue pending path-updated (129) node ids when the debounced contact rebuild throws, so their path history isn't dropped until the next advert. - Validate finite SNR/RSSI in MeshCoreProtocol.decodeRfRx; bound NodeInfo coordinates like decodePosition; keep node 0 raw packets (null check). - Persist the first-connect Meshtastic self node in both updateNodes branches and mirror updateNodes' pending-driver identity fallback in ensureNodeExists. - Reject value-taking eslint options with no following token in the pre-commit batching test guard. - Fix malformed/untranslated rateLimited (de/id/it), Spanish tooltip typo, and Dutch/Polish untranslated locale values. --- scripts/pre-commit.eslint-batch.test.mjs | 6 ++++- .../meshcore/meshcorePathUpdatedRuntime.ts | 7 +++++ .../lib/meshcore/meshcoreRfRxRuntime.test.ts | 7 ++++- .../lib/meshcore/meshcoreRfRxRuntime.ts | 20 +++++++++++--- .../meshtasticRuntimeWireEffects.ts | 3 +++ .../lib/protocols/MeshCoreProtocol.ts | 6 +++-- .../lib/protocols/MeshtasticProtocol.ts | 26 ++++++++++++++++--- src/renderer/locales/de/translation.json | 2 +- src/renderer/locales/es/translation.json | 2 +- src/renderer/locales/id/translation.json | 2 +- src/renderer/locales/it/translation.json | 2 +- src/renderer/locales/nl/translation.json | 4 +-- src/renderer/locales/pl/translation.json | 2 +- src/renderer/runtime/useMeshcoreRuntime.ts | 3 +++ src/renderer/runtime/useMeshtasticRuntime.ts | 5 +++- 15 files changed, 78 insertions(+), 19 deletions(-) diff --git a/scripts/pre-commit.eslint-batch.test.mjs b/scripts/pre-commit.eslint-batch.test.mjs index 7b1f81f84..c1fb3665e 100644 --- a/scripts/pre-commit.eslint-batch.test.mjs +++ b/scripts/pre-commit.eslint-batch.test.mjs @@ -37,7 +37,11 @@ function isSafeEslintInvocation(line) { const arg = args[i]; if (!arg.startsWith('-')) return false; if (arg === '--max-warnings' || arg.startsWith('--max-warnings=')) sawMaxWarnings = true; - if (OPTIONS_TAKING_VALUE.has(arg)) i += 1; + if (OPTIONS_TAKING_VALUE.has(arg)) { + // A value-taking option with no following token is a malformed invocation. + if (i + 1 >= args.length) return false; + i += 1; + } } return sawMaxWarnings; } diff --git a/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts b/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts index 3e4225952..3a16e42ed 100644 --- a/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts +++ b/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts @@ -65,6 +65,8 @@ export interface MeshcoreContactsRebuildDeps { pendingPathUpdateNodeIds: Set; onContacts: (contacts: MeshCoreContactRaw[]) => void; onNodes: (nodes: Map) => void; + /** Re-queue pending ids when the rebuild throws so their path history isn't lost until the next 129. */ + onPendingRetained?: (nodeIds: Set) => void; } /** @@ -100,5 +102,10 @@ export async function rebuildMeshcoreContactsAfterPathUpdated( console.warn( '[meshcorePathUpdatedRuntime] debounced contacts refresh error ' + errLikeToLogString(e), ); + // The caller already drained these ids into a fresh set, so a rejection here would otherwise + // discard them until another 129 arrives; hand them back so path history can retry. + if (deps.pendingPathUpdateNodeIds.size > 0) { + deps.onPendingRetained?.(deps.pendingPathUpdateNodeIds); + } } } diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts index ea1c97da8..97b780c9a 100644 --- a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts @@ -136,17 +136,22 @@ describe('handleMeshcoreRfRx', () => { // rfSenderId (arg 5) resolves from the packet's path/pubkey hash; rfFingerprint and // rfDisplayName (args 8-9) stay undefined once a concrete sender id is resolved. + // arg 6 is the per-packet cached node reader (snapshot of deps.readNodes), not the raw ref. expect(recordSpy).toHaveBeenCalledWith( 99, 'meshcore', -60, 5, expect.anything(), - deps.readNodes, + expect.any(Function), 'meshcore-radio-rf', undefined, undefined, ); + const cachedReader = recordSpy.mock.calls[0][5] as () => Map; + expect(cachedReader()).toBeInstanceOf(Map); + // Stable per-packet snapshot: repeated reads return the same Map, not a fresh materialization. + expect(cachedReader()).toBe(cachedReader()); }); it('publishes an MQTT packet log when MQTT is connected and the throttle allows it', () => { diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts index 1e1cdd9a0..9f7b5533a 100644 --- a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts @@ -598,7 +598,19 @@ export function handleMeshcoreRfRx(payload: MeshcoreRfRxPayload, deps: MeshcoreR const now = Date.now(); const loraPacketClass = rawU8 ? classifyPayload(rawU8) : null; - const senderInfo = resolveMeshcoreRfSenderInfo(rawU8, loraPacketClass, now, snr, rssi, deps); + // `readNodes()` materializes a fresh Map from identity records; snapshot once per RF packet + // so the several has/get lookups (and the recordForeignLora callback) share one O(n) rebuild. + const nodesSnapshot = deps.readNodes(); + const cachedDeps: MeshcoreRfRxDeps = { ...deps, readNodes: () => nodesSnapshot }; + + const senderInfo = resolveMeshcoreRfSenderInfo( + rawU8, + loraPacketClass, + now, + snr, + rssi, + cachedDeps, + ); appendMeshcoreRfDeviceLog(deps, now, senderInfo, snr, rssi); appendMeshcoreRfSignalTelemetry(deps, now, snr, rssi); @@ -606,7 +618,7 @@ export function handleMeshcoreRfRx(payload: MeshcoreRfRxPayload, deps: MeshcoreR if (rawU8) { const ctx = buildMeshcoreRfParseContext(rawU8, deps.pubKeyPrefixMapRef.current); - applyMeshcoreRfHopsAwayUpdate(ctx.fromNodeId, ctx.hopCount, now, snr, rssi, deps); + applyMeshcoreRfHopsAwayUpdate(ctx.fromNodeId, ctx.hopCount, now, snr, rssi, cachedDeps); const effectiveFromNodeId = ctx.fromNodeId ?? meshtasticSenderIdForRawLogFallback(ctx.parseOk, rawU8); @@ -623,11 +635,11 @@ export function handleMeshcoreRfRx(payload: MeshcoreRfRxPayload, deps: MeshcoreR rawU8, snr, rssi, - deps, + cachedDeps, ); if (bridgeResult.skip) return; } - recordMeshcoreForeignLoraFingerprint(rawU8, loraPacketClass, snr, rssi, deps); + recordMeshcoreForeignLoraFingerprint(rawU8, loraPacketClass, snr, rssi, cachedDeps); publishMeshcoreRfMqttPacketLog(mqttFields, snr, rssi, deps); } diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts index 9b70344cf..91b03fad4 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts @@ -581,6 +581,9 @@ export function attachMeshtasticRuntimeWireEffects( heard_via_mqtt_only: false, }; updated.set(info.myNodeNum, selfNode); + persistDbWrite('meshtastic runtime self node', () => + window.electronAPI.db.saveNode(selfNode), + ); } else { const selfNode: MeshNode = { ...existing, diff --git a/src/renderer/lib/protocols/MeshCoreProtocol.ts b/src/renderer/lib/protocols/MeshCoreProtocol.ts index 828317f39..4bb9ac989 100644 --- a/src/renderer/lib/protocols/MeshCoreProtocol.ts +++ b/src/renderer/lib/protocols/MeshCoreProtocol.ts @@ -574,12 +574,14 @@ export class MeshCoreProtocol implements Protocol { private decodeRfRx(raw: unknown): DomainEvent[] { const d = raw as { lastSnr?: number; lastRssi?: number; raw?: unknown }; + const finiteOrZero = (v: unknown): number => + typeof v === 'number' && Number.isFinite(v) ? v : 0; return [ { type: 'meshcore_rf_rx', payload: { - lastSnr: d.lastSnr ?? 0, - lastRssi: d.lastRssi ?? 0, + lastSnr: finiteOrZero(d.lastSnr), + lastRssi: finiteOrZero(d.lastRssi), raw: d.raw instanceof Uint8Array && d.raw.length > 0 ? d.raw : null, }, }, diff --git a/src/renderer/lib/protocols/MeshtasticProtocol.ts b/src/renderer/lib/protocols/MeshtasticProtocol.ts index dfb5b1688..3d0c805bc 100644 --- a/src/renderer/lib/protocols/MeshtasticProtocol.ts +++ b/src/renderer/lib/protocols/MeshtasticProtocol.ts @@ -113,6 +113,22 @@ function finiteOptionalNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } +/** Scaled 1e-7 lat/lon: finite + range-checked, else undefined (mirrors decodePosition). */ +function boundedLatLonFromScaledI( + latitudeI: number | undefined, + longitudeI: number | undefined, +): { latitude: number | undefined; longitude: number | undefined } { + if (!isFiniteNumber(latitudeI) || !isFiniteNumber(longitudeI)) { + return { latitude: undefined, longitude: undefined }; + } + const latitude = latitudeI / 1e7; + const longitude = longitudeI / 1e7; + if (Math.abs(latitude) > MAX_LATITUDE || Math.abs(longitude) > MAX_LONGITUDE) { + return { latitude: undefined, longitude: undefined }; + } + return { latitude, longitude }; +} + interface TraceRouteMeshPacket { from: number; to?: number; @@ -822,6 +838,10 @@ export class MeshtasticProtocol implements Protocol { }; const nodeId = normalizedNodeNum(p.num); if (!nodeId) return []; + const { latitude, longitude } = boundedLatLonFromScaledI( + p.position?.latitudeI, + p.position?.longitudeI, + ); return [ { type: 'node_info', @@ -836,8 +856,8 @@ export class MeshtasticProtocol implements Protocol { snr: p.snr, hopsAway: p.hopsAway, viaMqtt: p.viaMqtt, - latitude: p.position?.latitudeI != null ? p.position.latitudeI / 1e7 : undefined, - longitude: p.position?.longitudeI != null ? p.position.longitudeI / 1e7 : undefined, + latitude, + longitude, altitude: p.position?.altitude, batteryLevel: p.deviceMetrics?.batteryLevel, voltage: p.deviceMetrics?.voltage, @@ -1115,7 +1135,7 @@ export class MeshtasticProtocol implements Protocol { payloadVariant?: { case?: string; value?: { portnum?: number } }; }; const from = normalizedNodeNum(mp.from); - if (!from) return []; + if (from === undefined) return []; try { const serialized = toBinary(Mesh.MeshPacketSchema, raw as never); if (serialized.byteLength > MAX_RAW_PACKET_BYTES) return []; diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index eaf006318..0d3521838 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -548,7 +548,7 @@ "pkiFailed": "PKI-Verschlüsselung für diese Nachricht fehlgeschlagen.", "noChannel": "Kein Kanal verfügbar, um diese Nachricht weiterzuleiten.", "timeout": "Zeitüberschreitung der Nachricht im Funkgerät.", - "rateLimited": "Das Radio ist geschwindigkeitsbegrenzende Nachrichten. Versuchen Sie es in Kürze erneut.", + "rateLimited": "Das Funkgerät begrenzt die Nachrichtenrate. Bitte versuche es in Kürze erneut.", "noInterface": "Es ist keine Funkschnittstelle zum Senden dieser Nachricht verfügbar." }, "waitingMessagesQueued": "{{count}} in der Warteschlange stehende Nachricht(en) im Funk", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index a708187b8..f84a08826 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -2795,7 +2795,7 @@ "hybridMqttPathAria": "Ruta RF y MQTT", "connectedViaRfAndMqttTooltip": "Conectado a través de RF y MQTT", "connectedViaRfAndMqttAria": "Conectado a través de RF y MQTT", - "echoesConnectionHealthTooltip": "{{score}}% estado DE conexión" + "echoesConnectionHealthTooltip": "{{score}}% estado de la conexión" }, "nomadNetwork": { "title": "Nomad Network", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 2f077461c..591f39825 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -549,7 +549,7 @@ "pkiFailed": "Enkripsi PKI gagal untuk pesan ini.", "noChannel": "Tidak ada saluran yang tersedia untuk merutekan pesan ini.", "timeout": "Waktu pesan habis di radio.", - "rateLimited": "Radio adalah pesan yang membatasi tingkat. Coba lagi sebentar lagi.", + "rateLimited": "Radio sedang membatasi laju pengiriman pesan. Coba lagi sebentar lagi.", "noInterface": "Tidak ada antarmuka radio yang tersedia untuk mengirim pesan ini." }, "waitingMessagesQueued": "{{count}} antrean pesan di radio", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 34f010355..1880cf97e 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -548,7 +548,7 @@ "pkiFailed": "Crittografia PKI non riuscita per questo messaggio.", "noChannel": "Nessun canale disponibile per instradare questo messaggio.", "timeout": "Messaggio scaduto alla radio.", - "rateLimited": "La radio è messaggi limitanti la velocità. Riprova a breve.", + "rateLimited": "La radio sta limitando la frequenza dei messaggi. Riprova a breve.", "noInterface": "Nessuna interfaccia radio è disponibile per inviare questo messaggio." }, "waitingMessagesQueued": "{{count}} messaggio/i in coda alla radio", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index de85d3f64..3223f36df 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -591,7 +591,7 @@ "newOfferToast": "{{peer}} wil je een bestand sturen: {{file}}", "requestEnable": "Aanvraag inschakelen", "requestEnableAria": "Vraag deze collega om bestandsontvangst in te schakelen", - "destinationLabel": "rncp destination hash" + "destinationLabel": "RNCP-bestemmingshash" }, "shareLocation": "Deel locatie", "shareLocationLabel": "📍 Gedeelde locatie", @@ -4675,7 +4675,7 @@ "modeFetchAria": "Overschakelen naar ophaalmodus", "modeFetch": "Ophalen", "destinationPlaceholder": "Bestemmingshash (32 hex-tekens)", - "destinationAria": "rncp destination hash", + "destinationAria": "RNCP-bestemmingshash", "notAllowedHint": "Overdrachten zijn niet beschikbaar op dit pad: {{reason}}", "chooseFileAria": "Kies een lokaal bestand om te verzenden", "chooseFile": "Kies bestand…", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index e23c75c24..f8d458550 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -610,7 +610,7 @@ "dmNodeHops_few": "{{count}} przeskoki", "dmNodeHops_many": "{{count}} przeskoków", "dmNodeHops_other": "{{count}} przeskoku", - "notificationDmTitle": "DM from {{sender}}", + "notificationDmTitle": "Wiadomość prywatna od {{sender}}", "notificationMessageTitle": "Wiadomość od {{sender}}", "emojiButton": "Emoji" }, diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 94b04c0e6..a23e300bd 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -1631,6 +1631,9 @@ export function useMeshcoreRuntime() { onNodes: (newNodes) => { setNodes(meshcorePathUpdatedNodesMergeUpdater(newNodes)); }, + onPendingRetained: (retainedIds) => { + for (const id of retainedIds) meshcorePathUpdatePendingRef.current.add(id); + }, }); }, MESHCORE_PATH_UPDATED_CONTACTS_REBUILD_DEBOUNCE_MS); requestChatOutboxDrain('meshcore'); diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 9fadc30bc..dccc66d8d 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -606,7 +606,10 @@ export function useMeshtasticRuntime() { ); const ensureNodeExists = useCallback((nodeNum: number, source: 'rf' | 'mqtt') => { - const identityId = meshtasticIdentityIdRef.current; + // Mirror updateNodes' identity fallback so inbound senders still get a stub during the + // attach window (pending driver identity set, bound identity not yet assigned). + const identityId = + meshtasticIdentityIdRef.current ?? meshtasticPendingDriverIdentityRef.current; if (!identityId || nodeNum === 0 || getIdentityNode(identityId, nodeNum) != null) { return; } From aa4fd3c11c19263fa05527e7b3cdc08285a61a3e Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 12:08:00 -0600 Subject: [PATCH 09/13] fix: address second-pass CodeRabbit findings on PacketRouter ingress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flush waiting-drain node dirties as last_heard/name patches against the live store row so concurrent RF SNR/RSSI aren't overwritten by the drain snapshot. - Retain dirty node ids when identity isn't bound yet; annotate silent-drain syncNextMessage timeout with catch-no-log-ok. - Cache last-written MeshCore RF transport codes to skip redundant per-packet DB IPC; include MQTT→RF source flags in the hops-away unchanged guard; skip no-op Meshtastic-sender store writes. --- .../meshcore/meshcoreConnSideEffects.test.ts | 51 ++++++++++++++++ .../hooks/meshcore/meshcoreConnSideEffects.ts | 61 +++++++++++++++---- .../lib/meshcore/meshcoreRfRxRuntime.test.ts | 57 ++++++++++++++++- .../lib/meshcore/meshcoreRfRxRuntime.ts | 23 ++++++- 4 files changed, 176 insertions(+), 16 deletions(-) diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts index bd16fcba2..4a8ee24fb 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts @@ -372,6 +372,57 @@ describe('attachMeshcoreConnSideEffects', () => { expect(h.ctx.addMessagesBatch).toHaveBeenCalledTimes(1); }); + it('preserves concurrent RF snr/rssi when flushing waiting-drain last_heard', async () => { + const prefix = new Uint8Array([0xaa, 0xbb]); + useNodeStore.setState({ + nodes: { + [ID]: { + 42: { + nodeId: 42, + longName: 'Peer', + shortName: 'P', + snr: 5, + rssi: -80, + lastHeardAt: 100, + source: 'rf', + }, + }, + }, + }); + const h = makeHarness(); + h.ctx.pubKeyPrefixMapRef.current.set('aabb', 42); + vi.mocked(h.conn.getWaitingMessages).mockImplementation(() => { + // Concurrent RF RX while the drain still holds the start-of-drain workingNodes snapshot. + useNodeStore.setState((s) => ({ + nodes: { + ...s.nodes, + [ID]: { + ...s.nodes[ID], + 42: { ...s.nodes[ID]?.[42], nodeId: 42, snr: 9, rssi: -40 }, + }, + }, + })); + return Promise.resolve([ + { + contactMessage: { + pubKeyPrefix: prefix, + text: 'hello from queue', + senderTimestamp: 1_700_000_100, + }, + }, + ]); + }); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + await h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: true }); + + expect(useNodeStore.getState().nodes[ID]?.[42]).toMatchObject({ + snr: 9, + rssi: -40, + lastHeardAt: 1_700_000_100, + }); + }); + it('tears down the session and requests reconnect on disconnect', async () => { const h = makeHarness(); detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index 4c3ca4a76..e06a3f117 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -46,7 +46,7 @@ import { } from '../../lib/timeConstants'; import type { ChatMessage, MeshNode } from '../../lib/types'; import type { NodeRecord } from '../../stores/nodeStore'; -import { upsertNodeRecordsForIdentity } from '../../stores/nodeStore'; +import { upsertNodeRecordsForIdentity, useNodeStore } from '../../stores/nodeStore'; import { usePathHistoryStore } from '../../stores/pathHistoryStore'; import type { MeshcoreConnSideEffectsCtx, @@ -100,14 +100,46 @@ interface MeshcoreWaitingMessagesDrainState { workingNodes: Map; } +/** + * Waiting-drain mutations are last_heard (+ optional channel display-name). Rebuild patches + * against the live store row so concurrent RF SNR/RSSI writes aren't overwritten by the + * start-of-drain `workingNodes` snapshot. + */ function collectDirtyWaitingNodeRecords( + identityId: string, dirtyNodeIds: Set, workingNodes: Map, ): NodeRecord[] { - return Array.from(dirtyNodeIds) - .map((nodeId) => workingNodes.get(nodeId)) - .filter((node): node is MeshNode => node != null) - .map((node) => meshNodeToNodeRecord(node)); + const liveById = useNodeStore.getState().nodes[identityId] ?? {}; + const out: NodeRecord[] = []; + for (const nodeId of dirtyNodeIds) { + const working = workingNodes.get(nodeId); + if (!working) continue; + const live = liveById[nodeId]; + if (!live) { + out.push(meshNodeToNodeRecord(working)); + continue; + } + const nextLastHeard = Math.max(live.lastHeardAt ?? 0, working.last_heard ?? 0); + const patch: NodeRecord = { nodeId, lastHeardAt: nextLastHeard }; + const workingLong = working.long_name?.trim(); + const workingShort = working.short_name?.trim(); + if (workingLong && workingLong !== (live.longName ?? '').trim()) { + patch.longName = workingLong; + } + if (workingShort && workingShort !== (live.shortName ?? '').trim()) { + patch.shortName = workingShort; + } + if ( + nextLastHeard === (live.lastHeardAt ?? 0) && + patch.longName === undefined && + patch.shortName === undefined + ) { + continue; + } + out.push(patch); + } + return out; } interface FlushMeshcoreWaitingBatchOptions { @@ -124,11 +156,15 @@ function flushMeshcoreWaitingBatch(opts: FlushMeshcoreWaitingBatchOptions): void opts.pendingMessages.length = 0; } if (opts.dirtyNodeIds.size === 0) return; - if (opts.identityId) { - const dirtyRecords = collectDirtyWaitingNodeRecords(opts.dirtyNodeIds, opts.workingNodes); - if (dirtyRecords.length > 0) { - upsertNodeRecordsForIdentity(opts.identityId, dirtyRecords); - } + // Retain dirty ids when identity isn't bound yet so the next flush can retry. + if (!opts.identityId) return; + const dirtyRecords = collectDirtyWaitingNodeRecords( + opts.identityId, + opts.dirtyNodeIds, + opts.workingNodes, + ); + if (dirtyRecords.length > 0) { + upsertNodeRecordsForIdentity(opts.identityId, dirtyRecords); } opts.dirtyNodeIds.clear(); } @@ -234,7 +270,10 @@ async function drainWaitingMessagesSilent( 'MeshCore syncNextMessage', ); } catch (e: unknown) { - if (isMeshcoreSyncNextMessageTimeoutError(e)) break; + if (isMeshcoreSyncNextMessageTimeoutError(e)) { + // catch-no-log-ok syncNextMessage timeout means empty queue / end of silent drain + break; + } throw e; } const item = normalizeMeshcoreWaitingMessageItem(raw); diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts index 97b780c9a..5e416fc82 100644 --- a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts @@ -1,12 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; -import { useNodeStore } from '../../stores/nodeStore'; +import { upsertNodeRecord, useNodeStore } from '../../stores/nodeStore'; import { setMeshtasticConnectedMyNodeNum } from '../meshtasticConnectedNodeRef'; +import { meshNodeToNodeRecord } from '../storeRecordAdapters'; import type { MeshNode, TelemetryPoint } from '../types'; import type { DeviceLogEntry, MeshCoreSelfInfo, RxPacketEntry } from './meshcoreHookTypes'; import { createMeshcoreMqttPacketLogBucket } from './meshcoreMqttPacketLogThrottle'; -import { handleMeshcoreRfRx, type MeshcoreRfRxDeps } from './meshcoreRfRxRuntime'; +import { + applyMeshcoreRfHopsAwayUpdate, + handleMeshcoreRfRx, + type MeshcoreRfRxDeps, +} from './meshcoreRfRxRuntime'; const ID = 'meshcore-rf-rx-runtime-test'; @@ -174,4 +179,52 @@ describe('handleMeshcoreRfRx', () => { expect(publish).not.toHaveBeenCalled(); }); + + it('clears MQTT-only flags on RF hear even when hops/snr/rssi/last_heard are unchanged', () => { + const nowSec = Math.floor(Date.now() / 1000); + const node = makeNode(7, { + last_heard: nowSec, + snr: 4, + rssi: -70, + hops_away: 1, + source: 'mqtt', + heard_via_mqtt_only: true, + via_mqtt: true, + }); + upsertNodeRecord(ID, meshNodeToNodeRecord(node)); + const nodes = new Map([[7, node]]); + const { deps } = makeDeps({ + myNodeNumRef: ref(1), + readNodes: () => nodes, + }); + + applyMeshcoreRfHopsAwayUpdate(7, 1, Date.now(), 4, -70, deps); + + expect(useNodeStore.getState().nodes[ID]?.[7]).toMatchObject({ + source: 'rf', + heardViaMqttOnly: false, + viaMqtt: false, + hopsAway: 1, + snr: 4, + rssi: -70, + }); + }); + + it('skips Meshtastic-sender store writes when last_heard/snr/rssi are unchanged', () => { + const nowSec = Math.floor(Date.now() / 1000); + const nodes = new Map([ + [2, makeNode(2, { last_heard: nowSec, snr: 5.5, rssi: -55 })], + ]); + upsertNodeRecord(ID, meshNodeToNodeRecord(nodes.get(2)!)); + const { deps } = makeDeps({ + myNodeNumRef: ref(1), + readNodes: () => nodes, + }); + const setStateSpy = vi.spyOn(useNodeStore, 'setState'); + const raw = Uint8Array.from([1, 0x3f, 0, 0, 2, 0, 0, 0]); + + handleMeshcoreRfRx({ lastSnr: 5.5, lastRssi: -55, raw }, deps); + + expect(setStateSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts index 9f7b5533a..83c297763 100644 --- a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts @@ -91,13 +91,23 @@ interface MeshcoreRfMqttPacketLogFields { hash?: string; } +/** Last successfully queued RF transport codes per node — skip redundant IPC on stable codes. */ +const lastPersistedRfTransportByNodeId = new Map(); + function persistMeshcoreContactRfTransport( nodeId: number, transportCodes: readonly [number, number], ): void { + const key = `${transportCodes[0]}:${transportCodes[1]}`; + if (lastPersistedRfTransportByNodeId.get(nodeId) === key) return; + lastPersistedRfTransportByNodeId.set(nodeId, key); void window.electronAPI.db .updateMeshcoreContactRfTransport(nodeId, transportCodes[0], transportCodes[1]) .catch((e: unknown) => { + // Allow a later packet to retry after a failed write. + if (lastPersistedRfTransportByNodeId.get(nodeId) === key) { + lastPersistedRfTransportByNodeId.delete(nodeId); + } console.warn( '[meshcoreRfRxRuntime] updateMeshcoreContactRfTransport error ' + errLikeToLogString(e), ); @@ -147,11 +157,15 @@ function updateKnownMeshtasticSenderNode( const storeId = deps.meshcoreIdentityIdRef.current; if (!existing || !storeId) return; const nowSec = Math.floor(now / 1000); + const nextLastHeard = Math.max(existing.last_heard ?? 0, nowSec); + if (existing.last_heard === nextLastHeard && existing.snr === snr && existing.rssi === rssi) { + return; + } upsertNodeRecord( storeId, meshNodeToNodeRecord({ ...existing, - last_heard: Math.max(existing.last_heard ?? 0, nowSec), + last_heard: nextLastHeard, snr, rssi, }), @@ -260,7 +274,7 @@ function buildMeshcoreRfParseContext( } /** Update hops_away/SNR/RSSI on a known MeshCore node from the RF packet's own hop count. */ -function applyMeshcoreRfHopsAwayUpdate( +export function applyMeshcoreRfHopsAwayUpdate( fromNodeId: number | null, hopCount: number, now: number, @@ -290,7 +304,10 @@ function applyMeshcoreRfHopsAwayUpdate( existing.hops_away === updated.hops_away && existing.snr === snr && existing.rssi === rssi && - existing.last_heard === updated.last_heard; + existing.last_heard === updated.last_heard && + existing.source === updated.source && + existing.heard_via_mqtt_only === updated.heard_via_mqtt_only && + existing.via_mqtt === updated.via_mqtt; if (unchanged) return; upsertNodeRecord(storeId, meshNodeToNodeRecord(updated)); From 05d8864eadb98052c083a19dd1017064f5a0ade6 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 12:10:26 -0600 Subject: [PATCH 10/13] fix: clear remaining SonarCloud findings in meshcoreRfRxRuntime Alias the repeated MeshcoreRfRxDeps node-lookup Pick union and prefer Array.prototype.at(-1) for the raw-packet coalesce peek. --- src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts index 83c297763..5206ee2ce 100644 --- a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts @@ -64,6 +64,12 @@ export interface MeshcoreRfRxDeps { setRawPackets: Dispatch>; } +/** Node lookup + identity refs shared by Meshtastic-sender and hops-away RF updates. */ +type MeshcoreRfRxNodeLookupDeps = Pick< + MeshcoreRfRxDeps, + 'myNodeNumRef' | 'meshcoreIdentityIdRef' | 'readNodes' +>; + interface MeshcoreRfParseContext { parsed: ReturnType; routeTypeString: string | null; @@ -150,7 +156,7 @@ function updateKnownMeshtasticSenderNode( now: number, snr: number, rssi: number, - deps: Pick, + deps: MeshcoreRfRxNodeLookupDeps, ): void { if (senderId === deps.myNodeNumRef.current) return; const existing = deps.readNodes().get(senderId); @@ -179,7 +185,7 @@ export function resolveMeshcoreRfSenderInfo( now: number, snr: number, rssi: number, - deps: Pick, + deps: MeshcoreRfRxNodeLookupDeps, ): string { if (!rawU8 || rawU8.length < 8 || loraPacketClass == null) return ''; if (loraPacketClass === 'meshcore') return ' [meshcore]'; @@ -280,7 +286,7 @@ export function applyMeshcoreRfHopsAwayUpdate( now: number, snr: number, rssi: number, - deps: Pick, + deps: MeshcoreRfRxNodeLookupDeps, ): void { if (fromNodeId === null || fromNodeId === deps.myNodeNumRef.current) return; const existing = deps.readNodes().get(fromNodeId); @@ -350,7 +356,7 @@ function computeNextRawPacketLog( rxEntry: RxPacketEntry, myId: number, ): RxPacketEntry[] { - const last = prev[prev.length - 1]; + const last = prev.at(-1); const shouldCoalesce = myId !== 0 && shouldCoalesceSelfFloodAdvert(last, rxEntry, myId, MESHCORE_RAW_SELF_FLOOD_ADVERT_COALESCE_MS); From 36629ab8cfebc42fe669ed28050b9b726807d015 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 13:16:17 -0600 Subject: [PATCH 11/13] fix: address remaining clear-win CodeRabbit findings on PR #714 Skip PacketRouter listeners when local config writes are suppressed, track the getMetadata retry timer across teardown, and harden ACK/favoriting/ dedup/persist paths. Bundle packetPayload cycle guards, dbPersistRetry catch-no-log-ok, dead lastPacketLogAtRef removal, and the small test/doc nits from the triage pass. --- .../meshcore/meshcoreConnSideEffects.test.ts | 1 - .../hooks/meshcore/meshcoreConnSideEffects.ts | 2 - .../meshcore/meshcoreConnSideEffectsCtx.ts | 1 - src/renderer/lib/dbPersistRetry.ts | 2 + src/renderer/lib/drivers/ConnectionDriver.ts | 4 +- src/renderer/lib/drivers/PacketRouter.test.ts | 18 ++++ src/renderer/lib/drivers/PacketRouter.ts | 36 ++++++-- .../lib/meshcore/meshcoreDmAckRuntime.test.ts | 14 +-- .../meshcore/meshcoreMqttPacketLogThrottle.ts | 5 -- .../lib/meshcore/meshcoreRfRxRuntime.test.ts | 1 - .../lib/meshcore/meshcoreRfRxRuntime.ts | 8 +- .../meshcoreProcessWaitingMessageItem.test.ts | 2 +- .../lib/meshcoreProcessWaitingMessageItem.ts | 7 +- ...sticRuntimeWireEffects.post-reboot.test.ts | 4 - ...timeWireEffects.telemetry-nodeinfo.test.ts | 12 ++- .../meshtasticRuntimeWireEffects.ts | 28 ++++-- .../meshtasticSdkRoutingErrorLog.test.ts | 1 - .../meshtasticSdkRoutingErrorLog.ts | 9 +- src/renderer/lib/meshtasticSourceIcons.tsx | 2 +- src/renderer/lib/packetPayload.test.ts | 8 ++ src/renderer/lib/packetPayload.ts | 10 ++- .../lib/protocols/MeshtasticProtocol.test.ts | 2 +- .../lib/protocols/MeshtasticProtocol.ts | 4 +- src/renderer/lib/timeConstants.ts | 9 ++ src/renderer/runtime/useMeshcoreRuntime.ts | 17 ++-- src/renderer/runtime/useMeshtasticRuntime.ts | 86 +++++++++++-------- src/renderer/stores/nodeStore.test.ts | 11 +++ 27 files changed, 205 insertions(+), 99 deletions(-) diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts index 4a8ee24fb..88a3e3bd1 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts @@ -69,7 +69,6 @@ function makeHarness(overrides?: { handleResponseResult?: boolean }): Harness { meshcoreIdentityIdRef: ref(ID), meshcoreDriverConnectedRef: ref(true), connRef: ref(conn), - lastPacketLogAtRef: ref(0), lastPacketLogPublishFailureLogAtRef: ref(0), meshcoreContactsRefreshTimerRef: ref | null>(null), meshcoreHookMountedRef: ref(true), diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index e06a3f117..782356e78 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -345,7 +345,6 @@ export function attachMeshcoreConnSideEffects( meshcoreIdentityIdRef, meshcoreDriverConnectedRef, connRef, - lastPacketLogAtRef, lastPacketLogPublishFailureLogAtRef, meshcoreContactsRefreshTimerRef, meshcoreHookMountedRef, @@ -632,7 +631,6 @@ export function attachMeshcoreConnSideEffects( selfInfoRef, rawPacketsRef, mqttStatusRef, - lastPacketLogAtRef, lastPacketLogPublishFailureLogAtRef, mqttPacketLogBucket, setDeviceLogs, diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts index 8f726168b..258a4c194 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts @@ -32,7 +32,6 @@ export interface MeshcoreConnSideEffectsCtx { meshcoreIdentityIdRef: RefObject; meshcoreDriverConnectedRef: RefObject; connRef: RefObject; - lastPacketLogAtRef: RefObject; lastPacketLogPublishFailureLogAtRef: RefObject; meshcoreContactsRefreshTimerRef: RefObject | null>; meshcoreHookMountedRef: RefObject; diff --git a/src/renderer/lib/dbPersistRetry.ts b/src/renderer/lib/dbPersistRetry.ts index b1cc8c5c3..f872ac50d 100644 --- a/src/renderer/lib/dbPersistRetry.ts +++ b/src/renderer/lib/dbPersistRetry.ts @@ -39,6 +39,7 @@ async function flushRetries(): Promise { errLikeToLogString(error), ); } else { + // catch-no-log-ok intermediate failures are retried; only the final attempt warns pending.attempt++; pendingWrites.push(pending); } @@ -64,6 +65,7 @@ export function persistDbWrite(label: string, write: () => Promise): vo ); return; } + // catch-no-log-ok first failure is queued for bounded retry; final attempt warns in flush pendingWrites.push({ label, attempt: 0, write }); scheduleRetry(); }); diff --git a/src/renderer/lib/drivers/ConnectionDriver.ts b/src/renderer/lib/drivers/ConnectionDriver.ts index 5a5b27027..f5761758f 100644 --- a/src/renderer/lib/drivers/ConnectionDriver.ts +++ b/src/renderer/lib/drivers/ConnectionDriver.ts @@ -178,7 +178,7 @@ export class ConnectionDriver { info = await protocol.discoverSelf(handle); } catch (err) { await protocol.destroyDevice(handle).catch((e: unknown) => { - console.debug('[ConnectionDriver] destroy after discoverSelf failure ' + String(e)); + console.warn('[ConnectionDriver] destroy after discoverSelf failure ' + String(e)); }); if (createdProvisional) removeIdentityFromStore(identityId); throw err; @@ -258,7 +258,7 @@ export class ConnectionDriver { console.warn('[ConnectionDriver] teardown error ' + errLikeToLogString(e)); } await slot.protocol.destroyDevice(slot.handle).catch((e: unknown) => { - console.debug('[ConnectionDriver] destroy error ' + errLikeToLogString(e)); + console.warn('[ConnectionDriver] destroy error ' + errLikeToLogString(e)); }); this.slots.delete(slot.transportId); removeTransport(identityId, slot.transportId); diff --git a/src/renderer/lib/drivers/PacketRouter.test.ts b/src/renderer/lib/drivers/PacketRouter.test.ts index 74059e08f..87a05d22c 100644 --- a/src/renderer/lib/drivers/PacketRouter.test.ts +++ b/src/renderer/lib/drivers/PacketRouter.test.ts @@ -356,6 +356,24 @@ describe('PacketRouter', () => { expect(getDevice(ID).meshtasticConfigSlices.lora).toBeUndefined(); }); + it('skips listeners when a local config write is suppressed', () => { + const listener = vi.fn(); + const detach = attachTypedPacketListener(ID, 'module_config', listener); + setMeshtasticRemoteConfigTarget(ID, 0x1234); + + packetRouter.dispatch( + { + type: 'module_config', + payload: { configType: 'mqtt', value: { enabled: true } }, + }, + ID, + ); + + expect(listener).not.toHaveBeenCalled(); + expect(getDevice(ID).moduleConfigs.mqtt).toBeUndefined(); + detach(); + }); + it('dispatches general and typed listeners in registration order', () => { const calls: string[] = []; const detachGeneral = packetRouter.addListener(() => calls.push('general')); diff --git a/src/renderer/lib/drivers/PacketRouter.ts b/src/renderer/lib/drivers/PacketRouter.ts index 9cdad8229..cd006c77a 100644 --- a/src/renderer/lib/drivers/PacketRouter.ts +++ b/src/renderer/lib/drivers/PacketRouter.ts @@ -68,6 +68,9 @@ function upsertByIndex(arr: T[], item: T): T[] { * already updated for the event it is handling. * 2. Listeners then run in registration order. A throwing listener is logged * and skipped; later listeners and the store write still happen. + * 3. When a Meshtastic local-config write is suppressed (`shouldSuppressMeshtasticLocalConfigWrite`), + * both the store mutation and listener notification are skipped — listeners must not observe a + * "store first" update that never happened. * * Prefer `attachTypedPacketListener` over registering raw listeners so the * identity filter and payload narrowing stay in one place. @@ -179,6 +182,8 @@ class PacketRouter { /** Applies the store mutation for `event`, then notifies listeners in order. */ dispatch(event: DomainEvent, identityId: IdentityId): void { + // When a suppress branch skips the store write, skip listeners too (contract §3). + let skipListeners = false; switch (event.type) { case 'text_message': { const isMeshtastic = getIdentity(identityId)?.protocol.type === 'meshtastic'; @@ -286,7 +291,10 @@ class PacketRouter { upsertWaypoint(identityId, event.payload); break; case 'channel': { - if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) break; + if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) { + skipListeners = true; + break; + } const e = event.payload; const existing = getDevice(identityId); const name = e.name || (e.index === 0 ? 'Primary' : `Channel ${e.index}`); @@ -308,15 +316,24 @@ class PacketRouter { break; } case 'device_gps_state': - if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) break; + if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) { + skipListeners = true; + break; + } setDeviceGpsState(identityId, event.payload.gpsMode, event.payload.fixedPosition); break; case 'security_config': - if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) break; + if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) { + skipListeners = true; + break; + } setSecurityConfig(identityId, event.payload); break; case 'module_config': { - if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) break; + if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) { + skipListeners = true; + break; + } const current = getDevice(identityId).moduleConfigs; setModuleConfigs(identityId, { ...current, @@ -325,11 +342,17 @@ class PacketRouter { break; } case 'telemetry_interval': - if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) break; + if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) { + skipListeners = true; + break; + } setTelemetryDeviceUpdateInterval(identityId, event.payload.interval); break; case 'meshtastic_config_slice': - if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) break; + if (shouldSuppressMeshtasticLocalConfigWrite(identityId)) { + skipListeners = true; + break; + } setMeshtasticConfigSlice(identityId, event.payload.configCase, event.payload.value); break; case 'queue_status': { @@ -375,6 +398,7 @@ class PacketRouter { upsertMeshcoreChannel(identityId, event.payload); break; } + if (skipListeners) return; const listeners = [...this.listeners, ...(this.typedListeners.get(event.type) ?? [])].sort( (a, b) => a.id - b.id, ); diff --git a/src/renderer/lib/meshcore/meshcoreDmAckRuntime.test.ts b/src/renderer/lib/meshcore/meshcoreDmAckRuntime.test.ts index 8c9174c09..9c677786a 100644 --- a/src/renderer/lib/meshcore/meshcoreDmAckRuntime.test.ts +++ b/src/renderer/lib/meshcore/meshcoreDmAckRuntime.test.ts @@ -27,18 +27,22 @@ describe('meshcoreDmAckRuntime', () => { it('applyMeshcoreDmAckToPending clears matching pending entry', () => { const timeoutId = setTimeout(() => {}, 60_000); + // Signed CRC and its u32 form are distinct Map keys — exercises multi-alias cleanup. + const signedCrc = -42; + const u32Crc = signedCrc >>> 0; const entry: PendingDmAckEntry = { timeoutId, - mapKeys: [42, 42 >>> 0], - canonicalPacketIdU32: 42, + mapKeys: [signedCrc, u32Crc], + canonicalPacketIdU32: u32Crc, destNodeId: 7, pathHash: 'ab', }; const pending = new Map([ - [42, entry], - [42 >>> 0, entry], + [signedCrc, entry], + [u32Crc, entry], ]); - const resolution = applyMeshcoreDmAckToPending(42, pending); + expect(pending.size).toBe(2); + const resolution = applyMeshcoreDmAckToPending(signedCrc, pending); expect(resolution.pending).toBe(entry); expect(resolution.newStatus).toBe('acked'); expect(pending.size).toBe(0); diff --git a/src/renderer/lib/meshcore/meshcoreMqttPacketLogThrottle.ts b/src/renderer/lib/meshcore/meshcoreMqttPacketLogThrottle.ts index a053f0374..6e9b16f01 100644 --- a/src/renderer/lib/meshcore/meshcoreMqttPacketLogThrottle.ts +++ b/src/renderer/lib/meshcore/meshcoreMqttPacketLogThrottle.ts @@ -3,17 +3,12 @@ * Sustained RF floods must not enqueue unbounded MQTT IPC. */ -import { MS_PER_SECOND } from '../timeConstants'; - /** Burst capacity before MQTT packet-log publishes start dropping. */ export const MESHCORE_MQTT_PACKET_LOG_BUCKET_CAPACITY = 8; /** One token refilled every this many ms (~5 publishes/sec sustained). */ export const MESHCORE_MQTT_PACKET_LOG_REFILL_INTERVAL_MS = 200; -/** @deprecated Prefer the token bucket; kept for callers that still track last publish time. */ -export const MESHCORE_MQTT_PACKET_LOG_MIN_INTERVAL_MS = Math.floor(MS_PER_SECOND / 10); - export interface MeshcoreMqttPacketLogBucket { tokens: number; lastRefillMs: number; diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts index 5e416fc82..d87e3adff 100644 --- a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts @@ -53,7 +53,6 @@ function makeDeps(overrides?: Partial): { selfInfoRef: ref(null), rawPacketsRef: ref(rawPackets), mqttStatusRef: ref('disconnected' as const), - lastPacketLogAtRef: ref(0), lastPacketLogPublishFailureLogAtRef: ref(0), mqttPacketLogBucket: createMeshcoreMqttPacketLogBucket(), setDeviceLogs: (updater) => { diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts index 5206ee2ce..ace19895c 100644 --- a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts @@ -55,7 +55,6 @@ export interface MeshcoreRfRxDeps { selfInfoRef: RefObject; rawPacketsRef: RefObject; mqttStatusRef: RefObject; - lastPacketLogAtRef: RefObject; lastPacketLogPublishFailureLogAtRef: RefObject; /** Token bucket created once at attach time; shared across every RF RX in the session. */ mqttPacketLogBucket: MeshcoreMqttPacketLogBucket; @@ -576,17 +575,12 @@ function publishMeshcoreRfMqttPacketLog( rssi: number, deps: Pick< MeshcoreRfRxDeps, - | 'mqttStatusRef' - | 'lastPacketLogAtRef' - | 'lastPacketLogPublishFailureLogAtRef' - | 'selfInfoRef' - | 'mqttPacketLogBucket' + 'mqttStatusRef' | 'lastPacketLogPublishFailureLogAtRef' | 'selfInfoRef' | 'mqttPacketLogBucket' >, ): void { if (deps.mqttStatusRef.current !== 'connected') return; const nowMs = Date.now(); if (!tryTakeMeshcoreMqttPacketLogToken(deps.mqttPacketLogBucket, nowMs)) return; - deps.lastPacketLogAtRef.current = nowMs; void window.electronAPI.mqtt .publishMeshcorePacketLog({ origin: deps.selfInfoRef.current?.name ?? 'mesh-client', diff --git a/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts b/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts index 2edad7617..fb13a8469 100644 --- a/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts +++ b/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts @@ -448,7 +448,7 @@ describe('processMeshcoreWaitingMessageItem', () => { dispatchSpy.mockRestore(); }); - it('forwards pathLen into PacketRouter room dispatch as hopCount', () => { + it('maps pathLen 0xff to direct 0-hop room dispatch hopCount', () => { const dispatchSpy = vi.spyOn(packetRouter, 'dispatch').mockImplementation(() => {}); const { deps, roomPrefixBytes } = roomDeps(makePubKey(53)); const result = processMeshcoreWaitingMessageItem( diff --git a/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts b/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts index fcac090b8..aef41cd69 100644 --- a/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts +++ b/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts @@ -59,11 +59,10 @@ export function processMeshcoreWaitingMessageItem( } } else { const sender = deps.workingNodes.get(senderId); - const existing = deps.workingNodes.get(senderId); - if (existing) { + if (sender) { deps.workingNodes.set(senderId, { - ...existing, - last_heard: Math.max(existing.last_heard ?? 0, d.senderTimestamp), + ...sender, + last_heard: Math.max(sender.last_heard ?? 0, d.senderTimestamp), }); nodesDirty = true; updatedNodeIds.push(senderId); diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts index a58498da7..3cce9e7a2 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts @@ -63,7 +63,6 @@ function makeDeps() { meshtasticIngressDetachRef: { current: null }, mqttStatusRef: { current: 'disconnected' as const }, myNodeNumRef: { current: 0 }, - pendingTempIdRef: { current: undefined }, ackMeshPacketIdByTempIdRef: { current: new Map() }, pendingTracePacketIdToTargetRef: noopMapRef, pendingTraceRequestsRef: noopMapRef, @@ -105,13 +104,10 @@ function makeDeps() { setRemoteConfigSnapshot: noopSet, setRemoteAdminStatus: noopSet, setRemoteAdminError: noopSet, - setMessages: noopSet, setTelemetry: noopSet, setSignalTelemetry: noopSet, setEnvironmentTelemetry: noopSet, setDeviceOwner: noopSet, - setChannels: noopSet, - setChannelConfigs: noopSet, setDeviceGpsMode: noopSet, setDeviceFixedPosition: noopSet, setTelemetryDeviceUpdateInterval: noopSet, diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts index 9934ce0d3..d0edac463 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.telemetry-nodeinfo.test.ts @@ -1,6 +1,6 @@ import type { MeshDevice } from '@meshtastic/core'; import { Portnums } from '@meshtastic/protobufs'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useNodeStore } from '../../stores/nodeStore'; import { packetRouter } from '../drivers/PacketRouter'; @@ -156,12 +156,22 @@ function makeDeps() { } describe('meshtasticRuntimeWireEffects telemetry NodeInfo', () => { + let unsubscribes: (() => void)[] = []; + beforeEach(() => { useNodeStore.setState({ nodes: {} }); + unsubscribes = []; + }); + + afterEach(() => { + for (const unsub of unsubscribes.splice(0)) { + unsub(); + } }); it('creates stub and requests NodeInfo on first telemetry from unknown node', async () => { const { deps, ensureNodeExists } = makeDeps(); + unsubscribes = deps.unsubscribesRef.current; const sendPacket = vi.fn().mockResolvedValue(undefined); const noopSub = { subscribe: () => () => {} }; const device = { diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts index 91b03fad4..2fd196a31 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts @@ -25,6 +25,7 @@ import type { MeshtasticRemoteAdminClient } from '../meshtasticRemoteAdmin'; import type { MeshtasticRawPacketEntry } from '../rawPacketLogConstants'; import { getStoredMeshProtocol } from '../storedMeshProtocol'; import { + MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS, MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS, MESHTASTIC_LOCAL_LORA_CONFIG_DELAY_MS, } from '../timeConstants'; @@ -65,7 +66,12 @@ const REQUEST_NODEINFO_MIN_INTERVAL_MS = 120_000; const { DeviceStatusEnum } = Types; /** After configure, request NodeDB metadata once; retry once if the first call fails. */ -function requestMetadataAfterConfigure(device: MeshDevice, myNode: number, attempt: 1 | 2): void { +function requestMetadataAfterConfigure( + device: MeshDevice, + myNode: number, + attempt: 1 | 2, + retryTimerRef: { current: ReturnType | null }, +): void { void device.getMetadata(myNode).catch((e: unknown) => { console.debug( '[useMeshtasticRuntime] getMetadata after configure failed ' + @@ -73,8 +79,10 @@ function requestMetadataAfterConfigure(device: MeshDevice, myNode: number, attem (attempt === 2 ? ' (retry)' : ''), ); if (attempt === 1) { - setTimeout(() => { - requestMetadataAfterConfigure(device, myNode, 2); + if (retryTimerRef.current != null) clearTimeout(retryTimerRef.current); + retryTimerRef.current = setTimeout(() => { + retryTimerRef.current = null; + requestMetadataAfterConfigure(device, myNode, 2, retryTimerRef); }, MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS); } }); @@ -250,6 +258,16 @@ export function attachMeshtasticRuntimeWireEffects( opts: { driverIdentityId?: string } | undefined, deps: MeshtasticRuntimeWireEffectsDeps, ): void { + const metadataRetryTimerRef: { current: ReturnType | null } = { + current: null, + }; + deps.unsubscribesRef.current.push(() => { + if (metadataRetryTimerRef.current != null) { + clearTimeout(metadataRetryTimerRef.current); + metadataRetryTimerRef.current = null; + } + }); + const { channelConfigsRef, configureTargetNodeNumRef, @@ -430,7 +448,7 @@ export function attachMeshtasticRuntimeWireEffects( batteryCharging: undefined, }); clearConfigureTimeout(); - }, 30000); + }, MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS); } } @@ -454,7 +472,7 @@ export function attachMeshtasticRuntimeWireEffects( mqttClientProxyBridgeRef.current?.flushPendingToDevice(); const myNode = myNodeNumRef.current; if (myNode > 0) { - requestMetadataAfterConfigure(device, myNode, 1); + requestMetadataAfterConfigure(device, myNode, 1, metadataRetryTimerRef); } if (localLoraConfigTimerRef.current != null) { clearTimeout(localLoraConfigTimerRef.current); diff --git a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts index 11e6cdcbb..45e077998 100644 --- a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts @@ -48,7 +48,6 @@ interface SeedRow { function clearStoreMessages(): void { for (const key of Object.keys(storeMessages)) { - storeMessages[key] = undefined as unknown as Record; Reflect.deleteProperty(storeMessages, key); } } diff --git a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts index c94b900df..c916a2e99 100644 --- a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts +++ b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts @@ -136,10 +136,11 @@ function findFallbackSendingOutbound( return candidates.length === 1 ? candidates[0] : undefined; } -function storeMessagesAsChat(identityId: string): ChatMessage[] { - return messageRecordsToChatMessages( - Object.values(useMessageStore.getState().messages[identityId] ?? {}), +function storeOutboundMessagesAsChat(identityId: string, myNodeNum: number): ChatMessage[] { + const records = Object.values(useMessageStore.getState().messages[identityId] ?? {}).filter( + (r) => r.from === myNodeNum, ); + return messageRecordsToChatMessages(records); } function findOutboundTargetForWirePacketId( @@ -149,7 +150,7 @@ function findOutboundTargetForWirePacketId( const { myNodeNum, identityId, tempIdToWirePacketId } = ctx; if (!identityId) return undefined; - const messages = storeMessagesAsChat(identityId); + const messages = storeOutboundMessagesAsChat(identityId, myNodeNum); const matched = messages.find((m) => outboundMatchesWirePacketId(m, myNodeNum, wirePacketId, tempIdToWirePacketId), ); diff --git a/src/renderer/lib/meshtasticSourceIcons.tsx b/src/renderer/lib/meshtasticSourceIcons.tsx index 7e936750f..60083e1df 100644 --- a/src/renderer/lib/meshtasticSourceIcons.tsx +++ b/src/renderer/lib/meshtasticSourceIcons.tsx @@ -46,7 +46,7 @@ export function resolveMeshtasticPathBadge({ return 'none'; } -/** Shared hybrid-path tooltip / aria strings for list + detail (requires caller `t`). */ +/** Hybrid-path tooltip / aria strings for NodeListPanel (requires caller `t`). */ export function meshtasticHybridPathLabels( t: TFunction, isSelfHybrid: boolean, diff --git a/src/renderer/lib/packetPayload.test.ts b/src/renderer/lib/packetPayload.test.ts index 8c0f70272..5be80b05b 100644 --- a/src/renderer/lib/packetPayload.test.ts +++ b/src/renderer/lib/packetPayload.test.ts @@ -31,6 +31,14 @@ describe('toPacketPayloadBytes', () => { wrapper.data = wrapper; expect(toPacketPayloadBytes(wrapper)).toEqual(new Uint8Array()); }); + + it('does not recurse infinitely on a multi-level object cycle', () => { + const a: { payload?: unknown } = {}; + const b: { raw?: unknown } = {}; + a.payload = b; + b.raw = a; + expect(toPacketPayloadBytes(a)).toEqual(new Uint8Array()); + }); }); describe('truncatePacketText', () => { diff --git a/src/renderer/lib/packetPayload.ts b/src/renderer/lib/packetPayload.ts index f750b2421..155f00c1e 100644 --- a/src/renderer/lib/packetPayload.ts +++ b/src/renderer/lib/packetPayload.ts @@ -14,6 +14,10 @@ * payloads feed display-only panels. */ export function toPacketPayloadBytes(value: unknown): Uint8Array { + return toPacketPayloadBytesInner(value, new Set()); +} + +function toPacketPayloadBytesInner(value: unknown, visited: Set): Uint8Array { if (value instanceof Uint8Array) return value; if (value instanceof ArrayBuffer) return new Uint8Array(value); if (ArrayBuffer.isView(value)) { @@ -23,10 +27,12 @@ export function toPacketPayloadBytes(value: unknown): Uint8Array { return Uint8Array.from(value); } if (value && typeof value === 'object') { + if (visited.has(value)) return new Uint8Array(); + visited.add(value); const wrapper = value as { raw?: unknown; data?: unknown; payload?: unknown }; for (const nested of [wrapper.raw, wrapper.data, wrapper.payload]) { - if (nested === undefined || nested === value) continue; - const bytes = toPacketPayloadBytes(nested); + if (nested === undefined) continue; + const bytes = toPacketPayloadBytesInner(nested, visited); if (bytes.length > 0) return bytes; } } diff --git a/src/renderer/lib/protocols/MeshtasticProtocol.test.ts b/src/renderer/lib/protocols/MeshtasticProtocol.test.ts index 9ccfa5b2a..043e12a68 100644 --- a/src/renderer/lib/protocols/MeshtasticProtocol.test.ts +++ b/src/renderer/lib/protocols/MeshtasticProtocol.test.ts @@ -356,7 +356,7 @@ describe('MeshtasticProtocol outbound guards', () => { }); }); -describe('MeshtasticProtocol outbound guards', () => { +describe('MeshtasticProtocol position outbound guards', () => { function positionDevice() { const calls: unknown[] = []; return { diff --git a/src/renderer/lib/protocols/MeshtasticProtocol.ts b/src/renderer/lib/protocols/MeshtasticProtocol.ts index 3d0c805bc..88ddca88c 100644 --- a/src/renderer/lib/protocols/MeshtasticProtocol.ts +++ b/src/renderer/lib/protocols/MeshtasticProtocol.ts @@ -520,7 +520,7 @@ export class MeshtasticProtocol implements Protocol { } async sendWaypoint(handle: unknown, opts: SendWaypointOptions): Promise { - const device = handle as MeshDevice; + const device = requireHandle(handle, 'meshtastic sendWaypoint'); assertFiniteCoordinates('meshtastic sendWaypoint', opts.latitude, opts.longitude); const id = normalizedNodeNum(opts.id); if (id === undefined) { @@ -542,7 +542,7 @@ export class MeshtasticProtocol implements Protocol { } async deleteWaypoint(handle: unknown, id: number): Promise { - const device = handle as MeshDevice; + const device = requireHandle(handle, 'meshtastic deleteWaypoint'); const waypointId = normalizedNodeNum(id); if (waypointId === undefined) { throw new TypeError('meshtastic deleteWaypoint: id must be a valid unsigned integer'); diff --git a/src/renderer/lib/timeConstants.ts b/src/renderer/lib/timeConstants.ts index 753dadc32..9428240ea 100644 --- a/src/renderer/lib/timeConstants.ts +++ b/src/renderer/lib/timeConstants.ts @@ -139,6 +139,12 @@ export const MESHCORE_DM_RF_DEDUP_WINDOW_MS = 2 * MS_PER_MINUTE; * Wider than room post dedup (1 min) because client Date.now vs radio rxTime can skew several minutes. */ export const MESHTASTIC_TAPBACK_OPTIMISTIC_DEDUP_WINDOW_MS = 10 * MS_PER_MINUTE; +/** RF/MQTT packet-id dedup TTL (MQTT-only fallback map and ingest session). */ +export const MESHTASTIC_PACKET_DEDUP_TTL_MS = 10 * MS_PER_MINUTE; + +/** Hard cap for the MQTT-only packet dedup fallback map after TTL sweep. */ +export const MESHTASTIC_PACKET_DEDUP_FALLBACK_MAX_ENTRIES = 5_000; + /** Room post dedup window: optimistic client timestamp vs firmware echo / replay overlap. */ export const MESHCORE_ROOM_POST_DEDUP_WINDOW_MS = MS_PER_MINUTE; @@ -201,6 +207,9 @@ 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; + /** * Raw packet log: startup (and similar) can deliver two distinct LOG_RX frames for the same node's * FLOOD ADVERT within seconds; coalesce so the sniffer shows one row (newest wins). diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index a23e300bd..71f3ca1df 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -629,8 +629,6 @@ export function useMeshcoreRuntime() { ); /** Post-connect self telemetry (altitude); assigned to {@link requestTelemetry} below. */ const requestTelemetryMeshCoreRef = useRef<(nodeId: number) => Promise>(async () => {}); - /** Throttle LetsMesh packet-logger publishes (event 136 can be very frequent). */ - const lastPacketLogAtRef = useRef(0); /** Rate-limit debug logs when optional packet-logger IPC publish fails. */ const lastPacketLogPublishFailureLogAtRef = useRef(0); const meshcoreHookMountedRef = useRef(true); @@ -1758,7 +1756,6 @@ export function useMeshcoreRuntime() { meshcoreIdentityIdRef, meshcoreDriverConnectedRef, connRef, - lastPacketLogAtRef, lastPacketLogPublishFailureLogAtRef, meshcoreContactsRefreshTimerRef, meshcoreHookMountedRef, @@ -6018,20 +6015,22 @@ export function useMeshcoreRuntime() { getIdentityIdForProtocol('meshcore') ?? resolveMeshcoreStoreIdentityId(); const storeRecord = storeId ? useNodeStore.getState().nodes[storeId]?.[nodeId] : undefined; - if (!storeRecord) return; + // Local nodes map can lead the identity store briefly after commit; don't no-op favoriting. + const localNode = nodes.get(nodeId); + if (!storeRecord && !localNode) return; - const prevFav = storeRecord.favorited ?? false; + const prevFav = storeRecord?.favorited ?? localNode?.favorited ?? false; if (storeId) { patchNodeFavorited(storeId, nodeId, favorited); } setNodes((prev) => { - const n = prev.get(nodeId); + const n = prev.get(nodeId) ?? localNode; if (!n) return prev; const next = new Map(prev); next.set(nodeId, { ...n, favorited }); return next; }); - const pk = pubKeyMapRef.current.get(nodeId) ?? storeRecord.publicKey; + const pk = pubKeyMapRef.current.get(nodeId) ?? storeRecord?.publicKey; const hex = pk != null ? Array.from(pk) @@ -6048,7 +6047,7 @@ export function useMeshcoreRuntime() { patchNodeFavorited(storeId, nodeId, prevFav); } setNodes((prev) => { - const n = prev.get(nodeId); + const n = prev.get(nodeId) ?? localNode; if (!n) return prev; const next = new Map(prev); next.set(nodeId, { ...n, favorited: prevFav }); @@ -6056,7 +6055,7 @@ export function useMeshcoreRuntime() { }); } }, - [resolveMeshcoreStoreIdentityId], + [nodes, resolveMeshcoreStoreIdentityId], ); const sendReaction = useCallback( diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index dccc66d8d..6dbdbe4bc 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -202,6 +202,8 @@ import { } from '../lib/storeRecordAdapters'; import { delayUnlessSuspended } from '../lib/systemPowerState'; import { + MESHTASTIC_PACKET_DEDUP_FALLBACK_MAX_ENTRIES, + MESHTASTIC_PACKET_DEDUP_TTL_MS, MESHTASTIC_POST_REBOOT_RECONNECT_DELAY_MS, RF_SERIAL_OPEN_RETRY_DELAY_MS, } from '../lib/timeConstants'; @@ -671,33 +673,57 @@ export function useMeshtasticRuntime() { }, [moduleConfigs, state.status]); // ─── Packet dedup helper (shared by RF and MQTT handlers) ────── - const isDuplicate = useCallback((senderId: number, packetId: number): boolean => { - const session = meshtasticIngestSessionRef.current; - if (session) return session.isDuplicatePacket(senderId, packetId); - const now = Date.now(); - const key = meshtasticPacketDedupKey(senderId, packetId); - const expiry = mqttOnlySeenPacketIdsRef.current.get(key); - mqttOnlySeenPacketIdsRef.current.set(key, now + 10 * 60 * 1000); - if (mqttOnlySeenPacketIdsRef.current.size > 5_000) { - for (const [id, expiresAt] of mqttOnlySeenPacketIdsRef.current) { - if (expiresAt <= now) mqttOnlySeenPacketIdsRef.current.delete(id); - } + const pruneMqttOnlySeenPacketIds = useCallback((now: number): void => { + const map = mqttOnlySeenPacketIdsRef.current; + for (const [id, expiresAt] of map) { + if (expiresAt <= now) map.delete(id); } - return expiry !== undefined && expiry > now; - }, []); - - const markPacketSeen = useCallback((senderId: number, packetId: number): void => { - const session = meshtasticIngestSessionRef.current; - if (session) { - session.markPacketSeen(senderId, packetId); - return; + if (map.size <= MESHTASTIC_PACKET_DEDUP_FALLBACK_MAX_ENTRIES) return; + // Drop oldest expiries until under the hard cap (Map insertion order). + const overflow = map.size - MESHTASTIC_PACKET_DEDUP_FALLBACK_MAX_ENTRIES; + let removed = 0; + for (const id of map.keys()) { + map.delete(id); + removed += 1; + if (removed >= overflow) break; } - mqttOnlySeenPacketIdsRef.current.set( - meshtasticPacketDedupKey(senderId, packetId), - Date.now() + 10 * 60 * 1000, - ); }, []); + const isDuplicate = useCallback( + (senderId: number, packetId: number): boolean => { + const session = meshtasticIngestSessionRef.current; + if (session) return session.isDuplicatePacket(senderId, packetId); + const now = Date.now(); + const key = meshtasticPacketDedupKey(senderId, packetId); + const expiry = mqttOnlySeenPacketIdsRef.current.get(key); + mqttOnlySeenPacketIdsRef.current.set(key, now + MESHTASTIC_PACKET_DEDUP_TTL_MS); + if (mqttOnlySeenPacketIdsRef.current.size > MESHTASTIC_PACKET_DEDUP_FALLBACK_MAX_ENTRIES) { + pruneMqttOnlySeenPacketIds(now); + } + return expiry !== undefined && expiry > now; + }, + [pruneMqttOnlySeenPacketIds], + ); + + const markPacketSeen = useCallback( + (senderId: number, packetId: number): void => { + const session = meshtasticIngestSessionRef.current; + if (session) { + session.markPacketSeen(senderId, packetId); + return; + } + const now = Date.now(); + mqttOnlySeenPacketIdsRef.current.set( + meshtasticPacketDedupKey(senderId, packetId), + now + MESHTASTIC_PACKET_DEDUP_TTL_MS, + ); + if (mqttOnlySeenPacketIdsRef.current.size > MESHTASTIC_PACKET_DEDUP_FALLBACK_MAX_ENTRIES) { + pruneMqttOnlySeenPacketIds(now); + } + }, + [pruneMqttOnlySeenPacketIds], + ); + // Compact display name: short_name, truncated long_name, or hex ID const getNodeName = useCallback((nodeNum: number): string => { const node = getIdentityNode(meshtasticIdentityIdRef.current, nodeNum); @@ -2606,10 +2632,8 @@ export function useMeshtasticRuntime() { trackMeshtasticOutboundTempId(tempId, resolvedIdStr); updateMessageStatus(identityId, resolvedIdStr, 'acked'); } + const ackSenderId = outboundSendByTempIdRef.current.get(tempId)?.sender_id; outboundSendByTempIdRef.current.delete(tempId); - const ackSenderId = readIdentityMessages().find((m) => - meshtasticOutboundSendMatchesTempId(m, tempId), - )?.sender_id; void ( resolvedPid !== tempId ? window.electronAPI.db.updateMessagePacketId(tempId, resolvedPid, ackSenderId) @@ -2665,7 +2689,7 @@ export function useMeshtasticRuntime() { } } }, - [isDuplicate, meshtasticOutboundSendMatchesTempId, readIdentityMessages], + [isDuplicate, meshtasticOutboundSendMatchesTempId], ); // Keep the ref in sync so TransportManager always invokes the latest handler @@ -3899,13 +3923,7 @@ export function useMeshtasticRuntime() { replyTo: String(wireReplyId), }); trackMeshtasticOutboundTempId(reactionTempId, String(reactionTempId)); - void window.electronAPI.db - .saveMessage({ ...msg, packetId: reactionTempId }) - .catch((e: unknown) => { - console.debug( - '[useMeshtasticRuntime] sendReaction saveMessage failed ' + errLikeToLogString(e), - ); - }); + persistMeshtasticMessage({ ...msg, packetId: reactionTempId }); } else { // No identity bound yet — fall back to the hook-local message state. setMessages((prev) => { diff --git a/src/renderer/stores/nodeStore.test.ts b/src/renderer/stores/nodeStore.test.ts index d2db4cb8a..a4a6e629f 100644 --- a/src/renderer/stores/nodeStore.test.ts +++ b/src/renderer/stores/nodeStore.test.ts @@ -239,6 +239,17 @@ describe('nodeStore Meshtastic packet operations', () => { longitude: 2, timestamp: 1, }); + addTraceRoute(ID, { + from: NODE, + to: 43, + route: [7, 8], + timestamp: 1000, + }); + upsertNeighborInfo(ID, { + nodeId: NODE, + neighbors: [{ nodeId: 43, snr: 4, lastRxTime: 99 }], + timestamp: 1000, + }); clearNodeIdentity(ID); const state = useNodeStore.getState(); From 95316943726454c7fdb35da862b37fc7a8358cf8 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 13:33:02 -0600 Subject: [PATCH 12/13] test: make meshcoreRfRxRuntime timestamp assertions deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin Date.now for the MQTT→RF and Meshtastic-sender skip-unchanged cases so setup, handleMeshcoreRfRx, and lastHeardAt expectations share one clock. --- src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts index d87e3adff..90803004d 100644 --- a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.test.ts @@ -180,7 +180,9 @@ describe('handleMeshcoreRfRx', () => { }); it('clears MQTT-only flags on RF hear even when hops/snr/rssi/last_heard are unchanged', () => { - const nowSec = Math.floor(Date.now() / 1000); + const nowMs = 1_700_000_000_000; + const nowSec = Math.floor(nowMs / 1000); + vi.spyOn(Date, 'now').mockReturnValue(nowMs); const node = makeNode(7, { last_heard: nowSec, snr: 4, @@ -197,7 +199,7 @@ describe('handleMeshcoreRfRx', () => { readNodes: () => nodes, }); - applyMeshcoreRfHopsAwayUpdate(7, 1, Date.now(), 4, -70, deps); + applyMeshcoreRfHopsAwayUpdate(7, 1, nowMs, 4, -70, deps); expect(useNodeStore.getState().nodes[ID]?.[7]).toMatchObject({ source: 'rf', @@ -206,11 +208,14 @@ describe('handleMeshcoreRfRx', () => { hopsAway: 1, snr: 4, rssi: -70, + lastHeardAt: nowSec, }); }); it('skips Meshtastic-sender store writes when last_heard/snr/rssi are unchanged', () => { - const nowSec = Math.floor(Date.now() / 1000); + const nowMs = 1_700_000_000_000; + const nowSec = Math.floor(nowMs / 1000); + vi.spyOn(Date, 'now').mockReturnValue(nowMs); const nodes = new Map([ [2, makeNode(2, { last_heard: nowSec, snr: 5.5, rssi: -55 })], ]); From dd9a4aedec5b64f8c7b29e7a45c401a21ef47746 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 26 Jul 2026 13:48:17 -0600 Subject: [PATCH 13/13] fix: clear remaining CodeRabbit and Sonar findings on PR #714 Hoist Meshtastic node DB writes out of updateNodes updaters, round-trip gasResistance through MeshNode adapters, use ESM-safe test dirname, and trim packetPayload cognitive complexity under the Sonar threshold. --- .../meshtastic/meshtasticNodeSideEffects.ts | 1 + src/renderer/lib/packetPayload.ts | 29 ++++++++++++------- .../runtimeSideEffectAttach.contract.test.ts | 3 +- src/renderer/lib/storeRecordAdapters.test.ts | 3 ++ src/renderer/lib/storeRecordAdapters.ts | 2 ++ src/renderer/lib/types.ts | 1 + src/renderer/runtime/useMeshtasticRuntime.ts | 14 ++++++--- 7 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts index b58faee27..f83473710 100644 --- a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.ts @@ -394,6 +394,7 @@ function handleEnvironmentTelemetry( env_humidity: telemetry.relativeHumidity ?? existing.env_humidity, env_pressure: telemetry.barometricPressure ?? existing.env_pressure, env_iaq: telemetry.iaq ?? existing.env_iaq, + env_gas_resistance: telemetry.gasResistance ?? existing.env_gas_resistance, env_lux: telemetry.lux ?? existing.env_lux, env_wind_speed: telemetry.windSpeed ?? existing.env_wind_speed, env_wind_direction: telemetry.windDirection ?? existing.env_wind_direction, diff --git a/src/renderer/lib/packetPayload.ts b/src/renderer/lib/packetPayload.ts index 155f00c1e..cc0374ff6 100644 --- a/src/renderer/lib/packetPayload.ts +++ b/src/renderer/lib/packetPayload.ts @@ -7,6 +7,22 @@ * same `instanceof` ladder. */ +function isNumberByteArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((b) => typeof b === 'number'); +} + +function bytesFromObjectWrapper(value: object, visited: Set): Uint8Array | null { + if (visited.has(value)) return new Uint8Array(); + visited.add(value); + const wrapper = value as { raw?: unknown; data?: unknown; payload?: unknown }; + for (const nested of [wrapper.raw, wrapper.data, wrapper.payload]) { + if (nested === undefined) continue; + const bytes = toPacketPayloadBytesInner(nested, visited); + if (bytes.length > 0) return bytes; + } + return null; +} + /** * Coerce a decoded module payload into bytes. Accepts a `Uint8Array`, a wrapper * carrying one under `raw` / `data` / `payload`, an `ArrayBuffer`, or a byte @@ -23,18 +39,9 @@ function toPacketPayloadBytesInner(value: unknown, visited: Set): Uint8A if (ArrayBuffer.isView(value)) { return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); } - if (Array.isArray(value) && value.every((b) => typeof b === 'number')) { - return Uint8Array.from(value); - } + if (isNumberByteArray(value)) return Uint8Array.from(value); if (value && typeof value === 'object') { - if (visited.has(value)) return new Uint8Array(); - visited.add(value); - const wrapper = value as { raw?: unknown; data?: unknown; payload?: unknown }; - for (const nested of [wrapper.raw, wrapper.data, wrapper.payload]) { - if (nested === undefined) continue; - const bytes = toPacketPayloadBytesInner(nested, visited); - if (bytes.length > 0) return bytes; - } + return bytesFromObjectWrapper(value, visited) ?? new Uint8Array(); } return new Uint8Array(); } diff --git a/src/renderer/lib/runtimeSideEffectAttach.contract.test.ts b/src/renderer/lib/runtimeSideEffectAttach.contract.test.ts index 97233e9ee..9403fb551 100644 --- a/src/renderer/lib/runtimeSideEffectAttach.contract.test.ts +++ b/src/renderer/lib/runtimeSideEffectAttach.contract.test.ts @@ -3,7 +3,8 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -const rendererRoot = join(__dirname, '..'); +const TEST_DIR = import.meta.dirname ?? __dirname; +const rendererRoot = join(TEST_DIR, '..'); const MESHTASTIC_WIRE_SOURCE = readFileSync( join(rendererRoot, 'lib/meshtastic/meshtasticRuntimeWireEffects.ts'), 'utf-8', diff --git a/src/renderer/lib/storeRecordAdapters.test.ts b/src/renderer/lib/storeRecordAdapters.test.ts index 6513253c9..20d655a6a 100644 --- a/src/renderer/lib/storeRecordAdapters.test.ts +++ b/src/renderer/lib/storeRecordAdapters.test.ts @@ -146,6 +146,7 @@ describe('store record adapters (merge precedence)', () => { relativeHumidity: 40, barometricPressure: 850, iaq: 12, + gasResistance: 48.5, lux: 30, windSpeed: 5, windDirection: 180, @@ -166,6 +167,7 @@ describe('store record adapters (merge precedence)', () => { env_humidity: 40, env_pressure: 850, env_iaq: 12, + env_gas_resistance: 48.5, env_lux: 30, env_wind_speed: 5, env_wind_direction: 180, @@ -199,6 +201,7 @@ describe('store record adapters (merge precedence)', () => { relativeHumidity: 40, barometricPressure: 850, iaq: 12, + gasResistance: 48.5, lux: 30, windSpeed: 5, windDirection: 180, diff --git a/src/renderer/lib/storeRecordAdapters.ts b/src/renderer/lib/storeRecordAdapters.ts index 68bfcabbd..4c0be9305 100644 --- a/src/renderer/lib/storeRecordAdapters.ts +++ b/src/renderer/lib/storeRecordAdapters.ts @@ -168,6 +168,7 @@ export function nodeRecordToMeshNode(record: NodeRecord): MeshNode { env_humidity: record.relativeHumidity, env_pressure: record.barometricPressure, env_iaq: record.iaq, + env_gas_resistance: record.gasResistance, env_lux: record.lux, env_wind_speed: record.windSpeed, env_wind_direction: record.windDirection, @@ -307,6 +308,7 @@ export function meshNodeToNodeRecord(node: MeshNode): NodeRecord { relativeHumidity: node.env_humidity, barometricPressure: node.env_pressure, iaq: node.env_iaq, + gasResistance: node.env_gas_resistance, lux: node.env_lux, windSpeed: node.env_wind_speed, windDirection: node.env_wind_direction, diff --git a/src/renderer/lib/types.ts b/src/renderer/lib/types.ts index 157baeb1c..0b26ab592 100644 --- a/src/renderer/lib/types.ts +++ b/src/renderer/lib/types.ts @@ -203,6 +203,7 @@ export interface MeshNode { env_humidity?: number; env_pressure?: number; env_iaq?: number; + env_gas_resistance?: number; env_lux?: number; env_wind_speed?: number; env_wind_direction?: number; diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 6dbdbe4bc..81232cf01 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -1130,6 +1130,7 @@ export function useMeshtasticRuntime() { console.debug( `[useMeshtasticRuntime] MQTT-only identity: from=!${mqttOnlyId.toString(16).padStart(8, '0')} source=${mqttOnlyIdentitySource(lastRfSelfNodeIdRef.current)}`, ); + let mqttSelfNodeToPersist: MeshNode | undefined; updateNodes((prev) => { const updated = new Map(prev); if (lastRfSelfNodeIdRef.current > 0 && mqttOnlyId === lastRfSelfNodeIdRef.current) { @@ -1153,7 +1154,7 @@ export function useMeshtasticRuntime() { heard_via_mqtt_only: true, }; updated.set(mqttOnlyId, rfNode); - persistMeshtasticNode(rfNode); + mqttSelfNodeToPersist = rfNode; return updated; } const existing = updated.get(virtualId) ?? emptyNode(virtualId); @@ -1169,9 +1170,10 @@ export function useMeshtasticRuntime() { heard_via_mqtt_only: true, }; updated.set(virtualId, virtualNode); - persistMeshtasticNode(virtualNode); + mqttSelfNodeToPersist = virtualNode; return updated; }); + if (mqttSelfNodeToPersist) persistMeshtasticNode(mqttSelfNodeToPersist); // Periodic NodeInfo broadcast so other nodes see this client (every 5 min) if (mqttPresenceIntervalRef.current) clearInterval(mqttPresenceIntervalRef.current); const sendPresence = () => { @@ -1274,6 +1276,7 @@ export function useMeshtasticRuntime() { }); } + let mqttNodeToPersist: MeshNode | undefined; updateNodes((prev) => { const existing = prev.get(nodeUpdate.node_id) ?? emptyNode(nodeUpdate.node_id); const heardViaRF = rfHeardNodeIds.current.has(nodeUpdate.node_id); @@ -1331,9 +1334,10 @@ export function useMeshtasticRuntime() { node.node_id, ); updated.set(nodeUpdate.node_id, node); - persistMeshtasticNode(node); + mqttNodeToPersist = node; return updated; }); + if (mqttNodeToPersist) persistMeshtasticNode(mqttNodeToPersist); const updatedMqttNode = getIdentityNode(meshtasticIdentityIdRef.current, nodeUpdate.node_id); if (updatedMqttNode && getStoredMeshProtocol() === 'meshtastic') { useDiagnosticsStore @@ -3752,6 +3756,7 @@ export function useMeshtasticRuntime() { : 0; if (selfNodeId > 0) { const isVirtualNode = !hasDevice && selfNodeId === virtualNodeIdRef.current; + let selfNodeToPersist: MeshNode | undefined; updateNodes((prev) => { const updated = new Map(prev); const existing = updated.get(selfNodeId) ?? emptyNode(selfNodeId); @@ -3767,9 +3772,10 @@ export function useMeshtasticRuntime() { : {}), }; updated.set(selfNodeId, node); - if (!isVirtualNode) persistMeshtasticNode(node); + if (!isVirtualNode) selfNodeToPersist = node; return updated; }); + if (selfNodeToPersist) persistMeshtasticNode(selfNodeToPersist); } const isClientMute =