Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -165,7 +165,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`).
- **Self label / header:** `reticulumSelfNodeLabel.ts` (`resolveReticulumSelfHeaderLabel` — Network display name in app header)
- **Nomad errors:** `lib/nomad/nomadPageErrorHumanize.ts` (sidecar error codes → i18n); LinkClient Nomad overlay in `reticulum-sidecar/patches/`
- **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities`
- **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); peer reply `mesh-client:rncp-receive-dest:v1:<hash>` autofills via `applyRncpReceiveDestShare` only after `markRncpReceiveDestSharePending` (from `sendRncpRequestEnable`) within TTL; inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start
- **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); peer reply `mesh-client:rncp-receive-dest:v1:<hash>` autofills via `applyRncpReceiveDestShare` (prefer pending from `markRncpReceiveDestSharePending` / `sendRncpRequestEnable`; still apply without pending for older peers); enable-request modal + dest-share side effects deduped by LXMF `message_hash` (`rncpLxmfControlSideEffectDedup`) so catch-up cannot re-fire; already-listening auto-share is once per peer per request-enable cooldown; inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start
- **Runtime:** `useReticulumRuntime`, `lib/sessions/reticulumSession.ts`, `lib/ingest/reticulumIngest.ts`; connect starts sidecar, not `ConnectionDriver` RF. Sidecar RRC: `rrc_codec` / `rrc_link` / `rrc_session` / `api/rrc.rs`
- **Diagnostics:** `ReticulumDiagnosticEngine.ts` (Reticulum-native rows; no LoRa hop-goblin semantics) — includes `reticulum/sidecar-unhealthy` (60s grace), `reticulum/propagation-sync-stuck`, `reticulum/propagation-sync-failing` (1h TTL)
- **No Noble/MQTT** for Reticulum's own connections (sidecar owns BLE RNode via `btleplug`); gate UI with `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities`. On macOS/Windows, connecting a Reticulum BLE RNode may still **suspend/yield Noble** so it does not contend with the sidecar's BLE scan — see **Multi-protocol BLE** below.
Expand Down
10 changes: 8 additions & 2 deletions docs/reticulum.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1322,7 +1323,7 @@ See [reticulum.md — RNode over Wi-Fi](reticulum.md#rnode-over-wi-fi).

**Chat DM note**: the destination field is the peer's **`rncp.receive`** hash, not their LXMF/Chat hash. Prefer **Request enable** (mesh-client peers share the receive hash after they accept) or paste from their Remote → **My rncp receive destination**.

**Request enable / 422**: `sendRncpRequestEnable` must POST LXMF with a `text` field (not `content`) — wrong key → HTTP **422**. After you send request-enable, the peer's `mesh-client:rncp-receive-dest:v1:<hash>` reply is applied only if a pending mark exists (`rncpReceiveDestSharePending`, TTL); shares without a prior request-enable from this session are ignored.
**Request enable / 422**: `sendRncpRequestEnable` must POST LXMF with a `text` field (not `content`) — wrong key → HTTP **422**. After you send request-enable, the peer's `mesh-client:rncp-receive-dest:v1:<hash>` reply is applied when present (prefer a pending mark from `rncpReceiveDestSharePending` in this session; shares without that mark are still applied so older peers / pasted hashes autofill). Inbound enable-request modal enqueue and dest-share apply are deduped by LXMF `message_hash` so periodic catch-up / WS duplicates do not re-open the modal or re-toast; when the peer is already listening, dest is auto-shared at most once per peer per outbound request-enable cooldown.

**Sleep / wake**: Suspend clears in-memory rnsh sessions and rncp transfers; reopen Remote (or wait for resume reconnect) after wake. See [Sleep, wake, and long-running sessions](#sleep-wake-and-long-running-sessions).

Expand Down
57 changes: 51 additions & 6 deletions src/renderer/components/ReticulumRmapConnectionStatus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import { render, screen } 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 type { ReticulumInterfaceRow } from '@/renderer/lib/reticulum/useReticulumInterfaceSnapshot';

import { ReticulumRmapConnectionStatus } from './ReticulumRmapConnectionStatus';
Expand All @@ -25,7 +27,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(
Expand All @@ -35,7 +37,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:{}' }),
);
Expand All @@ -45,21 +50,61 @@ describe('ReticulumRmapConnectionStatus', () => {
).toHaveAttribute('href', 'https://rmap.world/');
});

it('shows publishing count and needs-sync warning', () => {
it('shows amber X of Y when partially publishing', async () => {
render(
<ReticulumRmapConnectionStatus
sidecarApiReady
interfaces={[
iface({ id: 'r1', type: 'rnode', serial_port: '/dev/ttyUSB0', discoverable: true }),
iface({ id: 'r2', type: 'ble_peer', discoverable: false }),
iface({ id: 't', type: 'tcp', host: 'rmap.world', port: 4242 }),
]}
/>,
);
const status = screen.getByRole('status');
expect(status).toHaveTextContent(
'connectionPanel.reticulumRmap.publishingOf:{"current":1,"total":2}',
);
expect(
screen.getByText('connectionPanel.reticulumRmap.publishing:{"count":1}'),
).toBeInTheDocument();
screen.getByText('connectionPanel.reticulumRmap.publishingOf:{"current":1,"total":2}'),
).toHaveClass('text-amber-300');
expect(screen.queryByText(/needsSync/)).not.toBeInTheDocument();
hydrateAxeThemeColors(status);
expect(await axe(status)).toHaveNoViolations();
});

it('shows green X of Y when fully publishing', async () => {
render(
<ReticulumRmapConnectionStatus
sidecarApiReady
interfaces={[
iface({ id: 'r1', type: 'rnode', serial_port: '/dev/ttyUSB0', discoverable: true }),
iface({ id: 'r2', type: 'ble_peer', discoverable: true }),
iface({ id: 'i', type: 'i2p', discoverable: true }),
]}
/>,
);
const status = screen.getByRole('status');
expect(
screen.getByText('connectionPanel.reticulumRmap.publishingOf:{"current":3,"total":3}'),
).toHaveClass('text-brand-green');
expect(
screen.getByText('connectionPanel.reticulumRmap.publishingOf:{"current":3,"total":3}'),
).not.toHaveClass('text-amber-300');
hydrateAxeThemeColors(status);
expect(await axe(status)).toHaveNoViolations();
});

it('excludes tcp hubs from Y and shows noPublishTargets when none eligible', () => {
render(
<ReticulumRmapConnectionStatus
sidecarApiReady
interfaces={[iface({ id: 't', type: 'tcp', host: 'rmap.world', port: 4242 })]}
/>,
);
expect(screen.getByText('connectionPanel.reticulumRmap.notPublishing:{}')).toBeInTheDocument();
expect(
screen.getByText('connectionPanel.reticulumRmap.needsSync:{"count":1}'),
screen.getByText('connectionPanel.reticulumRmap.noPublishTargets:{}'),
).toBeInTheDocument();
});
});
25 changes: 13 additions & 12 deletions src/renderer/components/ReticulumRmapConnectionStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 (
<div className="rounded border border-gray-700 bg-slate-900/40 px-3 py-2 text-xs" role="status">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className={summary.publishing ? 'text-brand-green' : 'text-gray-400'}>
{summary.publishing
? t('connectionPanel.reticulumRmap.publishing', {
count: summary.discoverableCount,
})
: t('connectionPanel.reticulumRmap.notPublishing')}
<span className={statusClass}>
{tone === 'off'
? t('connectionPanel.reticulumRmap.notPublishing')
: t('connectionPanel.reticulumRmap.publishingOf', {
current: summary.discoverableCount,
total: summary.publishTargetCount,
})}
</span>
{onOpenRmapSettings ? (
<button
Expand All @@ -56,12 +62,7 @@ export function ReticulumRmapConnectionStatus({
{t('connectionPanel.reticulumRmap.openGlobalMap')}
</a>
</div>
{summary.needsSyncCount > 0 ? (
<p className="mt-1 text-amber-300">
{t('connectionPanel.reticulumRmap.needsSync', { count: summary.needsSyncCount })}
</p>
) : null}
{summary.publishing && summary.publishTargetCount === 0 ? (
{summary.publishTargetCount === 0 ? (
<p className="mt-1 text-gray-400">{t('connectionPanel.reticulumRmap.noPublishTargets')}</p>
) : null}
</div>
Expand Down
Loading