diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c11cc4725..0ceeebe09 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -605,6 +605,14 @@ IPv6 addresses work for Meshtastic Wi‑Fi, MeshCore TCP, and Reticulum RNode Wi **Address examples:** `192.168.1.10:4403`, `meshtastic.local:4403`, `[fd00::1]:4403`. +### Connection panel Link quality (TCP) shows "—" or unexpected latency + +**Cause:** For **Meshtastic WiFi/TCP** and **MeshCore TCP/IP SoftAP**, the Connection panel signal bars reflect **live-session responsiveness** — an EWMA of write→first-data delay on the already-open TCP socket — not a separate connect probe. Bars may show **"—"** until traffic has produced a sample, or after ~2 minutes without a completed sample (covers idle heartbeat gaps). Meshtastic **WiFi/HTTP** still uses a `/json/report` RTT probe (separate from the TCP session). Reticulum hub/RMAP rows still use a short-lived TCP connect probe (different risk profile). + +**Why not a second TCP connect?** Probing the same `host:port` as the live session every few seconds can RST ESP32/lwIP-class devices (see PR discussion around competing connections). + +**Fix:** Exercise the link (chat, NodeDB traffic, companion RPCs). If bars stay empty while the session is healthy, that is expected during idle gaps; reconnect if the session itself drops. + ### Meshtastic HTTP fails immediately with "Invalid host format" **Cause:** Builds before v5.21.x validated the hostname incorrectly when the address included a port (`192.168.1.10:443`), rejecting every HTTP connect. diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index 7b5d8f784..be4c278d4 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -389,6 +389,31 @@ describe('Host link quality IPC (source contract)', () => { 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'"); + expect(INDEX_SOURCE).toContain("ipcMain.handle('hostLink:getSessionMeter'"); + }); + + it('wires live-session meters on both Meshtastic and MeshCore TCP bridges', () => { + expect(INDEX_SOURCE).toContain("resetLiveSessionMeter('meshtastic')"); + expect(INDEX_SOURCE).toContain("resetLiveSessionMeter('meshcore')"); + expect(INDEX_SOURCE).toContain("noteLiveSessionWrite('meshtastic')"); + expect(INDEX_SOURCE).toContain("noteLiveSessionWrite('meshcore')"); + expect(INDEX_SOURCE).toContain("noteLiveSessionData('meshtastic')"); + expect(INDEX_SOURCE).toContain("noteLiveSessionData('meshcore')"); + expect(INDEX_SOURCE).toContain("clearLiveSessionMeter('meshtastic')"); + expect(INDEX_SOURCE).toContain("clearLiveSessionMeter('meshcore')"); + // Accounting must ignore superseded sockets (same active-ref guard as #792 disconnect IPC). + expect(INDEX_SOURCE).toMatch( + /if \(meshcoreTcpSocket === socket\) \{\s*noteLiveSessionData\('meshcore'\)/, + ); + expect(INDEX_SOURCE).toMatch( + /if \(meshtasticTcpSocket === socket\) \{\s*noteLiveSessionData\('meshtastic'\)/, + ); + expect(INDEX_SOURCE).toMatch( + /if \(meshcoreTcpSocket === sock\) \{\s*noteLiveSessionWrite\('meshcore'\)/, + ); + expect(INDEX_SOURCE).toMatch( + /if \(meshtasticTcpSocket === sock\) \{\s*noteLiveSessionWrite\('meshtastic'\)/, + ); }); }); @@ -399,6 +424,7 @@ describe('Host link quality preload surface (source contract)', () => { expect(PRELOAD_SOURCE).toContain('hostLink:'); expect(PRELOAD_SOURCE).toContain("ipcRenderer.invoke('hostLink:probeHttpRtt'"); expect(PRELOAD_SOURCE).toContain("ipcRenderer.invoke('hostLink:probeTcpRtt'"); + expect(PRELOAD_SOURCE).toContain("ipcRenderer.invoke('hostLink:getSessionMeter'"); }); }); diff --git a/src/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index c9fc45101..3a3dbb3c5 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -116,11 +116,11 @@ describe('meshtastic:tcp-write byte validation (source contract)', () => { it('destroys prior socket before opening a new meshtastic tcp connection', () => { const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-connect'"); expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1200); + const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 1400); // Null the active ref before destroy so the superseded close does not emit // meshtastic:tcp-disconnected against a healthy replacement (#792). expect(handlerBody).toMatch( - /const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*prev\.destroy\(\)/, + /const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*clearLiveSessionMeter\('meshtastic'\);\s*prev\.destroy\(\)/, ); }); @@ -131,7 +131,7 @@ describe('meshtastic:tcp-write byte validation (source contract)', () => { expect(handlerIdx).toBeGreaterThan(-1); const closeIdx = INDEX_SOURCE.indexOf("socket.on('close'", handlerIdx); expect(closeIdx).toBeGreaterThan(handlerIdx); - const closeBody = INDEX_SOURCE.slice(closeIdx, closeIdx + 600); + const closeBody = INDEX_SOURCE.slice(closeIdx, closeIdx + 900); expect(closeBody).toContain('if (meshtasticTcpSocket === socket)'); expect(closeBody).toContain("mainWindow?.webContents.send('meshtastic:tcp-disconnected')"); // Emit must be inside the active-socket guard (not before it). @@ -146,9 +146,9 @@ describe('meshtastic:tcp-write byte validation (source contract)', () => { it('nulls meshtasticTcpSocket before destroy on disconnect (PR #792)', () => { const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshtastic:tcp-disconnect'"); expect(handlerIdx).toBeGreaterThan(-1); - const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 400); + const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 500); expect(handlerBody).toMatch( - /const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*prev\.destroy\(\)/, + /const prev = meshtasticTcpSocket;\s*meshtasticTcpSocket = null;\s*clearLiveSessionMeter\('meshtastic'\);\s*prev\.destroy\(\)/, ); }); @@ -393,16 +393,16 @@ describe('meshcore:tcp-connect hostname validation (source contract)', () => { it('nulls meshcoreTcpSocket before destroy on connect-replace and disconnect (PR #792)', () => { const connectIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-connect'"); expect(connectIdx).toBeGreaterThan(-1); - const connectBody = INDEX_SOURCE.slice(connectIdx, connectIdx + 1200); + const connectBody = INDEX_SOURCE.slice(connectIdx, connectIdx + 1400); expect(connectBody).toMatch( - /const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*prev\.destroy\(\)/, + /const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*clearLiveSessionMeter\('meshcore'\);\s*prev\.destroy\(\)/, ); const disconnectIdx = INDEX_SOURCE.indexOf("ipcMain.handle('meshcore:tcp-disconnect'"); expect(disconnectIdx).toBeGreaterThan(-1); - const disconnectBody = INDEX_SOURCE.slice(disconnectIdx, disconnectIdx + 400); + const disconnectBody = INDEX_SOURCE.slice(disconnectIdx, disconnectIdx + 500); expect(disconnectBody).toMatch( - /const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*prev\.destroy\(\)/, + /const prev = meshcoreTcpSocket;\s*meshcoreTcpSocket = null;\s*clearLiveSessionMeter\('meshcore'\);\s*prev\.destroy\(\)/, ); }); @@ -433,6 +433,19 @@ describe('meshcore:tcp-connect hostname validation (source contract)', () => { }); }); +describe('hostLink:getSessionMeter validation (source contract)', () => { + it('rejects protocols other than meshtastic/meshcore', () => { + const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('hostLink:getSessionMeter'"); + expect(handlerIdx).toBeGreaterThan(-1); + const handlerBody = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 400); + expect(handlerBody).toContain("assertIpcSender(event, 'hostLink:getSessionMeter')"); + expect(handlerBody).toContain("protocol !== 'meshtastic'"); + expect(handlerBody).toContain("protocol !== 'meshcore'"); + expect(handlerBody).toContain("throw new Error('Invalid protocol')"); + expect(handlerBody).toContain('snapshotLiveSessionMeter('); + }); +}); + // ─── meshtastic:tcp-connect hostname validation ────────────────────── describe('meshtastic:tcp-connect hostname validation (source contract)', () => { @@ -568,6 +581,7 @@ describe('privileged IPC sender validation (source contract)', () => { 'meshtastic:tcp-connect', 'meshtastic:tcp-write', 'meshtastic:tcp-disconnect', + 'hostLink:getSessionMeter', 'noble-ble-connect', 'noble-ble-disconnect', 'notify:message', @@ -637,7 +651,7 @@ describe('privileged IPC sender validation (source contract)', () => { it('meshtastic tcp-connect uses connect timeout', () => { expect(INDEX_SOURCE).toContain('MESHTASTIC_TCP_CONNECT_TIMEOUT_MS'); expect(INDEX_SOURCE).toMatch( - /meshtastic:tcp-connect[\s\S]{0,1200}meshtastic:tcp-connect: connection timeout/, + /meshtastic:tcp-connect[\s\S]{0,1800}meshtastic:tcp-connect: connection timeout/, ); }); diff --git a/src/main/index.ts b/src/main/index.ts index f6356ea6a..34099bdb3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -120,6 +120,13 @@ import { linuxWebBluetoothDeviceSelection, } from './linuxWebBluetoothDeviceSelection'; import { listMeshcoreDmPeersFromDb, listMeshtasticDmPeersFromDb } from './listDmPeers'; +import { + clearLiveSessionMeter, + noteLiveSessionData, + noteLiveSessionWrite, + resetLiveSessionMeter, + snapshotLiveSessionMeter, +} from './live-session-meter'; import { clearLogFile, exportLogTo, @@ -6123,6 +6130,7 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => { // meshcore:tcp-disconnected (renderer reconnect is driven by that event — #792). const prev = meshcoreTcpSocket; meshcoreTcpSocket = null; + clearLiveSessionMeter('meshcore'); prev.destroy(); } const socketHost = formatHostForSocket(host); @@ -6135,7 +6143,10 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => { const connectTimeout = setTimeout(() => { if (settled) return; settled = true; - if (meshcoreTcpSocket === socket) meshcoreTcpSocket = null; + if (meshcoreTcpSocket === socket) { + meshcoreTcpSocket = null; + clearLiveSessionMeter('meshcore'); + } socket.destroy(); reject(new Error('meshcore:tcp-connect: connection timeout')); }, MESHCORE_TCP_CONNECT_TIMEOUT_MS); @@ -6145,6 +6156,7 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => { logDeviceConnection( `transport=tcp stack=meshcore host=${sanitizeLogMessage(socketHost)} port=${p}`, ); + resetLiveSessionMeter('meshcore'); if (!settled) { settled = true; resolve(); @@ -6166,6 +6178,10 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => { } return; } + // Superseded sockets must not update the live session meter (#792 connect-replace). + if (meshcoreTcpSocket === socket) { + noteLiveSessionData('meshcore'); + } mainWindow?.webContents.send('meshcore:tcp-data', new Uint8Array(chunk)); }); socket.on('close', (hadError) => { @@ -6187,6 +6203,7 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => { // (renderer reconnect is driven by this event — see #792). if (meshcoreTcpSocket === socket) { meshcoreTcpSocket = null; + clearLiveSessionMeter('meshcore'); mainWindow?.webContents.send('meshcore:tcp-disconnected'); } }); @@ -6229,6 +6246,10 @@ ipcMain.handle('meshcore:tcp-write', (event, bytes: number[]) => { console.error('[IPC] meshcore:tcp-write error:', sanitizeLogMessage(err.message)); reject(err); } else { + // Ignore write completions from a superseded socket. + if (meshcoreTcpSocket === sock) { + noteLiveSessionWrite('meshcore'); + } resolve(); } }); @@ -6242,6 +6263,7 @@ ipcMain.handle('meshcore:tcp-disconnect', (event) => { // Null before destroy so this teardown close is not reported as a live link drop. const prev = meshcoreTcpSocket; meshcoreTcpSocket = null; + clearLiveSessionMeter('meshcore'); prev.destroy(); } }); @@ -6272,6 +6294,7 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) => // meshtastic:tcp-disconnected (renderer reconnect is driven by that event — #792). const prev = meshtasticTcpSocket; meshtasticTcpSocket = null; + clearLiveSessionMeter('meshtastic'); prev.destroy(); } const socketHost = formatHostForSocket(host); @@ -6282,7 +6305,10 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) => const connectTimeout = setTimeout(() => { if (settled) return; settled = true; - if (meshtasticTcpSocket === socket) meshtasticTcpSocket = null; + if (meshtasticTcpSocket === socket) { + meshtasticTcpSocket = null; + clearLiveSessionMeter('meshtastic'); + } socket.destroy(); reject(new Error('meshtastic:tcp-connect: connection timeout')); }, MESHTASTIC_TCP_CONNECT_TIMEOUT_MS); @@ -6292,6 +6318,7 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) => logDeviceConnection( `transport=tcp stack=meshtastic host=${sanitizeLogMessage(socketHost)} port=${p}`, ); + resetLiveSessionMeter('meshtastic'); if (!settled) { settled = true; resolve(); @@ -6313,6 +6340,10 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) => } return; } + // Superseded sockets must not update the live session meter (#792 connect-replace). + if (meshtasticTcpSocket === socket) { + noteLiveSessionData('meshtastic'); + } mainWindow?.webContents.send('meshtastic:tcp-data', new Uint8Array(chunk)); }); socket.on('close', (hadError) => { @@ -6323,6 +6354,7 @@ ipcMain.handle('meshtastic:tcp-connect', (event, host: string, port: number) => // (renderer reconnect is driven by this event — see #792). if (meshtasticTcpSocket === socket) { meshtasticTcpSocket = null; + clearLiveSessionMeter('meshtastic'); mainWindow?.webContents.send('meshtastic:tcp-disconnected'); } }); @@ -6365,6 +6397,10 @@ ipcMain.handle('meshtastic:tcp-write', (event, bytes: number[]) => { console.error('[IPC] meshtastic:tcp-write error:', sanitizeLogMessage(err.message)); reject(err); } else { + // Ignore write completions from a superseded socket. + if (meshtasticTcpSocket === sock) { + noteLiveSessionWrite('meshtastic'); + } resolve(); } }); @@ -6378,6 +6414,7 @@ ipcMain.handle('meshtastic:tcp-disconnect', (event) => { // Null before destroy so this teardown close is not reported as a live link drop. const prev = meshtasticTcpSocket; meshtasticTcpSocket = null; + clearLiveSessionMeter('meshtastic'); prev.destroy(); } }); @@ -6516,6 +6553,14 @@ ipcMain.handle('hostLink:probeTcpRtt', async (event, host: unknown, port: unknow return probeTcpRttMs(host, port as number); }); +ipcMain.handle('hostLink:getSessionMeter', (event, protocol: unknown) => { + assertIpcSender(event, 'hostLink:getSessionMeter'); + if (protocol !== 'meshtastic' && protocol !== 'meshcore') { + throw new Error('Invalid protocol'); + } + return snapshotLiveSessionMeter(protocol); +}); + 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/live-session-meter.test.ts b/src/main/live-session-meter.test.ts new file mode 100644 index 000000000..f8ded4121 --- /dev/null +++ b/src/main/live-session-meter.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + __resetLiveSessionMeterRegistryForTests, + clearLiveSessionMeter, + createLiveSessionMeter, + LIVE_SESSION_EWMA_ALPHA, + LIVE_SESSION_PENDING_SAMPLE_TIMEOUT_MS, + LIVE_SESSION_STALE_MS, + noteLiveSessionData, + noteLiveSessionWrite, + resetLiveSessionMeter, + snapshotLiveSessionMeter, +} from './live-session-meter'; + +describe('createLiveSessionMeter', () => { + let now = 0; + + beforeEach(() => { + now = 1_000; + }); + + function makeMeter() { + return createLiveSessionMeter({ + now: () => now, + alpha: LIVE_SESSION_EWMA_ALPHA, + pendingTimeoutMs: LIVE_SESSION_PENDING_SAMPLE_TIMEOUT_MS, + staleMs: LIVE_SESSION_STALE_MS, + }); + } + + it('returns null before any completed sample', () => { + const meter = makeMeter(); + expect(meter.snapshot().rttMs).toBeNull(); + meter.noteWrite(); + expect(meter.snapshot().rttMs).toBeNull(); + }); + + it('records write→data latency and applies EWMA on the second sample', () => { + const meter = makeMeter(); + meter.noteWrite(); + now += 40; + meter.noteData(); + expect(meter.snapshot().rttMs).toBe(40); + + meter.noteWrite(); + now += 100; + meter.noteData(); + // alpha * 100 + (1 - alpha) * 40 + const expected = LIVE_SESSION_EWMA_ALPHA * 100 + (1 - LIVE_SESSION_EWMA_ALPHA) * 40; + expect(meter.snapshot().rttMs).toBeCloseTo(expected, 5); + }); + + it('overwrites pending write so only the latest write→data is sampled', () => { + const meter = makeMeter(); + meter.noteWrite(); + now += 50; + meter.noteWrite(); + now += 10; + meter.noteData(); + expect(meter.snapshot().rttMs).toBe(10); + }); + + it('ignores noteData with no pending write', () => { + const meter = makeMeter(); + meter.noteWrite(); + now += 20; + meter.noteData(); + expect(meter.snapshot().rttMs).toBe(20); + + meter.noteData(); + expect(meter.snapshot().rttMs).toBe(20); + }); + + it('drops a pending write after the pending timeout without poisoning EWMA', () => { + const meter = makeMeter(); + meter.noteWrite(); + now += 25; + meter.noteData(); + expect(meter.snapshot().rttMs).toBe(25); + + meter.noteWrite(); + now += LIVE_SESSION_PENDING_SAMPLE_TIMEOUT_MS + 1; + meter.noteData(); + expect(meter.snapshot().rttMs).toBe(25); + }); + + it('returns null after the stale window and restores after a new sample', () => { + const meter = makeMeter(); + meter.noteWrite(); + now += 30; + meter.noteData(); + expect(meter.snapshot().rttMs).toBe(30); + + now += LIVE_SESSION_STALE_MS + 1; + expect(meter.snapshot().rttMs).toBeNull(); + + meter.noteWrite(); + now += 15; + meter.noteData(); + // Stale only hides the snapshot; EWMA continues from the prior sample. + const expected = LIVE_SESSION_EWMA_ALPHA * 15 + (1 - LIVE_SESSION_EWMA_ALPHA) * 30; + expect(meter.snapshot().rttMs).toBeCloseTo(expected, 5); + }); + + it('reset clears EWMA and pending', () => { + const meter = makeMeter(); + meter.noteWrite(); + now += 12; + meter.noteData(); + expect(meter.snapshot().rttMs).toBe(12); + + meter.noteWrite(); + meter.reset(); + expect(meter.snapshot().rttMs).toBeNull(); + now += 5; + meter.noteData(); + expect(meter.snapshot().rttMs).toBeNull(); + }); + + it('clear makes subsequent notes no-ops until reset', () => { + const meter = makeMeter(); + meter.noteWrite(); + now += 18; + meter.noteData(); + expect(meter.snapshot().rttMs).toBe(18); + + meter.clear(); + expect(meter.snapshot().rttMs).toBeNull(); + meter.noteWrite(); + now += 10; + meter.noteData(); + expect(meter.snapshot().rttMs).toBeNull(); + + meter.reset(); + meter.noteWrite(); + now += 22; + meter.noteData(); + expect(meter.snapshot().rttMs).toBe(22); + }); +}); + +describe('live session meter registry', () => { + beforeEach(() => { + __resetLiveSessionMeterRegistryForTests(); + }); + + afterEach(() => { + __resetLiveSessionMeterRegistryForTests(); + }); + + it.each(['meshtastic', 'meshcore'] as const)( + 'tracks %s session meter via registry helpers', + (protocol) => { + expect(snapshotLiveSessionMeter(protocol)).toBeNull(); + resetLiveSessionMeter(protocol); + noteLiveSessionWrite(protocol); + noteLiveSessionData(protocol); + const snap = snapshotLiveSessionMeter(protocol); + expect(snap).not.toBeNull(); + expect(snap?.rttMs).toBeGreaterThanOrEqual(0); + + clearLiveSessionMeter(protocol); + expect(snapshotLiveSessionMeter(protocol)).toBeNull(); + }, + ); +}); diff --git a/src/main/live-session-meter.ts b/src/main/live-session-meter.ts new file mode 100644 index 000000000..57e7e4b7c --- /dev/null +++ b/src/main/live-session-meter.ts @@ -0,0 +1,149 @@ +import { MS_PER_SECOND } from '../shared/timeConstants'; + +/** Drop a pending write→data sample if no data arrives within this window. */ +export const LIVE_SESSION_PENDING_SAMPLE_TIMEOUT_MS = 3 * MS_PER_SECOND; + +/** + * Hide bars when no completed sample within this window. + * Must exceed typical idle write cadence (Meshtastic heartbeat ~60s). + */ +export const LIVE_SESSION_STALE_MS = 120 * MS_PER_SECOND; + +/** EWMA smoothing factor for write→first-data latency samples. */ +export const LIVE_SESSION_EWMA_ALPHA = 0.3; + +export type LiveSessionMeterProtocol = 'meshtastic' | 'meshcore'; + +export interface LiveSessionMeterSnapshot { + rttMs: number | null; +} + +export interface LiveSessionMeter { + noteWrite: () => void; + noteData: () => void; + reset: () => void; + clear: () => void; + snapshot: () => LiveSessionMeterSnapshot; +} + +export interface LiveSessionMeterOptions { + now?: () => number; + alpha?: number; + pendingTimeoutMs?: number; + staleMs?: number; +} + +/** + * Passive link-quality meter for an already-open TCP session. + * Samples write→first-data latency (EWMA); never opens a second connection. + */ +export function createLiveSessionMeter(opts: LiveSessionMeterOptions = {}): LiveSessionMeter { + const nowFn = opts.now ?? Date.now; + const alpha = opts.alpha ?? LIVE_SESSION_EWMA_ALPHA; + const pendingTimeoutMs = opts.pendingTimeoutMs ?? LIVE_SESSION_PENDING_SAMPLE_TIMEOUT_MS; + const staleMs = opts.staleMs ?? LIVE_SESSION_STALE_MS; + + let active = true; + let ewmaMs: number | null = null; + let lastSampleAt: number | null = null; + let pendingWriteAt: number | null = null; + + const dropStalePending = (now: number): void => { + if (pendingWriteAt != null && now - pendingWriteAt > pendingTimeoutMs) { + pendingWriteAt = null; + } + }; + + return { + noteWrite(): void { + if (!active) return; + pendingWriteAt = nowFn(); + }, + + noteData(): void { + if (!active) return; + const now = nowFn(); + dropStalePending(now); + if (pendingWriteAt == null) return; + const sample = now - pendingWriteAt; + pendingWriteAt = null; + if (!Number.isFinite(sample) || sample < 0) return; + ewmaMs = ewmaMs == null ? sample : alpha * sample + (1 - alpha) * ewmaMs; + lastSampleAt = now; + }, + + reset(): void { + active = true; + ewmaMs = null; + lastSampleAt = null; + pendingWriteAt = null; + }, + + clear(): void { + active = false; + ewmaMs = null; + lastSampleAt = null; + pendingWriteAt = null; + }, + + snapshot(): LiveSessionMeterSnapshot { + if (!active || ewmaMs == null || lastSampleAt == null) { + return { rttMs: null }; + } + const now = nowFn(); + if (now - lastSampleAt > staleMs) { + return { rttMs: null }; + } + return { rttMs: ewmaMs }; + }, + }; +} + +/** Registry of active meters keyed by protocol (at most one live TCP session each). */ +const meters = new Map(); + +export function resetLiveSessionMeter(protocol: LiveSessionMeterProtocol): LiveSessionMeter { + const existing = meters.get(protocol); + if (existing) { + existing.reset(); + return existing; + } + const meter = createLiveSessionMeter(); + meters.set(protocol, meter); + return meter; +} + +export function getLiveSessionMeter(protocol: LiveSessionMeterProtocol): LiveSessionMeter | null { + return meters.get(protocol) ?? null; +} + +export function clearLiveSessionMeter(protocol: LiveSessionMeterProtocol): void { + const meter = meters.get(protocol); + if (!meter) return; + meter.clear(); + meters.delete(protocol); +} + +export function noteLiveSessionWrite(protocol: LiveSessionMeterProtocol): void { + meters.get(protocol)?.noteWrite(); +} + +export function noteLiveSessionData(protocol: LiveSessionMeterProtocol): void { + meters.get(protocol)?.noteData(); +} + +export function snapshotLiveSessionMeter( + protocol: LiveSessionMeterProtocol, +): LiveSessionMeterSnapshot | null { + const meter = meters.get(protocol); + if (!meter) return null; + return meter.snapshot(); +} + +/** Test-only: drop all registered meters. */ +export function __resetLiveSessionMeterRegistryForTests(): void { + for (const meter of meters.values()) { + meter.clear(); + } + meters.clear(); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 912a63cca..cc8415c4e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1014,6 +1014,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('hostLink:probeHttpRtt', host, tls), probeTcpRtt: (host: string, port: number): Promise => ipcRenderer.invoke('hostLink:probeTcpRtt', host, port), + getSessionMeter: ( + protocol: 'meshtastic' | 'meshcore', + ): Promise<{ rttMs: number | null } | null> => + ipcRenderer.invoke('hostLink:getSessionMeter', protocol), }, // ─── Meshtastic TCP bridge ──────────────────────────────────────── diff --git a/src/renderer/components/ConnectionPanel.hostLinkMeter.test.tsx b/src/renderer/components/ConnectionPanel.hostLinkMeter.test.tsx index 233e80247..4b3f01b5e 100644 --- a/src/renderer/components/ConnectionPanel.hostLinkMeter.test.tsx +++ b/src/renderer/components/ConnectionPanel.hostLinkMeter.test.tsx @@ -1,9 +1,11 @@ import { render, screen, waitFor } from '@testing-library/react'; import { act } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; import type { NobleBleLinkRssiPayload } from '@/shared/electron-api.types'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import ConnectionPanel from './ConnectionPanel'; describe('ConnectionPanel host link meter', () => { @@ -116,53 +118,67 @@ describe('ConnectionPanel host link meter', () => { 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.each(['linux', 'darwin', 'win32'] as const)( + 'shows Link quality for MeshCore TCP/IP via session meter on %s', + async (platform) => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue(platform); + vi.mocked(window.electronAPI.hostLink.getSessionMeter).mockResolvedValue({ rttMs: 88 }); + const { container } = render( + , + ); + expect(screen.getByText('Link quality')).toBeInTheDocument(); + await waitFor(() => { + expect(window.electronAPI.hostLink.getSessionMeter).toHaveBeenCalledWith('meshcore'); + expect(screen.getByText('88 ms')).toBeInTheDocument(); + }); + expect(window.electronAPI.hostLink.probeTcpRtt).not.toHaveBeenCalled(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }, + ); - 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(); - }); - }); + it.each(['linux', 'darwin', 'win32'] as const)( + 'shows Link quality for Meshtastic TCP via session meter on %s', + async (platform) => { + vi.mocked(window.electronAPI.getPlatform).mockReturnValue(platform); + vi.mocked(window.electronAPI.hostLink.getSessionMeter).mockResolvedValue({ rttMs: 120 }); + const { container } = render( + , + ); + expect(screen.getByText('Link quality')).toBeInTheDocument(); + await waitFor(() => { + expect(window.electronAPI.hostLink.getSessionMeter).toHaveBeenCalledWith('meshtastic'); + expect(screen.getByText('120 ms')).toBeInTheDocument(); + }); + expect(window.electronAPI.hostLink.probeTcpRtt).not.toHaveBeenCalled(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }, + ); }); diff --git a/src/renderer/hooks/hostLinkQuality.probes.test.ts b/src/renderer/hooks/hostLinkQuality.probes.test.ts index fba90edcd..33bb0c4c2 100644 --- a/src/renderer/hooks/hostLinkQuality.probes.test.ts +++ b/src/renderer/hooks/hostLinkQuality.probes.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { probeHttpLinkRttMs, probeTcpLinkRttMs } from '../lib/hostLinkQuality'; +import { probeHttpLinkRttMs, probeSessionMeter, probeTcpLinkRttMs } from '../lib/hostLinkQuality'; -describe('probeHttpLinkRttMs / probeTcpLinkRttMs', () => { +describe('probeHttpLinkRttMs / probeTcpLinkRttMs / probeSessionMeter', () => { beforeEach(() => { vi.mocked(window.electronAPI.hostLink.probeHttpRtt).mockResolvedValue(33); vi.mocked(window.electronAPI.hostLink.probeTcpRtt).mockResolvedValue(90); + vi.mocked(window.electronAPI.hostLink.getSessionMeter).mockResolvedValue({ rttMs: 55 }); }); afterEach(() => { @@ -41,4 +42,26 @@ describe('probeHttpLinkRttMs / probeTcpLinkRttMs', () => { vi.mocked(window.electronAPI.hostLink.probeTcpRtt).mockResolvedValue(Number.NaN); await expect(probeTcpLinkRttMs('10.0.0.8', 'meshtastic')).resolves.toBeNull(); }); + + it('reads session meter via getSessionMeter', async () => { + await expect(probeSessionMeter('meshtastic')).resolves.toBe(55); + expect(window.electronAPI.hostLink.getSessionMeter).toHaveBeenCalledWith('meshtastic'); + }); + + it('returns null when session meter is absent', async () => { + vi.mocked(window.electronAPI.hostLink.getSessionMeter).mockResolvedValue(null); + await expect(probeSessionMeter('meshcore')).resolves.toBeNull(); + }); + + it('returns null when session meter throws', async () => { + vi.mocked(window.electronAPI.hostLink.getSessionMeter).mockRejectedValue(new Error('boom')); + await expect(probeSessionMeter('meshtastic')).resolves.toBeNull(); + }); + + it('returns null when session meter rttMs is non-finite', async () => { + vi.mocked(window.electronAPI.hostLink.getSessionMeter).mockResolvedValue({ + rttMs: Number.NaN, + }); + await expect(probeSessionMeter('meshcore')).resolves.toBeNull(); + }); }); diff --git a/src/renderer/hooks/useHostLinkMeter.test.ts b/src/renderer/hooks/useHostLinkMeter.test.ts index fb2d050bf..d3cd56894 100644 --- a/src/renderer/hooks/useHostLinkMeter.test.ts +++ b/src/renderer/hooks/useHostLinkMeter.test.ts @@ -11,6 +11,7 @@ describe('useHostLinkMeter', () => { vi.mocked(window.electronAPI.onNobleBleLinkRssi).mockReturnValue(() => {}); vi.mocked(window.electronAPI.hostLink.probeHttpRtt).mockResolvedValue(40); vi.mocked(window.electronAPI.hostLink.probeTcpRtt).mockResolvedValue(80); + vi.mocked(window.electronAPI.hostLink.getSessionMeter).mockResolvedValue({ rttMs: 80 }); }); afterEach(() => { @@ -74,42 +75,99 @@ describe('useHostLinkMeter', () => { expect(result.current.level).toBe(4); }); expect(window.electronAPI.hostLink.probeHttpRtt).toHaveBeenCalled(); + expect(window.electronAPI.hostLink.getSessionMeter).not.toHaveBeenCalled(); + expect(window.electronAPI.hostLink.probeTcpRtt).not.toHaveBeenCalled(); }, ); - it('returns ip-rtt for Meshtastic TCP via probeTcpRtt', async () => { + it.each(['linux', 'darwin', 'win32'] as const)( + 'returns ip-rtt for Meshtastic TCP via getSessionMeter on %s', + async (platform) => { + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshtastic', + connectionType: 'tcp', + status: 'configured', + hostAddress: '10.0.0.5:4403', + platform, + }), + ); + 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.getSessionMeter).toHaveBeenCalledWith('meshtastic'); + expect(window.electronAPI.hostLink.probeTcpRtt).not.toHaveBeenCalled(); + }, + ); + + it.each(['linux', 'darwin', 'win32'] as const)( + 'returns ip-rtt for MeshCore TCP/IP via getSessionMeter on %s', + async (platform) => { + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshcore', + connectionType: 'http', + status: 'configured', + hostAddress: '192.168.1.20:5000', + platform, + }), + ); + expect(result.current.kind).toBe('ip-rtt'); + await waitFor(() => { + expect(result.current.rttMs).toBe(80); + }); + expect(window.electronAPI.hostLink.getSessionMeter).toHaveBeenCalledWith('meshcore'); + expect(window.electronAPI.hostLink.probeTcpRtt).not.toHaveBeenCalled(); + }, + ); + + it('keeps ip-rtt kind with null RTT when session meter has no sample', async () => { + vi.mocked(window.electronAPI.hostLink.getSessionMeter).mockResolvedValue(null); const { result } = renderHook(() => useHostLinkMeter({ protocol: 'meshtastic', connectionType: 'tcp', status: 'configured', hostAddress: '10.0.0.5:4403', - platform: 'darwin', + platform: 'linux', }), ); 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.getSessionMeter).toHaveBeenCalled(); }); - expect(window.electronAPI.hostLink.probeTcpRtt).toHaveBeenCalledWith('10.0.0.5', 4403); + expect(result.current.rttMs).toBeNull(); + expect(result.current.level).toBeNull(); + expect(result.current.kind).toBe('ip-rtt'); }); - 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', - }), + it('clears stale HTTP RTT when switching Meshtastic HTTP→TCP', async () => { + const { result, rerender } = renderHook( + (props: { connectionType: 'http' | 'tcp' }) => + useHostLinkMeter({ + protocol: 'meshtastic', + connectionType: props.connectionType, + status: 'configured', + hostAddress: '10.0.0.5', + platform: 'darwin', + }), + { initialProps: { connectionType: 'http' as 'http' | 'tcp' } }, ); - expect(result.current.kind).toBe('ip-rtt'); await waitFor(() => { - expect(result.current.rttMs).toBe(80); + expect(result.current.rttMs).toBe(40); + }); + + vi.mocked(window.electronAPI.hostLink.getSessionMeter).mockResolvedValue({ rttMs: 120 }); + rerender({ connectionType: 'tcp' }); + // Must not keep the prior HTTP RTT as TCP quality after the switch. + await waitFor(() => { + expect(result.current.rttMs).not.toBe(40); + expect(result.current.rttMs).toBe(120); }); - expect(window.electronAPI.hostLink.probeTcpRtt).toHaveBeenCalledWith('192.168.1.20', 5000); + expect(window.electronAPI.hostLink.probeTcpRtt).not.toHaveBeenCalled(); + expect(window.electronAPI.hostLink.getSessionMeter).toHaveBeenCalledWith('meshtastic'); }); it.each(['darwin', 'win32'] as const)('returns ble-rssi on %s', (platform) => { @@ -268,4 +326,82 @@ describe('useHostLinkMeter', () => { }); expect(result.current.rttMs).toBe(25); }); + + it('ignores stale session-meter results when a newer poll finishes first', async () => { + vi.useFakeTimers(); + let resolveSlow: ((value: { rttMs: number | null } | null) => void) | null = null; + let resolveFast: ((value: { rttMs: number | null } | null) => void) | null = null; + vi.mocked(window.electronAPI.hostLink.getSessionMeter) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSlow = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFast = resolve; + }), + ); + + const { result } = renderHook(() => + useHostLinkMeter({ + protocol: 'meshcore', + connectionType: 'http', + status: 'configured', + hostAddress: '192.168.1.20:5000', + 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?.({ rttMs: 30 }); + await Promise.resolve(); + }); + expect(result.current.rttMs).toBe(30); + + await act(async () => { + resolveSlow?.({ rttMs: 800 }); + await Promise.resolve(); + }); + expect(result.current.rttMs).toBe(30); + }); + + it('stops polling getSessionMeter after disconnect', async () => { + const { rerender } = renderHook( + (props: { status: 'configured' | 'disconnected' }) => + useHostLinkMeter({ + protocol: 'meshtastic', + connectionType: 'tcp', + status: props.status, + hostAddress: '10.0.0.5:4403', + platform: 'darwin', + }), + { initialProps: { status: 'configured' as 'configured' | 'disconnected' } }, + ); + await waitFor(() => { + expect(window.electronAPI.hostLink.getSessionMeter).toHaveBeenCalled(); + }); + const callsAfterConnect = vi.mocked(window.electronAPI.hostLink.getSessionMeter).mock.calls + .length; + + rerender({ status: 'disconnected' }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + expect(vi.mocked(window.electronAPI.hostLink.getSessionMeter).mock.calls.length).toBe( + callsAfterConnect, + ); + }); }); diff --git a/src/renderer/hooks/useHostLinkMeter.ts b/src/renderer/hooks/useHostLinkMeter.ts index 8f647fa06..d525cc9ac 100644 --- a/src/renderer/hooks/useHostLinkMeter.ts +++ b/src/renderer/hooks/useHostLinkMeter.ts @@ -4,8 +4,9 @@ import { useEffect, useState } from 'react'; import { type ConnectionLinkMeterKind, HOST_LINK_QUALITY_POLL_MS, + isLiveTcpSession, probeHttpLinkRttMs, - probeTcpLinkRttMs, + probeSessionMeter, rttToSignalLevel, type SignalBarLevel, } from '../lib/hostLinkQuality'; @@ -36,7 +37,8 @@ function isConnectedStatus(status: ConnectionStatus): boolean { /** * Host↔radio link meter state for Meshtastic / MeshCore Connection panels. - * BLE (darwin/win32): Noble link RSSI. BLE (linux): unavailable. HTTP/TCP: RTT probe. + * BLE (darwin/win32): Noble link RSSI. BLE (linux): unavailable. + * Live TCP sessions: passive session meter. Meshtastic HTTP: `/json/report` RTT. */ export function useHostLinkMeter(opts: { protocol: MeshProtocol; @@ -55,6 +57,9 @@ export function useHostLinkMeter(opts: { isConnectedStatus(status) && (connectionType === 'ble' || connectionType === 'http' || connectionType === 'tcp'); + const liveTcp = isLiveTcpSession(protocol, connectionType); + const meshtasticHttp = protocol === 'meshtastic' && connectionType === 'http'; + // BLE RSSI via Noble (macOS / Windows) useEffect(() => { if (!active || connectionType !== 'ble') { @@ -76,14 +81,14 @@ export function useHostLinkMeter(opts: { }; }, [active, connectionType, platform, protocol]); - // HTTP / TCP RTT probe + // HTTP probe or live-TCP session meter useEffect(() => { - if (!active || (connectionType !== 'http' && connectionType !== 'tcp')) { + if (!active || (!liveTcp && !meshtasticHttp)) { setRttMs(null); return; } - const address = hostAddress?.trim(); - if (!address) { + const httpAddress = hostAddress?.trim() ?? ''; + if (meshtasticHttp && !httpAddress) { setRttMs(null); return; } @@ -96,17 +101,16 @@ export function useHostLinkMeter(opts: { probeGeneration += 1; const generation = probeGeneration; 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 (liveTcp && (protocol === 'meshtastic' || protocol === 'meshcore')) { + next = await probeSessionMeter(protocol); + } else if (meshtasticHttp) { + next = await probeHttpLinkRttMs(httpAddress); } if (!cancelled && generation === probeGeneration) setRttMs(next); }; + // Clear immediately on transport switch so a prior HTTP RTT cannot flash as TCP quality. + setRttMs(null); void run(); timer = setInterval(() => { void run(); @@ -117,7 +121,7 @@ export function useHostLinkMeter(opts: { if (timer) clearInterval(timer); setRttMs(null); }; - }, [active, connectionType, hostAddress, protocol]); + }, [active, hostAddress, liveTcp, meshtasticHttp, protocol]); if (!active || !connectionType) return IDLE; diff --git a/src/renderer/lib/devElectronApiStub.ts b/src/renderer/lib/devElectronApiStub.ts index 2683ad98f..82b94aa47 100644 --- a/src/renderer/lib/devElectronApiStub.ts +++ b/src/renderer/lib/devElectronApiStub.ts @@ -255,6 +255,7 @@ export function createDevElectronApiStub(): typeof window.electronAPI { hostLink: { probeHttpRtt: async () => null, probeTcpRtt: async () => null, + getSessionMeter: async () => null, }, meshtastic: { tcp: { diff --git a/src/renderer/lib/hostLinkQuality.test.ts b/src/renderer/lib/hostLinkQuality.test.ts index 028ae475c..6b88b62ef 100644 --- a/src/renderer/lib/hostLinkQuality.test.ts +++ b/src/renderer/lib/hostLinkQuality.test.ts @@ -1,7 +1,13 @@ // @vitest-environment node import { describe, expect, it } from 'vitest'; -import { parseHttpProbeTarget, parseTcpProbeTarget, rttToSignalLevel } from './hostLinkQuality'; +import { + isLiveTcpSession, + parseHttpProbeTarget, + parseTcpProbeTarget, + rttToSignalLevel, +} from './hostLinkQuality'; +import type { ConnectionType, MeshProtocol } from './types'; describe('rttToSignalLevel', () => { it('maps latency buckets to 0–4 bars', () => { @@ -51,3 +57,21 @@ describe('parseTcpProbeTarget', () => { }); }); }); + +describe('isLiveTcpSession', () => { + it.each([ + ['meshtastic', 'tcp', true], + ['meshcore', 'http', true], + ['meshtastic', 'http', false], + ['meshcore', 'tcp', false], + ['meshcore', 'ble', false], + ['reticulum', 'ble', false], + ['reticulum', 'http', false], + ['meshtastic', null, false], + ] as const satisfies readonly (readonly [MeshProtocol, ConnectionType | null, boolean])[])( + '%s + %s → %s', + (protocol, connectionType, expected) => { + expect(isLiveTcpSession(protocol, connectionType)).toBe(expected); + }, + ); +}); diff --git a/src/renderer/lib/hostLinkQuality.ts b/src/renderer/lib/hostLinkQuality.ts index e15842bb8..60304213c 100644 --- a/src/renderer/lib/hostLinkQuality.ts +++ b/src/renderer/lib/hostLinkQuality.ts @@ -3,6 +3,7 @@ import { MS_PER_SECOND } from '@/shared/timeConstants'; import { parseMeshtasticTcpAddress } from './parseMeshtasticTcpAddress'; import { parseTcpAddress } from './parseTcpAddress'; +import type { ConnectionType, MeshProtocol } from './types'; /** Poll interval for host↔radio link quality (BLE RSSI / IP RTT). */ export const HOST_LINK_QUALITY_POLL_MS = 4 * MS_PER_SECOND; @@ -53,6 +54,9 @@ export interface ParsedTcpProbeTarget { * Parse a TCP probe target. * - `meshtastic`: default port 4403 (`parseMeshtasticTcpAddress`) * - `meshcore`: default port 5000 (`parseTcpAddress`) + * + * For Reticulum hubs / RMAP only. Do **not** use against Meshtastic/MeshCore live + * session ports — those use {@link probeSessionMeter} (competing connect causes RST). */ export function parseTcpProbeTarget( address: string, @@ -70,6 +74,20 @@ export function parseTcpProbeTarget( } } +/** + * True when the Connection panel transport is a live TCP session socket in main + * (`meshtastic:tcp-*` / `meshcore:tcp-*`). MeshCore SoftAP is stored as `http` + * (legacy enum) but is TCP on the wire. + */ +export function isLiveTcpSession( + protocol: MeshProtocol, + connectionType: ConnectionType | null, +): boolean { + if (protocol === 'meshtastic' && connectionType === 'tcp') return true; + if (protocol === 'meshcore' && connectionType === 'http') return true; + return false; +} + /** Probe Meshtastic HTTP `/json/report` RTT via main process. */ export async function probeHttpLinkRttMs(httpAddress: string): Promise { const target = parseHttpProbeTarget(httpAddress); @@ -88,7 +106,11 @@ export async function probeHttpLinkRttMs(httpAddress: string): Promise { + const api = window.electronAPI.hostLink.getSessionMeter; + if (typeof api !== 'function') return null; + try { + const snap = await api(protocol); + if (snap == null) return null; + const rtt = snap.rttMs; + return typeof rtt === 'number' && Number.isFinite(rtt) ? rtt : null; + } catch (err) { + console.debug( + '[hostLinkQuality] session meter read failed:', + err instanceof Error ? err.message : String(err), + ); + return null; + } +} diff --git a/src/renderer/vitest.electronApiMock.ts b/src/renderer/vitest.electronApiMock.ts index 0ac366c7e..4f9bd07c0 100644 --- a/src/renderer/vitest.electronApiMock.ts +++ b/src/renderer/vitest.electronApiMock.ts @@ -281,6 +281,7 @@ export function createElectronAPIMock(): ElectronAPI { hostLink: { probeHttpRtt: vi.fn().mockResolvedValue(null), probeTcpRtt: vi.fn().mockResolvedValue(null), + getSessionMeter: vi.fn().mockResolvedValue(null), }, meshtastic: { tcp: { diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index 3642f2306..fa58f14e1 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -1009,11 +1009,15 @@ export interface ElectronAPI { /** * Host↔radio link-quality probes (Connection panel meter). - * Returns RTT in ms, or null when the probe fails / times out. + * HTTP/TCP connect probes return RTT in ms, or null when the probe fails / times out. + * Live TCP sessions use getSessionMeter (passive write→data EWMA) — never a second connect. */ hostLink: { probeHttpRtt: (host: string, tls: boolean) => Promise; probeTcpRtt: (host: string, port: number) => Promise; + getSessionMeter: ( + protocol: 'meshtastic' | 'meshcore', + ) => Promise<{ rttMs: number | null } | null>; }; // ─── Meshtastic TCP bridge ────────────────────────────────────────────────────