From 67655f5ffee04e849e81bc4deff2e9fcec03751e Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 30 Jul 2026 07:04:59 -0600 Subject: [PATCH 1/8] fix: harden BLE reconnect, cross-protocol prune, and log noise Stop Meshtastic BLE reconnect flaps from overlapping opens, run retention for all protocols regardless of last-active tab, default sidecar RUST_LOG to warn with stdout filtering, and repair all NULL message statuses. --- src/main/db-schema-sync.test.ts | 34 +++ src/main/db-schema-sync.ts | 3 +- src/main/reticulum-sidecar-manager.test.ts | 2 + src/main/reticulum-sidecar-manager.ts | 4 + src/main/reticulumSidecarStderrLog.test.ts | 42 ++++ src/main/reticulumSidecarStderrLog.ts | 23 ++ src/main/verify-backup-repairs.test.ts | 9 +- src/renderer/lib/connection.ts | 5 +- .../meshtasticRuntimeWireEffects.ts | 21 +- .../meshtasticSdkRoutingErrorConsoleHook.ts | 18 +- .../meshtasticSdkRoutingErrorLog.test.ts | 15 ++ src/renderer/lib/meshtasticBacklogUtils.ts | 6 +- src/renderer/lib/startupDbPrune.test.ts | 57 +++++ src/renderer/lib/startupDbPrune.ts | 222 +++++++++--------- ...htasticRuntime.reconnect-hardening.test.ts | 38 +++ src/renderer/runtime/useMeshtasticRuntime.ts | 87 ++++++- 16 files changed, 428 insertions(+), 158 deletions(-) diff --git a/src/main/db-schema-sync.test.ts b/src/main/db-schema-sync.test.ts index 2469bb5e1..7f26e6452 100644 --- a/src/main/db-schema-sync.test.ts +++ b/src/main/db-schema-sync.test.ts @@ -85,6 +85,40 @@ describe('runSchemaUpgrade', { timeout: 30_000 }, () => { db.close(); }); + it('repairs NULL messages.status including rows with null received_via and packet_id', () => { + dir = mkdtempSync(join(tmpdir(), 'mesh-schema-null-status-')); + const db = new NodeSqliteDB(join(dir, 'test.db')); + db.pragma('journal_mode = WAL'); + runSchemaUpgrade(db); + + const ts = Date.now(); + db.prepareOnce( + `INSERT INTO messages (sender_id, payload, channel, timestamp, packet_id, status, received_via) + VALUES (1, 'with packet', 0, ?, 42, NULL, 'rf')`, + ).run(ts); + db.prepareOnce( + `INSERT INTO messages (sender_id, payload, channel, timestamp, packet_id, status, received_via) + VALUES (2, 'emoji only', 0, ?, NULL, NULL, NULL)`, + ).run(ts + 1); + + runSchemaUpgrade(db); + + const nullCount = ( + db.prepareOnce('SELECT COUNT(*) as c FROM messages WHERE status IS NULL').get() as { + c: number; + } + ).c; + expect(nullCount).toBe(0); + const statuses = db + .prepareOnce('SELECT payload, status FROM messages ORDER BY timestamp') + .all() as { payload: string; status: string }[]; + expect(statuses).toEqual([ + { payload: 'with packet', status: 'acked' }, + { payload: 'emoji only', status: 'acked' }, + ]); + db.close(); + }); + it('converts millisecond nodes.last_heard to Unix seconds (v36)', () => { dir = mkdtempSync(join(tmpdir(), 'mesh-schema-last-heard-')); const db = new NodeSqliteDB(join(dir, 'test.db')); diff --git a/src/main/db-schema-sync.ts b/src/main/db-schema-sync.ts index 8e3e6334e..d71fe012d 100644 --- a/src/main/db-schema-sync.ts +++ b/src/main/db-schema-sync.ts @@ -829,8 +829,7 @@ function repairMeshtasticInboundNullStatus(db: NodeSqliteDB): void { if (!tableExists(db, 'messages')) return; db.prepare( `UPDATE messages SET status = 'acked' - WHERE status IS NULL - AND (received_via IS NOT NULL OR packet_id IS NOT NULL)`, + WHERE status IS NULL`, ).run(); } diff --git a/src/main/reticulum-sidecar-manager.test.ts b/src/main/reticulum-sidecar-manager.test.ts index 64a750eb3..773cd4c0d 100644 --- a/src/main/reticulum-sidecar-manager.test.ts +++ b/src/main/reticulum-sidecar-manager.test.ts @@ -241,6 +241,8 @@ describe('ReticulumSidecarManager', () => { expect(first.running).toBe(true); expect(first.port).toBeGreaterThan(0); expect(first.pid).toBe(4242); + const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv | undefined; + expect(spawnEnv?.RUST_LOG).toBe('warn'); await manager.stop(); diff --git a/src/main/reticulum-sidecar-manager.ts b/src/main/reticulum-sidecar-manager.ts index 9277e3e5e..9d1ee1881 100644 --- a/src/main/reticulum-sidecar-manager.ts +++ b/src/main/reticulum-sidecar-manager.ts @@ -31,7 +31,9 @@ import { ReticulumSidecarAutoBeaconTracker } from './reticulumSidecarAutoBeaconT import { ReticulumSidecarInterfaceIssueTracker } from './reticulumSidecarIssueTracker'; import { logReticulumSidecarStderrLine, + resolveSidecarRustLog, ReticulumSidecarStderrDedupe, + shouldForwardReticulumSidecarStdout, } from './reticulumSidecarStderrLog'; import { startSidecarWatchdog } from './reticulumSidecarWatchdog'; @@ -50,6 +52,7 @@ export function sidecarChildEnv(): NodeJS.ProcessEnv { TMPDIR: process.env.TMPDIR, // NOSONAR passthrough of existing env var only; no temp file write here LANG: process.env.LANG, LC_ALL: process.env.LC_ALL, + RUST_LOG: resolveSidecarRustLog(), }; if (process.platform === 'win32') { env.APPDATA = process.env.APPDATA; @@ -337,6 +340,7 @@ export class ReticulumSidecarManager extends EventEmitter { proc.stdout?.on('data', (chunk: Buffer) => { const text = sanitizeLogMessage(chunk.toString('utf8').trim()); this.recordSidecarOutputLine(text); + if (!shouldForwardReticulumSidecarStdout(text)) return; console.debug('[ReticulumSidecar]', text); }); proc.stderr?.on('data', (chunk: Buffer) => { diff --git a/src/main/reticulumSidecarStderrLog.test.ts b/src/main/reticulumSidecarStderrLog.test.ts index a31b0dc87..bf6f0a5fd 100644 --- a/src/main/reticulumSidecarStderrLog.test.ts +++ b/src/main/reticulumSidecarStderrLog.test.ts @@ -2,9 +2,51 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { logReticulumSidecarStderrLine, + resolveSidecarRustLog, ReticulumSidecarStderrDedupe, + shouldForwardReticulumSidecarStdout, + SIDECAR_DEFAULT_RUST_LOG, } from './reticulumSidecarStderrLog'; +describe('shouldForwardReticulumSidecarStdout', () => { + it('forwards WARN and ERROR tracing lines', () => { + expect( + shouldForwardReticulumSidecarStdout( + '2026-07-30T11:23:18Z \u001b[33m WARN \u001b[0m auto: failed to select multicast', + ), + ).toBe(true); + expect(shouldForwardReticulumSidecarStdout('ERROR panic in link_manager')).toBe(true); + }); + + it('drops INFO and DEBUG packet-routing spam', () => { + expect( + shouldForwardReticulumSidecarStdout( + '\u001b[32m INFO \u001b[0m rns_transport::actor::inbound : data packet routing', + ), + ).toBe(false); + expect(shouldForwardReticulumSidecarStdout('DEBUG resource part received')).toBe(false); + }); +}); + +describe('resolveSidecarRustLog', () => { + it('defaults to warn', () => { + expect(resolveSidecarRustLog({})).toBe(SIDECAR_DEFAULT_RUST_LOG); + }); + + it('honors MESH_CLIENT_RUST_LOG over RUST_LOG', () => { + expect( + resolveSidecarRustLog({ + MESH_CLIENT_RUST_LOG: 'info', + RUST_LOG: 'debug', + }), + ).toBe('info'); + }); + + it('honors RUST_LOG when mesh override unset', () => { + expect(resolveSidecarRustLog({ RUST_LOG: 'reticulum=debug' })).toBe('reticulum=debug'); + }); +}); + describe('ReticulumSidecarStderrDedupe', () => { let dedupe: ReticulumSidecarStderrDedupe; diff --git a/src/main/reticulumSidecarStderrLog.ts b/src/main/reticulumSidecarStderrLog.ts index d9349a348..eb924e350 100644 --- a/src/main/reticulumSidecarStderrLog.ts +++ b/src/main/reticulumSidecarStderrLog.ts @@ -6,6 +6,29 @@ const AUTO_BEACON_TX_FAILED_MARKER = 'auto: beacon TX failed'; const BEACON_FAIL_WARN_INTERVAL_MS = 60 * MS_PER_SECOND; +/** Default tracing filter for sidecar child processes (overridable via env). */ +export const SIDECAR_DEFAULT_RUST_LOG = 'warn'; + +/** + * Whether a sidecar stdout line should be written to the app log. + * Tracing INFO/DEBUG packet routing floods the rotating log; keep WARN/ERROR only. + */ +export function shouldForwardReticulumSidecarStdout(text: string): boolean { + return /\b(?:WARN|ERROR)\b/.test(text); +} + +/** + * Resolve RUST_LOG for sidecar spawn. Honors MESH_CLIENT_RUST_LOG, then RUST_LOG, + * else defaults to warn so INFO packet spam does not fill mesh-client.log. + */ +export function resolveSidecarRustLog(env: NodeJS.ProcessEnv = process.env): string { + const fromMesh = env.MESH_CLIENT_RUST_LOG?.trim(); + if (fromMesh) return fromMesh; + const fromRust = env.RUST_LOG?.trim(); + if (fromRust) return fromRust; + return SIDECAR_DEFAULT_RUST_LOG; +} + export type ReticulumSidecarStderrSink = (message: string) => void; export interface ReticulumSidecarStderrLogDecision { diff --git a/src/main/verify-backup-repairs.test.ts b/src/main/verify-backup-repairs.test.ts index d1dc46195..54b50c0f2 100644 --- a/src/main/verify-backup-repairs.test.ts +++ b/src/main/verify-backup-repairs.test.ts @@ -41,12 +41,9 @@ describe('user backup repairs (local dumps)', () => { expect(mcNodes).toBe(0); const nullStatus = ( - db - .prepare( - `SELECT COUNT(*) as c FROM messages - WHERE status IS NULL AND received_via IS NOT NULL`, - ) - .get() as { c: number } + db.prepare(`SELECT COUNT(*) as c FROM messages WHERE status IS NULL`).get() as { + c: number; + } ).c; expect(nullStatus).toBe(0); diff --git a/src/renderer/lib/connection.ts b/src/renderer/lib/connection.ts index fc84e7ae3..fa8d15015 100644 --- a/src/renderer/lib/connection.ts +++ b/src/renderer/lib/connection.ts @@ -524,8 +524,9 @@ async function closeMeshtasticTransportStreamsBestEffort( ): Promise { if (!transport) return; try { - const toDevice = transport.toDevice as { close?: () => Promise } | undefined; - if (toDevice?.close) { + const toDevice = transport.toDevice as + { close?: () => Promise; getWriter?: () => unknown } | undefined; + if (toDevice != null && typeof toDevice.close === 'function') { await toDevice.close(); } } catch (e) { diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts index 2fd196a31..3b42488ad 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts @@ -12,7 +12,6 @@ import { setMeshtasticConfigSlice } from '../../stores/deviceStore'; import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; import { updateIdentity } from '../../stores/identityStore'; import { usePositionHistoryStore } from '../../stores/positionHistoryStore'; -import { safeDisconnect } from '../connection'; import { persistDbWrite } from '../dbPersistRetry'; import { connectionDriver } from '../drivers/ConnectionDriver'; import { isForeignLoraLogCandidate } from '../foreignLoraDetection'; @@ -428,26 +427,8 @@ export function attachMeshtasticRuntimeWireEffects( ) { configureTimeoutRef.current = setTimeout(() => { console.warn('[useMeshtasticRuntime] configure timeout (BLE 30s) — forcing disconnect'); - const activeDevice = deviceRef.current; - deviceRef.current = null; - if (activeDevice) { - void safeDisconnect(activeDevice).catch((e: unknown) => { - console.debug( - '[useMeshtasticRuntime] configure timeout safeDisconnect ' + errLikeToLogString(e), - ); - }); - } - cleanupSubscriptions(); - stopWatchdog(); - stopGpsInterval(); - setState({ - status: 'disconnected', - myNodeNum: 0, - connectionType: null, - batteryPercent: undefined, - batteryCharging: undefined, - }); clearConfigureTimeout(); + handleConnectionLostRef.current(); }, MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS); } } diff --git a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorConsoleHook.ts b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorConsoleHook.ts index 1f5137a37..f5d164e77 100644 --- a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorConsoleHook.ts +++ b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorConsoleHook.ts @@ -1,3 +1,5 @@ +import { errLikeToLogString } from '../errLikeToLogString'; +import { isMeshtasticConfigureRetryableError } from './meshtasticConfigureRetry'; import { parseMeshtasticSdkQueueRejection, parseMeshtasticSdkRoutingErrorLog, @@ -53,14 +55,24 @@ export function installMeshtasticSdkRoutingErrorConsoleHook( /** * Swallow unhandled `@meshtastic/core` queue rejections (`{ id, error }`) after applying * outbound chat failure state when a matching row exists. + * Also swallows disconnect mid-send `Packet does not exist` so teardown races are not logged + * as unhandled rejections. */ export function installMeshtasticSdkRoutingErrorUnhandledRejectionHandler( onQueueRejection: (reason: unknown) => boolean, ): () => void { const handler = (event: PromiseRejectionEvent) => { - if (!parseMeshtasticSdkQueueRejection(event.reason)) return; - const applied = onQueueRejection(event.reason); - if (applied) event.preventDefault(); + if (parseMeshtasticSdkQueueRejection(event.reason)) { + const applied = onQueueRejection(event.reason); + if (applied) event.preventDefault(); + return; + } + if (isMeshtasticConfigureRetryableError(event.reason)) { + console.debug( + '[Meshtastic] Ignoring disconnect mid-send rejection: ' + errLikeToLogString(event.reason), + ); + event.preventDefault(); + } }; window.addEventListener('unhandledrejection', handler); return () => { diff --git a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts index 45e077998..03bf5a05d 100644 --- a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts @@ -403,4 +403,19 @@ describe('installMeshtasticSdkRoutingErrorUnhandledRejectionHandler', () => { expect(preventDefault).not.toHaveBeenCalled(); restore(); }); + + it('preventDefault for disconnect mid-send Packet does not exist', () => { + const onQueueRejection = vi.fn(); + const restore = installMeshtasticSdkRoutingErrorUnhandledRejectionHandler(onQueueRejection); + const handler = vi.mocked(window.addEventListener).mock.calls[0]?.[1] as (event: { + reason: unknown; + preventDefault: () => void; + }) => void; + const reason = new Error('Packet does not exist'); + const preventDefault = vi.fn(); + handler({ reason, preventDefault }); + expect(onQueueRejection).not.toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalled(); + restore(); + }); }); diff --git a/src/renderer/lib/meshtasticBacklogUtils.ts b/src/renderer/lib/meshtasticBacklogUtils.ts index 03fe922c7..d90a60199 100644 --- a/src/renderer/lib/meshtasticBacklogUtils.ts +++ b/src/renderer/lib/meshtasticBacklogUtils.ts @@ -409,7 +409,11 @@ function sleepMs(ms: number): Promise { } async function writeToRadioDirectOnce(device: MeshDevice, toRadioBytes: Uint8Array): Promise { - const writer = device.transport.toDevice.getWriter(); + const toDevice = device.transport?.toDevice; + if (toDevice == null || typeof toDevice.getWriter !== 'function') { + throw new DOMException('Transport stream unavailable', 'InvalidStateError'); + } + const writer = toDevice.getWriter(); try { await writer.write(toRadioBytes); } finally { diff --git a/src/renderer/lib/startupDbPrune.test.ts b/src/renderer/lib/startupDbPrune.test.ts index 13879ef23..8e9dd161a 100644 --- a/src/renderer/lib/startupDbPrune.test.ts +++ b/src/renderer/lib/startupDbPrune.test.ts @@ -115,6 +115,9 @@ describe('runStartupDbPrune', () => { expect(pruneActivity).toHaveBeenCalledWith(14); expect(pruneByCount).toHaveBeenCalledWith(1234); expect(vacuum).not.toHaveBeenCalled(); + // Cross-protocol: Meshtastic node maintenance still runs on Reticulum tab. + expect(window.electronAPI.db.migrateRfStubNodes).toHaveBeenCalledTimes(1); + expect(window.electronAPI.db.deleteNodesNeverHeard).toHaveBeenCalledTimes(1); deleteByAge.mockClear(); vacuum.mockClear(); @@ -122,6 +125,60 @@ describe('runStartupDbPrune', () => { expect(deleteByAge).toHaveBeenCalledTimes(1); expect(vacuum).not.toHaveBeenCalled(); }); + + it('runs all protocol retention IPCs regardless of last-active tab', async () => { + localStorage.setItem(MESH_PROTOCOL_STORAGE_KEY, 'reticulum'); + localStorage.setItem( + 'mesh-client:appSettings', + JSON.stringify({ + autoPruneEnabled: true, + autoPruneDays: 21, + nodeCapEnabled: false, + pruneEmptyNamesEnabled: false, + positionHistoryPruneEnabled: true, + positionHistoryPruneDays: 10, + meshcoreAutoPruneEnabled: true, + meshcoreAutoPruneDays: 7, + meshcoreDeleteNeverAdvertised: true, + meshcoreContactCapEnabled: true, + meshcoreContactCapCount: 500, + reticulumAutoPruneEnabled: true, + reticulumAutoPruneDays: 14, + reticulumDestinationCapEnabled: true, + reticulumDestinationCapCount: 2000, + }), + ); + const deleteNodesByAge = vi.fn().mockResolvedValue(0); + const prunePosition = vi.fn().mockResolvedValue({ changes: 0 }); + const prunePositionPerNode = vi.fn().mockResolvedValue({ changes: 0 }); + const deleteMcNever = vi.fn().mockResolvedValue(0); + const deleteMcByAge = vi.fn().mockResolvedValue(0); + const pruneMcByCount = vi.fn().mockResolvedValue({ changes: 0 }); + const deleteRnByAge = vi.fn().mockResolvedValue({ changes: 0 }); + const pruneRnActivity = vi.fn().mockResolvedValue({ changes: 0 }); + const pruneRnByCount = vi.fn().mockResolvedValue({ changes: 0 }); + vi.mocked(window.electronAPI.db).deleteNodesByAge = deleteNodesByAge; + vi.mocked(window.electronAPI.db).prunePositionHistory = prunePosition; + vi.mocked(window.electronAPI.db).prunePositionHistoryPerNode = prunePositionPerNode; + vi.mocked(window.electronAPI.db).deleteMeshcoreContactsNeverAdvertised = deleteMcNever; + vi.mocked(window.electronAPI.db).deleteMeshcoreContactsByAge = deleteMcByAge; + vi.mocked(window.electronAPI.db).pruneMeshcoreContactsByCount = pruneMcByCount; + vi.mocked(window.electronAPI.db).deleteReticulumDestinationsByAge = deleteRnByAge; + vi.mocked(window.electronAPI.db).pruneReticulumIdentityActivityByAge = pruneRnActivity; + vi.mocked(window.electronAPI.db).pruneReticulumDestinationsByCount = pruneRnByCount; + + await runStartupDbPrune(); + + expect(deleteNodesByAge).toHaveBeenCalledWith(21); + expect(prunePosition).toHaveBeenCalledWith(10); + expect(prunePositionPerNode).toHaveBeenCalledWith(2000); + expect(deleteMcNever).toHaveBeenCalledTimes(1); + expect(deleteMcByAge).toHaveBeenCalledWith(7); + expect(pruneMcByCount).toHaveBeenCalledWith(500); + expect(deleteRnByAge).toHaveBeenCalledWith(14); + expect(pruneRnActivity).toHaveBeenCalledWith(14); + expect(pruneRnByCount).toHaveBeenCalledWith(2000); + }); }); describe('scheduleReticulumVacuumIfNeeded', () => { diff --git a/src/renderer/lib/startupDbPrune.ts b/src/renderer/lib/startupDbPrune.ts index a16e5f193..e21503b10 100644 --- a/src/renderer/lib/startupDbPrune.ts +++ b/src/renderer/lib/startupDbPrune.ts @@ -6,7 +6,6 @@ import { errLikeToLogString } from './errLikeToLogString'; import { fetchMessageRetention, RRC_MESSAGE_RETENTION_DEFAULT_AGE_DAYS } from './messageRetention'; import { parseStoredJson } from './parseStoredJson'; import { MAX_MESH_ENTITY_CAP, SESSION_DB_PRUNE_INTERVAL_MS } from './sessionMemoryCaps'; -import { getStoredMeshProtocol } from './storedMeshProtocol'; let startupDbPrunePromise: Promise | null = null; let sessionDbPrunePromise: Promise | null = null; @@ -97,128 +96,123 @@ export function resetStartupDbPruneForTests(): void { } async function executeDbPrune(label: 'startup' | 'session'): Promise { - const startupProtocol = getStoredMeshProtocol(); const raw = parseStoredJson>(getAppSettingsRaw(), 'App startup node pruning') ?? {}; const s = { ...DEFAULT_APP_SETTINGS_SHARED, ...raw }; const ops: Promise[] = []; - if (startupProtocol === 'meshtastic') { + // Retention runs for all protocols every startup/session — not only the last-active tab. + ops.push( + window.electronAPI.db.migrateRfStubNodes().catch((e: unknown) => { + console.warn('[App] startup migrateRfStubNodes failed ' + errLikeToLogString(e)); + }), + window.electronAPI.db.deleteNodesNeverHeard().catch((e: unknown) => { + console.warn('[App] startup deleteNodesNeverHeard failed ' + errLikeToLogString(e)); + }), + ); + if (s.autoPruneEnabled) { + const days = typeof s.autoPruneDays === 'number' && s.autoPruneDays > 0 ? s.autoPruneDays : 30; ops.push( - window.electronAPI.db.migrateRfStubNodes().catch((e: unknown) => { - console.warn('[App] startup migrateRfStubNodes failed ' + errLikeToLogString(e)); + window.electronAPI.db.deleteNodesByAge(days).catch((e: unknown) => { + console.warn('[App] startup deleteNodesByAge failed ' + errLikeToLogString(e)); }), - window.electronAPI.db.deleteNodesNeverHeard().catch((e: unknown) => { - console.warn('[App] startup deleteNodesNeverHeard failed ' + errLikeToLogString(e)); + ); + } + if (s.nodeCapEnabled) { + const cap = + typeof s.nodeCapCount === 'number' && s.nodeCapCount > 0 + ? s.nodeCapCount + : MAX_MESH_ENTITY_CAP; + ops.push( + window.electronAPI.db.pruneNodesByCount(cap).catch((e: unknown) => { + console.warn('[App] startup pruneNodesByCount failed ' + errLikeToLogString(e)); + }), + ); + } + if (s.pruneEmptyNamesEnabled) { + ops.push( + window.electronAPI.db.deleteNodesWithoutLongname().catch((e: unknown) => { + console.warn('[App] startup deleteNodesWithoutLongname failed ' + errLikeToLogString(e)); + }), + ); + } + if (s.positionHistoryPruneEnabled) { + const days = + typeof s.positionHistoryPruneDays === 'number' && s.positionHistoryPruneDays > 0 + ? s.positionHistoryPruneDays + : 30; + ops.push( + window.electronAPI.db.prunePositionHistory(days).catch((e: unknown) => { + console.warn('[App] startup prunePositionHistory failed ' + errLikeToLogString(e)); + }), + window.electronAPI.db.prunePositionHistoryPerNode(2000).catch((e: unknown) => { + console.warn('[App] startup prunePositionHistoryPerNode failed ' + errLikeToLogString(e)); + }), + ); + } + + if (s.meshcoreDeleteNeverAdvertised) { + ops.push( + window.electronAPI.db.deleteMeshcoreContactsNeverAdvertised().catch((e: unknown) => { + console.warn( + '[App] startup deleteMeshcoreContactsNeverAdvertised failed ' + errLikeToLogString(e), + ); + }), + ); + } + if (s.meshcoreAutoPruneEnabled) { + const days = + typeof s.meshcoreAutoPruneDays === 'number' && s.meshcoreAutoPruneDays > 0 + ? s.meshcoreAutoPruneDays + : 30; + ops.push( + window.electronAPI.db.deleteMeshcoreContactsByAge(days).catch((e: unknown) => { + console.warn('[App] startup deleteMeshcoreContactsByAge failed ' + errLikeToLogString(e)); + }), + ); + } + if (s.meshcoreContactCapEnabled) { + const cap = + typeof s.meshcoreContactCapCount === 'number' && s.meshcoreContactCapCount > 0 + ? s.meshcoreContactCapCount + : MAX_MESH_ENTITY_CAP; + ops.push( + window.electronAPI.db.pruneMeshcoreContactsByCount(cap).catch((e: unknown) => { + console.warn('[App] startup pruneMeshcoreContactsByCount failed ' + errLikeToLogString(e)); + }), + ); + } + + if (s.reticulumAutoPruneEnabled) { + const days = + typeof s.reticulumAutoPruneDays === 'number' && s.reticulumAutoPruneDays > 0 + ? s.reticulumAutoPruneDays + : 30; + ops.push( + window.electronAPI.db.deleteReticulumDestinationsByAge(days).catch((e: unknown) => { + console.warn( + `[App] ${label} deleteReticulumDestinationsByAge failed ` + errLikeToLogString(e), + ); + }), + window.electronAPI.db.pruneReticulumIdentityActivityByAge(days).catch((e: unknown) => { + console.warn( + `[App] ${label} pruneReticulumIdentityActivityByAge failed ` + errLikeToLogString(e), + ); + }), + ); + } + if (s.reticulumDestinationCapEnabled) { + const cap = + typeof s.reticulumDestinationCapCount === 'number' && s.reticulumDestinationCapCount > 0 + ? Math.min(50_000, s.reticulumDestinationCapCount) + : DEFAULT_APP_SETTINGS_SHARED.reticulumDestinationCapCount; + ops.push( + window.electronAPI.db.pruneReticulumDestinationsByCount(cap).catch((e: unknown) => { + console.warn( + `[App] ${label} pruneReticulumDestinationsByCount failed ` + errLikeToLogString(e), + ); }), ); - if (s.autoPruneEnabled) { - const days = - typeof s.autoPruneDays === 'number' && s.autoPruneDays > 0 ? s.autoPruneDays : 30; - ops.push( - window.electronAPI.db.deleteNodesByAge(days).catch((e: unknown) => { - console.warn('[App] startup deleteNodesByAge failed ' + errLikeToLogString(e)); - }), - ); - } - if (s.nodeCapEnabled) { - const cap = - typeof s.nodeCapCount === 'number' && s.nodeCapCount > 0 - ? s.nodeCapCount - : MAX_MESH_ENTITY_CAP; - ops.push( - window.electronAPI.db.pruneNodesByCount(cap).catch((e: unknown) => { - console.warn('[App] startup pruneNodesByCount failed ' + errLikeToLogString(e)); - }), - ); - } - if (s.pruneEmptyNamesEnabled) { - ops.push( - window.electronAPI.db.deleteNodesWithoutLongname().catch((e: unknown) => { - console.warn('[App] startup deleteNodesWithoutLongname failed ' + errLikeToLogString(e)); - }), - ); - } - if (s.positionHistoryPruneEnabled) { - const days = - typeof s.positionHistoryPruneDays === 'number' && s.positionHistoryPruneDays > 0 - ? s.positionHistoryPruneDays - : 30; - ops.push( - window.electronAPI.db.prunePositionHistory(days).catch((e: unknown) => { - console.warn('[App] startup prunePositionHistory failed ' + errLikeToLogString(e)); - }), - window.electronAPI.db.prunePositionHistoryPerNode(2000).catch((e: unknown) => { - console.warn('[App] startup prunePositionHistoryPerNode failed ' + errLikeToLogString(e)); - }), - ); - } - } else if (startupProtocol === 'meshcore') { - if (s.meshcoreDeleteNeverAdvertised) { - ops.push( - window.electronAPI.db.deleteMeshcoreContactsNeverAdvertised().catch((e: unknown) => { - console.warn( - '[App] startup deleteMeshcoreContactsNeverAdvertised failed ' + errLikeToLogString(e), - ); - }), - ); - } - if (s.meshcoreAutoPruneEnabled) { - const days = - typeof s.meshcoreAutoPruneDays === 'number' && s.meshcoreAutoPruneDays > 0 - ? s.meshcoreAutoPruneDays - : 30; - ops.push( - window.electronAPI.db.deleteMeshcoreContactsByAge(days).catch((e: unknown) => { - console.warn('[App] startup deleteMeshcoreContactsByAge failed ' + errLikeToLogString(e)); - }), - ); - } - if (s.meshcoreContactCapEnabled) { - const cap = - typeof s.meshcoreContactCapCount === 'number' && s.meshcoreContactCapCount > 0 - ? s.meshcoreContactCapCount - : MAX_MESH_ENTITY_CAP; - ops.push( - window.electronAPI.db.pruneMeshcoreContactsByCount(cap).catch((e: unknown) => { - console.warn( - '[App] startup pruneMeshcoreContactsByCount failed ' + errLikeToLogString(e), - ); - }), - ); - } - } else if (startupProtocol === 'reticulum') { - if (s.reticulumAutoPruneEnabled) { - const days = - typeof s.reticulumAutoPruneDays === 'number' && s.reticulumAutoPruneDays > 0 - ? s.reticulumAutoPruneDays - : 30; - ops.push( - window.electronAPI.db.deleteReticulumDestinationsByAge(days).catch((e: unknown) => { - console.warn( - `[App] ${label} deleteReticulumDestinationsByAge failed ` + errLikeToLogString(e), - ); - }), - window.electronAPI.db.pruneReticulumIdentityActivityByAge(days).catch((e: unknown) => { - console.warn( - `[App] ${label} pruneReticulumIdentityActivityByAge failed ` + errLikeToLogString(e), - ); - }), - ); - } - if (s.reticulumDestinationCapEnabled) { - const cap = - typeof s.reticulumDestinationCapCount === 'number' && s.reticulumDestinationCapCount > 0 - ? Math.min(50_000, s.reticulumDestinationCapCount) - : DEFAULT_APP_SETTINGS_SHARED.reticulumDestinationCapCount; - ops.push( - window.electronAPI.db.pruneReticulumDestinationsByCount(cap).catch((e: unknown) => { - console.warn( - `[App] ${label} pruneReticulumDestinationsByCount failed ` + errLikeToLogString(e), - ); - }), - ); - } } ops.push( diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index 1efe6d4d6..e608e7bf0 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -109,6 +109,44 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { ); }); + it('defers Noble disconnect during reconnect open/configure (single-flight)', () => { + expect(SOURCE).toContain('reconnectConnectInFlightRef'); + expect(SOURCE).toMatch( + /onNobleBleDisconnected[\s\S]*?reconnectConnectInFlightRef\.current[\s\S]*?defer reconnect until connect settles/, + ); + }); + + it('wraps BLE reconnect open in withNobleBleConnectMutex (MeshCore parity)', () => { + const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); + expect(reconnectBody).toContain("withNobleBleConnectMutex('meshtastic'"); + expect(reconnectBody).toContain('reconnectConnectInFlightRef.current = true'); + expect(reconnectBody).toContain('skip overlapping open'); + }); + + it('defers starting reconnect while open+configure is already in flight', () => { + const lostBody = extractUseCallbackBody(SOURCE, 'handleConnectionLost'); + expect(lostBody).toContain('reconnectConnectInFlightRef.current'); + expect(lostBody).toContain('defer reconnect until in-flight open settles'); + }); + + it('checks reconnect generation before open, wire, and configure', () => { + const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); + expect(reconnectBody).toContain('Reconnect superseded before open'); + expect(reconnectBody).toContain('Reconnect superseded after open'); + expect(reconnectBody).toContain('Reconnect superseded before configure'); + expect(reconnectBody).toContain('Reconnect superseded during configure'); + }); + + it('BLE configure timeout routes through handleConnectionLost (reconnect)', () => { + const wireSource = readFileSync( + join(TEST_DIR, '../lib/meshtastic/meshtasticRuntimeWireEffects.ts'), + 'utf-8', + ); + expect(wireSource).toMatch( + /configure timeout \(BLE 30s\)[\s\S]*?handleConnectionLostRef\.current\(\)/, + ); + }); + it('guards attachRfSession configure against reconnect generation supersession', () => { expect(SOURCE).toMatch( /attachRfSession[\s\S]{0,3500}reconnectGenerationRef\.current !== generation[\s\S]{0,200}Attach superseded during configure/, diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index ad275bd0b..5a72505bd 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -360,6 +360,8 @@ export function useMeshtasticRuntime() { const bleConnectInProgressRef = useRef(false); /** Disconnect during connect — run handleConnectionLost after connect settles. */ const meshtasticDeferredReconnectRef = useRef(false); + /** True while reconnect open+configure owns the session (single-flight; blocks overlapping opens). */ + const reconnectConnectInFlightRef = useRef(false); const reconnectGenerationRef = useRef(0); /** Cleanup for post-exhaustion serial port rediscovery poll. */ const serialRediscoveryStopRef = useRef<(() => void) | null>(null); @@ -1931,6 +1933,8 @@ export function useMeshtasticRuntime() { void (async () => { clearPostCommitRebootRecovery(); deviceConfiguredRef.current = false; + isConfiguringRef.current = false; + meshtasticIngestSessionRef.current?.setConfiguring(false); // Clean up existing connection before reconnect (BlueZ needs GATT fully torn down). clearConfigureTimeout(); const staleDevice = deviceRef.current; @@ -1957,6 +1961,15 @@ export function useMeshtasticRuntime() { ); }); } + // Single-flight: if open+configure is still running, generation bump invalidates it; + // that attempt's failure path (or finally deferred flush) schedules the next cycle. + if (reconnectConnectInFlightRef.current) { + console.debug( + '[useMeshtasticRuntime] Connection lost — defer reconnect until in-flight open settles', + ); + meshtasticDeferredReconnectRef.current = true; + return; + } void attemptReconnectRef.current(); })(); }, [ @@ -2073,17 +2086,47 @@ export function useMeshtasticRuntime() { // Check if user manually disconnected or started a new connection during the wait if (!isReconnectingRef.current || reconnectGenerationRef.current !== generation) return; + // Single-flight: overlapping handleConnectionLost must not open a second GATT session. + if (reconnectConnectInFlightRef.current) { + console.debug( + '[useMeshtasticRuntime] reconnect: skip overlapping open (connect already in flight)', + ); + meshtasticDeferredReconnectRef.current = true; + return; + } + let opened: Awaited> | undefined; + const isBleReconnect = params.type === 'ble'; + reconnectConnectInFlightRef.current = true; + if (isBleReconnect) bleConnectInProgressRef.current = true; try { - opened = await openMeshtasticTransport(params.type, { - httpAddress: params.httpAddress, - blePeripheralId: params.blePeripheralId, - lastSerialPortId: params.lastSerialPortId, - }); + if (reconnectGenerationRef.current !== generation) { + throw new Error('Reconnect superseded before open'); + } + opened = + isBleReconnect && isRendererNobleBlePlatform() + ? await withNobleBleConnectMutex('meshtastic', () => + openMeshtasticTransport(params.type, { + httpAddress: params.httpAddress, + blePeripheralId: params.blePeripheralId, + lastSerialPortId: params.lastSerialPortId, + }), + ) + : await openMeshtasticTransport(params.type, { + httpAddress: params.httpAddress, + blePeripheralId: params.blePeripheralId, + lastSerialPortId: params.lastSerialPortId, + }); + if (reconnectGenerationRef.current !== generation) { + throw new Error('Reconnect superseded after open'); + } deviceRef.current = opened.device; wireSubscriptions(opened.device, params.type, { driverIdentityId: opened.driverIdentityId, }); + if (reconnectGenerationRef.current !== generation) { + throw new Error('Reconnect superseded before configure'); + } await configureMeshtasticDeviceWithRetry(opened.device, { logTag: 'useMeshtasticRuntime reconnect', }); @@ -2106,6 +2149,7 @@ export function useMeshtasticRuntime() { } reconnectAttemptRef.current = 0; isReconnectingRef.current = false; + meshtasticDeferredReconnectRef.current = false; setState((s) => ({ ...s, serialNeedsReselect: false, @@ -2132,8 +2176,30 @@ export function useMeshtasticRuntime() { ' ' + errLikeToLogString(err), ); - // Retry - void attemptReconnectRef.current(); + // Retry only if this generation is still current. Deferred Noble drops are flushed in finally. + if ( + !meshtasticDeferredReconnectRef.current && + isReconnectingRef.current && + reconnectGenerationRef.current === generation + ) { + queueMicrotask(() => { + void attemptReconnectRef.current(); + }); + } + } finally { + reconnectConnectInFlightRef.current = false; + if (isBleReconnect) { + bleConnectInProgressRef.current = false; + if (meshtasticDeferredReconnectRef.current) { + meshtasticDeferredReconnectRef.current = false; + if (isReconnectingRef.current) { + console.debug( + '[useMeshtasticRuntime] reconnect settled — running deferred reconnect after Noble drop', + ); + queueMicrotask(() => handleConnectionLostRef.current()); + } + } + } } }, [ wireSubscriptions, @@ -2175,9 +2241,10 @@ export function useMeshtasticRuntime() { return window.electronAPI.onNobleBleDisconnected((sessionId) => { if (sessionId !== 'meshtastic') return; if ( - bleConnectInProgressRef.current && - !meshtasticDriverConnectedRef.current && - !deviceRef.current + bleConnectInProgressRef.current || + (isReconnectingRef.current && + reconnectConnectInFlightRef.current && + !deviceConfiguredRef.current) ) { meshtasticDeferredReconnectRef.current = true; console.debug( From 92cf9e8b7f9c29d72c69ec4ef14f09d34e46c89a Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 30 Jul 2026 07:10:33 -0600 Subject: [PATCH 2/8] fix(nomad): preserve Micron box padding and add browser tooltips Keep ASCII/Unicode box borders aligned by using white-space: pre/pre-wrap on Nomad pages, and mirror aria-labels as title tooltips on browser chrome buttons. --- .../components/NomadMicronPageView.test.tsx | 19 +++++++++++ .../components/NomadNetworkPanel.test.tsx | 9 +++++ src/renderer/components/NomadNetworkPanel.tsx | 15 +++++++++ src/renderer/lib/nomad/micronParser.test.ts | 33 +++++++++++++++++++ src/renderer/styles.css | 5 ++- 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/NomadMicronPageView.test.tsx b/src/renderer/components/NomadMicronPageView.test.tsx index b472ce840..e7b96801d 100644 --- a/src/renderer/components/NomadMicronPageView.test.tsx +++ b/src/renderer/components/NomadMicronPageView.test.tsx @@ -136,6 +136,25 @@ describe('NomadMicronPageView', () => { ); }); + it('keeps box padding spaces in the mounted DOM for fit-width and open-width', () => { + const markup = [ + ' │ This is the NomadNet page of the RMAP Project, a web interface │', + ' │ `F8f0•`f Visualize LoRa RNode Connection Info, │ │', + ].join('\n'); + + const { rerender } = render(); + const fitRoot = document.querySelector('.nomad-micron-page'); + expect(fitRoot).toHaveClass('nomad-micron-page--fit-width'); + expect(fitRoot?.textContent).toMatch(/web interface {2,}│/); + expect(fitRoot?.textContent).toMatch(/Connection Info, {2,}│ │/); + + rerender(); + const openRoot = document.querySelector('.nomad-micron-page'); + expect(openRoot).not.toHaveClass('nomad-micron-page--fit-width'); + expect(openRoot?.textContent).toMatch(/web interface {2,}│/); + expect(openRoot?.textContent).toMatch(/Connection Info, {2,}│ │/); + }); + it('fetches and mounts Micron partial content via onFetchPartial', async () => { const onFetchPartial = vi.fn().mockResolvedValue({ ok: true, diff --git a/src/renderer/components/NomadNetworkPanel.test.tsx b/src/renderer/components/NomadNetworkPanel.test.tsx index 799605256..adb576345 100644 --- a/src/renderer/components/NomadNetworkPanel.test.tsx +++ b/src/renderer/components/NomadNetworkPanel.test.tsx @@ -729,6 +729,15 @@ describe('NomadNetworkPanel', () => { const toggle = screen.getByLabelText('nomadNetwork.openWidth'); expect(toggle).toHaveAttribute('aria-pressed', 'true'); + expect(toggle).toHaveAttribute('title', 'nomadNetwork.openWidth'); + expect(screen.getByRole('button', { name: 'nomadNetwork.back' })).toHaveAttribute( + 'title', + 'nomadNetwork.back', + ); + expect(screen.getByRole('button', { name: 'nomadNetwork.reloadPage' })).toHaveAttribute( + 'title', + 'nomadNetwork.reloadPage', + ); await user.click(toggle); expect(localStorage.getItem('mesh-client:nomadPageFitWidth')).toBe('false'); diff --git a/src/renderer/components/NomadNetworkPanel.tsx b/src/renderer/components/NomadNetworkPanel.tsx index ee8a0ebcd..4cffaa89a 100644 --- a/src/renderer/components/NomadNetworkPanel.tsx +++ b/src/renderer/components/NomadNetworkPanel.tsx @@ -968,6 +968,10 @@ export default function NomadNetworkPanel({ name: selectedNode.display_name ?? selectedNode.destination_hash.slice(0, 16), })} + title={t('nomadNetwork.sendMessageAria', { + name: + selectedNode.display_name ?? selectedNode.destination_hash.slice(0, 16), + })} onClick={() => { onOpenDm(selectedNode.destination_hash); }} @@ -980,6 +984,7 @@ export default function NomadNetworkPanel({ disabled={!canGoBack} className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200 hover:bg-slate-800 disabled:opacity-40" aria-label={t('nomadNetwork.back')} + title={t('nomadNetwork.back')} onClick={() => { navigateHistory(-1); }} @@ -991,6 +996,7 @@ export default function NomadNetworkPanel({ disabled={!canGoForward} className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200 hover:bg-slate-800 disabled:opacity-40" aria-label={t('nomadNetwork.forward')} + title={t('nomadNetwork.forward')} onClick={() => { navigateHistory(1); }} @@ -1001,6 +1007,7 @@ export default function NomadNetworkPanel({ type="button" className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200 hover:bg-slate-800" aria-label={t('nomadNetwork.homePage')} + title={t('nomadNetwork.homePage')} onClick={() => { void loadNodePage( selectedNode.destination_hash, @@ -1021,6 +1028,9 @@ export default function NomadNetworkPanel({ aria-label={ showPageSource ? t('nomadNetwork.hideSource') : t('nomadNetwork.showSource') } + title={ + showPageSource ? t('nomadNetwork.hideSource') : t('nomadNetwork.showSource') + } aria-pressed={showPageSource} onClick={() => { setShowPageSource((prev) => !prev); @@ -1040,6 +1050,9 @@ export default function NomadNetworkPanel({ aria-label={ pageFitWidth ? t('nomadNetwork.openWidth') : t('nomadNetwork.fitWidth') } + title={ + pageFitWidth ? t('nomadNetwork.openWidth') : t('nomadNetwork.fitWidth') + } aria-pressed={pageFitWidth} onClick={() => { setPageFitWidth((prev) => { @@ -1056,6 +1069,7 @@ export default function NomadNetworkPanel({ type="button" className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200 hover:bg-slate-800" aria-label={t('nomadNetwork.reloadPage')} + title={t('nomadNetwork.reloadPage')} onClick={() => { void loadNodePage(selectedNode.destination_hash, pagePath, { forceReload: true, @@ -1070,6 +1084,7 @@ export default function NomadNetworkPanel({ type="button" className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200 hover:bg-slate-800" aria-label={t('nomadNetwork.closeViewer')} + title={t('nomadNetwork.closeViewer')} onClick={closeViewer} > ✕ diff --git a/src/renderer/lib/nomad/micronParser.test.ts b/src/renderer/lib/nomad/micronParser.test.ts index 5e739fcf0..40be4a221 100644 --- a/src/renderer/lib/nomad/micronParser.test.ts +++ b/src/renderer/lib/nomad/micronParser.test.ts @@ -1,4 +1,8 @@ // @vitest-environment jsdom +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { describe, expect, it, vi } from 'vitest'; import { @@ -19,6 +23,18 @@ import { splitNomadLinkDestination, } from './micronParser'; +const stylesCss = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '../../styles.css'), + 'utf8', +); + +describe('nomad-micron-page whitespace CSS contract', () => { + it('preserves spaces in open-width and wraps with pre-wrap in fit-width', () => { + expect(stylesCss).toMatch(/\.nomad-micron-page\s*\{[^}]*white-space:\s*pre;/s); + expect(stylesCss).toMatch(/\.nomad-micron-page--fit-width\s*\{[^}]*white-space:\s*pre-wrap;/s); + }); +}); + describe('renderNomadMicronPage', () => { it('renders headings, colors, separators, and links from Micron markup', () => { const markup = [ @@ -76,6 +92,23 @@ describe('renderNomadMicronPage', () => { expect(partial?.getAttribute('data-partial-destination')).toBe(`${hash}:/page/partial.mu`); expect(partial?.textContent).toContain('⧖'); }); + + it('preserves RMAP-style box padding spaces before Unicode borders', () => { + // Padding spaces before trailing │ must survive parse/mount (CSS white-space: pre* keeps them visible). + const markup = [ + ' │ This is the NomadNet page of the RMAP Project, a web interface │', + ' │ `F8f0•`f Visualize LoRa RNode Connection Info, │ │', + ].join('\n'); + const html = renderNomadMicronPage(markup); + const container = document.createElement('div'); + mountNomadMicronHtml(container, html); + const plainText = container.textContent ?? ''; + + expect(plainText).toMatch(/web interface {2,}│/); + expect(plainText).toMatch(/Connection Info, {2,}│ │/); + expect(plainText).not.toMatch(/web interface│/); + expect(plainText).not.toMatch(/Connection Info,││/); + }); }); describe('loadNomadMicronPartial', () => { diff --git a/src/renderer/styles.css b/src/renderer/styles.css index f8556132b..1146bbbbf 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -334,10 +334,12 @@ html[data-reduce-motion='true'] .cm-watermark-mesh-tube--flicker-off .cm-waterma /* Nomad Micron pages: monospace + optional locally installed Nerd Fonts for alignment. Open width grows with ASCII/Micron content so the page scroll parent can dual-axis scroll. - Fit width (--fit-width) constrains to the viewer so prose wraps without horizontal scrolling. */ + Fit width (--fit-width) constrains to the viewer so prose wraps without horizontal scrolling. + Preserve padding spaces so Unicode/ASCII box borders stay aligned (HTML defaults collapse them). */ .nomad-micron-page { width: max-content; min-width: 100%; + white-space: pre; font-family: 'JetBrainsMono Nerd Font', 'FiraCode Nerd Font', 'Hack Nerd Font', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; @@ -345,6 +347,7 @@ html[data-reduce-motion='true'] .cm-watermark-mesh-tube--flicker-off .cm-waterma .nomad-micron-page--fit-width { width: 100%; max-width: 100%; + white-space: pre-wrap; overflow-wrap: anywhere; } .nomad-micron-page--fit-width pre, From 7b5b4663da5af0103f2363372521f12aadb8715f Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 30 Jul 2026 07:17:44 -0600 Subject: [PATCH 3/8] fix(meshcore): close Radio GPS, Admin reboot, and foreign-LoRa parity gaps Move advertised position into Position/GPS, wire Send Position and reboot, show Diagnostics foreign-LoRa on the MeshCore tab, and refresh the parity matrix. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- docs/diagnostics.md | 6 +- docs/meshcore-meshtastic-parity.md | 70 +++++------ docs/troubleshooting.md | 2 +- src/renderer/App.tsx | 8 +- .../components/DiagnosticsPanel.test.tsx | 46 +++++++- src/renderer/components/DiagnosticsPanel.tsx | 27 +++-- src/renderer/components/RadioPanel.tsx | 110 ++++++++++++------ src/renderer/lib/meshcoreUtils.test.ts | 16 +++ src/renderer/lib/meshcoreUtils.ts | 20 ++++ src/renderer/locales/en/translation.json | 4 +- 12 files changed, 220 insertions(+), 93 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 957f16248..04e6430c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,7 +170,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **Engines:** `src/renderer/lib/diagnostics/`; `RoutingDiagnosticEngine.ts`, `RFDiagnosticEngine.ts` (includes MeshCore **High Companion TX Queue** when `queueLen > 200`), `RemediationEngine.ts`, `ReticulumDiagnosticEngine.ts`. - **Store:** `src/renderer/stores/diagnosticsStore.ts`; routing/RF rows, foreign LoRa, MQTT ignore, redundancy. -- **Tab scoping:** `filterDiagnosticRowsForProtocol()` — Meshtastic/MeshCore tabs show LoRa rows only; Reticulum tab shows `reticulum/*` only. Foreign-LoRa tables UI is Meshtastic-tab-only. +- **Tab scoping:** `filterDiagnosticRowsForProtocol()` — Meshtastic/MeshCore tabs show LoRa rows only; Reticulum tab shows `reticulum/*` only. Foreign-LoRa tables UI is on Meshtastic and MeshCore tabs (keyed by that protocol’s self node id). - **Extend:** adjust `DiagnosticRow` in `src/renderer/lib/types.ts`, add detector, wire `replaceRoutingRowsFromMap` / `replaceRfRowsForNode`; TTL defaults in `diagnosticRows.ts` (routing 24h, RF 1h). - **Full reference:** [docs/diagnostics.md](docs/diagnostics.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d81d2abd1..dbd46d3e7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -58,7 +58,7 @@ Sanitize user-controlled strings before logs and IPC per [AGENTS.md](AGENTS.md). - **Engines:** `src/renderer/lib/diagnostics/`; `RoutingDiagnosticEngine.ts`, `RFDiagnosticEngine.ts`, `RemediationEngine.ts`. - **Store:** `src/renderer/stores/diagnosticsStore.ts`; routing/RF rows, foreign LoRa, MQTT ignore, redundancy. -- **Tab scoping:** `filterDiagnosticRowsForProtocol()` — Meshtastic/MeshCore tabs show LoRa rows only; Reticulum tab shows `reticulum/*` only. Foreign-LoRa tables UI is Meshtastic-tab-only. +- **Tab scoping:** `filterDiagnosticRowsForProtocol()` — Meshtastic/MeshCore tabs show LoRa rows only; Reticulum tab shows `reticulum/*` only. Foreign-LoRa tables UI is on Meshtastic and MeshCore tabs (keyed by that protocol’s self node id). - **Extend:** adjust `DiagnosticRow` in `src/renderer/lib/types.ts`, add detector, wire `replaceRoutingRowsFromMap` / `replaceRfRowsForNode`; TTL defaults in `diagnosticRows.ts` (routing 24h, RF 1h). - **Node health score:** `src/renderer/lib/nodeHealthScore.ts`; `nodeHealthScore(node)` → `NodeHealthBreakdown`; `nodeHealthTier(total)` → color tier. - **Watch/notify:** `src/renderer/stores/watchedNodesStore.ts` (persisted Set); `src/renderer/hooks/useNodeStatusNotifier.ts` (fires OS Notification on online/offline transitions). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 509b269f8..2b3b91fe1 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -27,7 +27,7 @@ All three protocols share one **Diagnostics** sidebar tab; sections differ by `P **LoRa routing/RF per protocol:** Switching tabs calls `clearDiagnostics({ preserveForeignLora: true })` and `runReanalysis` with that tab's `nodesForUi` and capabilities — Meshtastic anomalies are computed from Meshtastic nodes only; MeshCore from MeshCore contacts only. -**Foreign LoRa overhear UI:** The MeshCore-heard and other-foreign-LoRa tables render on the **Meshtastic** tab only (`protocol === 'meshtastic'`). MeshCore may record foreign traffic internally when raw RX bytes are available, but the Diagnostics panel does not show those tables on the MeshCore tab. Reticulum RNode foreign overhear is not wired yet (sidecar packet tap exposes RNS-parsed frames only). +**Foreign LoRa overhear UI:** The MeshCore-heard and other-foreign-LoRa tables render on the **Meshtastic** and **MeshCore** LoRa tabs when connected with a known self node id (Meshtastic detections keyed by Meshtastic self id; MeshCore foreign overhear keyed by MeshCore self id). Reticulum RNode foreign overhear is not wired yet (sidecar packet tap exposes RNS-parsed frames only). **Other surfaces:** `NodeListPanel`, `MapPanel`, and `NodeInfoBody` also call `filterDiagnosticRowsForProtocol` so inline badges and halos match the active tab. @@ -177,9 +177,9 @@ These findings use packet-stats data from a MeshCore device's Repeater Status re ## 4. Foreign LoRa Detection -Foreign LoRa detection identifies **non-Meshtastic** LoRa traffic observed by your connected device's radio (or, in dual-radio setups, by a MeshCore companion overheard on the Meshtastic frequency). The detection window is the **last 90 minutes**. +Foreign LoRa detection identifies **cross-protocol / unrecognized** LoRa traffic observed by your connected device's radio (Meshtastic hearing MeshCore or unknown LoRa; MeshCore hearing Meshtastic / unknown LoRa; dual-radio setups can also bridge MeshCore RX into the Meshtastic listener map). The detection window is the **last 90 minutes**. -**Diagnostics UI:** Foreign-LoRa tables appear on the **Meshtastic** protocol tab only (see **Multi-protocol tab scoping**). +**Diagnostics UI:** Foreign-LoRa tables appear on the **Meshtastic** and **MeshCore** protocol tabs (see **Multi-protocol tab scoping**). **Signal classes:** diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index 3d4c6fa94..266c2542a 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -12,41 +12,41 @@ Shared UI gates use `ProtocolCapabilities` in [`src/renderer/lib/radio/BaseRadio ## Feature matrix -| Area | Meshtastic | MeshCore | Gap type | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| Transports | BLE, Serial, HTTP (`@meshtastic/core`), WiFi/TCP fast path (`TransportTcpIpc`, port 4403) | BLE, Web Serial, TCP bridge (5000) | Different stacks | -| Tab “Modules” / “Repeaters” | `ModulePanel` (protobuf modules; Remote Hardware GPIO, IP Tunnel status) | `RepeatersPanel` (trace, status, neighbors) | Product split | -| Tab “Administration” | `AdminPanel` (reboot, shutdown, factory reset, NodeDB reset, OTA/DFU) | `AdminPanel` (reboot only; meshcore.js limits) | **App** | -| MQTT broker UI | Full (with transport selection) | Same broker fields; transport protocol selected when connecting; MeshCore-only **LetsMesh** / **MeshMapper** / **Colorado Mesh** / **Ripple** / **Custom** presets fill known public brokers | **Post-MQTT** codec on broker path | -| MQTT wire format | `ServiceEnvelope` / `MeshPacket` ([`mqtt-manager.ts`](../src/main/mqtt-manager.ts)) | JSON **v1** chat on `{topicPrefix}/meshcore/chat` (non-LetsMesh / private brokers); **LetsMesh**: optional meshcoretomqtt-style **packet** JSON on `{topicPrefix}/meshcore/packets` ([`meshcore-mqtt-adapter.ts`](../src/main/meshcore-mqtt-adapter.ts)); chat parser in [`meshcoreMqttEnvelope.ts`](../src/shared/meshcoreMqttEnvelope.ts) | Adapter vs protobuf | -| MQTT channel crypto / uplink | AES-128/256-CTR, `channelPsks`, TLS ([`mqttTls.ts`](../src/renderer/lib/mqttTls.ts)), per-channel publish ([`meshtasticMqttPublish.ts`](../src/renderer/lib/meshtasticMqttPublish.ts)); [`mqtt-manager.ts`](../src/main/mqtt-manager.ts) | JSON v1 path unchanged | **App** (Meshtastic wire) | -| Node list hops / MQTT columns | `hops_away`, `via_mqtt` from device | Contact model; node-list `hops_away` derives from contact `outPathLen` (`meshcoreInferHopsFromOutPath`); per-message chat hop pills instead use the companion `pathLen` on RX events 7/8 (`meshcoreCompanionRxPathLenToHopCount`) — see [AGENTS.md](../AGENTS.md) Chat Panel §Hop badges | **App** (implemented) | -| RF diagnostics (LocalStats) | From protobuf | Different data model: Repeater Status `meshcore_local_stats` packet-stats feed **Elevated Noise Floor** / **Excessive Flooding** findings only (no CU/TX-based findings) | **App** (implemented, different metrics) | -| Routing diagnostics (hop-based) | `RoutingDiagnosticEngine` with hop count | `hasHopCount` is `true` (hops via `outPathLen`); same `RoutingDiagnosticEngine` hop anomalies run, plus MeshCore-only `weak_link` (per-hop trace SNR) | **App** (implemented) | -| Foreign LoRa overhear UI | Diagnostics tab tables (MeshCore / Reticulum RNS / unknown); Meshtastic decode-fail logs + dual-radio MeshCore RX | May record foreign traffic internally; **no** foreign-LoRa tables on MeshCore tab | **App** (Meshtastic-tab UI) | -| Neighbor UI | `neighborInfo` protobuf | Paged binary `GetNeighbours` (`MESHCORE_NEIGHBORS_PAGE_SIZE` request cap, `offset` append via `mergeMeshcoreNeighborPage`); **Load more** on RepeatersPanel and NodeDetailModal (firmware often returns fewer rows than requested) | Different primitive | -| Radio config | Full protobuf (role, presets, WiFi, etc.) | `setRadioParams`, channels, advert name/position | **Blocked** for Meshtastic-only admin | -| Channel URL sync | Radio tab import/export via [`meshtasticUrlEncoder.ts`](../src/shared/meshtasticUrlEncoder.ts) + [`meshtasticChannelApply.ts`](../src/shared/meshtasticChannelApply.ts) (`https://meshtastic.org/e/#…`, `meshtastic://`) | Not available | **App** (Meshtastic-only) | -| Position | Full GPS protobuf + request position | Advert lat/lon + `setAdvertLatLong` | **Partial** | -| Waypoints | Supported | Not in protocol surface | **Blocked** | -| Favorites | `nodes` table | `meshcore_contacts.favorited` + `db:updateMeshcoreContactFavorited` | **App** (implemented) | -| Environment telemetry charts | Device telemetry module | Cayenne LPP via `getTelemetry` → `environmentTelemetry` | **App** (implemented) | -| Chat transport badges / history | `received_via` (`rf` / `mqtt` / `both`) plus `via_store_forward` for S&F replays; router heartbeat triggers `CLIENT_HISTORY` via [`meshtasticBacklogUtils.ts`](../src/renderer/lib/meshtasticBacklogUtils.ts) | `meshcore_messages.received_via` (`rf` / `mqtt` / `both`) | **App** (implemented) | -| Chat search | `searchMessages` | `searchMeshcoreMessages`; UI search modal supports `user:` / `channel:` filters for cross-channel lookup | Parallel DB tables | -| Chat `@[Display Name]` tokens | Same on-wire pattern for replies / reactions / path-style lines | Same | **App**; chat body renders tokens as inline labels (see below) | -| Emoji reactions / tapbacks | `reactions.ts` decodes protobuf tapbacks (`emoji` flag + UTF-8 payload, legacy index 1–12); `ChatPanel` quick picker + `sendReaction` | Default outbound keyless `@[Name] emoji` / `@[Name] body`; optional **MeshCore Open compatibility** (App toggle) enables keyed replies, `r:HASH:INDEX`, and `g:GIFID` send — [`buildMeshcoreOutboundTapbackWire`](../src/renderer/lib/meshcoreChannelText.ts), [`buildMeshcoreOutboundSendText`](../src/renderer/lib/meshcoreChannelText.ts), [`meshcoreOpenReaction.ts`](../src/renderer/lib/meshcoreOpenReaction.ts), [`meshcoreGifWire.ts`](../src/renderer/lib/meshcoreGifWire.ts); inbound keyed/keyless + Open wire always parsed; emoji-only replies promoted via [`meshcorePromoteEmojiOnlyReplyToTapback`](../src/renderer/lib/meshcoreChannelText.ts); echo dedup in [`meshcoreStoreDedup.ts`](../src/renderer/lib/meshcoreStoreDedup.ts) | **App** (shared UI, protocol-specific wire) | -| MeshCore Open wire (experimental) | N/A | App toggle `meshcoreOpenWireCompatEnabled` ([`defaultAppSettings.ts`](../src/renderer/lib/defaultAppSettings.ts)): keyed replies, `r:` reactions, `g:` GIF send; default off (companion keyless wire) | **App** (MeshCore-only) | -| Chat composer | `ChatComposer.tsx` in `ChatPanel` | Same `ChatComposer` in `ChatPanel` and `RoomsPanel` | **App** (shared) | -| Repeater CLI | Not applicable | Per-repeater expandable CLI in `RepeatersPanel`; prefix-token correlation (`RepeaterCommandService`); **auto Ping** before the first multi-hop CLI command when no trace exists this session; **destructive-command confirm** (`reboot` / `erase` / factory-reset patterns via `meshcoreRepeaterCliDanger.ts`); ping-first guidance for multi-hop CLI; quick pills include `clock`, `clock sync`, `clear stats`, `advert`, `board`; **Flood Advert** and **Sync Clock** toolbar actions live on Radio panel (Device Actions) — distinct from the CLI **`clock sync`** pill; auto flood advert scheduling available in App Settings (disabled / 12h / 24h) | **App** (MeshCore-only) | -| Regional flood scope | Meshtastic region via LoRa config | Radio tab **flood scope** (`setFloodScope` / `clearFloodScope`); user-managed saved hashtags (`meshcoreFloodScopePresets`) + Chat split-Send override; `app_settings` reapply on connect | **App** (MeshCore v8+ transport keys) | -| Meshtastic MQTT downlink | Firmware MQTT module + `MqttClientProxyMessage` bridge when `proxy_to_client_enabled` (BLE/serial); per-channel downlink on Radio tab | N/A (JSON MQTT ingest only) | **App** (Meshtastic) | -| Security / PKI admin | `SecurityPanel` when `hasSecurityPanel`; DM backup/restore **per `nodeNum`** (full public + private pair) — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | `SecurityPanel` (partial): backup/restore **per `nodeId`**, sign, export/import; no Meshtastic PKI admin. Active MQTT cache: `mesh-client:meshcoreIdentity` — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | **Partial** — shared tab; protocol-specific backup + MC MQTT cache | -| PKC remote admin | `ConfigureNodeSelector`, [`meshtasticRemoteAdmin.ts`](../src/renderer/lib/meshtasticRemoteAdmin.ts), [`meshtasticRemoteAdminKeyStorage.ts`](../src/renderer/lib/meshtasticRemoteAdminKeyStorage.ts); local radio (2.5+) | Not available | **App** (Meshtastic-only) | -| Contact groups | Built-in groups (GPS, RF+MQTT) via `meshtasticContactGroupUtils`; user-managed via `ContactGroupsModal` | SQLite-backed groups + Nodes toolbar (`useContactGroups`, `ContactGroupsModal`); built-in Room filter | **App**; protocol-neutral with Meshtastic built-ins | -| Log analyzer | `LogPanel` → **Analyze** (`logAnalyzer.ts`, protocol-aware) | Same shared UI | **App** (implemented) | -| Room servers (BBS) | Not applicable | **Rooms** tab: login/post/admin CLI; optional **Remember password** (`app_settings`); **Auto-sync** periodic re-login while radio connected ([`meshcoreRoomSyncScheduler.ts`](../src/renderer/lib/meshcoreRoomSyncScheduler.ts), [`useMeshcoreRuntime.ts`](../src/renderer/runtime/useMeshcoreRuntime.ts)); RF-only (not MQTT) | **App** (MeshCore-only) | -| Repeater admin passwords | Not applicable | Per-repeater **Remember** (`meshcoreRepeaterCredential:` in `app_settings`); shared factory [`meshcorePerNodeCredentialStorage.ts`](../src/renderer/lib/meshcorePerNodeCredentialStorage.ts) with [`meshcoreRepeaterCredentialStorage.ts`](../src/renderer/lib/meshcoreRepeaterCredentialStorage.ts) / [`meshcoreRoomCredentialStorage.ts`](../src/renderer/lib/meshcoreRoomCredentialStorage.ts); [`useMeshcoreRepeaterRemoteAuth.tsx`](../src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.tsx), [`MeshcoreRepeaterPasswordControls.tsx`](../src/renderer/components/MeshcoreRepeaterPasswordControls.tsx); Repeaters sidebar **Saved repeater passwords** + Forget | **App** (MeshCore-only) | -| MsgWaiting background drain | Not applicable | Event 131 silent drain ([`meshcoreWaitingMessagesDrain.ts`](../src/renderer/lib/meshcoreWaitingMessagesDrain.ts)); **header status indicator** (queued backlog and active sync on any protocol tab; **paused/deferred** only on MeshCore tab); manual **Sync now** with determinate progress in the header indicator | **App** (MeshCore-only) | +| Area | Meshtastic | MeshCore | Gap type | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Transports | BLE, Serial, HTTP (`@meshtastic/core`), WiFi/TCP fast path (`TransportTcpIpc`, port 4403) | BLE, Web Serial, TCP bridge (5000) | Different stacks | +| Tab “Modules” / “Repeaters” | `ModulePanel` (protobuf modules; Remote Hardware GPIO, IP Tunnel status) | `RepeatersPanel` (trace, status, neighbors) | Product split | +| Tab “Administration” | `AdminPanel` (reboot, shutdown, factory reset, NodeDB reset, OTA/DFU) | `AdminPanel` (reboot via companion; meshcore.js limits for shutdown/factory/OTA) | **App** (implemented; reboot wired; extended admin capability-limited) | +| MQTT broker UI | Full (with transport selection) | Same broker fields; transport protocol selected when connecting; MeshCore-only **LetsMesh** / **MeshMapper** / **Colorado Mesh** / **Ripple** / **Custom** presets fill known public brokers | **Post-MQTT** codec on broker path | +| MQTT wire format | `ServiceEnvelope` / `MeshPacket` ([`mqtt-manager.ts`](../src/main/mqtt-manager.ts)) | JSON **v1** chat on `{topicPrefix}/meshcore/chat` (non-LetsMesh / private brokers); **LetsMesh**: optional meshcoretomqtt-style **packet** JSON on `{topicPrefix}/meshcore/packets` ([`meshcore-mqtt-adapter.ts`](../src/main/meshcore-mqtt-adapter.ts)); chat parser in [`meshcoreMqttEnvelope.ts`](../src/shared/meshcoreMqttEnvelope.ts) | Adapter vs protobuf | +| MQTT channel crypto / uplink | AES-128/256-CTR, `channelPsks`, TLS ([`mqttTls.ts`](../src/renderer/lib/mqttTls.ts)), per-channel publish ([`meshtasticMqttPublish.ts`](../src/renderer/lib/meshtasticMqttPublish.ts)); [`mqtt-manager.ts`](../src/main/mqtt-manager.ts) | JSON v1 path unchanged | **App** (Meshtastic wire) | +| Node list hops / MQTT columns | `hops_away`, `via_mqtt` from device | Contact model; node-list `hops_away` derives from contact `outPathLen` (`meshcoreInferHopsFromOutPath`); per-message chat hop pills instead use the companion `pathLen` on RX events 7/8 (`meshcoreCompanionRxPathLenToHopCount`) — see [AGENTS.md](../AGENTS.md) Chat Panel §Hop badges | **App** (implemented) | +| RF diagnostics (LocalStats) | From protobuf | Different data model: Repeater Status `meshcore_local_stats` packet-stats feed **Elevated Noise Floor** / **Excessive Flooding** findings only (no CU/TX-based findings) | **App** (implemented, different metrics) | +| Routing diagnostics (hop-based) | `RoutingDiagnosticEngine` with hop count | `hasHopCount` is `true` (hops via `outPathLen`); same `RoutingDiagnosticEngine` hop anomalies run, plus MeshCore-only `weak_link` (per-hop trace SNR) | **App** (implemented) | +| Foreign LoRa overhear UI | Diagnostics tab tables (MeshCore / Reticulum RNS / unknown); Meshtastic decode-fail logs + dual-radio MeshCore RX | Records foreign traffic; Diagnostics foreign-LoRa tables on MeshCore tab (keyed by MeshCore self id) and Meshtastic tab | **App** (implemented; tables on Meshtastic and MeshCore tabs) | +| Neighbor UI | `neighborInfo` protobuf | Paged binary `GetNeighbours` (`MESHCORE_NEIGHBORS_PAGE_SIZE` request cap, `offset` append via `mergeMeshcoreNeighborPage`); **Load more** on RepeatersPanel and NodeDetailModal (firmware often returns fewer rows than requested) | Different primitive | +| Radio config | Full protobuf (role, presets, WiFi, etc.) | `setRadioParams`, channels, advert name/position | **Blocked** for Meshtastic-only admin | +| Channel URL sync | Radio tab import/export via [`meshtasticUrlEncoder.ts`](../src/shared/meshtasticUrlEncoder.ts) + [`meshtasticChannelApply.ts`](../src/shared/meshtasticChannelApply.ts) (`https://meshtastic.org/e/#…`, `meshtastic://`) | Not available | **App** (Meshtastic-only) | +| Position | Full GPS protobuf + request position | Radio **Position / GPS**: advertised readout + lat/lon + `setAdvertLatLong` via Send Position; no GPS mode / broadcast intervals / altitude / request-position | **App** (implemented; advert lat/lon only — protocol) | +| Waypoints | Supported | Not in protocol surface | **Blocked** | +| Favorites | `nodes` table | `meshcore_contacts.favorited` + `db:updateMeshcoreContactFavorited` | **App** (implemented) | +| Environment telemetry charts | Device telemetry module | Cayenne LPP via `getTelemetry` → `environmentTelemetry` | **App** (implemented) | +| Chat transport badges / history | `received_via` (`rf` / `mqtt` / `both`) plus `via_store_forward` for S&F replays; router heartbeat triggers `CLIENT_HISTORY` via [`meshtasticBacklogUtils.ts`](../src/renderer/lib/meshtasticBacklogUtils.ts) | `meshcore_messages.received_via` (`rf` / `mqtt` / `both`) | **App** (implemented) | +| Chat search | `searchMessages` | `searchMeshcoreMessages`; UI search modal supports `user:` / `channel:` filters for cross-channel lookup | Parallel DB tables | +| Chat `@[Display Name]` tokens | Same on-wire pattern for replies / reactions / path-style lines | Same | **App** (implemented); chat body renders tokens as inline labels (see below) | +| Emoji reactions / tapbacks | `reactions.ts` decodes protobuf tapbacks (`emoji` flag + UTF-8 payload, legacy index 1–12); `ChatPanel` quick picker + `sendReaction` | Default outbound keyless `@[Name] emoji` / `@[Name] body`; optional **MeshCore Open compatibility** (App toggle) enables keyed replies, `r:HASH:INDEX`, and `g:GIFID` send — [`buildMeshcoreOutboundTapbackWire`](../src/renderer/lib/meshcoreChannelText.ts), [`buildMeshcoreOutboundSendText`](../src/renderer/lib/meshcoreChannelText.ts), [`meshcoreOpenReaction.ts`](../src/renderer/lib/meshcoreOpenReaction.ts), [`meshcoreGifWire.ts`](../src/renderer/lib/meshcoreGifWire.ts); inbound keyed/keyless + Open wire always parsed; emoji-only replies promoted via [`meshcorePromoteEmojiOnlyReplyToTapback`](../src/renderer/lib/meshcoreChannelText.ts); echo dedup in [`meshcoreStoreDedup.ts`](../src/renderer/lib/meshcoreStoreDedup.ts) | **App** (shared UI, protocol-specific wire) | +| MeshCore Open wire (experimental) | N/A | App toggle `meshcoreOpenWireCompatEnabled` ([`defaultAppSettings.ts`](../src/renderer/lib/defaultAppSettings.ts)): keyed replies, `r:` reactions, `g:` GIF send; default off (companion keyless wire) | **App** (MeshCore-only) | +| Chat composer | `ChatComposer.tsx` in `ChatPanel` | Same `ChatComposer` in `ChatPanel` and `RoomsPanel` | **App** (shared) | +| Repeater CLI | Not applicable | Per-repeater expandable CLI in `RepeatersPanel`; prefix-token correlation (`RepeaterCommandService`); **auto Ping** before the first multi-hop CLI command when no trace exists this session; **destructive-command confirm** (`reboot` / `erase` / factory-reset patterns via `meshcoreRepeaterCliDanger.ts`); ping-first guidance for multi-hop CLI; quick pills include `clock`, `clock sync`, `clear stats`, `advert`, `board`; **Flood Advert** and **Sync Clock** toolbar actions live on Radio panel (Device Actions) — distinct from the CLI **`clock sync`** pill; auto flood advert scheduling available in App Settings (disabled / 12h / 24h) | **App** (MeshCore-only) | +| Regional flood scope | Meshtastic region via LoRa config | Radio tab **flood scope** (`setFloodScope` / `clearFloodScope`); user-managed saved hashtags (`meshcoreFloodScopePresets`) + Chat split-Send override; `app_settings` reapply on connect | **App** (MeshCore v8+ transport keys) | +| Meshtastic MQTT downlink | Firmware MQTT module + `MqttClientProxyMessage` bridge when `proxy_to_client_enabled` (BLE/serial); per-channel downlink on Radio tab | N/A (JSON MQTT ingest only) | **App** (Meshtastic) | +| Security / PKI admin | `SecurityPanel` when `hasSecurityPanel`; DM backup/restore **per `nodeNum`** (full public + private pair) — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | `SecurityPanel` (partial): backup/restore **per `nodeId`**, sign, export/import; no Meshtastic PKI admin. Active MQTT cache: `mesh-client:meshcoreIdentity` — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | **Partial** — shared tab; protocol-specific backup + MC MQTT cache | +| PKC remote admin | `ConfigureNodeSelector`, [`meshtasticRemoteAdmin.ts`](../src/renderer/lib/meshtasticRemoteAdmin.ts), [`meshtasticRemoteAdminKeyStorage.ts`](../src/renderer/lib/meshtasticRemoteAdminKeyStorage.ts); local radio (2.5+) | Not available | **App** (Meshtastic-only) | +| Contact groups | Built-in groups (GPS, RF+MQTT) via `meshtasticContactGroupUtils`; user-managed via `ContactGroupsModal` | SQLite-backed groups + Nodes toolbar (`useContactGroups`, `ContactGroupsModal`); built-in Room filter | **App** (implemented); protocol-neutral with Meshtastic built-ins | +| Log analyzer | `LogPanel` → **Analyze** (`logAnalyzer.ts`, protocol-aware) | Same shared UI | **App** (implemented) | +| Room servers (BBS) | Not applicable | **Rooms** tab: login/post/admin CLI; optional **Remember password** (`app_settings`); **Auto-sync** periodic re-login while radio connected ([`meshcoreRoomSyncScheduler.ts`](../src/renderer/lib/meshcoreRoomSyncScheduler.ts), [`useMeshcoreRuntime.ts`](../src/renderer/runtime/useMeshcoreRuntime.ts)); RF-only (not MQTT) | **App** (MeshCore-only) | +| Repeater admin passwords | Not applicable | Per-repeater **Remember** (`meshcoreRepeaterCredential:` in `app_settings`); shared factory [`meshcorePerNodeCredentialStorage.ts`](../src/renderer/lib/meshcorePerNodeCredentialStorage.ts) with [`meshcoreRepeaterCredentialStorage.ts`](../src/renderer/lib/meshcoreRepeaterCredentialStorage.ts) / [`meshcoreRoomCredentialStorage.ts`](../src/renderer/lib/meshcoreRoomCredentialStorage.ts); [`useMeshcoreRepeaterRemoteAuth.tsx`](../src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.tsx), [`MeshcoreRepeaterPasswordControls.tsx`](../src/renderer/components/MeshcoreRepeaterPasswordControls.tsx); Repeaters sidebar **Saved repeater passwords** + Forget | **App** (MeshCore-only) | +| MsgWaiting background drain | Not applicable | Event 131 silent drain ([`meshcoreWaitingMessagesDrain.ts`](../src/renderer/lib/meshcoreWaitingMessagesDrain.ts)); **header status indicator** (queued backlog and active sync on any protocol tab; **paused/deferred** only on MeshCore tab); manual **Sync now** with determinate progress in the header indicator | **App** (MeshCore-only) | ## MeshCore: Room servers diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a50ed2bc4..9908e1a99 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1499,7 +1499,7 @@ Legacy SQLite rows could cross-contaminate the shared `nodes` table before proto **Symptoms**: MeshCore-heard or Reticulum traffic tables missing on MeshCore or Reticulum tabs. -**Fix**: By design — foreign-LoRa overhear tables render on the **Meshtastic** Diagnostics tab only. MeshCore may still record overhear internally when raw RX bytes are available. Reticulum RNode promiscuous foreign LoRa is not implemented (sidecar tap exposes parsed RNS frames only). +**Fix**: Foreign-LoRa overhear tables render on the **Meshtastic** and **MeshCore** Diagnostics tabs (keyed by that protocol’s self node id). Reticulum RNode promiscuous foreign LoRa is not implemented (sidecar tap exposes parsed RNS frames only). ### No signal bars on some nodes diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 270978fa3..ee95d2e79 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3401,7 +3401,9 @@ function AppContent() { onSendPositionToDevice={ capabilities.hasFullPositionConfig ? meshtasticPanelActions.sendPositionToDevice - : undefined + : capabilities.hasCompanionContactManagementConfig + ? meshcorePanelActions.sendPositionToDevice + : undefined } deviceOwner={effectiveDeviceOwner} onSetOwner={ @@ -3683,7 +3685,9 @@ function AppContent() { onReboot={ capabilities.hasShutdown ? meshtasticPanelActions.reboot - : async () => {} + : capabilities.hasCompanionContactManagementConfig + ? meshcorePanelActions.reboot + : async () => {} } onShutdown={ capabilities.hasShutdown diff --git a/src/renderer/components/DiagnosticsPanel.test.tsx b/src/renderer/components/DiagnosticsPanel.test.tsx index 7d4193142..af2fd15ce 100644 --- a/src/renderer/components/DiagnosticsPanel.test.tsx +++ b/src/renderer/components/DiagnosticsPanel.test.tsx @@ -418,7 +418,7 @@ describe('DiagnosticsPanel cross-protocol RF', () => { expect( screen.getByRole('heading', { - name: /other foreign lora on your meshtastic frequency \(2\)/i, + name: /other foreign lora overheard \(2\)/i, }), ).toBeInTheDocument(); expect(screen.getByText('Meshtastic Traffic')).toBeInTheDocument(); @@ -446,11 +446,11 @@ describe('DiagnosticsPanel cross-protocol RF', () => { screen.queryByRole('heading', { name: /meshcore nodes heard by your meshtastic radio/i }), ).not.toBeInTheDocument(); expect( - screen.queryByRole('heading', { name: /other foreign lora on your meshtastic frequency/i }), + screen.queryByRole('heading', { name: /other foreign lora overheard/i }), ).not.toBeInTheDocument(); }); - it('hides MeshCore heard-by-Meshtastic section on MeshCore diagnostics protocol', () => { + it('does not show Meshtastic-keyed MeshCore-heard rows on the MeshCore tab', () => { const myId = 0xface; const foreignId = 0xabc12345; diagnosticsStoreState.foreignLoraDetections = new Map([ @@ -490,6 +490,46 @@ describe('DiagnosticsPanel cross-protocol RF', () => { screen.queryByRole('heading', { name: /meshcore nodes heard by your meshtastic radio/i }), ).not.toBeInTheDocument(); }); + + it('shows other foreign LoRa on the MeshCore tab keyed by MeshCore self id', () => { + const myMcId = 0xbeef; + diagnosticsStoreState.foreignLoraDetections = new Map([ + [ + myMcId, + new Map([ + [ + 'meshtastic:0x111', + { + detectedAt: Date.now(), + packetClass: 'meshtastic', + proximity: 'nearby', + count: 3, + lastSenderId: 0x111, + source: 'meshcore-radio-rf', + }, + ], + ]), + ], + ]); + + render( + , + ); + + expect( + screen.getByRole('heading', { name: /other foreign lora overheard \(1\)/i }), + ).toBeInTheDocument(); + expect(screen.getByText('Meshtastic Traffic')).toBeInTheDocument(); + }); }); describe('DiagnosticsPanel reticulum scope', () => { diff --git a/src/renderer/components/DiagnosticsPanel.tsx b/src/renderer/components/DiagnosticsPanel.tsx index 6d07bed63..52cf673ef 100644 --- a/src/renderer/components/DiagnosticsPanel.tsx +++ b/src/renderer/components/DiagnosticsPanel.tsx @@ -146,7 +146,7 @@ interface Props { capabilities?: ProtocolCapabilities; /** Active radio protocol — auto-traceroute preference is stored per protocol. */ protocol: MeshProtocol; - /** Meshtastic node id used to look up foreign-LoRa detections (stable across panel remounts). */ + /** Meshtastic node id used to look up foreign-LoRa detections when on the Meshtastic tab. */ meshtasticListenerNodeId?: number; /** MeshCore contacts only — used for heard-by-Meshtastic links (not merged Meshtastic nodes). */ meshcoreNodes?: Map; @@ -230,9 +230,16 @@ export default function DiagnosticsPanel({ const distanceOffsetKm = useDiagnosticsStore((s) => s.distanceOffsetKm); const setDistanceOffsetKm = useDiagnosticsStore((s) => s.setDistanceOffsetKm); const foreignLoraDetections = useDiagnosticsStore((s) => s.foreignLoraDetections); + /** Map key for foreign-LoRa detections: Meshtastic self id on MT tab, MeshCore self id on MC tab. */ + const foreignLoraListenerNodeId = + protocol === 'meshcore' && myNodeNum > 0 + ? myNodeNum + : protocol === 'meshtastic' && meshtasticListenerNodeId > 0 + ? meshtasticListenerNodeId + : 0; const foreignLoraBySender = useMemo( - () => foreignLoraDetections.get(meshtasticListenerNodeId), - [foreignLoraDetections, meshtasticListenerNodeId], + () => foreignLoraDetections.get(foreignLoraListenerNodeId), + [foreignLoraDetections, foreignLoraListenerNodeId], ); const meshcoreHeardList = useMemo( () => @@ -254,8 +261,10 @@ export default function DiagnosticsPanel({ r.condition === 'Potential MeshCore Repeater Conflict', ), ); - const showMeshtasticForeignLora = - protocol === 'meshtastic' && meshtasticListenerNodeId > 0 && isConnected; + const showForeignLoraTables = + (protocol === 'meshtastic' || protocol === 'meshcore') && + foreignLoraListenerNodeId > 0 && + isConnected; const [search, setSearch] = useState(''); const [tracePendingNodes, setTracePendingNodes] = useState>(() => new Set()); @@ -477,9 +486,7 @@ export default function DiagnosticsPanel({ const selfRows = anomalyList.filter((r) => r.nodeId === myNodeNum && !isForeignLoraRfRow(r)); const foreignLoraListenerId = - protocol === 'meshtastic' && meshtasticListenerNodeId > 0 - ? meshtasticListenerNodeId - : myNodeNum; + foreignLoraListenerNodeId > 0 ? foreignLoraListenerNodeId : myNodeNum; const otherCrossProtocolRows = anomalyList.filter( (r) => r.nodeId === foreignLoraListenerId && isForeignLoraRfRow(r) && !isMeshCoreInterferenceRow(r), @@ -993,7 +1000,7 @@ export default function DiagnosticsPanel({ )} {/* MeshCore nodes heard by Meshtastic radio (per transmitter) */} - {showMeshtasticForeignLora && meshcoreHeardList.length > 0 && ( + {showForeignLoraTables && meshcoreHeardList.length > 0 && (

@@ -1075,7 +1082,7 @@ export default function DiagnosticsPanel({ )} {/* Meshtastic + unknown-lora foreign traffic on Meshtastic frequency */} - {showMeshtasticForeignLora && otherForeignList.length > 0 && ( + {showForeignLoraTables && otherForeignList.length > 0 && (

diff --git a/src/renderer/components/RadioPanel.tsx b/src/renderer/components/RadioPanel.tsx index cd64203e5..c0169a028 100644 --- a/src/renderer/components/RadioPanel.tsx +++ b/src/renderer/components/RadioPanel.tsx @@ -45,6 +45,7 @@ import { meshcoreOffloadAbortRemovedCount, } from '../lib/meshcoreOffload'; import { + formatMeshcoreAdvertisedPositionDegrees, MESHCORE_CHANNEL_INDEX_MAX, MESHCORE_CHANNEL_NAME_MAX_LEN, MESHCORE_CONTACTS_WARNING_THRESHOLD, @@ -760,6 +761,10 @@ export default function RadioPanel({ const a = ourPosition?.altitudeMeters; return a != null && Number.isFinite(a) ? String(a) : '0'; }); + /** True after the user edits lat/lon (or Use current GPS) until a successful send. */ + const meshcorePositionFormDirtyRef = useRef(false); + /** Last MeshCore advert lat/lon strings applied to the form (skip overwrite while dirty). */ + const syncedMeshcoreAdvertRef = useRef<{ lat: string; lon: string } | null>(null); const [gpsMode, setGpsMode] = useState(0); const [positionPrecision, setPositionPrecision] = useState(10); const [smartPositionEnabled, setSmartPositionEnabled] = useState(false); @@ -911,6 +916,32 @@ export default function RadioPanel({ setAltStr(String(a)); }, [ourPosition?.altitudeMeters]); + // MeshCore: sync lat/lon from companion advert when the form has not been user-edited. + useEffect(() => { + if (capabilities?.hasFullPositionConfig !== false) return; + if (!meshcoreSelfInfo) return; + const { lat, lon } = meshcoreScaledAdvLatLonToDeg( + meshcoreSelfInfo.advLat, + meshcoreSelfInfo.advLon, + ); + if (lat == null || lon == null) return; + const nextLat = String(lat); + const nextLon = String(lon); + const synced = syncedMeshcoreAdvertRef.current; + if (synced?.lat === nextLat && synced?.lon === nextLon) { + return; + } + if (meshcorePositionFormDirtyRef.current) return; + syncedMeshcoreAdvertRef.current = { lat: nextLat, lon: nextLon }; + setLatStr(nextLat); + setLonStr(nextLon); + }, [ + capabilities?.hasFullPositionConfig, + meshcoreSelfInfo, + meshcoreSelfInfo?.advLat, + meshcoreSelfInfo?.advLon, + ]); + // ─── Shared state ───────────────────────────────────────────── const [status, setStatus] = useState(null); const [applyingSection, setApplyingSection] = useState(null); @@ -2094,12 +2125,29 @@ export default function RadioPanel({ {/* For MeshCore: lat/lon always shown (fixed position is the only option) */} {(fixedPosition || capabilities?.hasFullPositionConfig === false) && (
+ {capabilities?.hasFullPositionConfig === false && + (() => { + const advertised = formatMeshcoreAdvertisedPositionDegrees( + meshcoreSelfInfo?.advLat, + meshcoreSelfInfo?.advLon, + ); + if (!advertised) return null; + return ( +

+ {t('radioPanel.advertisedPositionLabel', { + lat: advertised.lat, + lon: advertised.lon, + })} +

+ ); + })()}

{t('radioPanel.setCoordinatesHint')} {ourPosition && (

-
- - { - setAltStr(e.target.value); - }} - disabled={disabled || applyingSection !== null} - placeholder="0" - className="bg-secondary-dark focus:border-brand-green w-36 rounded-lg border border-gray-600 px-3 py-2 text-gray-200 focus:outline-none disabled:opacity-50" - /> -
+ {capabilities?.hasFullPositionConfig !== false && ( +
+ + { + setAltStr(e.target.value); + }} + disabled={disabled || applyingSection !== null} + placeholder="0" + className="bg-secondary-dark focus:border-brand-green w-36 rounded-lg border border-gray-600 px-3 py-2 text-gray-200 focus:outline-none disabled:opacity-50" + /> +
+ )} {open && ( -
+
{relevantOffers.length > 0 && (

@@ -227,25 +375,103 @@ export function ChatDmRncpControl({

)} + {peerTransfers.length > 0 && ( +
+

+ {t('chatPanel.rncp.transfersTitle')} +

+ {peerTransfers.map((transfer) => ( +
+
+ {transfer.file_name ?? '—'} + + {transfer.status === 'active' + ? `${transfer.progress}%` + : t(`reticulumRemote.transfer.status.${transfer.status}`)} + + {transfer.status === 'active' && ( + + )} +
+ {transfer.status === 'active' && ( +
+
+
+ )} + {transfer.status === 'failed' && transfer.error && ( + + {transfer.error} + + )} +
+ ))} +
+ )} +

{t('chatPanel.rncp.destinationHelp')}

+ {otherSavedLabels.length > 0 && ( +

+ {t('chatPanel.rncp.savedForOtherPeers', { peers: otherSavedLabels.join(', ') })} +

+ )}
{ - setDestinationInput(e.target.value); + handleDestinationChange(e.target.value); + }} + onBlur={() => { + const parsed = parseReticulumDestinationInput(destinationInput); + if (parsed) setDestinationInput(parsed); }} aria-label={t('reticulumRemote.transfer.destinationAria')} className="bg-secondary-dark/80 min-w-0 flex-1 rounded border border-gray-600/50 px-2 py-1 text-xs text-gray-200 focus:border-blue-500/50 focus:outline-none" />
+ {pathConstrained && capability && ( +

+ {t('reticulumRemote.transfer.notAllowedHint', { + reason: capability.reason_key + ? t( + resolveRemoteReasonI18nKey(capability.reason_key) ?? + 'reticulumRemote.reasons.pathConstrained', + ) + : t('reticulumRemote.reasons.pathConstrained'), + })} +

+ )} {!savedAddress && (