Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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