Skip to content
Merged
27 changes: 27 additions & 0 deletions src/main/ble-coexistence-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,31 @@ describe('BleCoexistenceCoordinator', () => {
coordinator.assertCanConnect('noble:meshtastic', 'aa:bb:cc:dd:ee:01');
}).not.toThrow();
});

it('nests same-owner acquireScan so nested release does not drop the outer lease', async () => {
const noble = {
pauseScanningForExternalScan: vi.fn().mockResolvedValue(undefined),
resumeScanningAfterExternalScan: vi.fn().mockResolvedValue(undefined),
};
const coordinator = new BleCoexistenceCoordinator();
coordinator.setNobleManager(noble as never);

// Outer hold (Noble yield for BLE RNode connect).
await coordinator.acquireScan('reticulum');
expect(coordinator.getState().scanOwner).toBe('reticulum');

// Nested RSSI poll acquire — same owner.
await coordinator.acquireScan('reticulum');
expect(coordinator.getState().scanOwner).toBe('reticulum');

// Nested release must not drop the yield hold.
coordinator.releaseScan('reticulum');
expect(coordinator.getState().scanOwner).toBe('reticulum');
expect(noble.resumeScanningAfterExternalScan).not.toHaveBeenCalled();

// Outer release clears ownership.
coordinator.releaseScan('reticulum');
expect(coordinator.getState().scanOwner).toBeNull();
expect(noble.resumeScanningAfterExternalScan).toHaveBeenCalledTimes(1);
});
});
13 changes: 12 additions & 1 deletion src/main/ble-coexistence-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export { normalizeBleMac };
export class BleCoexistenceCoordinator {
private connections = new Map<string, BlePeripheralOwner>();
private scanOwner: BleScanOwner | null = null;
/** Nested same-owner acquires (e.g. Noble yield + RSSI poll) — release only at 0. */
private scanOwnerDepth = 0;
private nobleManager: NobleBleManager | null = null;
private nobleScanPausedForExternal = false;

Expand Down Expand Up @@ -98,7 +100,10 @@ export class BleCoexistenceCoordinator {
}

async acquireScan(owner: BleScanOwner): Promise<void> {
if (this.scanOwner === owner) return;
if (this.scanOwner === owner) {
this.scanOwnerDepth += 1;
return;
}
if (this.scanOwner !== null) {
throw new BleScanBusyError(this.scanOwner);
}
Expand All @@ -107,10 +112,16 @@ export class BleCoexistenceCoordinator {
this.nobleScanPausedForExternal = true;
}
this.scanOwner = owner;
this.scanOwnerDepth = 1;
}

releaseScan(owner: BleScanOwner): void {
if (this.scanOwner !== owner) return;
if (this.scanOwnerDepth > 1) {
this.scanOwnerDepth -= 1;
return;
}
this.scanOwnerDepth = 0;
this.scanOwner = null;
if (owner === 'reticulum' && this.nobleScanPausedForExternal && this.nobleManager) {
this.nobleScanPausedForExternal = false;
Expand Down
5 changes: 3 additions & 2 deletions src/renderer/components/ReticulumStackPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ export function ReticulumStackPanel({
beginBleConnectGrace,
handleSidecarEvent,
} = useReticulumInterfaceSnapshot({
sidecarApiReady,
pollActive: sidecarApiReady && sidecarUiRunning,
sidecarRunning: sidecarUiRunning,
pollActive: sidecarUiRunning,
});

useEffect(() => {
Expand Down Expand Up @@ -284,6 +284,7 @@ export function ReticulumStackPanel({
/>
<ReticulumInterfacesPanel
sidecarApiReady={sidecarApiReady}
sidecarRunning={sidecarUiRunning}
connecting={connecting}
identityConfigured={identity?.configured === true}
identityDisplayName={identity?.display_name ?? null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,60 @@ describe('ReticulumInterfacesPanel', () => {
});
});

it('seeds BLE RSSI while sidecar is running during connecting (api not ready)', 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: -59, 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(
<ReticulumInterfacesPanel
{...defaultProps}
sidecarApiReady={false}
sidecarRunning
connecting
interfaces={[
{
id: 'rnode-ble',
name: 'RNode BLE',
type: 'rnode',
enabled: true,
status: 'up',
serial_port: 'ble://AA:BB:CC:DD:EE:FF',
},
]}
/>,
);

await waitFor(() => {
expect(
within(screen.getByTestId('reticulum-ble-signal-rnode-ble')).getByText(
'connectionPanel.bleRssiDbm',
),
).toBeInTheDocument();
});
expect(window.electronAPI.reticulum.proxyGet).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/ble/scan'),
);
});

it('shows Link quality on enabled TCP Client rows', async () => {
render(<ReticulumInterfacesPanel {...defaultProps} interfaces={[rmapWorldHub]} />);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ type ReticulumIfaceUiType =

export interface ReticulumInterfacesPanelProps {
sidecarApiReady: boolean;
/** Sidecar process up — BLE RNode RSSI polls during connect, not only when API-ready. */
sidecarRunning?: boolean;
connecting: boolean;
identityConfigured?: boolean;
identityDisplayName?: string | null;
Expand All @@ -170,6 +172,7 @@ export interface ReticulumInterfacesPanelProps {
/** Connection tab: Reticulum interface list, add/edit/delete, device picker. */
export function ReticulumInterfacesPanel({
sidecarApiReady,
sidecarRunning,
connecting,
identityConfigured = true,
identityDisplayName = null,
Expand All @@ -182,6 +185,7 @@ export function ReticulumInterfacesPanel({
onRefresh,
onBeginBleConnectGrace,
}: ReticulumInterfacesPanelProps) {
const sidecarRunningForRssi = sidecarRunning ?? sidecarApiReady;
const { t } = useTranslation();
const { addToast } = useToast();
const [ifaceType, setIfaceType] = useState<ReticulumIfaceUiType>('tcp');
Expand Down Expand Up @@ -810,6 +814,7 @@ export function ReticulumInterfacesPanel({
bleBondRemovedNames={bleBondRemovedNames}
effectivePrimaryLocalSerialInterfaceId={effectivePrimaryLocalSerialInterfaceId}
sidecarReady={sidecarApiReady}
sidecarRunning={sidecarRunningForRssi}
actionsDisabled={actionsDisabled}
ifaceType={ifaceType}
ifaceMode={ifaceMode}
Expand Down Expand Up @@ -1651,6 +1656,7 @@ function InterfacesSection({
bleBondRemovedNames,
effectivePrimaryLocalSerialInterfaceId,
sidecarReady,
sidecarRunning,
actionsDisabled,
ifaceType,
ifaceMode,
Expand Down Expand Up @@ -1710,6 +1716,7 @@ function InterfacesSection({
bleBondRemovedNames?: readonly string[];
effectivePrimaryLocalSerialInterfaceId: string | null;
sidecarReady: boolean;
sidecarRunning: boolean;
actionsDisabled: boolean;
ifaceType: ReticulumIfaceUiType;
ifaceMode: string;
Expand Down Expand Up @@ -1769,7 +1776,7 @@ function InterfacesSection({
}) {
const { t } = useTranslation();
const purposeIconTrigger = useIconTrigger();
const bleRnodeRssiByAddress = useReticulumBleRnodeRssiMap(interfaces, sidecarReady);
const bleRnodeRssiByAddress = useReticulumBleRnodeRssiMap(interfaces, sidecarRunning);
const tcpRttById = useReticulumTcpLinkQualityMap(interfaces, sidecarReady);
const enabledLocalSerialCount = countEnabledLocallyConnectedSerialInterfaces(interfaces);
const showPrimaryControls = enabledLocalSerialCount >= 2;
Expand Down
125 changes: 124 additions & 1 deletion src/renderer/hooks/useReticulumBleRnodeRssiMap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ describe('rssiForReticulumBleRnodeRow', () => {

describe('useReticulumBleRnodeRssiMap', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
window.electronAPI.bleCoexistence.acquireScan = vi.fn().mockResolvedValue({});
window.electronAPI.bleCoexistence.releaseScan = vi.fn().mockResolvedValue({});
window.electronAPI.reticulum.proxyGet = vi.fn().mockImplementation((path: string) => {
Expand All @@ -82,6 +83,7 @@ describe('useReticulumBleRnodeRssiMap', () => {
});

afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});

Expand All @@ -107,7 +109,7 @@ describe('useReticulumBleRnodeRssiMap', () => {
expect(window.electronAPI.bleCoexistence.releaseScan).toHaveBeenCalledWith('reticulum');
});

it('stays empty when sidecar is not ready', () => {
it('stays empty when sidecar is not running', () => {
const { result } = renderHook(() =>
useReticulumBleRnodeRssiMap(
[
Expand Down Expand Up @@ -172,4 +174,125 @@ describe('useReticulumBleRnodeRssiMap', () => {
});
expect(scanCalls()).toBe(afterFirst);
});

it('preserves last RSSI when a later scan omits the address', async () => {
let scanCount = 0;
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')) {
scanCount += 1;
if (scanCount === 1) {
return Promise.resolve({
devices: [{ address: 'AA:BB:CC:DD:EE:FF', rssi: -62, kind: 'rnode' }],
});
}
return Promise.resolve({ devices: [] });
}
return Promise.resolve({});
});

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(-62);
});

await act(async () => {
await vi.advanceTimersByTimeAsync(15_000);
});

await waitFor(() => {
expect(scanCount).toBeGreaterThanOrEqual(2);
});
expect(result.current.get('aa:bb:cc:dd:ee:ff')).toBe(-62);
});

it('does not clear RSSI map on brief empty interface hydrate while sidecar is running', async () => {
const { result, rerender } = renderHook(
({ ifaces }: { ifaces: Parameters<typeof useReticulumBleRnodeRssiMap>[0] }) =>
useReticulumBleRnodeRssiMap(ifaces, true),
{
initialProps: {
ifaces: [
{
id: '1',
enabled: true,
type: 'rnode',
serial_port: 'ble://AA:BB:CC:DD:EE:FF',
},
],
},
},
);

await waitFor(() => {
expect(result.current.get('aa:bb:cc:dd:ee:ff')).toBe(-58);
});

act(() => {
rerender({ ifaces: [] });
});
expect(result.current.get('aa:bb:cc:dd:ee:ff')).toBe(-58);
});

it('bursts until first sample then steadies', async () => {
let scanCount = 0;
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')) {
scanCount += 1;
if (scanCount < 3) {
return Promise.resolve({ devices: [] });
}
return Promise.resolve({
devices: [{ address: 'AA:BB:CC:DD:EE:FF', rssi: -55, kind: 'rnode' }],
});
}
return Promise.resolve({});
});

const { result } = renderHook(() =>
useReticulumBleRnodeRssiMap(
[
{
id: '1',
enabled: true,
type: 'rnode',
serial_port: 'ble://AA:BB:CC:DD:EE:FF',
},
],
true,
),
);

await act(async () => {
await vi.advanceTimersByTimeAsync(3_000);
});
expect(result.current.get('aa:bb:cc:dd:ee:ff')).toBeUndefined();

await act(async () => {
await vi.advanceTimersByTimeAsync(3_000);
});

await waitFor(() => {
expect(result.current.get('aa:bb:cc:dd:ee:ff')).toBe(-55);
});
expect(scanCount).toBeGreaterThanOrEqual(3);
});
});
Loading