From f7f4e1060dd4eb5ca46428937b1568c8ad91356e Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Tue, 22 Sep 2026 15:40:05 -0600 Subject: [PATCH 01/10] feat(mecp): add Mesh Emergency Communication Protocol support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode/compose MECP on chat, ALERT_APP ingest, sev0-1 mute-bypass siren, durable mecp-received audit/export, and opt-in Meshtastic↔MeshCore RF rebroadcast. --- AGENTS.md | 1 + docs/agents/README.md | 1 + docs/agents/chat.md | 2 +- docs/agents/mecp.md | 54 +++ docs/agents/meshtastic.md | 1 + docs/credits.md | 9 +- docs/troubleshooting.md | 20 +- src/main/index.ipc-security.test.ts | 8 + src/main/index.ts | 41 +++ src/main/mecp-received-log.test.ts | 71 ++++ src/main/mecp-received-log.ts | 145 ++++++++ src/main/mqtt-manager.ts | 13 +- src/main/support-bundle.ts | 17 + src/preload/index.contract.test.ts | 2 + src/preload/index.ts | 11 + src/renderer/App.tsx | 30 ++ src/renderer/components/AppPanel.tsx | 3 + src/renderer/components/ChatPanel.tsx | 79 ++++- src/renderer/components/Toast.tsx | 4 +- .../components/mecp/MecpComposeModal.test.tsx | 38 +++ .../components/mecp/MecpComposeModal.tsx | 276 +++++++++++++++ .../mecp/MecpRebroadcastSettings.tsx | 190 +++++++++++ .../components/mecp/MecpSeverityBadge.tsx | 38 +++ .../hooks/useMecpAlertWatcher.test.tsx | 119 +++++++ src/renderer/hooks/useMecpAlertWatcher.ts | 197 +++++++++++ src/renderer/lib/chatNotifications.test.ts | 51 +++ src/renderer/lib/chatNotifications.ts | 95 +++++- src/renderer/lib/chatUnreadCounts.ts | 3 + src/renderer/lib/devElectronApiStub.ts | 4 + src/renderer/lib/mecp/engine/decoder.ts | 200 +++++++++++ src/renderer/lib/mecp/engine/encoder.ts | 118 +++++++ src/renderer/lib/mecp/engine/index.ts | 22 ++ src/renderer/lib/mecp/engine/types.ts | 141 ++++++++ src/renderer/lib/mecp/languages/cs.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/de.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/en.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/es.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/fa.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/fr.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/it.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/ja.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/nl.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/no.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/pl.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/pt.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/ru.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/sk.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/sr.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/sv.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/tr.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/uk.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/zh-cn.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/languages/zh-tw.json | 313 ++++++++++++++++++ src/renderer/lib/mecp/mecpAlert.ts | 75 +++++ src/renderer/lib/mecp/mecpMessages.test.ts | 63 ++++ src/renderer/lib/mecp/mecpMessages.ts | 145 ++++++++ src/renderer/lib/mecp/mecpRebroadcast.test.ts | 176 ++++++++++ src/renderer/lib/mecp/mecpRebroadcast.ts | 165 +++++++++ src/renderer/lib/mecp/sendMecpRebroadcast.ts | 40 +++ .../meshtasticModulePortSideEffects.ts | 2 +- .../lib/protocols/MeshtasticProtocol.test.ts | 30 ++ .../lib/protocols/MeshtasticProtocol.ts | 3 +- src/renderer/locales/cs/translation.json | 38 +++ src/renderer/locales/de/translation.json | 38 +++ src/renderer/locales/en/translation.json | 38 +++ src/renderer/locales/es/translation.json | 38 +++ src/renderer/locales/fr/translation.json | 38 +++ src/renderer/locales/id/translation.json | 38 +++ src/renderer/locales/it/translation.json | 38 +++ src/renderer/locales/ja/translation.json | 38 +++ src/renderer/locales/ko/translation.json | 38 +++ src/renderer/locales/nl/translation.json | 38 +++ src/renderer/locales/pl/translation.json | 38 +++ src/renderer/locales/pt-BR/translation.json | 38 +++ src/renderer/locales/ru/translation.json | 38 +++ src/renderer/locales/tr/translation.json | 38 +++ src/renderer/locales/uk/translation.json | 38 +++ src/renderer/locales/zh/translation.json | 38 +++ src/renderer/vitest.electronApiMock.ts | 4 + src/shared/electron-api.types.ts | 19 ++ 80 files changed, 9567 insertions(+), 27 deletions(-) create mode 100644 docs/agents/mecp.md create mode 100644 src/main/mecp-received-log.test.ts create mode 100644 src/main/mecp-received-log.ts create mode 100644 src/renderer/components/mecp/MecpComposeModal.test.tsx create mode 100644 src/renderer/components/mecp/MecpComposeModal.tsx create mode 100644 src/renderer/components/mecp/MecpRebroadcastSettings.tsx create mode 100644 src/renderer/components/mecp/MecpSeverityBadge.tsx create mode 100644 src/renderer/hooks/useMecpAlertWatcher.test.tsx create mode 100644 src/renderer/hooks/useMecpAlertWatcher.ts create mode 100644 src/renderer/lib/mecp/engine/decoder.ts create mode 100644 src/renderer/lib/mecp/engine/encoder.ts create mode 100644 src/renderer/lib/mecp/engine/index.ts create mode 100644 src/renderer/lib/mecp/engine/types.ts create mode 100644 src/renderer/lib/mecp/languages/cs.json create mode 100644 src/renderer/lib/mecp/languages/de.json create mode 100644 src/renderer/lib/mecp/languages/en.json create mode 100644 src/renderer/lib/mecp/languages/es.json create mode 100644 src/renderer/lib/mecp/languages/fa.json create mode 100644 src/renderer/lib/mecp/languages/fr.json create mode 100644 src/renderer/lib/mecp/languages/it.json create mode 100644 src/renderer/lib/mecp/languages/ja.json create mode 100644 src/renderer/lib/mecp/languages/nl.json create mode 100644 src/renderer/lib/mecp/languages/no.json create mode 100644 src/renderer/lib/mecp/languages/pl.json create mode 100644 src/renderer/lib/mecp/languages/pt.json create mode 100644 src/renderer/lib/mecp/languages/ru.json create mode 100644 src/renderer/lib/mecp/languages/sk.json create mode 100644 src/renderer/lib/mecp/languages/sr.json create mode 100644 src/renderer/lib/mecp/languages/sv.json create mode 100644 src/renderer/lib/mecp/languages/tr.json create mode 100644 src/renderer/lib/mecp/languages/uk.json create mode 100644 src/renderer/lib/mecp/languages/zh-cn.json create mode 100644 src/renderer/lib/mecp/languages/zh-tw.json create mode 100644 src/renderer/lib/mecp/mecpAlert.ts create mode 100644 src/renderer/lib/mecp/mecpMessages.test.ts create mode 100644 src/renderer/lib/mecp/mecpMessages.ts create mode 100644 src/renderer/lib/mecp/mecpRebroadcast.test.ts create mode 100644 src/renderer/lib/mecp/mecpRebroadcast.ts create mode 100644 src/renderer/lib/mecp/sendMecpRebroadcast.ts diff --git a/AGENTS.md b/AGENTS.md index fdd5ece3e..5c736112b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,6 +156,7 @@ Deep, file-level subsystem detail now lives in [`docs/agents/`](docs/agents/READ | Diagnostics engines, rows, tab scoping | [`docs/agents/diagnostics.md`](docs/agents/diagnostics.md) | | i18n / localization workflow, auto-translate, language selector | [`docs/agents/i18n.md`](docs/agents/i18n.md) | | Connection panel helpers (error hints, rehydrate, storage migrations) | [`docs/agents/connection-panel.md`](docs/agents/connection-panel.md) | +| MECP emergency reports, siren, audit log, ALERT_APP, RF rebroadcast | [`docs/agents/mecp.md`](docs/agents/mecp.md) | | Symptom → where-to-check index | [`docs/agents/common-issues.md`](docs/agents/common-issues.md) | **Always-remember invariants** (details in the linked files): diff --git a/docs/agents/README.md b/docs/agents/README.md index ed2c79197..5b842c6d9 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -15,6 +15,7 @@ Deep, file-level subsystem detail for AI assistants, split out of [`AGENTS.md`]( | Diagnostics engines, rows, tab scoping | [diagnostics.md](diagnostics.md) | | i18n / localization workflow, auto-translate, language selector | [i18n.md](i18n.md) | | Connection panel helpers (error hints, rehydrate, storage migrations) | [connection-panel.md](connection-panel.md) | +| MECP emergency reports, siren alerts, audit log, ALERT_APP, RF rebroadcast | [mecp.md](mecp.md) | | Symptom → where-to-check index | [common-issues.md](common-issues.md) | For human-facing deep dives, see the top-level docs (e.g. [../reticulum.md](../reticulum.md), [../diagnostics.md](../diagnostics.md), [../meshcore-meshtastic-parity.md](../meshcore-meshtastic-parity.md), [../troubleshooting.md](../troubleshooting.md)). diff --git a/docs/agents/chat.md b/docs/agents/chat.md index eef29a87a..ae4608e89 100644 --- a/docs/agents/chat.md +++ b/docs/agents/chat.md @@ -6,7 +6,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Ch - **Composer limits / send cadence:** `chatComposerLimits.ts` — `getMaxChunks(protocol)` via `ProtocolCapabilities.composerMaxChunks` (MeshCore = 1: no outbound `[i/N]` split; `splitChatMessage` returns `null` when text needs more than one packet), room payload via `getMeshcoreRoomPayloadLimit`, `computeComposerLimitStatus` phases (`warn` surfaces a single-packet ⓘ hint; `overMaxSingle` disables send and shows a `role="note"` callout). MeshCore also gets a **non-blocking** ~5s "sending too fast" advisory (`role="status"`, dismissible) from an app-wide clock in `meshcoreSendRateNotice.ts` (`recordMeshcoreSend` / `isMeshcoreSendTooFast`, `MESHCORE_FAST_SEND_WARN_INTERVAL_MS`). Every **live** MeshCore send advances the clock and can show the advisory — text (`handleSend`), GIF, and share-location in `ChatComposer`, plus outbox drain (`useChatOutbox.ts`). Legacy MeshCore outbox rows with `groupTotal > 1` or `[i/N]` payloads are quarantined (`blocked`) on drain instead of being transmitted. Inbound multi-part `[i/N]` merge is unchanged. i18n: `chatPanel.composeLimit.meshcoreSingleNotice.*`, `chatPanel.meshcoreFastSend.warning`, `chatPanel.outboxLegacyMultipartBlocked`. See [`meshcore-meshtastic-parity.md`](../meshcore-meshtastic-parity.md). - **Payload / links:** `ChatPayloadText.tsx` — mention highlighting, search marks, URL linkification; link previews via `chat:fetchLinkPreview` (`src/main/fetchLinkPreview.ts`): Open Graph for HTML pages; **YouTube** watch/shorts/youtu.be via oEmbed + thumbnail; **direct image URLs** (path extension via `chatDirectImageUrl.ts` or raster `Content-Type`) return `kind: 'image'` and render as inline embeds (`ChatInlineImage` / `DirectImageEmbed`); OG/YouTube use card layout. Security: DNS-pinned undici `Agent`, private/loopback blocked, magic-byte MIME sniff (`safeRasterImageMime.ts`), HTTPS-only image embeds, 10s fetch / 3s DNS, 64 KiB HTML cap, **2 MiB** image fetch cap (256 KiB cache payload cap), LRU caches, single-flight dedup (renderer map capped). Previews load even when scrolled up. LXMF attachment rasters: `chat:readReticulumAttachmentAsDataUrl` (`reticulum-attachment-image.ts`; path jail, magic-byte MIME, SVG rejected, 2 MiB, IPC rate limit) → `ReticulumAttachmentLine`. LXMF voice memos (`hasReticulumVoiceMemo`): Chat DM mic → sidecar `/api/v1/voice/memo/*` via `electronAPI.reticulum.voiceMemo.*`; playback via `chat:readReticulumAttachmentBytes` (`reticulum-attachment-audio.ts`; OggS sniff, 256 KiB) → `ReticulumVoiceMemoLine`. 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), MeshCore flood-scope overrides per chat view (`mesh-client:floodScopeOverrides:`, channel or DM `viewKey`). -- **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`). +- **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); **`mecp`** = triple ascending pulse; **`mecpSiren`** = loud multi-cycle siren for MECP severity 0–1 (mute-bypass via `useMecpAlertWatcher` / `mecpAlert.ts`, not `pickAudibleNotificationType`). 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`). MECP compose/export and red-dashed bubbles: see [`mecp.md`](mecp.md). - **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 (`meshcoreConnSideEffects`). **Meshtastic:** `MeshtasticProtocol` uses `meshtasticComputedRfHopsAway` — omit hops for `viaMqtt` and `hopStart === 0`; else `hopStart - hopLimit` when `hopStart > 0 && hopLimit <= hopStart`. - **Relay coverage (in-memory, outgoing bubbles only):** `relayCoverageStore` keyed by `identityId:messageId` (not persisted). UI: `RelayCoverageLine` in `ChatPanel` status row via `relayCoverageMessageKey`. **MeshCore:** `openHeardRepeatWindow` from Chat `useSendMessage` channel sends and runtime channel TX; during `MESHCORE_HEARD_REPEAT_WINDOW_MS` (**120s** — large meshes often need ≥60s for multi-hop returns), every overheard `GRP_TXT` flood-path segment credits a forwarder (Repeater/Room when resolved via `pubKeyMapRef` / pubkey prefix; otherwise synthetic hex as “additional unidentified”). Chat hops are never credited. Path-invariant payload id (`meshCorePathInvariantPayloadId`: CRC lookup + full type/payload equality) binds only from **own-TX** or **empty-path** channel echo (never from the first credited hop); once bound, mismatched floods are ignored — unbound windows credit all in-window forwarders (pre-#888 best-effort). Naming is reliable on **2/3-byte** path modes; **1-byte** is best-effort (collisions common). Only forwarders whose rebroadcast reaches our radio are credited (multi-hop peers appear via accumulated path hashes). `renameMessageId` re-keys coverage + window; disconnect clears window + identity coverage. **Meshtastic:** binary heard/timeout for RF channel/broadcast via `meshtasticHeardRepeat` + `useMeshtasticRuntime` transport status (DMs/MQTT ignored). **Reticulum:** predicted route at send (`reticulumRouteCoverage` / `sendReticulumChatMessage`); hops-only vs via-first-hop i18n; pending→hash rename re-keys; `clearReticulumSessionStores` strips reticulum coverage. i18n: `chatPanel.heardBy*`, `chatPanel.route*Predicted*`. diff --git a/docs/agents/mecp.md b/docs/agents/mecp.md new file mode 100644 index 000000000..a05b77660 --- /dev/null +++ b/docs/agents/mecp.md @@ -0,0 +1,54 @@ +# Agent reference: MECP (Mesh Emergency Communication Protocol) + +Deep subsystem reference for AI assistants. Open when a task touches MECP compose, alerts, audit log, ALERT_APP ingest, or cross-protocol RF rebroadcast. Hard rules live in [`AGENTS.md`](../../AGENTS.md). + +## Wire format + +``` +MECP// [freetext] +``` + +- Severity: `0` MAYDAY, `1` URGENT, `2` SAFETY, `3` ROUTINE +- Codes: letter + two digits (`M01`, …); drill `D01`/`D02` set `isDrill` (suppresses alerts) +- Max **200** UTF-8 bytes (`MAX_MESSAGE_BYTES`) +- Vendored engine: [`src/renderer/lib/mecp/engine/`](../../src/renderer/lib/mecp/engine/) from [xiang-dev-1/MECP](https://github.com/xiang-dev-1/MECP) (GPLv3) +- Language packs: [`src/renderer/lib/mecp/languages/`](../../src/renderer/lib/mecp/languages/) (CC BY 4.0) +- App wrappers: `mecpMessages.ts` (`MECP_REGEX`, `tryParseMecp`), `mecpAlert.ts`, `mecpRebroadcast.ts` + +## Receive path + +1. Messages land in `messageStore` as normal chat text (Meshtastic RF/MQTT, MeshCore, Reticulum). +2. Meshtastic **`ALERT_APP`** (port 11) is decoded like `TEXT_MESSAGE_APP` in `MeshtasticProtocol` / MQTT. +3. `useMecpAlertWatcher` (mounted once from `App.tsx`): + - Seeds a dedup set at mount (no alert/audit on hydration) + - New inbound MECP → durable audit append (`mecp:appendReceived`) + - Alerts: sev **0–1** loud `'mecpSiren'` + emergency toast (**ignore** mutes); sev **2–3** `'mecp'` tone when unmuted; drills never alert + - Optional RF rebroadcast (§ below) + +## Durable audit log + +- File: `mecp-received.log` (+ `.1` size rotate) under Electron `userData` — **not** session `mesh-client.log` +- IPC: `mecp:appendReceived`, `mecp:exportReceivedLog` (Save dialog) +- Included in support bundles +- Chat UI: **Export MECP log** next to MECP compose + +## Send path + +- Chat **MECP** button → `MecpComposeModal` → encode → existing `handleSendChunk` / `useSendMessage` (follows open DM/channel) +- Meshtastic outbound uses normal text (`TEXT_MESSAGE_APP`), not ALERT_APP + +## RF rebroadcast (default off) + +- App → MECP settings: rules `{ enabled, bidirectional, endpointA, endpointB }` (Meshtastic/MeshCore channel indices) +- One-way **A→B** by default; **Bidirectional** toggle enables B→A +- Trigger: new inbound MECP with `receivedVia` `rf`/`both` (not mqtt-only); skip own/history/drill +- Loop guard: payload+dest fingerprint TTL +- Implementation: `mecpRebroadcast.ts` + `sendMecpRebroadcast.ts` + +## Out of scope (follow-ups) + +- RetAlert (`!RETALERT!…`) +- SQLite `mecpParsed` column / in-memory emergency panel +- MeshCore Rooms bubble styling +- Send via `ALERT_APP` portnum +- Reticulum DM bridge endpoints diff --git a/docs/agents/meshtastic.md b/docs/agents/meshtastic.md index 24246e512..a3a4a6312 100644 --- a/docs/agents/meshtastic.md +++ b/docs/agents/meshtastic.md @@ -11,3 +11,4 @@ Deep subsystem reference for AI assistants. Open this when a task touches Meshta - **PKC remote admin (firmware 2.5+):** `meshtasticRemoteAdmin.ts` — PKI-wrapped `AdminMessage` via `MeshDevice.sendRaw()` (`pkiEncrypted: true`, channel omitted on wire); session passkeys (~300s); tab-scoped snapshot routes in `meshtasticRemoteAdminSnapshot.ts` (Channels-first LoRa load). Per-node keys: `meshtasticRemoteAdminKeyStorage.ts` (`meshtasticRemoteAdminKey:` in `app_settings`; base64 / `base64:` / 64-char hex paste). Dest public key: NodeDB hex first, stored admin-key base64 fallback. `useMeshtasticRuntime`: `configureTargetNodeNum`, `remoteConfigSnapshot`, `runRemoteAdminOp` (errors → UI + toast); serialize admin reads with S&F (`remoteAdminReadsActiveCount` in `meshtasticBacklogUtils.ts`). **Requires connected local radio** (MQTT-only cannot admin). UI: `ConfigureNodeSelector.tsx`; NodeDetailModal admin key + **Configure node remotely**; SecurityPanel **Copy** public key. Persist last target in `meshtasticConfigureTargetNodeNum`. Gate with `hasRemoteAdmin`. Legacy admin channel (PSK + `"admin"`) out of scope. - **Meshtastic last heard:** `meshtasticLastHeard.ts` — bump `last_heard` on live RF packets (not only text); `computeNodeInfoLastHeardMs` merges radio NodeDB timestamps with client-side values (max wins). **Configure replay guard** must apply in `nodeStore` (`upsertNode`, `updatePosition`, `meshtasticLastHeardPatch`) via `meshtasticConfigurePhase.ts`, not only in `meshtasticNodeSideEffects` — PacketRouter updates the store before side effects run. During `device.configure()`, the Meshtastic SDK replays NodeDB as `node_info` frames and may emit synthetic `onUserPacket` / `onPositionPacket` with `rxTime = now`; guards skip those bumps. UserPacket path uses `mergeMeshtasticUserPacketLastHeard` (ms); NodeDB path uses `computeNodeInfoLastHeardMs` (sec). **Protocol decode must use `meshtasticPacketRxTimeMs`** — `@meshtastic/core` `PacketMetadata.rxTime` is already a `Date` (ms); never `rxTime * 1000` (Date×1000 → ~1e15; regression lock in `MeshtasticProtocol.test.ts` Date-shaped rxTime + source-policy `meshtastic-protocol-rxtime-via-helper`). SQLite stores `last_heard` as unix seconds; `meshtasticDbCacheHydration.ts` normalizes to ms on hydrate (`normalizeLastHeardMs`); stale/online checks use `effectiveLastHeardMs`. BLE configure uses a **stall watchdog** (`MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS`, reset on each replayed `node_info` / position / telemetry via `touchMeshtasticConfigureProgress`) so large NodeDBs are not cut off mid-stream. Node list JSON export emits `last_heard` as unix seconds with `last_heard_unit: 'unix_sec'`. - **Static GPS:** `src/renderer/lib/gpsSource.ts` — App tab static coordinates sync to self-node, map, and radio `setPosition`. +- **ALERT_APP (port 11):** Decoded as UTF-8 text like `TEXT_MESSAGE_APP` in `MeshtasticProtocol` and MQTT (`mqtt-manager.ts`) so MECP / critical alerts reach chat + `useMecpAlertWatcher`. See [`mecp.md`](mecp.md). diff --git a/docs/credits.md b/docs/credits.md index 3abb8badd..3cb8631c0 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -32,6 +32,7 @@ We were inspired by features from these projects: - [Mesh Monitor](https://meshmonitor.org/): Web-based mesh network monitoring dashboard - [CoreScope](https://github.com/Kpa-clawbot/CoreScope): Self-hosted MeshCore network analyzer with RF analytics, packet visualization, and topology tools - [Ratspeak](https://github.com/ratspeak/Ratspeak): Primary reference for the Reticulum/rsReticulum/rsLXMF stack, sidecar IPC patterns, and peer interop ([rsReticulum](https://github.com/ratspeak/rsReticulum), [rsLXMF](https://github.com/ratspeak/rsLXMF)) +- [MECP](https://github.com/xiang-dev-1/MECP): Mesh Emergency Communication Protocol — structured emergency text for LoRa mesh (engine GPLv3; language packs CC BY 4.0) ### Bundled binaries @@ -49,9 +50,11 @@ Application source (Electron main / preload / renderer) is **GPL-3.0-or-later**; ### Vendored -| Source / file | License | Role | -| ------------------ | ------- | ----------------------------------- | -| `micron-parser-js` | MIT | Nomad Micron (.mu) → HTML (RFnexus) | +| Source / file | License | Role | +| ----------------------------------------------------------------------------------------- | --------- | ----------------------------------------------------------------------- | +| `micron-parser-js` | MIT | Nomad Micron (.mu) → HTML (RFnexus) | +| `src/renderer/lib/mecp/engine/` ([xiang-dev-1/MECP](https://github.com/xiang-dev-1/MECP)) | GPLv3 | MECP encode/decode engine | +| `src/renderer/lib/mecp/languages/*.json` | CC BY 4.0 | MECP localized code/category strings (see upstream `LICENSE-LANGUAGES`) | ## Third-party licenses diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7bf56c0eb..1f01f52a4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -61,7 +61,7 @@ Works on macOS, Windows, Linux (.deb / .rpm / AppImage), and Flatpak. Local data | `meshcore.orphanRoomMessageCount` | number | Room posts whose `room_server_id` is not in current contacts | | `meshcore.roomNodeCount` / `roomMessageCount` / `roomsLastReadKeyCount` | numbers | Rooms triage counts | -Zip contents also include **`mesh-client.log.1`** when present (prior session preserved on restart, or size-rotated backup; export may tail-cap large backups). +Zip contents also include **`mesh-client.log.1`** when present (prior session preserved on restart, or size-rotated backup; export may tail-cap large backups). When present, support bundles also include **`mecp-received.log`** / **`mecp-received.log.1`** (durable MECP emergency audit trail — separate from the session app log). The top-level **`legend`** explains that ids like `offline-meshcore` are **internal hydration-slot store keys**, not “disconnected.” When connect reuses that slot (`hydrationSlotIsLiveSession: true`), the id still contains `offline-` while BLE/MQTT are up — that is **expected**. @@ -1988,6 +1988,24 @@ The app functions fully offline; this is not a critical error. If "Update check ### Language and Translations +## MECP (emergency reports) + +**Where is the MECP received log?** + +Inbound MECP messages are appended to a durable audit file under the app `userData` folder (not the rotating session `mesh-client.log`): + +- macOS: `~/Library/Application Support/mesh-client/mecp-received.log` (rotated backup `mecp-received.log.1`) +- Windows: `%APPDATA%\mesh-client\mecp-received.log` +- Linux: `~/.config/mesh-client/mecp-received.log` + +Use **Chat → Export MECP log**, or open a GitHub/Developer support bundle (includes the file when non-empty). Agent reference: [`docs/agents/mecp.md`](agents/mecp.md). + +**MAYDAY/URGENT alerts ignore mute** + +Severity 0–1 MECP alerts play a loud siren and emergency toast even when global or conversation mute is on. Severity 2–3 respect mutes. Drill codes (D01/D02) never alert. Configure Meshtastic↔MeshCore RF bridging under **App → MECP RF rebroadcast** (default off; optional bidirectional). + +## Language / i18n + **How do I change the language?** Click the **globe icon** in the header to select from the 16 supported languages. Your preference is saved across restarts. diff --git a/src/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index bcfd8403c..c6271abd2 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -626,6 +626,13 @@ describe('privileged IPC sender validation (source contract)', () => { expect(body).toContain('isValidHttpHostname(s.server.trim())'); }); + it('mecp:appendReceived validates entry shape', () => { + const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('mecp:appendReceived'"); + expect(handlerIdx).toBeGreaterThan(-1); + const body = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 500); + expect(body).toContain('isValidMecpAppendPayload'); + }); + it('chat:export caps message array length', () => { expect(INDEX_SOURCE).toContain('CHAT_EXPORT_MAX_MESSAGES'); }); @@ -650,6 +657,7 @@ describe('privileged IPC sender validation (source contract)', () => { 'support:exportBundle', 'storage:encrypt', 'storage:decrypt', + 'mecp:exportReceivedLog', ] as const) { const handlerIdx = INDEX_SOURCE.indexOf(`ipcMain.handle('${channel}'`); expect(handlerIdx).toBeGreaterThan(-1); diff --git a/src/main/index.ts b/src/main/index.ts index 507e976be..e9698eccb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -146,6 +146,11 @@ import { sanitizeLogMessage, setMainWindow, } from './log-service'; +import { + appendMecpReceivedLog, + isValidMecpAppendPayload, + readMecpReceivedLogForExport, +} from './mecp-received-log'; import { MeshcoreMqttAdapter } from './meshcore-mqtt-adapter'; import { decodePathPayload, isPathPacket } from './meshcore-path-decoder'; import { ensureMicrophoneAccess, isAllowedMicrophonePrivacySettingsUrl } from './microphoneAccess'; @@ -4865,6 +4870,42 @@ ipcMain.handle('chat:export', async (event, messages: unknown) => { } }); +ipcMain.handle('mecp:appendReceived', (event, entry: unknown) => { + if (!validateIpcSender(event)) throw new Error('IPC sender validation failed'); + if (!isValidMecpAppendPayload(entry)) { + throw new Error('mecp:appendReceived: invalid entry'); + } + appendMecpReceivedLog(entry); + return { ok: true as const }; +}); + +ipcMain.handle('mecp:exportReceivedLog', async (event) => { + if (!validateIpcSender(event)) throw new Error('IPC sender validation failed'); + exportIpcRateLimit.checkOrThrow(); + if (!mainWindow) return { success: false as const }; + try { + const text = await readMecpReceivedLogForExport(); + if (!text.trim()) return { success: false as const, reason: 'empty' as const }; + const result = await dialog.showSaveDialog(mainWindow, { + title: 'Export MECP received log', + defaultPath: `mecp-received-${new Date().toISOString().slice(0, 10)}.txt`, + filters: [ + { name: 'Text file', extensions: ['txt'] }, + { name: 'JSON Lines', extensions: ['jsonl'] }, + ], + }); + if (result.canceled || !result.filePath) return { success: false as const }; + await fs.promises.writeFile(result.filePath, text, 'utf8'); + return { success: true as const, path: result.filePath }; + } catch (err) { + console.error( + '[IPC] mecp:exportReceivedLog failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } +}); + ipcMain.handle('gps:exportGpx', async (event, opts: unknown) => { if (!validateIpcSender(event)) throw new Error('IPC sender validation failed'); const o = opts && typeof opts === 'object' ? (opts as Record) : {}; diff --git a/src/main/mecp-received-log.test.ts b/src/main/mecp-received-log.test.ts new file mode 100644 index 000000000..31e5fed84 --- /dev/null +++ b/src/main/mecp-received-log.test.ts @@ -0,0 +1,71 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { + getPath: () => os.tmpdir(), + }, +})); + +describe('mecp-received-log', () => { + let workDir: string; + + beforeEach(async () => { + workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mecp-log-')); + vi.resetModules(); + }); + + afterEach(async () => { + try { + await fs.promises.rm(workDir, { recursive: true, force: true }); + } catch { + // catch-no-log-ok cleanup + } + }); + + it('appends sanitized JSONL and exports content', async () => { + const mod = await import('./mecp-received-log'); + const logPath = path.join(workDir, 'mecp-received.log'); + mod.setMecpReceivedLogPathForTests(logPath); + + mod.appendMecpReceivedLog({ + protocol: 'meshtastic', + severity: 0, + drill: false, + from: '!\x00bad', + payload: 'MECP/0/M01', + decoded: 'Injury', + }); + + // Wait for append chain + await new Promise((r) => setTimeout(r, 50)); + const text = await mod.readMecpReceivedLogForExport(); + expect(text).toContain('MECP/0/M01'); + expect(text).toContain('"severity":0'); + const line = JSON.parse(text.trim().split('\n').pop()!) as { from?: string }; + expect(line.from).not.toContain('\x00'); + }); + + it('validates append payloads', async () => { + const { isValidMecpAppendPayload } = await import('./mecp-received-log'); + expect( + isValidMecpAppendPayload({ + protocol: 'meshcore', + severity: 1, + drill: true, + payload: 'MECP/1/D01', + }), + ).toBe(true); + expect(isValidMecpAppendPayload({ protocol: 'x', drill: false })).toBe(false); + expect( + isValidMecpAppendPayload({ + protocol: 'meshtastic', + severity: 9, + drill: false, + payload: 'MECP/0/M01', + }), + ).toBe(false); + }); +}); diff --git a/src/main/mecp-received-log.ts b/src/main/mecp-received-log.ts new file mode 100644 index 000000000..1f2db7faa --- /dev/null +++ b/src/main/mecp-received-log.ts @@ -0,0 +1,145 @@ +/** + * Durable audit log for received MECP (emergency) messages. + * Survives app restarts; not wiped by session `mesh-client.log` promote. + */ + +import { app } from 'electron'; +import fs from 'fs'; +import path from 'path'; + +import { sanitizeLogMessage } from './sanitize-log-message'; + +export const MECP_RECEIVED_LOG_FILENAME = 'mecp-received.log'; +export const MECP_RECEIVED_LOG_BACKUP_FILENAME = 'mecp-received.log.1'; +const MECP_RECEIVED_LOG_MAX_BYTES = 20 * 1024 * 1024; // 20 MB +const MAX_FIELD_CHARS = 2000; + +export interface MecpReceivedLogEntry { + ts?: string; + protocol: string; + severity: number | null; + drill: boolean; + from?: string; + channel?: number | string; + payload: string; + decoded?: string; + direction?: 'received' | 'rebroadcast'; + toProtocol?: string; + toChannel?: number | string; + bidirectional?: boolean; + messageId?: string; +} + +let logFilePath: string | null = null; +let appendChain: Promise = Promise.resolve(); + +export function getMecpReceivedLogPath(): string { + if (!logFilePath) { + logFilePath = path.join(app.getPath('userData'), MECP_RECEIVED_LOG_FILENAME); + } + return logFilePath; +} + +/** @internal Test helper */ +export function setMecpReceivedLogPathForTests(p: string | null): void { + logFilePath = p; +} + +function clampField(value: string | number): string { + const sanitized = sanitizeLogMessage(typeof value === 'string' ? value : String(value)); + return sanitized.length > MAX_FIELD_CHARS ? sanitized.slice(0, MAX_FIELD_CHARS) : sanitized; +} + +function rotateIfNeeded(filePath: string): void { + try { + if (!fs.existsSync(filePath)) return; + const { size } = fs.statSync(filePath); + if (size < MECP_RECEIVED_LOG_MAX_BYTES) return; + const backup = path.join(path.dirname(filePath), MECP_RECEIVED_LOG_BACKUP_FILENAME); + if (fs.existsSync(backup)) { + fs.unlinkSync(backup); + } + fs.renameSync(filePath, backup); + } catch (e) { + console.warn( + '[mecp-received-log] rotate failed', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + } +} + +export function formatMecpReceivedLogLine(entry: MecpReceivedLogEntry): string { + const record = { + ts: entry.ts ?? new Date().toISOString(), + protocol: clampField(entry.protocol), + severity: entry.severity, + drill: entry.drill, + from: entry.from != null ? clampField(entry.from) : undefined, + channel: entry.channel != null ? clampField(entry.channel) : undefined, + payload: clampField(entry.payload), + decoded: entry.decoded != null ? clampField(entry.decoded) : undefined, + direction: entry.direction ?? 'received', + toProtocol: entry.toProtocol != null ? clampField(entry.toProtocol) : undefined, + toChannel: entry.toChannel != null ? clampField(entry.toChannel) : undefined, + bidirectional: entry.bidirectional, + messageId: entry.messageId != null ? clampField(entry.messageId) : undefined, + }; + return `${JSON.stringify(record)}\n`; +} + +export function appendMecpReceivedLog(entry: MecpReceivedLogEntry): void { + const filePath = getMecpReceivedLogPath(); + const line = formatMecpReceivedLogLine(entry); + appendChain = appendChain + .then(async () => { + rotateIfNeeded(filePath); + await fs.promises.appendFile(filePath, line, 'utf8'); + }) + .catch((e: unknown) => { + console.warn( + '[mecp-received-log] append failed', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + }); +} + +export async function readMecpReceivedLogForExport(): Promise { + const filePath = getMecpReceivedLogPath(); + const backup = path.join(path.dirname(filePath), MECP_RECEIVED_LOG_BACKUP_FILENAME); + const parts: string[] = []; + try { + if (fs.existsSync(backup)) { + parts.push(await fs.promises.readFile(backup, 'utf8')); + } + } catch { + // catch-no-log-ok missing backup is fine + } + try { + if (fs.existsSync(filePath)) { + parts.push(await fs.promises.readFile(filePath, 'utf8')); + } + } catch { + // catch-no-log-ok missing current is fine + } + return parts.join(''); +} + +export function isValidMecpAppendPayload(raw: unknown): raw is MecpReceivedLogEntry { + if (!raw || typeof raw !== 'object') return false; + const o = raw as Record; + if (typeof o.protocol !== 'string' || o.protocol.length === 0 || o.protocol.length > 32) { + return false; + } + if ( + typeof o.payload !== 'string' || + o.payload.length === 0 || + o.payload.length > MAX_FIELD_CHARS + ) { + return false; + } + if (typeof o.drill !== 'boolean') return false; + if (o.severity != null && (typeof o.severity !== 'number' || o.severity < 0 || o.severity > 3)) { + return false; + } + return true; +} diff --git a/src/main/mqtt-manager.ts b/src/main/mqtt-manager.ts index e0318ec9b..23c4d658d 100644 --- a/src/main/mqtt-manager.ts +++ b/src/main/mqtt-manager.ts @@ -1015,8 +1015,8 @@ export class MQTTManager extends EventEmitter { }; try { - if (portnum === PortNum.TEXT_MESSAGE_APP) { - body.type = 'text'; + if (portnum === PortNum.TEXT_MESSAGE_APP || portnum === PortNum.ALERT_APP) { + body.type = portnum === PortNum.ALERT_APP ? 'alert' : 'text'; const textBytes = rawData.payload ?? new Uint8Array(); const textStr = new TextDecoder().decode(textBytes); let payloadVal: unknown; @@ -1984,13 +1984,16 @@ export class MQTTManager extends EventEmitter { this.upsertNodeCache({ node_id: nodeId, last_heard: Date.now() }); this.emitMinimalNodeUpdate(nodeId, hopsAway, portnum); } - } else if (portnum === PortNum.TEXT_MESSAGE_APP && (payload?.length || data.emoji)) { + } else if ( + (portnum === PortNum.TEXT_MESSAGE_APP || portnum === PortNum.ALERT_APP) && + (payload?.length || data.emoji) + ) { try { const payloadBytes = payload ?? new Uint8Array(); const resolved = resolveMeshtasticTextMessagePayload(payloadBytes); if (!resolved) { console.debug( - `[Meshtastic MQTT] Dropped non-readable TEXT_MESSAGE from node ${nodeId} len=${payloadBytes.length}`, + `[Meshtastic MQTT] Dropped non-readable ${portnum === PortNum.ALERT_APP ? 'ALERT_APP' : 'TEXT_MESSAGE'} from node ${nodeId} len=${payloadBytes.length}`, ); // log-filter-ok Meshtastic MQTT logs → App log panel this.upsertNodeCache({ node_id: nodeId, last_heard: Date.now() }); this.emitMinimalNodeUpdate(nodeId, hopsAway, portnum); @@ -2211,7 +2214,7 @@ export class MQTTManager extends EventEmitter { return false; } - if (portnum === PortNum.TEXT_MESSAGE_APP) { + if (portnum === PortNum.TEXT_MESSAGE_APP || portnum === PortNum.ALERT_APP) { if (data.emoji === MESHTASTIC_TAPBACK_DATA_EMOJI_FLAG) return true; if (!payload?.length && !data.emoji) return false; return resolveMeshtasticTextMessagePayload(payload ?? new Uint8Array()) !== null; diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 903952240..4e29afa61 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -144,6 +144,8 @@ Contents: debug-snapshot.json — UI/session state for triage (Meshtastic, MeshCore, Reticulum sidecar) mesh-client.log — Application log (current session) mesh-client.log.1 — Prior session log (preserved on restart) or size-rotated backup + mecp-received.log — Durable MECP (emergency) received audit log + mecp-received.log.1 — Size-rotated MECP audit backup (if present) manifest.json — App version, buildChannel, and platform metadata README.txt — This file @@ -173,6 +175,8 @@ Contents: reticulum/lxmf-outbound.log — Filtered LXMF outbound / PN cascade lines from app logs mesh-client.log — Application log (current session) mesh-client.log.1 — Prior session log (preserved on restart) or size-rotated backup + mecp-received.log — Durable MECP (emergency) received audit log + mecp-received.log.1 — Size-rotated MECP audit backup (if present) manifest.json — App version, buildChannel, and platform metadata README.txt — This file `; @@ -306,6 +310,19 @@ export async function buildSupportBundleZip( zip.file(LOG_BACKUP_FILENAME, backupLog); } + const mecpLogPath = path.join(logDir, 'mecp-received.log'); + const mecpLog = await readFileOrEmpty(mecpLogPath); + if (mecpLog.length > 0) { + zip.file('mecp-received.log', mecpLog); + } + const mecpBackupPath = path.join(logDir, 'mecp-received.log.1'); + if (fs.existsSync(mecpBackupPath)) { + zip.file( + 'mecp-received.log.1', + await readFileTailOrEmpty(mecpBackupPath, MAX_SUPPORT_BUNDLE_LOG_BACKUP_BYTES), + ); + } + zip.file('manifest.json', JSON.stringify(buildManifest(mode), null, 2)); zip.file('README.txt', buildReadme(mode)); diff --git a/src/preload/index.contract.test.ts b/src/preload/index.contract.test.ts index 5efcc2fd5..b143a78d4 100644 --- a/src/preload/index.contract.test.ts +++ b/src/preload/index.contract.test.ts @@ -42,6 +42,8 @@ describe('preload bridge contract', () => { it('preload invokes chat export IPC', () => { expect(PRELOAD_SOURCE).toContain("'chat:export'"); + expect(PRELOAD_SOURCE).toContain("'mecp:appendReceived'"); + expect(PRELOAD_SOURCE).toContain("'mecp:exportReceivedLog'"); }); it('preload exposes readReticulumAttachmentAsDataUrl and linkPreview kind', () => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7c42ffcfa..be7764551 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1366,6 +1366,17 @@ contextBridge.exposeInMainWorld('electronAPI', { reason?: 'empty' | 'cancelled' | 'no_db' | 'no_window'; }>, }, + mecp: { + appendReceived: (entry: unknown) => + ipcRenderer.invoke('mecp:appendReceived', entry) as Promise<{ ok: true }>, + exportReceivedLog: () => + ipcRenderer.invoke('mecp:exportReceivedLog') as Promise<{ + success: boolean; + path?: string; + reason?: 'empty'; + }>, + }, + chat: { export: (messages: unknown[]) => ipcRenderer.invoke('chat:export', messages) as Promise<{ success: boolean; path?: string }>, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0b14112a3..f5b9fc67e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -18,6 +18,7 @@ import { import { useTranslation } from 'react-i18next'; import { MESHCORE_ROOM_MESSAGE_CHANNEL } from '@/renderer/hooks/meshcore/meshcoreHookPreamble'; +import { useMecpAlertWatcher } from '@/renderer/hooks/useMecpAlertWatcher'; import { isAppWindowInactive } from '@/renderer/lib/appWindowActivity'; import { resolveInactiveChatNotificationType } from '@/renderer/lib/chatInactiveNotifications'; import { @@ -1293,6 +1294,35 @@ function AppContent() { [reticulumIdentity, reticulumRuntime.selfNodeId, reticulumRuntime.state.myNodeNum], ); + const mecpMeshtasticSlice = useMemo( + () => ({ + protocol: 'meshtastic' as const, + messages: meshtasticStoreMessages, + ownNodeIds: meshtasticOwnNodeIdSet, + ownSenderId: meshtasticRuntime.state.myNodeNum, + }), + [meshtasticStoreMessages, meshtasticOwnNodeIdSet, meshtasticRuntime.state.myNodeNum], + ); + const mecpMeshcoreSlice = useMemo( + () => ({ + protocol: 'meshcore' as const, + messages: meshcoreStoreMessages, + ownNodeIds: meshcoreOwnNodeIdSet, + ownSenderId: meshcoreRuntime.selfNodeId, + }), + [meshcoreStoreMessages, meshcoreOwnNodeIdSet, meshcoreRuntime.selfNodeId], + ); + const mecpReticulumSlice = useMemo( + () => ({ + protocol: 'reticulum' as const, + messages: reticulumStoreMessages, + ownNodeIds: reticulumOwnNodeIdSet, + ownSenderId: reticulumRuntime.state.myNodeNum, + }), + [reticulumStoreMessages, reticulumOwnNodeIdSet, reticulumRuntime.state.myNodeNum], + ); + useMecpAlertWatcher(mecpMeshtasticSlice, mecpMeshcoreSlice, mecpReticulumSlice); + useEffect(() => { if (!reticulumIdentityId || reticulumLastReadSanitizedRef.current) return; if (localStorage.getItem('mesh-client:lastReadSanitized:reticulum') === '1') { diff --git a/src/renderer/components/AppPanel.tsx b/src/renderer/components/AppPanel.tsx index 38658f45a..f16b56a55 100644 --- a/src/renderer/components/AppPanel.tsx +++ b/src/renderer/components/AppPanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { MecpRebroadcastSettings } from '@/renderer/components/mecp/MecpRebroadcastSettings'; import { copyDebugSnapshotToClipboard } from '@/renderer/lib/debugSnapshot'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { exportSupportBundleToDisk } from '@/renderer/lib/exportSupportBundle'; @@ -2142,6 +2143,8 @@ export default function AppPanel({ )} + + {/* Danger Zone — collapsible; same pattern as Appearance → Color scheme */}

{t('appPanel.dangerZoneSection')}

diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index 275fd3fc3..049406249 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -142,6 +142,12 @@ import { resolveChatDmPeer, } from '../lib/chatUnreadCounts'; import { applyControlledEditableValue } from '../lib/controlledEditableValue'; +import { + getCachedMecpLanguage, + localizeMecpCodes, + mecpLanguageForAppLocale, + tryParseMecp, +} from '../lib/mecp/mecpMessages'; import { findMeshcoreParentMessageForReply, meshcoreChatMessagesForDisplay, @@ -179,6 +185,8 @@ import { ChatDmPaperShareControl, ChatPaperScanControl } from './ChatDmPaperCont import { ChatPayloadText } from './ChatPayloadText'; import { ChatRfHopLabel } from './ChatRfHopLabel'; import { HelpTooltip } from './HelpTooltip'; +import { MecpComposeModal } from './mecp/MecpComposeModal'; +import { MecpSeverityBadge } from './mecp/MecpSeverityBadge'; import MeshcoreChatChannelManager from './MeshcoreChatChannelManager'; import { MessageStatusBadge } from './MessageStatusBadge'; import { RelayCoverageLine, relayCoverageMessageKey } from './RelayCoverageLine'; @@ -639,7 +647,7 @@ function ChatPanel({ resolveShareLocation, onSendLocationWaypoint, }: ChatPanelProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const capabilities = useRadioProvider(protocol); const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const parentIconTrigger = useParentIconTrigger(); @@ -905,6 +913,7 @@ function ChatPanel({ viewKey: string; } | null>(null); const [replyTo, setReplyTo] = useState(null); + const [mecpComposeOpen, setMecpComposeOpen] = useState(false); const [pickerOpenFor, setPickerOpenFor] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [showSearch, setShowSearch] = useState(false); @@ -3075,8 +3084,14 @@ function ChatPanel({ > {/* Message bubble */}
{ + const mecp = tryParseMecp(msg.payload); + if (mecp) { + return isOwn + ? 'border border-red-400/70 bg-red-900/30 font-semibold' + : 'border border-dashed border-red-500 bg-red-950/40 font-semibold'; + } + return compactMerged ? `${compactStackTop ? 'rounded-t-none border-t-0' : ''} ${compactStackBottom ? 'rounded-b-none border-b-0' : ''} ${ isDm ? isOwn @@ -3092,8 +3107,8 @@ function ChatPanel({ : `${isFollowedByContinuation ? 'rounded-bl-none' : 'rounded-bl-sm'} border border-purple-600/30 bg-purple-700/20${isContinuation ? 'rounded-tl-sm' : ''}` : isOwn ? `${isFollowedByContinuation ? 'rounded-br-none' : 'rounded-br-sm'} border border-blue-500/30 bg-blue-600/20${isContinuation ? 'rounded-tr-sm' : ''}` - : `${isFollowedByContinuation ? 'rounded-bl-none' : 'rounded-bl-sm'} border-chat-incoming-border border bg-chat-incoming-bg${isContinuation ? 'rounded-tl-sm' : ''}` - }`} + : `${isFollowedByContinuation ? 'rounded-bl-none' : 'rounded-bl-sm'} border-chat-incoming-border border bg-chat-incoming-bg${isContinuation ? 'rounded-tl-sm' : ''}`; + })()}`} > {/* Header: sender name (clickable) + DM indicator + time */} {!isContinuation && @@ -3341,6 +3356,24 @@ function ChatPanel({ }} /> )} + {(() => { + const mecp = tryParseMecp(msg.payload); + if (mecp?.severity == null) return null; + const lang = getCachedMecpLanguage( + mecpLanguageForAppLocale(i18n.language || 'en'), + ); + return ( +
+ +

+ {localizeMecpCodes(mecp, lang)} +

+
+ ); + })()}
{/* Transport + RF hop count (incoming) */} @@ -3718,6 +3751,42 @@ function ChatPanel({ {protocol === 'reticulum' && hasLxmfPaper ? ( ) : null} +
+ + +
+ { + setMecpComposeOpen(false); + }} + onSend={async (text) => { + await handleSendChunk(text); + }} + /> { + it('encodes and sends when codes selected', async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render( {}} onSend={onSend} />); + const injury = screen.queryByRole('button', { name: /M01/i }); + if (injury) { + await user.click(injury); + await user.click(screen.getByRole('button', { name: /send mecp/i })); + expect(onSend).toHaveBeenCalled(); + expect(String(onSend.mock.calls[0]?.[0])).toMatch(/^MECP\/0\//); + } + }); + + it('has no axe violations', async () => { + const { container } = render( {}} onSend={() => {}} />); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe('MecpSeverityBadge', () => { + it('has no axe violations for MAYDAY', async () => { + const { container } = render(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/renderer/components/mecp/MecpComposeModal.tsx b/src/renderer/components/mecp/MecpComposeModal.tsx new file mode 100644 index 000000000..ed2543cd7 --- /dev/null +++ b/src/renderer/components/mecp/MecpComposeModal.tsx @@ -0,0 +1,276 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + CATEGORIES, + type CategoryLetter, + encode, + getByteLength, + getCachedMecpLanguage, + loadMecpLanguage, + MAX_MESSAGE_BYTES, + mecpLanguageForAppLocale, + type Severity, + severityLabelKey, +} from '@/renderer/lib/mecp/mecpMessages'; + +const SEVERITY_ORDER: Severity[] = [0, 1, 2, 3]; + +interface MecpComposeModalProps { + open: boolean; + onClose: () => void; + onSend: (mecpString: string) => void | Promise; +} + +export function MecpComposeModal({ open, onClose, onSend }: MecpComposeModalProps) { + const { t, i18n } = useTranslation(); + const [severity, setSeverity] = useState(0); + const [category, setCategory] = useState('M'); + const [codes, setCodes] = useState([]); + const [freetext, setFreetext] = useState(''); + const [drill, setDrill] = useState(false); + const [langFile, setLangFile] = useState(() => + getCachedMecpLanguage(mecpLanguageForAppLocale(i18n.language || 'en')), + ); + const [sending, setSending] = useState(false); + + useEffect(() => { + void loadMecpLanguage(mecpLanguageForAppLocale(i18n.language || 'en')).then(setLangFile); + }, [i18n.language]); + + const codesForCategory = useMemo(() => { + return Object.keys(langFile.codes) + .filter((c) => c.startsWith(category)) + .sort(); + }, [langFile.codes, category]); + + const effectiveCodes = useMemo(() => { + const list = [...codes]; + if (drill && !list.includes('D01')) list.unshift('D01'); + return list; + }, [codes, drill]); + + const encoded = useMemo( + () => encode(severity, effectiveCodes, freetext.trim() || undefined), + [severity, effectiveCodes, freetext], + ); + + const byteLen = encoded.byteLength || getByteLength(encoded.message); + const canSend = effectiveCodes.length > 0 && !encoded.overLimit && !sending; + + const toggleCode = useCallback((code: string) => { + setCodes((prev) => (prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code])); + }, []); + + const attachGps = useCallback(() => { + if (typeof navigator === 'undefined' || !navigator.geolocation) return; + navigator.geolocation.getCurrentPosition( + (pos) => { + const gps = `${pos.coords.latitude.toFixed(5)},${pos.coords.longitude.toFixed(5)}`; + setFreetext((prev) => (prev.trim() ? `${prev.trim()} ${gps}` : gps)); + }, + (err) => { + console.warn('[MecpComposeModal] GPS failed', err.message); + }, + { enableHighAccuracy: true, timeout: 10_000 }, + ); + }, []); + + const handleSend = useCallback(async () => { + if (!canSend) return; + setSending(true); + try { + await onSend(encoded.message); + onClose(); + setCodes([]); + setFreetext(''); + setDrill(false); + } finally { + setSending(false); + } + }, [canSend, encoded.message, onClose, onSend]); + + if (!open) return null; + + return ( +
+
+
+

{t('mecp.compose.title')}

+ +
+ +

{t('mecp.compose.severity')}

+
+ {SEVERITY_ORDER.map((s) => ( + + ))} +
+ +

{t('mecp.compose.category')}

+
+ {(Object.keys(CATEGORIES) as CategoryLetter[]).map((letter) => ( + + ))} +
+ +

{t('mecp.compose.codes')}

+
+ {codesForCategory.map((code) => ( + + ))} +
+ {codes.length > 0 ? ( +
+ {codes.map((code) => ( + + ))} +
+ ) : null} + + +