From 1145c53f9424a7b8c7683898c68955db71602663 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 2 Aug 2026 11:56:11 -0600 Subject: [PATCH 1/3] feat(connection): add host-link strength meter for BLE and WiFi/TCP Show Noble BLE RSSI and HTTP/TCP RTT link quality on Meshtastic/MeshCore Connection panels, plus Reticulum BLE RNode and TCP Client rows. Linux BLE shows Unavailable (Web Bluetooth). --- src/main/host-link-rtt.test.ts | 66 ++++++ src/main/host-link-rtt.ts | 83 +++++++ src/main/index.contract.test.ts | 21 +- src/main/index.ts | 25 ++ src/main/noble-ble-manager.test.ts | 20 ++ src/main/noble-ble-manager.ts | 76 ++++++ src/preload/index.ts | 16 ++ src/renderer/App.test.tsx | 1 + .../components/ConnectionLinkMeter.test.tsx | 36 +++ .../components/ConnectionLinkMeter.tsx | 83 +++++++ .../ConnectionPanel.hostLinkMeter.test.tsx | 168 ++++++++++++++ src/renderer/components/ConnectionPanel.tsx | 26 ++- src/renderer/components/SignalBars.test.tsx | 36 +++ src/renderer/components/SignalBars.tsx | 20 +- .../ReticulumInterfacesPanel.test.tsx | 173 ++++++++++++++ .../reticulum/ReticulumInterfacesPanel.tsx | 62 +++++ .../hooks/hostLinkQuality.probes.test.ts | 44 ++++ src/renderer/hooks/useHostLinkMeter.test.ts | 219 ++++++++++++++++++ src/renderer/hooks/useHostLinkMeter.ts | 134 +++++++++++ .../hooks/useReticulumBleRnodeRssiMap.test.ts | 175 ++++++++++++++ .../hooks/useReticulumBleRnodeRssiMap.ts | 142 ++++++++++++ .../useReticulumTcpLinkQualityMap.test.ts | 134 +++++++++++ .../hooks/useReticulumTcpLinkQualityMap.ts | 135 +++++++++++ src/renderer/lib/devElectronApiStub.ts | 5 + src/renderer/lib/hostLinkQuality.test.ts | 53 +++++ src/renderer/lib/hostLinkQuality.ts | 110 +++++++++ src/renderer/locales/cs/translation.json | 8 +- src/renderer/locales/de/translation.json | 8 +- src/renderer/locales/en/translation.json | 6 + src/renderer/locales/es/translation.json | 8 +- src/renderer/locales/fr/translation.json | 8 +- src/renderer/locales/id/translation.json | 8 +- src/renderer/locales/it/translation.json | 8 +- src/renderer/locales/ja/translation.json | 8 +- src/renderer/locales/ko/translation.json | 8 +- src/renderer/locales/nl/translation.json | 8 +- src/renderer/locales/pl/translation.json | 8 +- src/renderer/locales/pt-BR/translation.json | 8 +- src/renderer/locales/ru/translation.json | 8 +- src/renderer/locales/tr/translation.json | 8 +- src/renderer/locales/uk/translation.json | 8 +- src/renderer/locales/zh/translation.json | 8 +- src/renderer/vitest.electronApiMock.ts | 5 + src/shared/electron-api.types.ts | 17 ++ 44 files changed, 2187 insertions(+), 24 deletions(-) create mode 100644 src/main/host-link-rtt.test.ts create mode 100644 src/main/host-link-rtt.ts create mode 100644 src/renderer/components/ConnectionLinkMeter.test.tsx create mode 100644 src/renderer/components/ConnectionLinkMeter.tsx create mode 100644 src/renderer/components/ConnectionPanel.hostLinkMeter.test.tsx create mode 100644 src/renderer/components/SignalBars.test.tsx create mode 100644 src/renderer/hooks/hostLinkQuality.probes.test.ts create mode 100644 src/renderer/hooks/useHostLinkMeter.test.ts create mode 100644 src/renderer/hooks/useHostLinkMeter.ts create mode 100644 src/renderer/hooks/useReticulumBleRnodeRssiMap.test.ts create mode 100644 src/renderer/hooks/useReticulumBleRnodeRssiMap.ts create mode 100644 src/renderer/hooks/useReticulumTcpLinkQualityMap.test.ts create mode 100644 src/renderer/hooks/useReticulumTcpLinkQualityMap.ts create mode 100644 src/renderer/lib/hostLinkQuality.test.ts create mode 100644 src/renderer/lib/hostLinkQuality.ts diff --git a/src/main/host-link-rtt.test.ts b/src/main/host-link-rtt.test.ts new file mode 100644 index 000000000..24966b5e8 --- /dev/null +++ b/src/main/host-link-rtt.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment node +import net from 'net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { probeHttpRttMs, probeTcpRttMs } from './host-link-rtt'; + +describe('probeTcpRttMs', () => { + let server: net.Server | null = null; + + afterEach(async () => { + await new Promise((resolve) => { + if (!server) { + resolve(); + return; + } + server.close(() => { + resolve(); + }); + server = null; + }); + }); + + it('returns a finite RTT when the host accepts a TCP connect', async () => { + server = net.createServer((socket) => { + socket.destroy(); + }); + await new Promise((resolve) => { + server!.listen(0, '127.0.0.1', () => { + resolve(); + }); + }); + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new Error('expected TCP port'); + const rtt = await probeTcpRttMs('127.0.0.1', addr.port); + expect(rtt).not.toBeNull(); + expect(rtt!).toBeGreaterThanOrEqual(0); + expect(rtt!).toBeLessThan(3000); + }); + + it('returns null when the port is closed', async () => { + const rtt = await probeTcpRttMs('127.0.0.1', 1); + expect(rtt).toBeNull(); + }); +}); + +describe('probeHttpRttMs', () => { + it('returns null when fetch fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new Error('network down'))), + ); + await expect(probeHttpRttMs('127.0.0.1', false)).resolves.toBeNull(); + vi.unstubAllGlobals(); + }); + + it('returns RTT when the host answers', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, status: 200 } as Response)), + ); + const rtt = await probeHttpRttMs('example.test', false); + expect(rtt).not.toBeNull(); + expect(rtt!).toBeGreaterThanOrEqual(0); + vi.unstubAllGlobals(); + }); +}); diff --git a/src/main/host-link-rtt.ts b/src/main/host-link-rtt.ts new file mode 100644 index 000000000..a485e5ccf --- /dev/null +++ b/src/main/host-link-rtt.ts @@ -0,0 +1,83 @@ +import net from 'net'; + +import { formatHostForSocket } from '../shared/connectHost'; +import { MS_PER_SECOND } from '../shared/timeConstants'; +import { sanitizeLogMessage } from './log-service'; + +/** Align with renderer `HOST_LINK_RTT_PROBE_TIMEOUT_MS`. */ +export const HOST_LINK_RTT_PROBE_TIMEOUT_MS = 3 * MS_PER_SECOND; + +/** + * Time a Meshtastic HTTP `/json/report` GET. Returns RTT ms, or null on failure. + * Does not throw — callers treat null as "no bars". + */ +export async function probeHttpRttMs(host: string, tls: boolean): Promise { + const protocol = tls ? 'https' : 'http'; + const url = `${protocol}://${host}/json/report`; + const started = Date.now(); + try { + const res = await fetch(url, { signal: AbortSignal.timeout(HOST_LINK_RTT_PROBE_TIMEOUT_MS) }); + const rtt = Date.now() - started; + if (!res.ok) { + console.debug( + `[hostLink] HTTP probe non-OK ${res.status} for ${sanitizeLogMessage(host)} rtt=${rtt}ms`, + ); + // Still usable as latency if the host answered quickly. + return rtt; + } + return rtt; + } catch (err) { + console.debug( + `[hostLink] HTTP probe failed for ${sanitizeLogMessage(host)}:`, + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + return null; + } +} + +/** + * Time a TCP connect to host:port, then destroy the socket immediately. + * Measures LAN reachability latency without attaching a protocol session. + */ +export function probeTcpRttMs(host: string, port: number): Promise { + return new Promise((resolve) => { + let settled = false; + const socketHost = formatHostForSocket(host); + const socket = new net.Socket(); + const started = Date.now(); + const finish = (rtt: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + socket.removeAllListeners(); + socket.destroy(); + } catch { + // catch-no-log-ok probe socket cleanup + } + resolve(rtt); + }; + const timer = setTimeout(() => { + finish(null); + }, HOST_LINK_RTT_PROBE_TIMEOUT_MS); + socket.once('connect', () => { + finish(Date.now() - started); + }); + socket.once('error', (err) => { + console.debug( + `[hostLink] TCP probe failed for ${sanitizeLogMessage(socketHost)}:${port}:`, + sanitizeLogMessage(err.message), + ); + finish(null); + }); + try { + socket.connect(port, socketHost); + } catch (err) { + console.debug( + `[hostLink] TCP probe connect threw for ${sanitizeLogMessage(socketHost)}:${port}:`, + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + finish(null); + } + }); +} diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index 2fd751bb0..268a4670e 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import { describe, expect, it } from 'vitest'; const INDEX_SOURCE = readFileSync(join(__dirname, 'index.ts'), 'utf-8'); +const PRELOAD_SOURCE = readFileSync(join(__dirname, '../preload/index.ts'), 'utf-8'); describe('IPC payload size limits (source contract)', () => { it('defines meshcore tcp-write, http:write, and noble-ble limits and uses them in handlers', () => { @@ -292,8 +293,6 @@ describe('Reticulum sidecar IPC handlers (source contract)', () => { join(__dirname, 'ipc/reticulum-db-handlers.ts'), 'utf8', ); - const PRELOAD_SOURCE = readFileSync(join(__dirname, '../preload/index.ts'), 'utf8'); - it('registers reticulum lifecycle and proxy handlers', () => { expect(INDEX_SOURCE).toContain('registerReticulumIpcHandlers'); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:start'"); @@ -362,6 +361,24 @@ describe('HTTP bridge IPC handlers (source contract)', () => { }); }); +describe('Host link quality IPC (source contract)', () => { + it('forwards Noble link RSSI and registers HTTP/TCP RTT probes', () => { + expect(INDEX_SOURCE).toContain("webContents.send('noble-ble-link-rssi'"); + expect(INDEX_SOURCE).toContain("ipcMain.handle('hostLink:probeHttpRtt'"); + expect(INDEX_SOURCE).toContain("ipcMain.handle('hostLink:probeTcpRtt'"); + }); +}); + +describe('Host link quality preload surface (source contract)', () => { + it('exposes onNobleBleLinkRssi and hostLink probe APIs', () => { + expect(PRELOAD_SOURCE).toContain('onNobleBleLinkRssi:'); + expect(PRELOAD_SOURCE).toContain("ipcRenderer.on('noble-ble-link-rssi'"); + expect(PRELOAD_SOURCE).toContain('hostLink:'); + expect(PRELOAD_SOURCE).toContain("ipcRenderer.invoke('hostLink:probeHttpRtt'"); + expect(PRELOAD_SOURCE).toContain("ipcRenderer.invoke('hostLink:probeTcpRtt'"); + }); +}); + describe('Native crash observability (source contract)', () => { it('starts crashReporter without upload and logs child-process-gone', () => { expect(INDEX_SOURCE).toContain( diff --git a/src/main/index.ts b/src/main/index.ts index 2fe17d2a3..52c5b333c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -103,6 +103,7 @@ import { finishDbIpcHandler, finishDbIpcReadHandler, getDbForIpc } from './db-ip import { formatDatabaseSchemaTooNewMessage, showFatalStartupError } from './fatal-startup-dialog'; import { fetchLinkPreview } from './fetchLinkPreview'; import { formatGpxTracks, GPX_EXPORT_MAX_POINTS } from './gpxExportFormat'; +import { probeHttpRttMs, probeTcpRttMs } from './host-link-rtt'; import { isValidHttpHostname } from './httpHostValidation'; import { registerGpsIpcHandlers } from './ipc/gps-handlers'; import { registerReticulumDbIpcHandlers } from './ipc/reticulum-db-handlers'; @@ -2609,6 +2610,12 @@ nobleBleManager.on( mainWindow?.webContents.send('noble-ble-device-discovered', device); }, ); +nobleBleManager.on( + 'linkRssi', + ({ sessionId, rssi }: { sessionId: NobleSessionId; rssi: number | null }) => { + mainWindow?.webContents.send('noble-ble-link-rssi', { sessionId, rssi }); + }, +); nobleBleManager.on('connected', ({ sessionId }: { sessionId: NobleSessionId }) => { mainWindow?.webContents.send('noble-ble-connected', { sessionId }); }); @@ -6339,6 +6346,24 @@ ipcMain.handle('http:preflight', async (event, host: unknown, tls: unknown) => { await httpPreflight(host, tls); }); +ipcMain.handle('hostLink:probeHttpRtt', async (event, host: unknown, tls: unknown) => { + assertIpcSender(event, 'hostLink:probeHttpRtt'); + validateHttpHost(host); + if (typeof tls !== 'boolean') { + throw new Error('Invalid tls'); + } + return probeHttpRttMs(host, tls); +}); + +ipcMain.handle('hostLink:probeTcpRtt', async (event, host: unknown, port: unknown) => { + assertIpcSender(event, 'hostLink:probeTcpRtt'); + validateHttpHost(host); + if (!Number.isInteger(port) || (port as number) < 1 || (port as number) > 65535) { + throw new Error('Invalid port'); + } + return probeTcpRttMs(host, port as number); +}); + ipcMain.handle('http:connect', async (event, host: unknown, tls: unknown) => { if (!validateIpcSender(event)) throw new Error('http:connect: unauthorized sender'); validateHttpHost(host); diff --git a/src/main/noble-ble-manager.test.ts b/src/main/noble-ble-manager.test.ts index 91825ec21..33ad020e1 100644 --- a/src/main/noble-ble-manager.test.ts +++ b/src/main/noble-ble-manager.test.ts @@ -146,6 +146,26 @@ describe('NobleBleManager.connect — macOS wake zombie peripheral (regression)' * MeshCore uses notify-only (like Web Bluetooth); GATT read on NUS TX fails on Windows WinRT. * Meshtastic keeps a non-Darwin read-pump safety net when notify is active. */ +describe('NobleBleManager — connected link RSSI polling (regression)', () => { + it('declares link RSSI poll timer fields and start/stop helpers', () => { + expect(SOURCE).toContain('linkRssiPollTimer: ReturnType | null'); + expect(SOURCE).toContain('NOBLE_LINK_RSSI_POLL_MS'); + expect(SOURCE).toContain('startLinkRssiPolling'); + expect(SOURCE).toContain('stopLinkRssiPolling'); + expect(SOURCE).toContain("emit('linkRssi'"); + expect(SOURCE).toContain('updateRssiAsync'); + }); + + it('starts link RSSI polling after successful connect and stops in clearSessionState', () => { + expect(SOURCE).toMatch( + /this\.startLinkRssiPolling\(sessionId, session, peripheral, connectRssi\)/, + ); + const clearMatch = /private clearSessionState\([\s\S]+?\n {2}\}/.exec(SOURCE); + expect(clearMatch).not.toBeNull(); + expect(clearMatch![0]).toContain('stopLinkRssiPolling'); + }); +}); + describe('NobleBleManager — notify-first fromRadio read pump strategy (regression)', () => { it('declares fromRadioNotifyOnly in session state and initialises it to false', () => { expect(SOURCE).toContain('fromRadioNotifyOnly: boolean'); diff --git a/src/main/noble-ble-manager.ts b/src/main/noble-ble-manager.ts index da225eeb6..a7b310041 100644 --- a/src/main/noble-ble-manager.ts +++ b/src/main/noble-ble-manager.ts @@ -43,6 +43,8 @@ interface NoblePeripheral { removeAllListeners(event?: 'mtu'): this; connectAsync(): Promise; disconnectAsync(): Promise; + /** Refresh connected-peripheral RSSI (macOS/Windows Noble). */ + updateRssiAsync(): Promise; discoverAllServicesAndCharacteristicsAsync(): Promise; discoverSomeServicesAndCharacteristicsAsync( serviceUuids: string[], @@ -123,6 +125,10 @@ const BLE_START_SCAN_TIMEOUT_MS = IS_DARWIN ? 15_000 : 30_000; const BLE_MTU_POST_GATT_WAIT_MS = 1500; /** Poll interval while waiting for first `peripheral.mtu` value. */ const BLE_MTU_POLL_MS = 50; +/** Host↔radio BLE RSSI poll while GATT is connected (Connection panel meter). */ +export const NOBLE_LINK_RSSI_POLL_MS = 4_000; +/** Bound a single updateRssiAsync so a hung stack cannot stall the poll loop. */ +const NOBLE_LINK_RSSI_UPDATE_TIMEOUT_MS = 5_000; function normalizeUuid(uuid: string): string { return uuid.toLowerCase().replace(/-/g, ''); @@ -236,6 +242,10 @@ interface NobleBleSession { * operation; concurrent writes accumulate past Noble's 10-listener limit. */ writeQueue: Promise; + /** Interval timer for connected-link RSSI polls; cleared on disconnect. */ + linkRssiPollTimer: ReturnType | null; + /** True while updateRssiAsync is in flight (skip overlapping polls). */ + linkRssiPollInflight: boolean; /** Sanitized ATT MTU (23–517) from Noble `peripheral.mtu` / `mtu` events; drives write chunking. */ attMtuSanitized: number; /** True after first `console.debug` for Noble-reported MTU below 23 (binding quirks, e.g. raw 20 on Darwin). */ @@ -347,6 +357,8 @@ export class NobleBleManager extends EventEmitter { registeredMac: null, gattSetupInflight: null, writeQueue: Promise.resolve(), + linkRssiPollTimer: null, + linkRssiPollInflight: false, attMtuSanitized: attMtuOrDefault(null), attMtuSuspiciousLogged: false, peripheralMtuHandler: null, @@ -355,6 +367,68 @@ export class NobleBleManager extends EventEmitter { }; } + private stopLinkRssiPolling(session: NobleBleSession): void { + if (session.linkRssiPollTimer !== null) { + clearInterval(session.linkRssiPollTimer); + session.linkRssiPollTimer = null; + } + session.linkRssiPollInflight = false; + } + + private emitLinkRssi(sessionId: NobleSessionId, rssi: number | null): void { + this.emit('linkRssi', { sessionId, rssi }); + } + + /** + * Seed + periodic host BLE RSSI while GATT is up (Connection panel strength meter). + * Uses Noble updateRssiAsync; no-op when the method is missing. + */ + private startLinkRssiPolling( + sessionId: NobleSessionId, + session: NobleBleSession, + peripheral: NoblePeripheral, + seedRssi: number | null, + ): void { + this.stopLinkRssiPolling(session); + if (seedRssi != null && Number.isFinite(seedRssi)) { + this.emitLinkRssi(sessionId, seedRssi); + } else if (typeof peripheral.rssi === 'number' && Number.isFinite(peripheral.rssi)) { + this.emitLinkRssi(sessionId, peripheral.rssi); + } + + const pollOnce = (): void => { + if (session.closing || session.connectedPeripheral !== peripheral) return; + if (session.linkRssiPollInflight) return; + if (typeof peripheral.updateRssiAsync !== 'function') return; + if (peripheral.state !== 'connected') return; + session.linkRssiPollInflight = true; + void withTimeout( + peripheral.updateRssiAsync(), + NOBLE_LINK_RSSI_UPDATE_TIMEOUT_MS, + 'BLE updateRssiAsync', + ) + .then((rssi) => { + if (session.closing || session.connectedPeripheral !== peripheral) return; + if (typeof rssi === 'number' && Number.isFinite(rssi)) { + this.emitLinkRssi(sessionId, rssi); + } + }) + .catch((err: unknown) => { + console.debug( + `[BLE:${sessionId}] link RSSI poll failed:`, + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + }) + .finally(() => { + session.linkRssiPollInflight = false; + }); + }; + + // First refresh soon after connect; then steady interval. + session.linkRssiPollTimer = setInterval(pollOnce, NOBLE_LINK_RSSI_POLL_MS); + pollOnce(); + } + private getSession(sessionId: NobleSessionId): NobleBleSession { const session = this.sessions.get(sessionId); if (!session) throw new Error(`Unknown noble session: ${sessionId}`); @@ -390,6 +464,7 @@ export class NobleBleManager extends EventEmitter { clearTimeout(session.notifyWatchdogTimer); session.notifyWatchdogTimer = null; } + this.stopLinkRssiPolling(session); session.connectedPeripheral = null; session.connectedPeripheralDisconnectHandler = null; session.toRadioChar = null; @@ -1453,6 +1528,7 @@ export class NobleBleManager extends EventEmitter { session.registeredMac = registeredMac; session.lastConnectedPeripheralId = peripheralId; session.sessionEstablishedAtMs = Date.now(); + this.startLinkRssiPolling(sessionId, session, peripheral, connectRssi); this.emit('connected', { sessionId }); } catch (err) { console.warn(`[BLE:${sessionId}] connect failed:`, err instanceof Error ? err.message : err); // log-injection-ok noble internal error diff --git a/src/preload/index.ts b/src/preload/index.ts index 4cd82afde..58094a90f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ import type { MQTTStatus, NobleBleConnectResult, NobleBleDevice, + NobleBleLinkRssiPayload, NobleBleSessionId, OutboxEntry, OutboxEntryInput, @@ -645,6 +646,13 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('noble-ble-device-discovered', handler); return () => ipcRenderer.off('noble-ble-device-discovered', handler); }, + onNobleBleLinkRssi: (cb: (payload: NobleBleLinkRssiPayload) => void) => { + const handler = (_: unknown, payload: NobleBleLinkRssiPayload) => { + cb(payload); + }; + ipcRenderer.on('noble-ble-link-rssi', handler); + return () => ipcRenderer.off('noble-ble-link-rssi', handler); + }, onNobleBleConnected: (cb: (sessionId: NobleBleSessionId) => void) => { const handler = (_: unknown, payload: { sessionId: NobleBleSessionId }) => { cb(payload.sessionId); @@ -984,6 +992,14 @@ contextBridge.exposeInMainWorld('electronAPI', { }, }, + // ─── Host↔radio link quality (Connection panel) ─────────────────── + hostLink: { + probeHttpRtt: (host: string, tls: boolean): Promise => + ipcRenderer.invoke('hostLink:probeHttpRtt', host, tls), + probeTcpRtt: (host: string, port: number): Promise => + ipcRenderer.invoke('hostLink:probeTcpRtt', host, port), + }, + // ─── Meshtastic TCP bridge ──────────────────────────────────────── meshtastic: { tcp: { diff --git a/src/renderer/App.test.tsx b/src/renderer/App.test.tsx index 21611977d..17c8cee63 100644 --- a/src/renderer/App.test.tsx +++ b/src/renderer/App.test.tsx @@ -460,6 +460,7 @@ vi.mock('../preload', () => ({ disconnectNobleBle: vi.fn(), onNobleBleDisconnected: vi.fn(), onNobleBleDeviceDiscovered: vi.fn(), + onNobleBleLinkRssi: vi.fn(), startNobleBleScanning: vi.fn(), onSerialPortsDiscovered: vi.fn(), }, diff --git a/src/renderer/components/ConnectionLinkMeter.test.tsx b/src/renderer/components/ConnectionLinkMeter.test.tsx new file mode 100644 index 000000000..e399c1458 --- /dev/null +++ b/src/renderer/components/ConnectionLinkMeter.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import ConnectionLinkMeter from './ConnectionLinkMeter'; + +describe('ConnectionLinkMeter', () => { + it('shows BLE RSSI bars and dBm', () => { + render(); + expect(screen.getByText('Signal')).toBeInTheDocument(); + expect(screen.getByText('-65 dBm')).toBeInTheDocument(); + }); + + it('shows em dash when BLE RSSI is unknown', () => { + render(); + expect(screen.getByText('Signal')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('shows link quality RTT in ms', () => { + render(); + expect(screen.getByText('Link quality')).toBeInTheDocument(); + expect(screen.getByText('42 ms')).toBeInTheDocument(); + }); + + it('shows em dash when IP RTT is unknown', () => { + render(); + expect(screen.getByText('Link quality')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('shows Web Bluetooth unavailable phrase', () => { + render(); + expect(screen.getByText('Unavailable (Web Bluetooth)')).toBeInTheDocument(); + expect(screen.getByLabelText('Signal')).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/ConnectionLinkMeter.tsx b/src/renderer/components/ConnectionLinkMeter.tsx new file mode 100644 index 000000000..69a2f8172 --- /dev/null +++ b/src/renderer/components/ConnectionLinkMeter.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from 'react-i18next'; + +import type { ConnectionLinkMeterKind, SignalBarLevel } from '../lib/hostLinkQuality'; +import SignalBars from './SignalBars'; + +export type { ConnectionLinkMeterKind }; + +export interface ConnectionLinkMeterProps { + kind: ConnectionLinkMeterKind; + /** BLE host RSSI in dBm when kind is ble-rssi. */ + rssi?: number | null; + /** IP RTT in ms when kind is ip-rtt. */ + rttMs?: number | null; + /** Precomputed bar level for ip-rtt (from rttToSignalLevel). */ + level?: SignalBarLevel | null; + className?: string; +} + +/** + * Host↔radio link meter for the Connection panel (not LoRa Telemetry SignalMeter). + * BLE = RSSI dBm; HTTP/TCP = RTT ms; Linux BLE = Web Bluetooth unavailable phrase. + */ +export default function ConnectionLinkMeter({ + kind, + rssi, + rttMs, + level, + className, +}: ConnectionLinkMeterProps) { + const { t } = useTranslation(); + + if (kind === 'unavailable') { + return ( +
+ {t('connectionPanel.hostSignal')} + + + {t('connectionPanel.signalUnavailableWebBluetooth')} + +
+ ); + } + + if (kind === 'ip-rtt') { + const hasRtt = rttMs != null && Number.isFinite(rttMs); + return ( +
+ {t('connectionPanel.linkQuality')} + + + {hasRtt + ? t('connectionPanel.linkQualityMs', { ms: Math.round(rttMs) }) + : t('connectionPanel.linkQualityUnavailable')} + +
+ ); + } + + const hasRssi = rssi != null && Number.isFinite(rssi); + return ( +
+ {t('connectionPanel.hostSignal')} + + + {hasRssi + ? t('connectionPanel.bleRssiDbm', { rssi: Math.round(rssi) }) + : t('connectionPanel.hostSignalUnavailable')} + +
+ ); +} diff --git a/src/renderer/components/ConnectionPanel.hostLinkMeter.test.tsx b/src/renderer/components/ConnectionPanel.hostLinkMeter.test.tsx new file mode 100644 index 000000000..233e80247 --- /dev/null +++ b/src/renderer/components/ConnectionPanel.hostLinkMeter.test.tsx @@ -0,0 +1,168 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { act } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { NobleBleLinkRssiPayload } from '@/shared/electron-api.types'; + +import ConnectionPanel from './ConnectionPanel'; + +describe('ConnectionPanel host link meter', () => { + afterEach(() => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue('linux'); + vi.clearAllMocks(); + }); + + it('shows Signal meter for configured BLE on darwin', async () => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue('darwin'); + vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124 Safari/537.36', + ); + let rssiCb: ((p: NobleBleLinkRssiPayload) => void) | null = null; + vi.mocked(window.electronAPI.onNobleBleLinkRssi).mockImplementation((cb) => { + rssiCb = cb; + return () => {}; + }); + + render( + , + ); + expect(screen.getByText('Signal')).toBeInTheDocument(); + act(() => { + rssiCb?.({ sessionId: 'meshtastic', rssi: -66 }); + }); + await waitFor(() => { + expect(screen.getByText('-66 dBm')).toBeInTheDocument(); + }); + }); + + it('shows Unavailable (Web Bluetooth) for configured BLE on linux', () => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue('linux'); + render( + , + ); + expect(screen.getByText('Unavailable (Web Bluetooth)')).toBeInTheDocument(); + }); + + it('shows Link quality for configured HTTP on linux', async () => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue('linux'); + vi.mocked(window.electronAPI.hostLink.probeHttpRtt).mockResolvedValue(45); + render( + , + ); + expect(screen.getByText('Link quality')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('45 ms')).toBeInTheDocument(); + }); + }); + + it('hides host link meter for serial connections', () => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue('darwin'); + render( + , + ); + expect(screen.queryByText('Signal')).not.toBeInTheDocument(); + expect(screen.queryByText('Link quality')).not.toBeInTheDocument(); + }); + + it('shows Link quality for MeshCore TCP/IP on linux', async () => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue('linux'); + vi.mocked(window.electronAPI.hostLink.probeTcpRtt).mockResolvedValue(88); + render( + , + ); + expect(screen.getByText('Link quality')).toBeInTheDocument(); + await waitFor(() => { + expect(window.electronAPI.hostLink.probeTcpRtt).toHaveBeenCalled(); + }); + }); + + it('shows Link quality for Meshtastic TCP', async () => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue('darwin'); + vi.mocked(window.electronAPI.hostLink.probeTcpRtt).mockResolvedValue(120); + render( + , + ); + expect(screen.getByText('Link quality')).toBeInTheDocument(); + await waitFor(() => { + expect(window.electronAPI.hostLink.probeTcpRtt).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index 34a82af9b..16085d605 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -34,6 +34,7 @@ import { formatMeshtasticNodeId } from '@/shared/nodeNameUtils'; import { clampTcpPort, parseTcpPortFromString } from '@/shared/tcpPort'; import { useActiveMeshIdentity } from '../hooks/useActiveMeshIdentity'; +import { useHostLinkMeter } from '../hooks/useHostLinkMeter'; import { useNobleBleConnectMutexWait } from '../hooks/useNobleBleConnectMutexWait'; import { flushPendingMqttSave, @@ -121,6 +122,7 @@ import { useTimeFormatStore } from '../stores/timeFormatStore'; import { BleWeakSignalBanner } from './BleWeakSignalBanner'; import { ConfirmModal } from './ConfirmModal'; import ConnectionBatteryGauge from './ConnectionBatteryGauge'; +import ConnectionLinkMeter from './ConnectionLinkMeter'; import FirmwareStatusIndicator from './FirmwareStatusIndicator'; import { HelpTooltip } from './HelpTooltip'; import { ReticulumStackPanel } from './ReticulumStackPanel'; @@ -417,9 +419,23 @@ export default function ConnectionPanel({ const activeHostAddress = protocol === 'meshcore' ? `${tcpHost}:${tcpPort}` - : connectionType === 'tcp' + : connectionType === 'tcp' || state.connectionType === 'tcp' ? tcpAddress : httpAddress; + const hostLinkMeter = useHostLinkMeter({ + protocol, + connectionType: state.connectionType, + status: state.status, + hostAddress: + state.connectionType === 'tcp' + ? tcpAddress + : state.connectionType === 'http' + ? protocol === 'meshcore' + ? `${tcpHost}:${tcpPort}` + : httpAddress + : activeHostAddress, + platform: window.electronAPI.getPlatform() as NodeJS.Platform, + }); // ─── MQTT settings state ─────────────────────────────────────── const [mqttSettings, setMqttSettings] = useState(loadMqttSettings); @@ -3062,6 +3078,14 @@ export default function ConnectionPanel({ {t('connectionPanel.connectionType')} {state.connectionType} + {hostLinkMeter.kind != null && ( + + )} {state.myNodeNum > 0 && (
{t('connectionPanel.myNode')} diff --git a/src/renderer/components/SignalBars.test.tsx b/src/renderer/components/SignalBars.test.tsx new file mode 100644 index 000000000..163a725c2 --- /dev/null +++ b/src/renderer/components/SignalBars.test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import SignalBars from './SignalBars'; + +describe('SignalBars', () => { + it('renders filled bars from RSSI', () => { + const { container } = render(); + const rects = container.querySelectorAll('rect'); + expect(rects).toHaveLength(4); + // -55 → level 4 → all filled green + expect(rects[3].getAttribute('fill')).toBe('#4ade80'); + }); + + it('uses explicit level override for IP RTT mapping', () => { + const { container } = render(); + const rects = container.querySelectorAll('rect'); + expect(rects[0].getAttribute('fill')).toBe('#4ade80'); + expect(rects[1].getAttribute('fill')).toBe('#4ade80'); + expect(rects[2].getAttribute('fill')).toBe('#374151'); + }); + + it('renders grey bars when noData', () => { + const { container } = render(); + const rects = container.querySelectorAll('rect'); + for (const rect of rects) { + expect(rect.getAttribute('fill')).toBe('#4b5563'); + } + }); + + it('treats null rssi as no data', () => { + const { container } = render(); + const rects = container.querySelectorAll('rect'); + expect(rects[0].getAttribute('fill')).toBe('#4b5563'); + }); +}); diff --git a/src/renderer/components/SignalBars.tsx b/src/renderer/components/SignalBars.tsx index 67d3b89e0..05caabaf3 100644 --- a/src/renderer/components/SignalBars.tsx +++ b/src/renderer/components/SignalBars.tsx @@ -2,6 +2,7 @@ import { Link2 } from 'lucide-react-motion'; import { useIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; +import type { SignalBarLevel } from '../lib/hostLinkQuality'; import { rssiToSignalLevel } from '../lib/signal'; // Exported for reuse wherever a "directly connected" indicator is needed @@ -19,7 +20,14 @@ export function LinkIcon({ className }: { className?: string }) { } interface Props { - rssi: number | null | undefined; + rssi?: number | null | undefined; + /** + * Explicit 0–4 bar level (e.g. IP RTT mapping). When set, overrides `rssi`. + * Pass with `noData` for grey empty bars (unavailable). + */ + level?: SignalBarLevel | null; + /** Force grey empty bars (unknown / unavailable). */ + noData?: boolean; isSelf?: boolean; className?: string; } @@ -29,16 +37,16 @@ const FILLED_COLOR = '#4ade80'; const UNFILLED_COLOR = '#374151'; const NO_DATA_COLOR = '#4b5563'; -export default function SignalBars({ rssi, isSelf, className }: Props) { +export default function SignalBars({ rssi, level, noData, isSelf, className }: Props) { if (isSelf) { return ; } - const level = rssiToSignalLevel(rssi); - const noData = rssi == null; + const resolvedLevel: SignalBarLevel = level ?? rssiToSignalLevel(rssi); + const showNoData = noData === true || (level == null && rssi == null); return ( - + {BAR_HEIGHTS.map((h, i) => ( ))} diff --git a/src/renderer/components/reticulum/ReticulumInterfacesPanel.test.tsx b/src/renderer/components/reticulum/ReticulumInterfacesPanel.test.tsx index 8453c041c..621079ac0 100644 --- a/src/renderer/components/reticulum/ReticulumInterfacesPanel.test.tsx +++ b/src/renderer/components/reticulum/ReticulumInterfacesPanel.test.tsx @@ -79,6 +79,9 @@ describe('ReticulumInterfacesPanel', () => { window.electronAPI.reticulum.proxyPost = vi.fn().mockResolvedValue({ ok: true }); window.electronAPI.reticulum.proxyPut = vi.fn().mockResolvedValue({ ok: true }); window.electronAPI.reticulum.proxyDelete = vi.fn().mockResolvedValue({ ok: true }); + window.electronAPI.hostLink.probeTcpRtt = vi.fn().mockResolvedValue(42); + window.electronAPI.bleCoexistence.acquireScan = vi.fn().mockResolvedValue({}); + window.electronAPI.bleCoexistence.releaseScan = vi.fn().mockResolvedValue({}); hydrateAxeThemeColors(document.documentElement); window.electronAPI.reticulum.proxyGet = vi.fn().mockImplementation((path: string) => { if (path === '/api/v1/serial/ports') { @@ -174,6 +177,176 @@ describe('ReticulumInterfacesPanel', () => { ).toBeInTheDocument(); }); + it('always shows Signal UI on enabled BLE RNode rows even when RSSI is unknown', () => { + render( + , + ); + + const meter = screen.getByTestId('reticulum-ble-signal-rnode-ble'); + expect(meter).toBeInTheDocument(); + expect(within(meter).getByText('connectionPanel.hostSignalUnavailable')).toBeInTheDocument(); + expect(screen.queryByTestId('reticulum-tcp-link-rnode-ble')).not.toBeInTheDocument(); + }); + + it('shows BLE RSSI dBm on enabled BLE RNode rows when scan provides RSSI', async () => { + window.electronAPI.reticulum.proxyGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/v1/ble/availability') { + return Promise.resolve({ available: true }); + } + if (typeof path === 'string' && path.startsWith('/api/v1/ble/scan')) { + return Promise.resolve({ + devices: [{ address: 'AA:BB:CC:DD:EE:FF', rssi: -63, kind: 'rnode' }], + }); + } + if (path === '/api/v1/serial/ports') return Promise.resolve({ ports: [] }); + if (path === '/api/v1/rnode/presets') return Promise.resolve({ presets: [] }); + if (path === '/api/v1/config/audit') return Promise.resolve({ issues: [] }); + if (path === '/api/v1/stack/settings') { + return Promise.resolve({ + enable_transport: true, + share_instance: false, + loglevel: 4, + }); + } + return Promise.resolve({}); + }); + + render( + , + ); + + await waitFor(() => { + expect( + within(screen.getByTestId('reticulum-ble-signal-rnode-ble')).getByText( + 'connectionPanel.bleRssiDbm', + ), + ).toBeInTheDocument(); + }); + }); + + it('shows Link quality on enabled TCP Client rows', async () => { + render(); + + const meter = screen.getByTestId('reticulum-tcp-link-rmap-world'); + expect(meter).toBeInTheDocument(); + await waitFor(() => { + expect(within(meter).getByText('connectionPanel.linkQualityMs')).toBeInTheDocument(); + }); + expect(window.electronAPI.hostLink.probeTcpRtt).toHaveBeenCalledWith('rmap.world', 4242); + }); + + it('does not show host-link meters on serial RNode rows', () => { + render( + , + ); + + expect(screen.queryByTestId('reticulum-ble-signal-heltec')).not.toBeInTheDocument(); + expect(screen.queryByTestId('reticulum-tcp-link-heltec')).not.toBeInTheDocument(); + }); + + it('hides Signal UI when BLE RNode interface is disabled', () => { + render( + , + ); + expect(screen.queryByTestId('reticulum-ble-signal-rnode-ble')).not.toBeInTheDocument(); + }); + + it('shows Link quality unavailable when TCP probe fails', async () => { + window.electronAPI.hostLink.probeTcpRtt = vi.fn().mockResolvedValue(null); + render(); + const meter = screen.getByTestId('reticulum-tcp-link-rmap-world'); + await waitFor(() => { + expect(within(meter).getByText('connectionPanel.linkQualityUnavailable')).toBeInTheDocument(); + }); + }); + + it('hides Link quality when TCP Client interface is disabled', () => { + render( + , + ); + expect(screen.queryByTestId('reticulum-tcp-link-rmap-world')).not.toBeInTheDocument(); + }); + + it('shows both BLE Signal and TCP Link quality when both interface types are enabled', async () => { + render( + , + ); + expect(screen.getByTestId('reticulum-ble-signal-rnode-ble')).toBeInTheDocument(); + expect(screen.getByTestId('reticulum-tcp-link-rmap-world')).toBeInTheDocument(); + await waitFor(() => { + expect( + within(screen.getByTestId('reticulum-tcp-link-rmap-world')).getByText( + 'connectionPanel.linkQualityMs', + ), + ).toBeInTheDocument(); + }); + }); + it('edit BLE RNode shows Bluetooth address instead of serial stale hint', async () => { const user = userEvent.setup(); render( diff --git a/src/renderer/components/reticulum/ReticulumInterfacesPanel.tsx b/src/renderer/components/reticulum/ReticulumInterfacesPanel.tsx index d45ddaa2d..146727b00 100644 --- a/src/renderer/components/reticulum/ReticulumInterfacesPanel.tsx +++ b/src/renderer/components/reticulum/ReticulumInterfacesPanel.tsx @@ -4,12 +4,23 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useToast } from '@/renderer/components/Toast'; +import { + rssiForReticulumBleRnodeRow, + useReticulumBleRnodeRssiMap, +} from '@/renderer/hooks/useReticulumBleRnodeRssiMap'; import type { ReticulumDevicePickerSelection } from '@/renderer/hooks/useReticulumInterfaceDevicePicker'; import { useReticulumInterfaceDevicePicker } from '@/renderer/hooks/useReticulumInterfaceDevicePicker'; +import { + isReticulumTcpClientLinkQualityRow, + rttForReticulumTcpRow, + useReticulumTcpLinkQualityMap, +} from '@/renderer/hooks/useReticulumTcpLinkQualityMap'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { rttToSignalLevel } from '@/renderer/lib/hostLinkQuality'; import { DetailsChevron } from '@/renderer/lib/icons/detailsChevron'; import { useIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { restartReticulumStack } from '@/renderer/lib/reticulum/restartReticulumStack'; +import { isReticulumBleRnodeInterfaceRow } from '@/renderer/lib/reticulum/reticulumBleAdapterConflict'; import { fetchReticulumConfigAudit, repairReticulumConfig, @@ -87,6 +98,7 @@ import { clampTcpPort } from '@/shared/tcpPort'; import { ConfirmModal } from '../ConfirmModal'; import { HelpTooltip } from '../HelpTooltip'; +import SignalBars from '../SignalBars'; import { ReticulumInterfaceDevicePickerModal } from './ReticulumInterfaceDevicePickerModal'; import { hzToKhzFieldValue, @@ -1757,6 +1769,8 @@ function InterfacesSection({ }) { const { t } = useTranslation(); const purposeIconTrigger = useIconTrigger(); + const bleRnodeRssiByAddress = useReticulumBleRnodeRssiMap(interfaces, sidecarReady); + const tcpRttById = useReticulumTcpLinkQualityMap(interfaces, sidecarReady); const enabledLocalSerialCount = countEnabledLocallyConnectedSerialInterfaces(interfaces); const showPrimaryControls = enabledLocalSerialCount >= 2; const primaryInterfaceName = @@ -2152,6 +2166,12 @@ function InterfacesSection({ showPrimaryControls && effectivePrimaryLocalSerialInterfaceId != null && iface.id === effectivePrimaryLocalSerialInterfaceId; + const showBleRnodeSignal = iface.enabled && isReticulumBleRnodeInterfaceRow(iface); + const bleRnodeRssi = showBleRnodeSignal + ? rssiForReticulumBleRnodeRow(iface, bleRnodeRssiByAddress) + : null; + const showTcpLinkQuality = isReticulumTcpClientLinkQualityRow(iface); + const tcpRttMs = showTcpLinkQuality ? rttForReticulumTcpRow(iface, tcpRttById) : null; return (
  • {formatReticulumInterfaceRowSummary(t, iface)} + {showBleRnodeSignal ? ( + + {bleRnodeRssi != null ? ( + <> + + {t('connectionPanel.bleRssiDbm', { + rssi: Math.round(bleRnodeRssi), + })} + + ) : ( + <> + + {t('connectionPanel.hostSignalUnavailable')} + + )} + + ) : null} + {showTcpLinkQuality ? ( + + {tcpRttMs != null ? ( + <> + + {t('connectionPanel.linkQualityMs', { + ms: Math.round(tcpRttMs), + })} + + ) : ( + <> + + {t('connectionPanel.linkQualityUnavailable')} + + )} + + ) : null} { + beforeEach(() => { + vi.mocked(window.electronAPI.hostLink.probeHttpRtt).mockResolvedValue(33); + vi.mocked(window.electronAPI.hostLink.probeTcpRtt).mockResolvedValue(90); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('probes HTTP via parsed host/tls', async () => { + await expect(probeHttpLinkRttMs('https://radio.local')).resolves.toBe(33); + expect(window.electronAPI.hostLink.probeHttpRtt).toHaveBeenCalledWith('radio.local:443', true); + }); + + it('returns null for empty HTTP address', async () => { + await expect(probeHttpLinkRttMs('')).resolves.toBeNull(); + expect(window.electronAPI.hostLink.probeHttpRtt).not.toHaveBeenCalled(); + }); + + it('returns null when HTTP probe throws', async () => { + vi.mocked(window.electronAPI.hostLink.probeHttpRtt).mockRejectedValue(new Error('boom')); + await expect(probeHttpLinkRttMs('meshtastic.local')).resolves.toBeNull(); + }); + + it('probes Meshtastic TCP with default port 4403', async () => { + await expect(probeTcpLinkRttMs('10.0.0.8', 'meshtastic')).resolves.toBe(90); + expect(window.electronAPI.hostLink.probeTcpRtt).toHaveBeenCalledWith('10.0.0.8', 4403); + }); + + it('probes MeshCore TCP with default port 5000', async () => { + await expect(probeTcpLinkRttMs('10.0.0.8', 'meshcore')).resolves.toBe(90); + expect(window.electronAPI.hostLink.probeTcpRtt).toHaveBeenCalledWith('10.0.0.8', 5000); + }); + + it('returns null when TCP probe returns non-finite', async () => { + vi.mocked(window.electronAPI.hostLink.probeTcpRtt).mockResolvedValue(Number.NaN); + await expect(probeTcpLinkRttMs('10.0.0.8', 'meshtastic')).resolves.toBeNull(); + }); +}); diff --git a/src/renderer/hooks/useHostLinkMeter.test.ts b/src/renderer/hooks/useHostLinkMeter.test.ts new file mode 100644 index 000000000..7d8de17fc --- /dev/null +++ b/src/renderer/hooks/useHostLinkMeter.test.ts @@ -0,0 +1,219 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { NobleBleLinkRssiPayload } from '@/shared/electron-api.types'; + +import { useHostLinkMeter } from './useHostLinkMeter'; + +describe('useHostLinkMeter', () => { + beforeEach(() => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue('darwin'); + vi.mocked(window.electronAPI.onNobleBleLinkRssi).mockReturnValue(() => {}); + vi.mocked(window.electronAPI.hostLink.probeHttpRtt).mockResolvedValue(40); + vi.mocked(window.electronAPI.hostLink.probeTcpRtt).mockResolvedValue(80); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns unavailable for Linux BLE', () => { + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshtastic', + connectionType: 'ble', + status: 'configured', + hostAddress: null, + platform: 'linux', + }), + ); + expect(result.current.kind).toBe('unavailable'); + }); + + it('returns ble-rssi on darwin and applies Noble IPC updates', async () => { + let rssiCb: ((p: NobleBleLinkRssiPayload) => void) | null = null; + vi.mocked(window.electronAPI.onNobleBleLinkRssi).mockImplementation((cb) => { + rssiCb = cb; + return () => {}; + }); + + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshtastic', + connectionType: 'ble', + status: 'configured', + hostAddress: null, + platform: 'darwin', + }), + ); + expect(result.current.kind).toBe('ble-rssi'); + act(() => { + rssiCb?.({ sessionId: 'meshtastic', rssi: -72 }); + }); + await waitFor(() => { + expect(result.current.rssi).toBe(-72); + }); + }); + + it.each(['linux', 'darwin', 'win32'] as const)( + 'returns ip-rtt for Meshtastic HTTP on %s', + async (platform) => { + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshtastic', + connectionType: 'http', + status: 'configured', + hostAddress: 'meshtastic.local', + platform, + }), + ); + expect(result.current.kind).toBe('ip-rtt'); + await waitFor(() => { + expect(result.current.rttMs).toBe(40); + expect(result.current.level).toBe(4); + }); + expect(window.electronAPI.hostLink.probeHttpRtt).toHaveBeenCalled(); + }, + ); + + it('returns ip-rtt for Meshtastic TCP via probeTcpRtt', async () => { + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshtastic', + connectionType: 'tcp', + status: 'configured', + hostAddress: '10.0.0.5:4403', + platform: 'darwin', + }), + ); + expect(result.current.kind).toBe('ip-rtt'); + await waitFor(() => { + expect(result.current.rttMs).toBe(80); + expect(result.current.level).toBe(3); + }); + expect(window.electronAPI.hostLink.probeTcpRtt).toHaveBeenCalledWith('10.0.0.5', 4403); + }); + + it('returns ip-rtt for MeshCore TCP/IP (http transport) via probeTcpRtt', async () => { + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshcore', + connectionType: 'http', + status: 'configured', + hostAddress: '192.168.1.20:5000', + platform: 'linux', + }), + ); + expect(result.current.kind).toBe('ip-rtt'); + await waitFor(() => { + expect(result.current.rttMs).toBe(80); + }); + expect(window.electronAPI.hostLink.probeTcpRtt).toHaveBeenCalledWith('192.168.1.20', 5000); + }); + + it.each(['darwin', 'win32'] as const)('returns ble-rssi on %s', (platform) => { + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshcore', + connectionType: 'ble', + status: 'connected', + hostAddress: null, + platform, + }), + ); + expect(result.current.kind).toBe('ble-rssi'); + }); + + it('ignores Noble RSSI events for a different session', () => { + let rssiCb: ((p: NobleBleLinkRssiPayload) => void) | null = null; + vi.mocked(window.electronAPI.onNobleBleLinkRssi).mockImplementation((cb) => { + rssiCb = cb; + return () => {}; + }); + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshtastic', + connectionType: 'ble', + status: 'configured', + hostAddress: null, + platform: 'darwin', + }), + ); + act(() => { + rssiCb?.({ sessionId: 'meshcore', rssi: -50 }); + }); + expect(result.current.rssi).toBeNull(); + }); + + it('hides meter when disconnected', () => { + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshtastic', + connectionType: 'ble', + status: 'disconnected', + hostAddress: null, + platform: 'darwin', + }), + ); + expect(result.current.kind).toBeNull(); + }); + + it('hides meter for serial', () => { + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshcore', + connectionType: 'serial', + status: 'configured', + hostAddress: null, + platform: 'darwin', + }), + ); + expect(result.current.kind).toBeNull(); + }); + + it('hides meter for reticulum protocol', () => { + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'reticulum', + connectionType: 'ble', + status: 'configured', + hostAddress: null, + platform: 'darwin', + }), + ); + expect(result.current.kind).toBeNull(); + }); + + it('hides meter while connecting', () => { + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshtastic', + connectionType: 'http', + status: 'connecting', + hostAddress: 'meshtastic.local', + platform: 'darwin', + }), + ); + expect(result.current.kind).toBeNull(); + expect(window.electronAPI.hostLink.probeHttpRtt).not.toHaveBeenCalled(); + }); + + it('keeps ip-rtt kind with null RTT when HTTP probe fails', async () => { + vi.mocked(window.electronAPI.hostLink.probeHttpRtt).mockResolvedValue(null); + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshtastic', + connectionType: 'http', + status: 'configured', + hostAddress: 'meshtastic.local', + platform: 'linux', + }), + ); + expect(result.current.kind).toBe('ip-rtt'); + await waitFor(() => { + expect(window.electronAPI.hostLink.probeHttpRtt).toHaveBeenCalled(); + }); + expect(result.current.rttMs).toBeNull(); + expect(result.current.level).toBeNull(); + }); +}); diff --git a/src/renderer/hooks/useHostLinkMeter.ts b/src/renderer/hooks/useHostLinkMeter.ts new file mode 100644 index 000000000..025161e24 --- /dev/null +++ b/src/renderer/hooks/useHostLinkMeter.ts @@ -0,0 +1,134 @@ +/* eslint-disable react-hooks/set-state-in-effect -- clear meter when inactive; async Noble/RTT probes update state */ +import { useEffect, useState } from 'react'; + +import { + type ConnectionLinkMeterKind, + HOST_LINK_QUALITY_POLL_MS, + probeHttpLinkRttMs, + probeTcpLinkRttMs, + rttToSignalLevel, + type SignalBarLevel, +} from '../lib/hostLinkQuality'; +import type { ConnectionStatus, ConnectionType, MeshProtocol } from '../lib/types'; + +export interface HostLinkMeterState { + kind: ConnectionLinkMeterKind | null; + rssi: number | null; + rttMs: number | null; + level: SignalBarLevel | null; +} + +const IDLE: HostLinkMeterState = { + kind: null, + rssi: null, + rttMs: null, + level: null, +}; + +function isConnectedStatus(status: ConnectionStatus): boolean { + return ( + status === 'connected' || + status === 'configured' || + status === 'stale' || + status === 'reconnecting' + ); +} + +/** + * Host↔radio link meter state for Meshtastic / MeshCore Connection panels. + * BLE (darwin/win32): Noble link RSSI. BLE (linux): unavailable. HTTP/TCP: RTT probe. + */ +export function useHostLinkMeter(opts: { + protocol: MeshProtocol; + connectionType: ConnectionType | null; + status: ConnectionStatus; + /** Active HTTP/TCP address (panel local state or last connection). */ + hostAddress: string | null | undefined; + platform: NodeJS.Platform | null; +}): HostLinkMeterState { + const { protocol, connectionType, status, hostAddress, platform } = opts; + const [rssi, setRssi] = useState(null); + const [rttMs, setRttMs] = useState(null); + + const active = + protocol !== 'reticulum' && + isConnectedStatus(status) && + (connectionType === 'ble' || connectionType === 'http' || connectionType === 'tcp'); + + // BLE RSSI via Noble (macOS / Windows) + useEffect(() => { + if (!active || connectionType !== 'ble') { + setRssi(null); + return; + } + if (platform === 'linux') { + setRssi(null); + return; + } + const sessionId = protocol === 'meshcore' ? 'meshcore' : 'meshtastic'; + const unsub = window.electronAPI.onNobleBleLinkRssi((payload) => { + if (payload.sessionId !== sessionId) return; + setRssi(payload.rssi != null && Number.isFinite(payload.rssi) ? payload.rssi : null); + }); + return () => { + unsub(); + setRssi(null); + }; + }, [active, connectionType, platform, protocol]); + + // HTTP / TCP RTT probe + useEffect(() => { + if (!active || (connectionType !== 'http' && connectionType !== 'tcp')) { + setRttMs(null); + return; + } + const address = hostAddress?.trim(); + if (!address) { + setRttMs(null); + return; + } + + let cancelled = false; + let timer: ReturnType | null = null; + + const run = async () => { + let next: number | null = null; + if (protocol === 'meshtastic' && connectionType === 'http') { + next = await probeHttpLinkRttMs(address); + } else if (protocol === 'meshtastic' && connectionType === 'tcp') { + next = await probeTcpLinkRttMs(address, 'meshtastic'); + } else if (protocol === 'meshcore' && connectionType === 'http') { + // MeshCore "http" transport is TCP/IP host:port + next = await probeTcpLinkRttMs(address, 'meshcore'); + } + if (!cancelled) setRttMs(next); + }; + + void run(); + timer = setInterval(() => { + void run(); + }, HOST_LINK_QUALITY_POLL_MS); + + return () => { + cancelled = true; + if (timer) clearInterval(timer); + setRttMs(null); + }; + }, [active, connectionType, hostAddress, protocol]); + + if (!active || !connectionType) return IDLE; + + if (connectionType === 'ble') { + if (platform === 'linux') { + return { kind: 'unavailable', rssi: null, rttMs: null, level: null }; + } + return { kind: 'ble-rssi', rssi, rttMs: null, level: null }; + } + + if (connectionType === 'http' || connectionType === 'tcp') { + const level = rttMs != null ? rttToSignalLevel(rttMs) : null; + return { kind: 'ip-rtt', rssi: null, rttMs, level }; + } + + return IDLE; +} diff --git a/src/renderer/hooks/useReticulumBleRnodeRssiMap.test.ts b/src/renderer/hooks/useReticulumBleRnodeRssiMap.test.ts new file mode 100644 index 000000000..30e889280 --- /dev/null +++ b/src/renderer/hooks/useReticulumBleRnodeRssiMap.test.ts @@ -0,0 +1,175 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + rssiForReticulumBleRnodeRow, + useReticulumBleRnodeRssiMap, +} from './useReticulumBleRnodeRssiMap'; + +describe('rssiForReticulumBleRnodeRow', () => { + it('returns RSSI for enabled BLE RNode rows', () => { + const map = new Map([['aa:bb:cc:dd:ee:ff', -61]]); + expect( + rssiForReticulumBleRnodeRow( + { + id: '1', + enabled: true, + type: 'RNodeInterface', + serial_port: 'ble://AA:BB:CC:DD:EE:FF', + }, + map, + ), + ).toBe(-61); + }); + + it('returns null when disabled or not ble://', () => { + const map = new Map([['aa:bb:cc:dd:ee:ff', -61]]); + expect( + rssiForReticulumBleRnodeRow( + { + id: '1', + enabled: false, + type: 'RNodeInterface', + serial_port: 'ble://AA:BB:CC:DD:EE:FF', + }, + map, + ), + ).toBeNull(); + expect( + rssiForReticulumBleRnodeRow( + { + id: '2', + enabled: true, + type: 'RNodeInterface', + serial_port: '/dev/ttyUSB0', + }, + map, + ), + ).toBeNull(); + }); + + it('normalizes MAC case when looking up RSSI', () => { + const map = new Map([['aa:bb:cc:dd:ee:ff', -70]]); + expect( + rssiForReticulumBleRnodeRow( + { + id: '1', + enabled: true, + type: 'rnode', + serial_port: 'ble://aa:bb:cc:dd:ee:ff', + }, + map, + ), + ).toBe(-70); + }); +}); + +describe('useReticulumBleRnodeRssiMap', () => { + beforeEach(() => { + window.electronAPI.bleCoexistence.acquireScan = vi.fn().mockResolvedValue({}); + window.electronAPI.bleCoexistence.releaseScan = vi.fn().mockResolvedValue({}); + window.electronAPI.reticulum.proxyGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/v1/ble/availability') { + return Promise.resolve({ available: true }); + } + if (path.startsWith('/api/v1/ble/scan')) { + return Promise.resolve({ + devices: [{ address: 'AA:BB:CC:DD:EE:FF', rssi: -58, kind: 'rnode' }], + }); + } + return Promise.resolve({}); + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('polls sidecar scan and maps RSSI for enabled BLE RNode addresses', async () => { + const { result } = renderHook(() => + useReticulumBleRnodeRssiMap( + [ + { + id: '1', + enabled: true, + type: 'rnode', + serial_port: 'ble://AA:BB:CC:DD:EE:FF', + }, + ], + true, + ), + ); + + await waitFor(() => { + expect(result.current.get('aa:bb:cc:dd:ee:ff')).toBe(-58); + }); + expect(window.electronAPI.bleCoexistence.acquireScan).toHaveBeenCalledWith('reticulum'); + expect(window.electronAPI.bleCoexistence.releaseScan).toHaveBeenCalledWith('reticulum'); + }); + + it('stays empty when sidecar is not ready', () => { + const { result } = renderHook(() => + useReticulumBleRnodeRssiMap( + [ + { + id: '1', + enabled: true, + type: 'rnode', + serial_port: 'ble://AA:BB:CC:DD:EE:FF', + }, + ], + false, + ), + ); + expect(result.current.size).toBe(0); + expect(window.electronAPI.reticulum.proxyGet).not.toHaveBeenCalled(); + }); + + it('does not scan when there are no enabled BLE RNode targets', () => { + const { result } = renderHook(() => + useReticulumBleRnodeRssiMap( + [ + { + id: '1', + enabled: false, + type: 'rnode', + serial_port: 'ble://AA:BB:CC:DD:EE:FF', + }, + ], + true, + ), + ); + expect(result.current.size).toBe(0); + }); + + it('does not restart BLE scan poll when interfaces array identity churns', async () => { + const { result } = renderHook(() => + useReticulumBleRnodeRssiMap( + [ + { + id: '1', + enabled: true, + type: 'rnode', + serial_port: 'ble://AA:BB:CC:DD:EE:FF', + }, + ], + true, + ), + ); + await waitFor(() => { + expect(result.current.get('aa:bb:cc:dd:ee:ff')).toBe(-58); + }); + const scanCalls = () => + vi + .mocked(window.electronAPI.reticulum.proxyGet) + .mock.calls.filter((c) => typeof c[0] === 'string' && c[0].includes('/api/v1/ble/scan')) + .length; + const afterFirst = scanCalls(); + // setState from the first poll re-renders with a new inline interfaces[]; must not loop. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(scanCalls()).toBe(afterFirst); + }); +}); diff --git a/src/renderer/hooks/useReticulumBleRnodeRssiMap.ts b/src/renderer/hooks/useReticulumBleRnodeRssiMap.ts new file mode 100644 index 000000000..9c120ecdd --- /dev/null +++ b/src/renderer/hooks/useReticulumBleRnodeRssiMap.ts @@ -0,0 +1,142 @@ +/* eslint-disable react-hooks/set-state-in-effect -- clear map when inactive; async BLE scan poll updates state */ +import { useEffect, useMemo, useState } from 'react'; + +import { MS_PER_SECOND } from '@/shared/timeConstants'; + +import { isReticulumBleRnodeInterfaceRow } from '../lib/reticulum/reticulumBleAdapterConflict'; +import { + acquireReticulumBleScan, + normalizeBleMac, + parseBleMacFromReticulumSerialPort, + releaseReticulumBleScan, +} from '../lib/reticulum/reticulumBleAdapterLease'; + +/** Slow poll — BLE scan is expensive and must not thrash the adapter. */ +const RETICULUM_BLE_RSSI_POLL_MS = 15 * MS_PER_SECOND; +const RETICULUM_BLE_RSSI_SCAN_TIMEOUT_SECS = 3; + +export interface ReticulumBleRssiInterfaceRow { + id: string; + enabled: boolean; + type: string; + serial_port?: string | null; +} + +function enabledBleRnodeAddresses(interfaces: readonly ReticulumBleRssiInterfaceRow[]): string[] { + const addrs: string[] = []; + for (const iface of interfaces) { + if (!iface.enabled || !isReticulumBleRnodeInterfaceRow(iface)) continue; + const raw = parseBleMacFromReticulumSerialPort(iface.serial_port ?? ''); + if (!raw) continue; + addrs.push(normalizeBleMac(raw)); + } + return addrs; +} + +/** + * Map of normalized BLE address → last scan RSSI for enabled Reticulum BLE RNode rows. + * Uses sidecar `/api/v1/ble/scan` without disabling interfaces (picker pause is skipped). + */ +export function useReticulumBleRnodeRssiMap( + interfaces: readonly ReticulumBleRssiInterfaceRow[], + sidecarReady: boolean, +): ReadonlyMap { + const [rssiByAddress, setRssiByAddress] = useState>(() => new Map()); + + // Content key only — do not depend on `interfaces` array identity (inline props re-render loop). + const enabledKey = useMemo( + () => enabledBleRnodeAddresses(interfaces).slice().sort().join('|'), + [interfaces], + ); + + useEffect(() => { + const enabledBleTargets = enabledKey ? enabledKey.split('|') : []; + if (!sidecarReady || enabledBleTargets.length === 0) { + setRssiByAddress(new Map()); + return; + } + + let cancelled = false; + let timer: ReturnType | null = null; + let inflight = false; + + const poll = async () => { + if (cancelled || inflight) return; + inflight = true; + let scanAcquired = false; + try { + const avail = (await window.electronAPI.reticulum.proxyGet('/api/v1/ble/availability')) as { + available?: boolean; + }; + if (!avail.available) return; + + const acquired = await acquireReticulumBleScan(); + if (!acquired) return; + scanAcquired = true; + + const body = (await window.electronAPI.reticulum.proxyGet( + `/api/v1/ble/scan?timeout_secs=${RETICULUM_BLE_RSSI_SCAN_TIMEOUT_SECS}&mode=rnode`, + )) as { + devices?: { address?: string; rssi?: number | null }[]; + error?: string; + ok?: boolean; + }; + if (cancelled || body.error || body.ok === false) return; + + const next = new Map(); + for (const device of body.devices ?? []) { + const addr = typeof device.address === 'string' ? normalizeBleMac(device.address) : ''; + if (!addr) continue; + if ( + device.rssi != null && + Number.isFinite(device.rssi) && + enabledBleTargets.includes(addr) + ) { + next.set(addr, device.rssi); + } + } + // Preserve previous readings for addresses missing from this scan. + setRssiByAddress((prev) => { + const merged = new Map(); + for (const addr of enabledBleTargets) { + if (next.has(addr)) merged.set(addr, next.get(addr)!); + else if (prev.has(addr)) merged.set(addr, prev.get(addr)!); + } + return merged; + }); + } catch (err) { + console.debug( + '[Reticulum] BLE RNode RSSI poll failed:', + err instanceof Error ? err.message : String(err), + ); + } finally { + if (scanAcquired) await releaseReticulumBleScan(); + inflight = false; + } + }; + + void poll(); + timer = setInterval(() => { + void poll(); + }, RETICULUM_BLE_RSSI_POLL_MS); + + return () => { + cancelled = true; + if (timer) clearInterval(timer); + }; + }, [sidecarReady, enabledKey]); + + return rssiByAddress; +} + +/** Look up RSSI for a BLE RNode interface row (null when unknown). */ +export function rssiForReticulumBleRnodeRow( + iface: ReticulumBleRssiInterfaceRow, + rssiByAddress: ReadonlyMap, +): number | null { + if (!iface.enabled || !isReticulumBleRnodeInterfaceRow(iface)) return null; + const raw = parseBleMacFromReticulumSerialPort(iface.serial_port ?? ''); + if (!raw) return null; + const rssi = rssiByAddress.get(normalizeBleMac(raw)); + return rssi != null && Number.isFinite(rssi) ? rssi : null; +} diff --git a/src/renderer/hooks/useReticulumTcpLinkQualityMap.test.ts b/src/renderer/hooks/useReticulumTcpLinkQualityMap.test.ts new file mode 100644 index 000000000..660104807 --- /dev/null +++ b/src/renderer/hooks/useReticulumTcpLinkQualityMap.test.ts @@ -0,0 +1,134 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + isReticulumTcpClientLinkQualityRow, + rttForReticulumTcpRow, + useReticulumTcpLinkQualityMap, +} from './useReticulumTcpLinkQualityMap'; + +describe('isReticulumTcpClientLinkQualityRow', () => { + it('accepts enabled tcp rows with host and port', () => { + expect( + isReticulumTcpClientLinkQualityRow({ + id: '1', + enabled: true, + type: 'tcp', + host: 'rmap.world', + port: 4242, + }), + ).toBe(true); + }); + + it('rejects disabled, non-tcp, or incomplete rows', () => { + expect( + isReticulumTcpClientLinkQualityRow({ + id: '1', + enabled: false, + type: 'tcp', + host: 'rmap.world', + port: 4242, + }), + ).toBe(false); + expect( + isReticulumTcpClientLinkQualityRow({ + id: '1', + enabled: true, + type: 'rnode', + host: 'rmap.world', + port: 4242, + }), + ).toBe(false); + expect( + isReticulumTcpClientLinkQualityRow({ + id: '1', + enabled: true, + type: 'tcp', + host: null, + port: 4242, + }), + ).toBe(false); + }); +}); + +describe('rttForReticulumTcpRow', () => { + it('returns finite RTT for enabled TCP rows', () => { + const map = new Map([['hub', 55]]); + expect( + rttForReticulumTcpRow({ id: 'hub', enabled: true, type: 'tcp', host: 'h', port: 4242 }, map), + ).toBe(55); + }); + + it('returns null when map has null RTT', () => { + const map = new Map([['hub', null]]); + expect( + rttForReticulumTcpRow({ id: 'hub', enabled: true, type: 'tcp', host: 'h', port: 4242 }, map), + ).toBeNull(); + }); +}); + +describe('useReticulumTcpLinkQualityMap', () => { + beforeEach(() => { + vi.mocked(window.electronAPI.hostLink.probeTcpRtt).mockResolvedValue(42); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('probes enabled TCP rows and stores RTT by id', async () => { + const { result } = renderHook(() => + useReticulumTcpLinkQualityMap( + [{ id: 'hub', enabled: true, type: 'tcp', host: 'rmap.world', port: 4242 }], + true, + ), + ); + await waitFor(() => { + expect(result.current.get('hub')).toBe(42); + }); + expect(window.electronAPI.hostLink.probeTcpRtt).toHaveBeenCalledWith('rmap.world', 4242); + }); + + it('stores null when probe fails', async () => { + vi.mocked(window.electronAPI.hostLink.probeTcpRtt).mockRejectedValue(new Error('timeout')); + const { result } = renderHook(() => + useReticulumTcpLinkQualityMap( + [{ id: 'hub', enabled: true, type: 'tcp', host: 'rmap.world', port: 4242 }], + true, + ), + ); + await waitFor(() => { + expect(result.current.has('hub')).toBe(true); + expect(result.current.get('hub')).toBeNull(); + }); + }); + + it('clears map when sidecar is not ready', () => { + const { result } = renderHook(() => + useReticulumTcpLinkQualityMap( + [{ id: 'hub', enabled: true, type: 'tcp', host: 'rmap.world', port: 4242 }], + false, + ), + ); + expect(result.current.size).toBe(0); + expect(window.electronAPI.hostLink.probeTcpRtt).not.toHaveBeenCalled(); + }); + + it('does not restart TCP probe poll when interfaces array identity churns', async () => { + const { result } = renderHook(() => + useReticulumTcpLinkQualityMap( + [{ id: 'hub', enabled: true, type: 'tcp', host: 'rmap.world', port: 4242 }], + true, + ), + ); + await waitFor(() => { + expect(result.current.get('hub')).toBe(42); + }); + const afterFirst = vi.mocked(window.electronAPI.hostLink.probeTcpRtt).mock.calls.length; + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(vi.mocked(window.electronAPI.hostLink.probeTcpRtt).mock.calls.length).toBe(afterFirst); + }); +}); diff --git a/src/renderer/hooks/useReticulumTcpLinkQualityMap.ts b/src/renderer/hooks/useReticulumTcpLinkQualityMap.ts new file mode 100644 index 000000000..9fbdda1e4 --- /dev/null +++ b/src/renderer/hooks/useReticulumTcpLinkQualityMap.ts @@ -0,0 +1,135 @@ +/* eslint-disable react-hooks/set-state-in-effect -- clear map when inactive; async TCP RTT probes update state */ +import { useEffect, useMemo, useState } from 'react'; + +import { formatHostForSocket } from '@/shared/connectHost'; + +import { HOST_LINK_QUALITY_POLL_MS } from '../lib/hostLinkQuality'; + +export interface ReticulumTcpLinkQualityRow { + id: string; + enabled: boolean; + type: string; + host?: string | null; + port?: number | null; +} + +interface TcpProbeTarget { + id: string; + host: string; + port: number; +} + +function isEnabledTcpClientRow(iface: ReticulumTcpLinkQualityRow): boolean { + if (!iface.enabled) return false; + if (iface.type.toLowerCase() !== 'tcp') return false; + const host = iface.host?.trim(); + const port = iface.port; + return Boolean(host) && typeof port === 'number' && Number.isInteger(port) && port > 0; +} + +function tcpProbeTargets(interfaces: readonly ReticulumTcpLinkQualityRow[]): TcpProbeTarget[] { + return interfaces.filter(isEnabledTcpClientRow).map((iface) => ({ + id: iface.id, + host: formatHostForSocket(iface.host!.trim()), + port: iface.port!, + })); +} + +/** Encode/decode probe targets so the effect can depend on a content string only. */ +function encodeTcpProbeTargetKey(targets: readonly TcpProbeTarget[]): string { + return targets + .map((t) => `${t.id}\0${t.host}\0${t.port}`) + .sort() + .join('|'); +} + +function decodeTcpProbeTargetKey(targetKey: string): TcpProbeTarget[] { + if (!targetKey) return []; + return targetKey.split('|').map((part) => { + const [id, host, portStr] = part.split('\0'); + return { id, host, port: Number(portStr) }; + }); +} + +/** + * Map of interface id → last TCP connect RTT (ms) for enabled Reticulum TCP Client rows. + */ +export function useReticulumTcpLinkQualityMap( + interfaces: readonly ReticulumTcpLinkQualityRow[], + sidecarReady: boolean, +): ReadonlyMap { + const [rttById, setRttById] = useState>(() => new Map()); + + // Content key only — do not depend on `interfaces` array identity (inline props re-render loop). + const targetKey = useMemo( + () => encodeTcpProbeTargetKey(tcpProbeTargets(interfaces)), + [interfaces], + ); + + useEffect(() => { + const targets = decodeTcpProbeTargetKey(targetKey); + if (!sidecarReady || targets.length === 0) { + setRttById(new Map()); + return; + } + + let cancelled = false; + let timer: ReturnType | null = null; + let inflight = false; + + const poll = async () => { + if (cancelled || inflight) return; + inflight = true; + const probe = window.electronAPI?.hostLink?.probeTcpRtt; + if (typeof probe !== 'function') { + inflight = false; + return; + } + try { + const next = new Map(); + await Promise.all( + targets.map(async (t) => { + try { + const rtt = await probe(t.host, t.port); + next.set(t.id, typeof rtt === 'number' && Number.isFinite(rtt) ? rtt : null); + } catch (err) { + console.debug( + '[Reticulum] TCP link-quality probe failed:', + err instanceof Error ? err.message : String(err), + ); + next.set(t.id, null); + } + }), + ); + if (!cancelled) setRttById(next); + } finally { + inflight = false; + } + }; + + void poll(); + timer = setInterval(() => { + void poll(); + }, HOST_LINK_QUALITY_POLL_MS); + + return () => { + cancelled = true; + if (timer) clearInterval(timer); + }; + }, [sidecarReady, targetKey]); + + return rttById; +} + +export function rttForReticulumTcpRow( + iface: ReticulumTcpLinkQualityRow, + rttById: ReadonlyMap, +): number | null { + if (!isEnabledTcpClientRow(iface)) return null; + const rtt = rttById.get(iface.id); + return rtt != null && Number.isFinite(rtt) ? rtt : null; +} + +export function isReticulumTcpClientLinkQualityRow(iface: ReticulumTcpLinkQualityRow): boolean { + return isEnabledTcpClientRow(iface); +} diff --git a/src/renderer/lib/devElectronApiStub.ts b/src/renderer/lib/devElectronApiStub.ts index aef24d82d..149994eb4 100644 --- a/src/renderer/lib/devElectronApiStub.ts +++ b/src/renderer/lib/devElectronApiStub.ts @@ -154,6 +154,7 @@ export function createDevElectronApiStub(): typeof window.electronAPI { }, onNobleBleAdapterState: noopUnsub, onNobleBleDeviceDiscovered: noopUnsub, + onNobleBleLinkRssi: noopUnsub, onNobleBleConnected: noopUnsub, onNobleBleDisconnected: noopUnsub, onNobleBleConnectAborted: noopUnsub, @@ -239,6 +240,10 @@ export function createDevElectronApiStub(): typeof window.electronAPI { disconnect: noopAsync, onData: noopUnsub, }, + hostLink: { + probeHttpRtt: async () => null, + probeTcpRtt: async () => null, + }, meshtastic: { tcp: { connect: noopAsync, diff --git a/src/renderer/lib/hostLinkQuality.test.ts b/src/renderer/lib/hostLinkQuality.test.ts new file mode 100644 index 000000000..028ae475c --- /dev/null +++ b/src/renderer/lib/hostLinkQuality.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { parseHttpProbeTarget, parseTcpProbeTarget, rttToSignalLevel } from './hostLinkQuality'; + +describe('rttToSignalLevel', () => { + it('maps latency buckets to 0–4 bars', () => { + expect(rttToSignalLevel(null)).toBe(0); + expect(rttToSignalLevel(undefined)).toBe(0); + expect(rttToSignalLevel(Number.NaN)).toBe(0); + expect(rttToSignalLevel(-1)).toBe(0); + expect(rttToSignalLevel(0)).toBe(4); + expect(rttToSignalLevel(50)).toBe(4); + expect(rttToSignalLevel(51)).toBe(3); + expect(rttToSignalLevel(100)).toBe(3); + expect(rttToSignalLevel(101)).toBe(2); + expect(rttToSignalLevel(250)).toBe(2); + expect(rttToSignalLevel(251)).toBe(1); + expect(rttToSignalLevel(500)).toBe(1); + expect(rttToSignalLevel(501)).toBe(0); + }); +}); + +describe('parseHttpProbeTarget', () => { + it('parses bare host and https', () => { + expect(parseHttpProbeTarget('meshtastic.local')).toEqual({ + urlHost: 'meshtastic.local:80', + tls: false, + }); + expect(parseHttpProbeTarget('https://radio.local/')).toEqual({ + urlHost: 'radio.local:443', + tls: true, + }); + }); + + it('returns null for empty input', () => { + expect(parseHttpProbeTarget('')).toBeNull(); + expect(parseHttpProbeTarget(' ')).toBeNull(); + }); +}); + +describe('parseTcpProbeTarget', () => { + it('defaults Meshtastic to 4403 and MeshCore to 5000', () => { + expect(parseTcpProbeTarget('10.0.0.5', 'meshtastic')).toEqual({ + host: '10.0.0.5', + port: 4403, + }); + expect(parseTcpProbeTarget('10.0.0.5', 'meshcore')).toEqual({ + host: '10.0.0.5', + port: 5000, + }); + }); +}); diff --git a/src/renderer/lib/hostLinkQuality.ts b/src/renderer/lib/hostLinkQuality.ts new file mode 100644 index 000000000..e15842bb8 --- /dev/null +++ b/src/renderer/lib/hostLinkQuality.ts @@ -0,0 +1,110 @@ +import { formatHostForSocket, formatHostForUrl, parseConnectHostPort } from '@/shared/connectHost'; +import { MS_PER_SECOND } from '@/shared/timeConstants'; + +import { parseMeshtasticTcpAddress } from './parseMeshtasticTcpAddress'; +import { parseTcpAddress } from './parseTcpAddress'; + +/** Poll interval for host↔radio link quality (BLE RSSI / IP RTT). */ +export const HOST_LINK_QUALITY_POLL_MS = 4 * MS_PER_SECOND; + +/** Max wait for a single HTTP/TCP RTT probe. */ +export const HOST_LINK_RTT_PROBE_TIMEOUT_MS = 3 * MS_PER_SECOND; + +export type SignalBarLevel = 0 | 1 | 2 | 3 | 4; + +/** Connection panel host-link meter modes (not LoRa Telemetry). */ +export type ConnectionLinkMeterKind = 'ble-rssi' | 'ip-rtt' | 'unavailable'; + +/** + * Map IP round-trip time to the same 0–4 bar scale as BLE RSSI UI. + * Lower latency → stronger bars. Null/non-finite → no data (0). + */ +export function rttToSignalLevel(rttMs: number | null | undefined): SignalBarLevel { + if (rttMs == null || !Number.isFinite(rttMs) || rttMs < 0) return 0; + if (rttMs <= 50) return 4; + if (rttMs <= 100) return 3; + if (rttMs <= 250) return 2; + if (rttMs <= 500) return 1; + return 0; +} + +export interface ParsedHttpProbeTarget { + urlHost: string; + tls: boolean; +} + +/** Normalize a Meshtastic HTTP address field into host/tls for the main-process probe. */ +export function parseHttpProbeTarget(httpAddress: string): ParsedHttpProbeTarget | null { + const trimmed = httpAddress.trim(); + if (!trimmed) return null; + const tls = trimmed.startsWith('https://'); + const raw = trimmed.replace(/^https?:\/\//, '').replace(/\/+$/, ''); + if (!raw) return null; + const { host, port } = parseConnectHostPort(raw, tls ? 443 : 80); + return { urlHost: formatHostForUrl(host, port), tls }; +} + +export interface ParsedTcpProbeTarget { + host: string; + port: number; +} + +/** + * Parse a TCP probe target. + * - `meshtastic`: default port 4403 (`parseMeshtasticTcpAddress`) + * - `meshcore`: default port 5000 (`parseTcpAddress`) + */ +export function parseTcpProbeTarget( + address: string, + protocol: 'meshtastic' | 'meshcore' = 'meshtastic', +): ParsedTcpProbeTarget | null { + const trimmed = address.trim(); + if (!trimmed) return null; + try { + const { host, port } = + protocol === 'meshcore' ? parseTcpAddress(trimmed) : parseMeshtasticTcpAddress(trimmed); + return { host: formatHostForSocket(host), port }; + } catch { + // catch-no-log-ok invalid address for link-quality probe + return null; + } +} + +/** Probe Meshtastic HTTP `/json/report` RTT via main process. */ +export async function probeHttpLinkRttMs(httpAddress: string): Promise { + const target = parseHttpProbeTarget(httpAddress); + if (!target) return null; + const api = window.electronAPI.hostLink.probeHttpRtt; + if (typeof api !== 'function') return null; + try { + const rtt = await api(target.urlHost, target.tls); + return typeof rtt === 'number' && Number.isFinite(rtt) ? rtt : null; + } catch (err) { + console.debug( + '[hostLinkQuality] HTTP RTT probe failed:', + err instanceof Error ? err.message : String(err), + ); + return null; + } +} + +/** Probe TCP connect RTT via main process (connect then destroy). */ +export async function probeTcpLinkRttMs( + address: string, + protocol: 'meshtastic' | 'meshcore' = 'meshtastic', +): Promise { + const target = parseTcpProbeTarget(address, protocol); + if (!target) return null; + const api = window.electronAPI.hostLink.probeTcpRtt; + if (typeof api !== 'function') return null; + try { + const rtt = await api(target.host, target.port); + return typeof rtt === 'number' && Number.isFinite(rtt) ? rtt : null; + } catch (err) { + console.debug( + '[hostLinkQuality] TCP RTT probe failed:', + err instanceof Error ? err.message : String(err), + ); + return null; + } +} diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index b9fb42dc7..a790887b4 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -1275,7 +1275,13 @@ "letsMeshKnownBrokersOnly": "Přednastavení LetsMesh /MeshMapper podporuje pouze známé makléře podepisující zařízení. Použít vlastní pro ostatní makléře.", "letsMeshUsernameV1Hex": "Uživatelské jméno musí být v1_ následované 64 hexadecimálními znaky (veřejný klíč).", "bleWeakSignalWarning": "Signál Bluetooth je slabý ({{rssi}} dBm). Přibližte se pro stabilní připojení.", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "Signál", + "hostSignalUnavailable": "—", + "linkQuality": "Kvalita spoje", + "linkQualityMs": "{{ms}} ms", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "Nedostupné (webový Bluetooth)" }, "contactGroupsModal": { "failedLoadMembers": "Nepodařilo se načíst členy: {{message}}", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index b242d2200..bb46f65c6 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -1273,7 +1273,13 @@ "letsMeshKnownBrokersOnly": "Die LetsMesh /MeshMapper-Voreinstellung unterstützt nur bekannte Gerätesignatur-Broker. Verwenden Sie Custom für andere Broker.", "letsMeshUsernameV1Hex": "Der Benutzername muss v1_ gefolgt von 64 Hexadezimalzeichen (öffentlicher Schlüssel) sein.", "bleWeakSignalWarning": "Bluetooth-Signal ist schwach ({{rssi}} dBm). Nähern Sie sich für eine stabile Verbindung.", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "Signal", + "hostSignalUnavailable": "—", + "linkQuality": "Verbindungsqualität", + "linkQualityMs": "{{ms}} ms", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "Nicht verfügbar (Web-Bluetooth)" }, "contactGroupsModal": { "failedLoadMembers": "Mitglieder konnten nicht geladen werden: {{message}}", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index c42c229e2..adbb76994 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -754,6 +754,12 @@ "selectBluetoothDevice": "Select Bluetooth Device", "bleWeakSignalWarning": "Bluetooth signal is weak ({{rssi}} dBm). Move closer for a stable connection.", "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "Signal", + "hostSignalUnavailable": "—", + "linkQuality": "Link quality", + "linkQualityMs": "{{ms}} ms", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "Unavailable (Web Bluetooth)", "devicesFound": "{{count}} found", "scanningDevices": "Scanning for {{protocol}} devices...", "selectSerialPort": "Select Serial Port", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 48b9c4487..b1c07183a 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -1273,7 +1273,13 @@ "letsMeshKnownBrokersOnly": "El ajuste preestablecido de LetsMesh/MeshMapper solo admite brókers de firma de dispositivos conocidos. Utilice Personalizado para otros brókers.", "letsMeshUsernameV1Hex": "El nombre de usuario debe ser v1_ seguido de 64 caracteres hexadecimales (clave pública).", "bleWeakSignalWarning": "La señal Bluetooth es débil ({{rssi}} dBm). Acérquese para una conexión estable.", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "Señal", + "hostSignalUnavailable": "—", + "linkQuality": "Calidad de enlace", + "linkQualityMs": "{{ms}} ms", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "No disponible (Bluetooth web)" }, "contactGroupsModal": { "failedLoadMembers": "No se han podido cargar los miembros: {{message}}", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index dd21b3f7b..5d9f57991 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -1273,7 +1273,13 @@ "letsMeshKnownBrokersOnly": "Le préréglage LetsMesh/ MeshMapper ne prend en charge que les courtiers de signature d'appareils connus. Utilisez Personnalisé pour les autres courtiers.", "letsMeshUsernameV1Hex": "Le nom d'utilisateur doit être v1_ suivi de 64 caractères hexadécimaux (clé publique).", "bleWeakSignalWarning": "Le signal Bluetooth est faible ({{rssi}} dBm). Rapprochez-vous pour une connexion stable.", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "Signal", + "hostSignalUnavailable": "—", + "linkQuality": "Qualité du lien", + "linkQualityMs": "{{ms}} ms", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "Non disponible (Web Bluetooth)" }, "contactGroupsModal": { "failedLoadMembers": "Échec du chargement des membres : {{message}}", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 77fec1b57..08c0d2650 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -1273,7 +1273,13 @@ "letsMeshKnownBrokersOnly": "Preset LetsMesh/ MeshMapper hanya mendukung broker penandatanganan perangkat yang dikenal. Gunakan Custom untuk broker lain.", "letsMeshUsernameV1Hex": "Nama pengguna harus v1_disusul oleh 64 karakter hex (kunci publik).", "bleWeakSignalWarning": "Sinyal Bluetooth lemah ({{rssi}} dBm). Bergerak lebih dekat untuk koneksi yang stabil.", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "Sinyal", + "hostSignalUnavailable": "—", + "linkQuality": "Kualitas tautan", + "linkQualityMs": "{{ms}} ms", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "Tidak tersedia (Bluetooth Web)" }, "contactGroupsModal": { "failedLoadMembers": "Gagal memuat anggota: {{message}}", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 08d659a00..f5f87d771 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -1273,7 +1273,13 @@ "letsMeshKnownBrokersOnly": "Il preset LetsMesh / MeshMapper supporta solo broker noti per la firma del dispositivo. Usa Custom per altri broker.", "letsMeshUsernameV1Hex": "Il nome utente deve essere v1_ seguito da 64 caratteri esadecimali (chiave pubblica).", "bleWeakSignalWarning": "Il segnale Bluetooth è debole ({{rssi}} dBm). Avvicinati per una connessione stabile.", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "Segnale", + "hostSignalUnavailable": "—", + "linkQuality": "Qualità del collegamento", + "linkQualityMs": "{{ms}} ms", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "Non disponibile (Web Bluetooth)" }, "contactGroupsModal": { "failedLoadMembers": "Impossibile caricare i membri: {{message}}", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 7abf4b3d4..8c3f9e8c1 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -1273,7 +1273,13 @@ "letsMeshKnownBrokersOnly": "LetsMesh/MeshMapperプリセットは、既知のデバイス署名ブローカーのみをサポートします。他のブローカーにはカスタムを使用します。", "letsMeshUsernameV1Hex": "ユーザー名はv 1_の後に64文字の16進数文字(公開鍵)でなければなりません。", "bleWeakSignalWarning": "Bluetooth信号が弱い( {{rssi}} dBm )。安定した接続のために近づいてください。", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "シグナル", + "hostSignalUnavailable": "—", + "linkQuality": "リンクの品質", + "linkQualityMs": "{{ms}}ミリ秒", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "利用できません( Web Bluetooth )" }, "contactGroupsModal": { "failedLoadMembers": "メンバーのロードに失敗しました: {{message}}", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 8dc0096e7..b7716524a 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -1273,7 +1273,13 @@ "letsMeshKnownBrokersOnly": "LetsMesh/MeshMapper 사전 설정은 알려진 장치 서명 브로커만 지원합니다. 다른 브로커에 대해 사용자 정의를 사용하십시오.", "letsMeshUsernameV1Hex": "사용자 이름은 v1_뒤에 64 16진수 문자 (공개 키) 이어야 합니다.", "bleWeakSignalWarning": "블루투스 신호가 약합니다 ({{rssi}} dBm). 안정적인 연결을 위해 더 가까이 이동하세요.", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "신호", + "hostSignalUnavailable": "—", + "linkQuality": "연결 품질", + "linkQualityMs": "{{ms}} ms", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "사용할 수 없음 (웹 블루투스)" }, "contactGroupsModal": { "failedLoadMembers": "멤버를 로드하지 못했습니다: {{message}}", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 5cec9f075..decfa07e0 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -1273,7 +1273,13 @@ "letsMeshKnownBrokersOnly": "LetsMesh / MeshMapper preset ondersteunt alleen bekende apparaatondertekenende makelaars. Gebruik Aangepast voor andere makelaars.", "letsMeshUsernameV1Hex": "Gebruikersnaam moet v1_zijn, gevolgd door 64 hexadecimale tekens (openbare sleutel).", "bleWeakSignalWarning": "Bluetooth-signaal is zwak ({{rssi}} dBm). Kom dichterbij voor een stabiele verbinding.", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "Signaal", + "hostSignalUnavailable": "—", + "linkQuality": "Verbindingskwaliteit", + "linkQualityMs": "{{ms}} ms", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "Niet beschikbaar (Web Bluetooth)" }, "contactGroupsModal": { "failedLoadMembers": "Kan leden niet laden: {{message}}", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 67e0bde4a..15de968d3 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -1277,7 +1277,13 @@ "letsMeshKnownBrokersOnly": "Ustawienie wstępne LetsMesh /MeshMapper obsługuje tylko znane brokery podpisujące urządzenia. Użyj Custom dla innych brokerów.", "letsMeshUsernameV1Hex": "Nazwa użytkownika musi składać się z v1_oraz 64 znaków szesnastkowych (klucz publiczny).", "bleWeakSignalWarning": "Sygnał Bluetooth jest słaby ({{rssi}} dBm). Podejdź bliżej, aby uzyskać stabilne połączenie.", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "Sygnał", + "hostSignalUnavailable": "—", + "linkQuality": "Jakość odnośnika", + "linkQualityMs": "{{ms}} ms", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "Niedostępne (Bluetooth internetowy)" }, "contactGroupsModal": { "failedLoadMembers": "Nie udało się załadować członków: {{message}}", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 4454c9e35..aa2d3d151 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -1273,7 +1273,13 @@ "letsMeshKnownBrokersOnly": "A predefinição LetsMesh /MeshMapper suporta apenas corretores de assinatura de dispositivos conhecidos. Use o Custom para outras corretoras.", "letsMeshUsernameV1Hex": "O nome de usuário deve ser v1_ seguido por 64 caracteres hexadecimais (chave pública).", "bleWeakSignalWarning": "O sinal Bluetooth é fraco ({{rssi}} dBm). Aproxime-se para uma conexão estável.", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "Sinal", + "hostSignalUnavailable": "—", + "linkQuality": "Qualidade da ligação", + "linkQualityMs": "{{ms}} ms", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "Indisponível (Web Bluetooth)" }, "contactGroupsModal": { "failedLoadMembers": "Falha ao carregar membros: {{message}}", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 061e0944c..226b892a0 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -1275,7 +1275,13 @@ "letsMeshKnownBrokersOnly": "Предустановка LetsMesh /MeshMapper поддерживает только известные брокеры подписи устройств. Используйте Custom для других брокеров.", "letsMeshUsernameV1Hex": "Имя пользователя должно быть v1_, за которым должны следовать 64 шестнадцатеричных символа (открытый ключ).", "bleWeakSignalWarning": "Сигнал Bluetooth слабый ({{rssi}} dBm). Переместитесь ближе для стабильного соединения.", - "bleRssiDbm": "{{rssi}} дБм" + "bleRssiDbm": "{{rssi}} дБм", + "hostSignal": "Сигнал", + "hostSignalUnavailable": "—", + "linkQuality": "Качество связи:", + "linkQualityMs": "{{ms}} мс", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "Недоступно (веб-Bluetooth)" }, "contactGroupsModal": { "failedLoadMembers": "Не удалось загрузить участников: {{message}}.", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 3d7d28bcc..7db988a18 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -1273,7 +1273,13 @@ "letsMeshKnownBrokersOnly": "LetsMesh /MeshMapper ön ayarı yalnızca bilinen cihaz imzalama aracılarını destekler. Diğer brokerler için Özel'i kullanın.", "letsMeshUsernameV1Hex": "Kullanıcı adı v1_ ve ardından 64 onaltılık karakter (genel anahtar) olmalıdır.", "bleWeakSignalWarning": "Bluetooth sinyali zayıf ({{rssi}} dBm). Sabit bir bağlantı için yaklaşın.", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "Sinyal", + "hostSignalUnavailable": "—", + "linkQuality": "Hat kalitesi", + "linkQualityMs": "{{ms}} ms", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "Kullanılamıyor (Web Bluetooth)" }, "contactGroupsModal": { "failedLoadMembers": "Üyeler yüklenemedi: {{message}}", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index d7cb9953b..b8296908e 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -1275,7 +1275,13 @@ "letsMeshKnownBrokersOnly": "Попереднє налаштування LetsMesh /MeshMapper підтримує лише відомих брокерів, які підписують пристрої. Використовуйте Custom для інших брокерів.", "letsMeshUsernameV1Hex": "Ім'я користувача має бути v1_ з 64 шістнадцятковими символами (відкритий ключ).", "bleWeakSignalWarning": "Сигнал Bluetooth слабкий ({{rssi}} dBm). Підійдіть ближче для стабільного з'єднання.", - "bleRssiDbm": "{{rssi}} дБм" + "bleRssiDbm": "{{rssi}} дБм", + "hostSignal": "Сигнал", + "hostSignalUnavailable": "—", + "linkQuality": "Якість зв'язку", + "linkQualityMs": "{{ms}} мс", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "Недоступно (веб Bluetooth)" }, "contactGroupsModal": { "failedLoadMembers": "Не вдалося завантажити учасників: {{message}}", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 1e4f103ff..1766c0125 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -1273,7 +1273,13 @@ "letsMeshKnownBrokersOnly": "LetsMesh/MeshMapper预设仅支持已知的设备签名代理。对其他经纪商使用自定义。", "letsMeshUsernameV1Hex": "用户名必须为v1_后跟64个十六进制字符(公钥)。", "bleWeakSignalWarning": "蓝牙信号弱( {{rssi}} dBm )。靠近一点,以获得稳定的连接。", - "bleRssiDbm": "{{rssi}} dBm" + "bleRssiDbm": "{{rssi}} dBm", + "hostSignal": "信号源", + "hostSignalUnavailable": "—", + "linkQuality": "连接质量", + "linkQualityMs": "{{ms}}毫秒", + "linkQualityUnavailable": "—", + "signalUnavailableWebBluetooth": "不可用(网页蓝牙)" }, "contactGroupsModal": { "failedLoadMembers": "无法加载会员: {{message}}", diff --git a/src/renderer/vitest.electronApiMock.ts b/src/renderer/vitest.electronApiMock.ts index 0b6fcc55f..c87367380 100644 --- a/src/renderer/vitest.electronApiMock.ts +++ b/src/renderer/vitest.electronApiMock.ts @@ -175,6 +175,7 @@ export function createElectronAPIMock(): ElectronAPI { }, onNobleBleAdapterState: vi.fn().mockReturnValue(() => {}), onNobleBleDeviceDiscovered: vi.fn().mockReturnValue(() => {}), + onNobleBleLinkRssi: vi.fn().mockReturnValue(() => {}), onNobleBleConnected: vi.fn().mockReturnValue(() => {}), onNobleBleDisconnected: vi.fn().mockReturnValue(() => {}), onNobleBleConnectAborted: vi.fn().mockReturnValue(() => {}), @@ -265,6 +266,10 @@ export function createElectronAPIMock(): ElectronAPI { disconnect: vi.fn().mockResolvedValue(undefined), onData: vi.fn().mockReturnValue(() => {}), }, + hostLink: { + probeHttpRtt: vi.fn().mockResolvedValue(null), + probeTcpRtt: vi.fn().mockResolvedValue(null), + }, meshtastic: { tcp: { connect: vi.fn().mockResolvedValue(undefined), diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index 2e0c28261..c8ee598b5 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -145,6 +145,13 @@ export interface NobleBleDevice { export type NobleBleSessionId = MeshProtocol; export type NobleBleConnectResult = { ok: true } | { ok: false; error: string }; +/** Host↔radio BLE RSSI while GATT is connected (Noble updateRssiAsync). */ +export interface NobleBleLinkRssiPayload { + sessionId: NobleBleSessionId; + /** RSSI in dBm; null when the last poll failed or returned non-finite. */ + rssi: number | null; +} + export interface SerialPort { portId: string; displayName: string; @@ -789,6 +796,7 @@ export interface ElectronAPI { // ─── Noble BLE ─────────────────────────────────────────────────────────────── onNobleBleAdapterState: (cb: (state: string) => void) => () => void; onNobleBleDeviceDiscovered: (cb: (device: NobleBleDevice) => void) => () => void; + onNobleBleLinkRssi: (cb: (payload: NobleBleLinkRssiPayload) => void) => () => void; onNobleBleConnected: (cb: (sessionId: NobleBleSessionId) => void) => () => void; onNobleBleDisconnected: (cb: (sessionId: NobleBleSessionId) => void) => () => void; onNobleBleConnectAborted: ( @@ -952,6 +960,15 @@ export interface ElectronAPI { onData: (cb: (bytes: Uint8Array) => void) => () => void; }; + /** + * Host↔radio link-quality probes (Connection panel meter). + * Returns RTT in ms, or null when the probe fails / times out. + */ + hostLink: { + probeHttpRtt: (host: string, tls: boolean) => Promise; + probeTcpRtt: (host: string, port: number) => Promise; + }; + // ─── Meshtastic TCP bridge ──────────────────────────────────────────────────── meshtastic: { tcp: { From c78ea122a918752ab91f12a985b3111e0c017bc8 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 2 Aug 2026 12:14:51 -0600 Subject: [PATCH 2/3] fix(connection): ignore stale host-link RTT probe results Gate HTTP/TCP meter updates on a probe generation so a slower older poll cannot overwrite a newer RTT after overlapping interval runs. --- src/renderer/hooks/useHostLinkMeter.test.ts | 52 +++++++++++++++++++++ src/renderer/hooks/useHostLinkMeter.ts | 5 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/renderer/hooks/useHostLinkMeter.test.ts b/src/renderer/hooks/useHostLinkMeter.test.ts index 7d8de17fc..fb2d050bf 100644 --- a/src/renderer/hooks/useHostLinkMeter.test.ts +++ b/src/renderer/hooks/useHostLinkMeter.test.ts @@ -14,6 +14,7 @@ describe('useHostLinkMeter', () => { }); afterEach(() => { + vi.useRealTimers(); vi.clearAllMocks(); }); @@ -216,4 +217,55 @@ describe('useHostLinkMeter', () => { expect(result.current.rttMs).toBeNull(); expect(result.current.level).toBeNull(); }); + + it('ignores stale HTTP probe results when a newer probe finishes first', async () => { + vi.useFakeTimers(); + let resolveSlow: ((value: number | null) => void) | null = null; + let resolveFast: ((value: number | null) => void) | null = null; + vi.mocked(window.electronAPI.hostLink.probeHttpRtt) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSlow = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFast = resolve; + }), + ); + + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshtastic', + connectionType: 'http', + status: 'configured', + hostAddress: 'meshtastic.local', + platform: 'darwin', + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + expect(resolveSlow).not.toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + }); + expect(resolveFast).not.toBeNull(); + + await act(async () => { + resolveFast?.(25); + await Promise.resolve(); + }); + expect(result.current.rttMs).toBe(25); + + await act(async () => { + resolveSlow?.(900); + await Promise.resolve(); + }); + expect(result.current.rttMs).toBe(25); + }); }); diff --git a/src/renderer/hooks/useHostLinkMeter.ts b/src/renderer/hooks/useHostLinkMeter.ts index 025161e24..8f647fa06 100644 --- a/src/renderer/hooks/useHostLinkMeter.ts +++ b/src/renderer/hooks/useHostLinkMeter.ts @@ -89,9 +89,12 @@ export function useHostLinkMeter(opts: { } let cancelled = false; + let probeGeneration = 0; let timer: ReturnType | null = null; const run = async () => { + probeGeneration += 1; + const generation = probeGeneration; let next: number | null = null; if (protocol === 'meshtastic' && connectionType === 'http') { next = await probeHttpLinkRttMs(address); @@ -101,7 +104,7 @@ export function useHostLinkMeter(opts: { // MeshCore "http" transport is TCP/IP host:port next = await probeTcpLinkRttMs(address, 'meshcore'); } - if (!cancelled) setRttMs(next); + if (!cancelled && generation === probeGeneration) setRttMs(next); }; void run(); From 1a6d85e8520b03c34635564df9cf12b9085cece9 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 2 Aug 2026 12:24:10 -0600 Subject: [PATCH 3/3] chore(coderabbit): skip bot PRs and expand path filters Exclude Dependabot/Renovate auto-reviews and ignore common lockfile and generated paths alongside the existing Free-tier filters. --- .coderabbit.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 0fd29174d..10d00bbbe 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -12,6 +12,8 @@ reviews: enable_prompt_for_ai_agents: true path_filters: - '!src/renderer/locales/**' + - '!**/package-lock.json' + - '!**/yarn.lock' - '!**/pnpm-lock.yaml' - '!flatpak/generated-sources.json' - '!patches/**' @@ -19,6 +21,7 @@ reviews: - '!**/coverage/**' - '!**/dist/**' - '!**/dist-electron/**' + - '!**/generated/**' - '!**/target/**' path_instructions: - path: '**/*' @@ -38,6 +41,9 @@ reviews: enabled: true drafts: false auto_pause_after_reviewed_commits: 2 + exclude_user_handles: + - 'dependabot[bot]' + - 'renovate[bot]' ignore_title_keywords: - 'chore: bump' - 'chore(deps)'