From 284b47d7cab77276d6f85afff2d52eae4b7877b2 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Fri, 31 Jul 2026 10:35:05 -0600 Subject: [PATCH 1/4] feat(reticulum): RMAP X of Y status and all-eligible Network publish Show publishing coverage as X of Y with amber for partial, check Network publish only when every eligible interface is discoverable, and lock the type matrix with tests and docs. --- AGENTS.md | 2 +- docs/reticulum.md | 10 +- docs/troubleshooting.md | 3 +- .../ReticulumRmapConnectionStatus.test.tsx | 49 +++++- .../ReticulumRmapConnectionStatus.tsx | 25 +-- .../ReticulumRmapDiscoveryControls.test.tsx | 116 +++++++++++++ .../ReticulumRmapDiscoveryControls.tsx | 14 +- .../ReticulumInterfacesPanel.test.tsx | 68 ++++++++ .../reticulum/reticulumRmapDiscovery.test.ts | 164 ++++++++++++++---- .../lib/reticulum/reticulumRmapDiscovery.ts | 44 ++++- src/renderer/locales/cs/translation.json | 9 +- src/renderer/locales/de/translation.json | 9 +- src/renderer/locales/en/translation.json | 5 +- src/renderer/locales/es/translation.json | 9 +- src/renderer/locales/fr/translation.json | 9 +- src/renderer/locales/id/translation.json | 9 +- src/renderer/locales/it/translation.json | 9 +- src/renderer/locales/ja/translation.json | 9 +- src/renderer/locales/ko/translation.json | 9 +- src/renderer/locales/nl/translation.json | 9 +- src/renderer/locales/pl/translation.json | 9 +- src/renderer/locales/pt-BR/translation.json | 9 +- src/renderer/locales/ru/translation.json | 9 +- src/renderer/locales/tr/translation.json | 9 +- src/renderer/locales/uk/translation.json | 9 +- src/renderer/locales/zh/translation.json | 9 +- 26 files changed, 499 insertions(+), 136 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 739e69802..1d6d3d065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,7 +146,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **Sidecar:** `reticulum-sidecar/` (AGPL Rust binary `mesh-client-reticulum`; path deps `rsReticulum`/`rsLXMF`/`rsNomad`); dev: `pnpm run reticulum:sidecar:dev` - **IPC:** `reticulum:*` main handlers — `start` / `stop` / `getStatus` / `syncInterfaceIssueScope`, `proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete`, **`factoryReset`** (blocked on generic proxy), config file read/import dialog, `showNomadContentSourceDialog`, `setNomadContentSource`, Remote `rncpSend` / `rncpFetch` / `setRncpListener` / `showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` / `revealInFolder`. Also `media:ensureCameraAccess`, `gps:exportGpx`, `db:setReticulumDestinationVerified`, Remote DB `db:listReticulumRemoteAddresses` / upsert / delete and `db:listReticulumInboundPolicy` / upsert / delete (`src/main/ipc/reticulum-db-handlers.ts`), `mesh-client:openUrl` / `electronAPI.deepLink.onOpenUrl`. Renderer uses `electronAPI.reticulum` proxy (no direct localhost). `ReticulumStackPanel` + `useReticulumInterfaceSnapshot` sync enabled interface names after hydrate so TCP/TX issue banners clear when hubs are disabled; `reticulumSidecarIssueTracker` keeps that enabled set sticky while reading sidecar logs. -- **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces, issue banner), `ReticulumNetworkPanel` (Network — identity **slots** + QR share/ingest, stack/announce settings, propagation rename/delete, config import), `ReticulumMapPanel` (Map — RMAP v4 discovery), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus` (RMAP publish), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers — path request + probe + verified badge), `NomadNetworkPanel` (Nomad — browse + **My Pages** watched-folder static host via `NomadPageServerPanel`/rsNomad; `nomad_serving_enabled` + `nomad_serving_content_source` restore hosting after live stack start; lazy-mount keep-alive, dual-axis page scroll; fit-width default and open-width toggle), `ReticulumRemotePanel` (Remote — rnsh multi-session shell + rncp send/receive/fetch; Saved addresses + inbound policy; Chat DM send-file via `ChatDmRncpControl`), `RrcPanel` (RRC — multi-hub relay chat) +- **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces, issue banner), `ReticulumNetworkPanel` (Network — identity **slots** + QR share/ingest, stack/announce settings, propagation rename/delete, config import), `ReticulumMapPanel` (Map — RMAP v4 discovery), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus` (RMAP publish: Network enable-all eligible interfaces; Connection **X of Y** status), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers — path request + probe + verified badge), `NomadNetworkPanel` (Nomad — browse + **My Pages** watched-folder static host via `NomadPageServerPanel`/rsNomad; `nomad_serving_enabled` + `nomad_serving_content_source` restore hosting after live stack start; lazy-mount keep-alive, dual-axis page scroll; fit-width default and open-width toggle), `ReticulumRemotePanel` (Remote — rnsh multi-session shell + rncp send/receive/fetch; Saved addresses + inbound policy; Chat DM send-file via `ChatDmRncpControl`), `RrcPanel` (RRC — multi-hub relay chat) - **Deep links / QR:** OS scheme is **`lxm://`** (not `mesh-client://`); `MeshClientDeepLinkHost`, `meshClientDeepLink.ts`, `QrIngestControl` / `QrCodeImage`. External contact imports confirm before upsert. - **Decommissioned hubs:** `src/shared/reticulumDecommissionedHubs.ts` (Dublin / Amsterdam / BetweenTheBorders) — stack-start auto-disable + **Add default hubs** disables matching enabled TCP rows; keep TS↔Rust synced via `pnpm run check:reticulum-decommissioned-hubs`. Current presets: US-East / I2P / Yggdrasil / Ratspeak / RMAP World - **Propagation sync:** `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation — `refreshFromSidecar` must **not** clear the active attempt while `sync.active` diff --git a/docs/reticulum.md b/docs/reticulum.md index 58497a59b..0a685ca45 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -94,7 +94,13 @@ The **Map** tab shows **local** RMAP v4 discovery data — interfaces your stack | **Map tab (local)** | rsReticulum `DiscoveryStore` via `GET /api/v1/rmap/discovered`; refreshed on a timer and via WebSocket `rmap.discovery` | | **Global map (external)** | [rmap.world](https://rmap.world/) — link in Map tab and Network → RMAP controls | -**Publish (appear on maps):** Network → **RMAP v4 discovery** or per-interface RMAP toggles on Connection. Requires App → GPS coordinates for map markers. LoRa-only stacks need an enabled TCP hub (for example `rmap.world:4242`) so discovery announces reach the wider network — see config audit `rmap_no_tcp_hub`. +**Publish (appear on maps):** Network → **RMAP v4 discovery** or per-interface **RMAP** toggles on Connection. Requires App → GPS coordinates for map markers. LoRa-only stacks need an enabled TCP hub (for example `rmap.world:4242`) so discovery announces reach the wider network — see config audit `rmap_no_tcp_hub`. + +**Eligible publish interfaces:** enabled RNode / RNode Multi / KISS (with serial port), BLE peer, I2P, UDP, and pipe. **Not eligible:** Auto, outbound TCP client hubs (including community presets), and system-managed shared-instance rows. + +**Network → Publish on RMAP v4:** enables `discoverable` on **all** eligible enabled interfaces (plus LoRa/BLE transport + `rmap.world` hub when needed). The checkbox is checked only when every eligible interface is publishing; a partial set is indeterminate — check again to sync the rest. Uncheck clears discoverable on all eligible rows. + +**Connection status:** shows **publishing X of Y** (eligible interfaces only; TCP hubs do not count toward Y). Amber when `0 < X < Y`, brand green when `X === Y`, gray when not publishing. **Consume (Map tab):** Sidecar bootstrap migrations in rnsd config: `discover_interfaces = Yes` so the stack listens for discovery announces; when `announce_interval_sec` is absent, writes **3600** (explicit **0** is preserved). Markers show GPS when coordinates were included in the announce; interfaces without coords appear in the sidebar list only. **Reachable** badges join discovery rows with the RNS path table (Peers tab) by matching `transport_id` against peer `destination_hash` or `via_hash`. @@ -112,7 +118,7 @@ Reticulum destination age prune is enabled by default at **30 days** and affects **Config audit kinds:** `rmap_missing_coordinates`, `rmap_no_tcp_hub`, `rmap_transport_disabled`, `rmap_i2p_not_connectable`. -**Implementation:** `ReticulumMapPanel.tsx`, `reticulumDiscoveryMapStore.ts`, `reticulumDiscoveryMapLayout.ts`, `reticulumRmapDiscovery.ts`, `useReticulumRuntime.ts` (WS `rmap.discovery`). +**Implementation:** `ReticulumMapPanel.tsx`, `reticulumDiscoveryMapStore.ts`, `reticulumDiscoveryMapLayout.ts`, `reticulumRmapDiscovery.ts` (capable gate, Network all-eligible checked state, X-of-Y tone), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus`, `useReticulumRuntime.ts` (WS `rmap.discovery`). **Related panels:** **Topology** = logical hops (no geography); **Peers** = path table; **Map** = geographic discovery + reachability. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 620c1deef..124414c3c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1090,7 +1090,8 @@ Unrecognized codes pass through unchanged. 7. **Stub sidecar** — dev builds without `rns-stack` return an empty discovered list. 8. **Filter empty** — interface-type filter pills may exclude all rows; try **All**. 9. **Refresh errors** — transient sidecar errors show inline `refreshFailed` without clearing last-good markers. -10. **No publish-capable interface** — Auto and outbound TCP client types cannot publish RMAP discovery. +10. **No publish-capable interface** — Auto and outbound TCP client types cannot publish RMAP discovery. Eligible types are RNode / RNode Multi / KISS (with serial), BLE peer, I2P, UDP, and pipe. +11. **Partial publishing (amber X of Y)** — Connection shows **publishing X of Y** in amber when some but not all eligible interfaces have `discoverable=yes`. TCP hubs never count toward Y. Use Network → **Publish on RMAP v4** (check again while indeterminate) or per-interface **RMAP** toggles on Connection to sync the rest. ### Reticulum BLE RNode blocks Meshtastic/MeshCore Noble BLE diff --git a/src/renderer/components/ReticulumRmapConnectionStatus.test.tsx b/src/renderer/components/ReticulumRmapConnectionStatus.test.tsx index a8af83995..fa2cdcf5a 100644 --- a/src/renderer/components/ReticulumRmapConnectionStatus.test.tsx +++ b/src/renderer/components/ReticulumRmapConnectionStatus.test.tsx @@ -25,7 +25,7 @@ function iface( } describe('ReticulumRmapConnectionStatus', () => { - it('shows not publishing and opens Network settings', async () => { + it('shows not publishing in gray and opens Network settings', async () => { const user = userEvent.setup(); const onOpen = vi.fn(); render( @@ -35,7 +35,10 @@ describe('ReticulumRmapConnectionStatus', () => { onOpenRmapSettings={onOpen} />, ); - expect(screen.getByText('connectionPanel.reticulumRmap.notPublishing:{}')).toBeInTheDocument(); + const status = screen.getByText('connectionPanel.reticulumRmap.notPublishing:{}'); + expect(status).toBeInTheDocument(); + expect(status).toHaveClass('text-gray-400'); + expect(status).not.toHaveClass('text-amber-300'); await user.click( screen.getByRole('button', { name: 'connectionPanel.reticulumRmap.openSettingsAria:{}' }), ); @@ -45,21 +48,53 @@ describe('ReticulumRmapConnectionStatus', () => { ).toHaveAttribute('href', 'https://rmap.world/'); }); - it('shows publishing count and needs-sync warning', () => { + it('shows amber X of Y when partially publishing', () => { render( , ); + const status = screen.getByText( + 'connectionPanel.reticulumRmap.publishingOf:{"current":1,"total":2}', + ); + expect(status).toBeInTheDocument(); + expect(status).toHaveClass('text-amber-300'); + expect(screen.queryByText(/needsSync/)).not.toBeInTheDocument(); + }); + + it('shows green X of Y when fully publishing', () => { + render( + , + ); + const status = screen.getByText( + 'connectionPanel.reticulumRmap.publishingOf:{"current":3,"total":3}', + ); + expect(status).toHaveClass('text-brand-green'); + expect(status).not.toHaveClass('text-amber-300'); + }); + + it('excludes tcp hubs from Y and shows noPublishTargets when none eligible', () => { + render( + , + ); + expect(screen.getByText('connectionPanel.reticulumRmap.notPublishing:{}')).toBeInTheDocument(); expect( - screen.getByText('connectionPanel.reticulumRmap.publishing:{"count":1}'), - ).toBeInTheDocument(); - expect( - screen.getByText('connectionPanel.reticulumRmap.needsSync:{"count":1}'), + screen.getByText('connectionPanel.reticulumRmap.noPublishTargets:{}'), ).toBeInTheDocument(); }); }); diff --git a/src/renderer/components/ReticulumRmapConnectionStatus.tsx b/src/renderer/components/ReticulumRmapConnectionStatus.tsx index a81964c18..5ce8f30cd 100644 --- a/src/renderer/components/ReticulumRmapConnectionStatus.tsx +++ b/src/renderer/components/ReticulumRmapConnectionStatus.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { RMAP_GLOBAL_MAP_URL, + rmapPublishCoverageTone, summarizeRmapPublishStatus, } from '@/renderer/lib/reticulum/reticulumRmapDiscovery'; import type { ReticulumInterfaceRow } from '@/renderer/lib/reticulum/useReticulumInterfaceSnapshot'; @@ -21,20 +22,25 @@ export function ReticulumRmapConnectionStatus({ }: ReticulumRmapConnectionStatusProps) { const { t } = useTranslation(); const summary = useMemo(() => summarizeRmapPublishStatus(interfaces), [interfaces]); + const tone = useMemo(() => rmapPublishCoverageTone(summary), [summary]); if (!sidecarApiReady) { return null; } + const statusClass = + tone === 'full' ? 'text-brand-green' : tone === 'partial' ? 'text-amber-300' : 'text-gray-400'; + return (
- - {summary.publishing - ? t('connectionPanel.reticulumRmap.publishing', { - count: summary.discoverableCount, - }) - : t('connectionPanel.reticulumRmap.notPublishing')} + + {tone === 'off' + ? t('connectionPanel.reticulumRmap.notPublishing') + : t('connectionPanel.reticulumRmap.publishingOf', { + current: summary.discoverableCount, + total: summary.publishTargetCount, + })} {onOpenRmapSettings ? (
- {summary.needsSyncCount > 0 ? ( -

- {t('connectionPanel.reticulumRmap.needsSync', { count: summary.needsSyncCount })} -

- ) : null} - {summary.publishing && summary.publishTargetCount === 0 ? ( + {summary.publishTargetCount === 0 ? (

{t('connectionPanel.reticulumRmap.noPublishTargets')}

) : null}
diff --git a/src/renderer/components/ReticulumRmapDiscoveryControls.test.tsx b/src/renderer/components/ReticulumRmapDiscoveryControls.test.tsx index 273dc97b2..cded9183c 100644 --- a/src/renderer/components/ReticulumRmapDiscoveryControls.test.tsx +++ b/src/renderer/components/ReticulumRmapDiscoveryControls.test.tsx @@ -109,6 +109,122 @@ describe('ReticulumRmapDiscoveryControls', () => { expect(await screen.findByText('reticulumRmapDiscovery.restartTitle')).toBeInTheDocument(); }); + it('enables RMAP on every eligible interface when Network publish is checked', async () => { + localStorage.setItem( + GPS_SETTINGS_STORAGE_KEY, + JSON.stringify({ staticLat: 40.01, staticLon: -105.02 }), + ); + window.electronAPI.reticulum.proxyGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/v1/interfaces') { + return Promise.resolve({ + interfaces: [ + { + id: 'rnode-1', + name: 'LoRa', + type: 'rnode', + enabled: true, + status: 'up', + serial_port: '/dev/ttyUSB0', + discoverable: false, + }, + { + id: 'ble-1', + name: 'BLE', + type: 'ble_peer', + enabled: true, + status: 'up', + discoverable: false, + }, + { + id: 'i2p-1', + name: 'I2P', + type: 'i2p', + enabled: true, + status: 'up', + discoverable: false, + }, + { + id: 'hub-1', + name: 'RMAP World', + type: 'tcp', + enabled: true, + status: 'up', + host: 'rmap.world', + port: 4242, + discoverable: false, + }, + ], + }); + } + if (path === '/api/v1/stack/settings') { + return Promise.resolve({ + enable_transport: true, + share_instance: true, + loglevel: 4, + }); + } + return Promise.resolve({}); + }); + const user = userEvent.setup(); + renderControls(); + await screen.findByLabelText('reticulumRmapDiscovery.publishToggle'); + await user.click(screen.getByLabelText('reticulumRmapDiscovery.publishToggle')); + + await waitFor(() => { + expect(window.electronAPI.reticulum.proxyPut).toHaveBeenCalledWith( + '/api/v1/interfaces/rnode-1', + expect.objectContaining({ discoverable: true }), + ); + }); + expect(window.electronAPI.reticulum.proxyPut).toHaveBeenCalledWith( + '/api/v1/interfaces/ble-1', + expect.objectContaining({ discoverable: true }), + ); + expect(window.electronAPI.reticulum.proxyPut).toHaveBeenCalledWith( + '/api/v1/interfaces/i2p-1', + expect.objectContaining({ discoverable: true, connectable: true }), + ); + expect(window.electronAPI.reticulum.proxyPut).not.toHaveBeenCalledWith( + '/api/v1/interfaces/hub-1', + expect.anything(), + ); + }); + + it('shows indeterminate Network checkbox when only some eligible interfaces publish', async () => { + window.electronAPI.reticulum.proxyGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/v1/interfaces') { + return Promise.resolve({ + interfaces: [ + { + id: 'rnode-1', + name: 'LoRa', + type: 'rnode', + enabled: true, + status: 'up', + serial_port: '/dev/ttyUSB0', + discoverable: true, + }, + { + id: 'ble-1', + name: 'BLE', + type: 'ble_peer', + enabled: true, + status: 'up', + discoverable: false, + }, + ], + }); + } + return Promise.resolve({}); + }); + renderControls(); + const checkbox = await screen.findByLabelText('reticulumRmapDiscovery.publishToggle'); + await waitFor(() => { + expect(checkbox).not.toBeChecked(); + expect(checkbox).toHaveProperty('indeterminate', true); + }); + }); + it('has no serious axe violations on GPS warning state', async () => { const { container } = renderControls(); await screen.findByText('reticulumRmapDiscovery.gpsMissingWarning'); diff --git a/src/renderer/components/ReticulumRmapDiscoveryControls.tsx b/src/renderer/components/ReticulumRmapDiscoveryControls.tsx index 6d32f4743..3aa1e7128 100644 --- a/src/renderer/components/ReticulumRmapDiscoveryControls.tsx +++ b/src/renderer/components/ReticulumRmapDiscoveryControls.tsx @@ -1,6 +1,6 @@ /* eslint-disable react-hooks/set-state-in-effect */ import { ExternalLink } from 'lucide-react-motion'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { getAppSettingsRaw } from '@/renderer/lib/appSettingsStorage'; @@ -12,6 +12,7 @@ import { clampRmapAnnounceIntervalMin, disableReticulumRmapDiscovery, persistRmapUiPrefs, + readRmapPublishPartial, readRmapPublishState, resolveRmapCoordinates, ReticulumRmapGpsRequiredError, @@ -46,6 +47,8 @@ export function ReticulumRmapDiscoveryControls({ const { addToast } = useToast(); const [interfaces, setInterfaces] = useState([]); const [publishOn, setPublishOn] = useState(false); + const [publishPartial, setPublishPartial] = useState(false); + const publishCheckboxRef = useRef(null); const [announceIntervalMin, setAnnounceIntervalMin] = useState( RMAP_ANNOUNCE_INTERVAL_DEFAULT_MIN, ); @@ -85,6 +88,7 @@ export function ReticulumRmapDiscoveryControls({ const rows = body.interfaces ?? []; setInterfaces(rows); setPublishOn(readRmapPublishState(rows)); + setPublishPartial(readRmapPublishPartial(rows)); } catch (e) { console.debug('[ReticulumRmapDiscoveryControls] refresh ' + errLikeToLogString(e)); } @@ -98,6 +102,13 @@ export function ReticulumRmapDiscoveryControls({ void refreshInterfaces(); }, [refreshInterfaces]); + useEffect(() => { + const el = publishCheckboxRef.current; + if (el) { + el.indeterminate = publishPartial; + } + }, [publishPartial]); + useEffect(() => { setCoords(resolveRmapCoordinates()); }, [publishOn, interfaces.length]); @@ -215,6 +226,7 @@ export function ReticulumRmapDiscoveryControls({