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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/credits.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- [Soord](https://github.com/soord)
- [WB3IHY](https://github.com/WB3IHY)
- [Letark](https://github.com/Letark) - Apple code signing & notarization CI
- FuzzyChaos (ADL) - Donation for devices

## Colorado Mesh

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"megabear - KD5IHC",
"Soord https://github.com/soord",
"WB3IHY https://github.com/WB3IHY",
"Letark https://github.com/Letark"
"Letark https://github.com/Letark",
"FuzzyChaos (ADL)"
],
"main": "dist-electron/main/index.js",
"scripts": {
Expand Down
106 changes: 106 additions & 0 deletions src/renderer/components/ConnectionPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1577,6 +1577,112 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => {
}
});

// OS-specific: dual-Noble deferred BLE auto-connect (Cancel-before-primary-settle) exists only
// on macOS/Windows Noble. Linux uses Web Bluetooth and skips remembered BLE cold-start in
// useProtocolRfAutoConnect (`!isLinux`) — there is no equivalent dual-radio deferral to cancel.
it.each(['darwin', 'win32'] as const)(
'cancels deferred meshcore BLE auto-connect when user cancels before primary settle completes (%s)',
async (platform) => {
const { restore } = mockNobleBlePlatform(platform);
const mcConnKey = 'mesh-client:lastConnection:meshcore';
const mtConnKey = 'mesh-client:lastConnection:meshtastic';
localStorage.setItem(protocolKey, 'meshtastic');
localStorage.setItem('mesh-client:lastBleDevice:meshcore', 'meshcore-ble-device');
localStorage.setItem('mesh-client:lastBleDevice:meshtastic', 'meshtastic-ble-device');
localStorage.setItem(
mcConnKey,
JSON.stringify({ type: 'ble', bleDeviceId: 'meshcore-ble-device' }),
);
localStorage.setItem(
mtConnKey,
JSON.stringify({ type: 'ble', bleDeviceId: 'meshtastic-ble-device' }),
);
const onAutoConnect = vi.fn().mockResolvedValue(undefined);
const dualNoble = await import('../lib/meshcoreDualNobleBleInit');
dualNoble.resetNobleBleConnectMutexForTests();
dualNoble.initNobleBleDualRadioStartup();
let releaseSettle!: () => void;
vi.spyOn(dualNoble, 'awaitNobleBlePrimaryAutoConnectSettled').mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseSettle = resolve;
}),
);

const user = userEvent.setup();
try {
render(
<ConnectionPanel
state={disconnectedState}
onConnect={vi.fn().mockResolvedValue(undefined)}
onAutoConnect={onAutoConnect}
onDisconnect={vi.fn().mockResolvedValue(undefined)}
mqttStatus="disconnected"
protocol="meshcore"
/>,
);

// Secondary BLE waits on Meshtastic — connecting UI exposes Cancel.
const cancelBtn = await screen.findByRole('button', { name: /^Cancel$/i });
await user.click(cancelBtn);

releaseSettle();
await Promise.resolve();
await Promise.resolve();
expect(onAutoConnect).not.toHaveBeenCalled();
} finally {
localStorage.removeItem(mcConnKey);
localStorage.removeItem(mtConnKey);
localStorage.removeItem('mesh-client:lastBleDevice:meshcore');
localStorage.removeItem('mesh-client:lastBleDevice:meshtastic');
dualNoble.resetNobleBleConnectMutexForTests();
restore();
}
},
);

it('cancels ProtocolAutoConnectCoordinator when user clicks Reconnect with a pending last connection', async () => {
const user = userEvent.setup();
const lastConnKey = 'mesh-client:lastConnection:meshtastic';
localStorage.setItem(
lastConnKey,
JSON.stringify({ type: 'tcp', httpAddress: '192.168.1.50:4403' }),
);
const gate = await import('../lib/protocolRfAutoConnectGate');
const cancelSpy = vi.spyOn(gate, 'cancelProtocolRfAutoConnect');
const onConnect = vi.fn().mockResolvedValue(undefined);

try {
render(
<ConnectionPanel
state={disconnectedState}
onConnect={onConnect}
onAutoConnect={vi.fn().mockResolvedValue(undefined)}
onDisconnect={vi.fn().mockResolvedValue(undefined)}
mqttStatus="disconnected"
protocol="meshtastic"
suppressMountAutoConnect
/>,
);

await user.click(await screen.findByRole('button', { name: /^Reconnect$/i }));

expect(cancelSpy).toHaveBeenCalledWith('meshtastic');
await waitFor(() => {
expect(onConnect).toHaveBeenCalledWith('tcp', '192.168.1.50:4403');
});
const cancelOrder = cancelSpy.mock.invocationCallOrder[0];
const connectOrder = onConnect.mock.invocationCallOrder[0];
if (cancelOrder === undefined || connectOrder === undefined) {
throw new Error('expected cancelProtocolRfAutoConnect and onConnect call order');
}
expect(cancelOrder).toBeLessThan(connectOrder);
} finally {
cancelSpy.mockRestore();
localStorage.removeItem(lastConnKey);
}
});

it('shows shared-peripheral notice when meshcore is active and targets the same BLE device as meshtastic', async () => {
const { restore } = mockMacNoblePlatform();
const sharedId = 'shared-ble-peripheral';
Expand Down
80 changes: 73 additions & 7 deletions src/renderer/components/ConnectionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { markMqttUserDisconnect } from '@/renderer/lib/mqttDisconnectIntent';
import { mqttUsesTls } from '@/renderer/lib/mqttTls';
import { parseTcpAddress } from '@/renderer/lib/parseTcpAddress';
import { cancelProtocolRfAutoConnect } from '@/renderer/lib/protocolRfAutoConnectGate';
import { useRadioProvider } from '@/renderer/lib/radio/providerFactory';
import type { RfConnectAutomaticFn, RfConnectFn } from '@/renderer/lib/rfConnectionTypes';
import { isPairingRelatedError } from '@/shared/blePairingError';
Expand Down Expand Up @@ -704,6 +705,8 @@ export default function ConnectionPanel({
);
const autoConnectFiredRef = useRef(false);
const autoConnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** Set when the user starts a manual connect so deferred BLE auto-connect must not call onAutoConnect. */
const autoConnectCancelRef = useRef(false);
const isAutoConnectingRef = useRef(false);
const [isAutoConnecting, setIsAutoConnecting] = useState(false);
const [autoConnectBleTarget, setAutoConnectBleTarget] = useState<string | null>(null);
Expand Down Expand Up @@ -1164,6 +1167,18 @@ export default function ConnectionPanel({
}, [isAutoConnecting, lastConnection]);

const handleConnect = useCallback(async () => {
// Cancel deferred dual-Noble BLE auto-connect so it cannot race prepareRfConnect against
// a manual TCP/serial/HTTP connect (orphan TCP socket + connectType flip).
// Panel-local autoConnectCancelRef is inert when suppressMountAutoConnect — also cancel the
// ProtocolAutoConnectCoordinator path.
autoConnectCancelRef.current = true;
cancelProtocolRfAutoConnect(protocol);
if (isAutoConnectingRef.current) {
console.debug('[ConnectionPanel] cancelling in-flight BLE auto-connect for manual connect');
}
isAutoConnectingRef.current = false;
setIsAutoConnecting(false);
setAutoConnectBleTarget(null);
if (autoConnectTimeoutRef.current) {
clearTimeout(autoConnectTimeoutRef.current);
autoConnectTimeoutRef.current = null;
Expand Down Expand Up @@ -1283,6 +1298,8 @@ export default function ConnectionPanel({
}, [connectionType, activeHostAddress, onConnect, protocol, isLinux, t]);

const handleCancelConnection = useCallback(async () => {
autoConnectCancelRef.current = true;
cancelProtocolRfAutoConnect(protocol);
isAutoConnectingRef.current = false;
setIsAutoConnecting(false);
if (autoConnectTimeoutRef.current) {
Expand Down Expand Up @@ -1444,6 +1461,7 @@ export default function ConnectionPanel({
}

autoConnectFiredRef.current = true;
autoConnectCancelRef.current = false;

const lastBleId = lc.bleDeviceId ?? loadLastBleDevice(protocol);

Expand Down Expand Up @@ -1492,6 +1510,10 @@ export default function ConnectionPanel({
return false;
}
void (async () => {
if (autoConnectCancelRef.current) {
maybeNotifyPrimaryBleAutoConnectSettled();
return;
}
const bleTargetLabel = resolveBleAutoConnectLabel(
lastBleId,
lc,
Expand All @@ -1506,6 +1528,9 @@ export default function ConnectionPanel({
setConnectionStage('connectionPanel.stageConnecting');
// Primary: notify secondary after the first connect attempt (not after scan fallback).
await reconnectBleWithScan(protocol, lastBleId, () => {
if (autoConnectCancelRef.current) {
return Promise.reject(new DOMException('Auto-connect cancelled', 'AbortError'));
}
const attempt = onAutoConnectRef.current('ble', undefined, undefined, lastBleId);
if (
dualNobleBleBothRadiosConfigured() &&
Expand All @@ -1519,6 +1544,10 @@ export default function ConnectionPanel({
}
return attempt;
});
if (autoConnectCancelRef.current) {
maybeNotifyPrimaryBleAutoConnectSettled();
return;
}
isAutoConnectingRef.current = false;
setIsAutoConnecting(false);
setConnecting(false);
Expand Down Expand Up @@ -1568,11 +1597,26 @@ export default function ConnectionPanel({
: STAGE_WAITING_NOBLE_BLE_MESHCORE,
);
await awaitNobleBlePrimaryAutoConnectSettled(POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS);
if (autoConnectCancelRef.current) {
console.debug(
`[ConnectionPanel] ${protocol} secondary BLE auto-connect cancelled after primary settle`,
);
isAutoConnectingRef.current = false;
setIsAutoConnecting(false);
setAutoConnectBleTarget(null);
setConnecting(false);
setConnectionStage('');
return;
}
setConnectionStage('connectionPanel.stageConnecting');
void reconnectBleWithScan(protocol, bleId, () =>
onAutoConnectRef.current('ble', undefined, undefined, bleId),
)
void reconnectBleWithScan(protocol, bleId, () => {
if (autoConnectCancelRef.current) {
return Promise.reject(new DOMException('Auto-connect cancelled', 'AbortError'));
}
return onAutoConnectRef.current('ble', undefined, undefined, bleId);
})
.then(() => {
if (autoConnectCancelRef.current) return;
isAutoConnectingRef.current = false;
setIsAutoConnecting(false);
setConnecting(false);
Expand Down Expand Up @@ -1644,7 +1688,8 @@ export default function ConnectionPanel({
} else {
maybeNotifyPrimaryBleAutoConnectSettled();
}
// HTTP: do not auto-trigger — show one-click reconnect card instead
// HTTP/TCP launch auto-connect is owned by ProtocolAutoConnectCoordinator /
// useProtocolRfAutoConnect; this panel path only settles (reconnect card if needed).
}, [protocol, isLinux, t, capabilities.hasReticulumInterfaceConfig, suppressMountAutoConnect]);

// Cleanup timeout on unmount
Expand All @@ -1657,6 +1702,20 @@ export default function ConnectionPanel({

const handleReconnect = useCallback(() => {
if (!lastConnection) return;
// Same cancel as handleConnect — Reconnect must not race deferred ProtocolAutoConnectCoordinator
// BLE/serial auto-connect (orphan socket / connectType flip).
autoConnectCancelRef.current = true;
cancelProtocolRfAutoConnect(protocol);
if (isAutoConnectingRef.current) {
console.debug('[ConnectionPanel] cancelling in-flight BLE auto-connect for reconnect');
}
isAutoConnectingRef.current = false;
setIsAutoConnecting(false);
setAutoConnectBleTarget(null);
if (autoConnectTimeoutRef.current) {
clearTimeout(autoConnectTimeoutRef.current);
autoConnectTimeoutRef.current = null;
}
setError(null);

if (lastConnection.type === 'ble') {
Expand Down Expand Up @@ -1827,10 +1886,16 @@ export default function ConnectionPanel({
if (!rfBusy || !isRendererNobleBlePlatform()) return;

if (nobleBleMutexWait.waitingOnNobleBlePeer) {
const primary = nobleBleMutexWait.primaryProtocol;
if (primary === 'meshtastic') {
// Mutex peer wait: show who holds the mutex (`active`), not dual-radio primary.
// Using primaryProtocol alone made MeshCore show "Waiting for MeshCore… Meshtastic will
// connect" while MeshCore itself was queued behind Meshtastic GATT.
const waitingFor =
nobleBleMutexWait.waitingForPeer && nobleBleMutexWait.active
? nobleBleMutexWait.active
: nobleBleMutexWait.primaryProtocol;
if (waitingFor === 'meshtastic') {
setConnectionStage(STAGE_WAITING_NOBLE_BLE_MESHTASTIC);
} else if (primary === 'meshcore') {
} else if (waitingFor === 'meshcore') {
setConnectionStage(STAGE_WAITING_NOBLE_BLE_MESHCORE);
}
return;
Expand All @@ -1849,6 +1914,7 @@ export default function ConnectionPanel({
state.status,
protocol,
nobleBleMutexWait.waitingOnNobleBlePeer,
nobleBleMutexWait.waitingForPeer,
nobleBleMutexWait.active,
nobleBleMutexWait.primaryProtocol,
connectionStage,
Expand Down
Loading