Skip to content

Commit

Permalink
Connection flow tweaks to deal with unusual cases (#220)
Browse files Browse the repository at this point in the history
- Wait for gatt.connect promise in order to show micro:bit connecting dialog
- Wait for the micro:bit to be in a good state after disconnecting before reconnecting (Bluetooth connection on Windows only)
- Add code to show a reasonable connection help dialog on an initial connect failure as opposed to a reconnect failure
- Improve error types and use these to show the right connect help dialog
- Add a timeout around bluetooth.requestDevice - this addresses a bug where the browser device selection prompt is not shown. We instead reload the page and show our connection dialog at the appropriate part of the flow.
  • Loading branch information
microbit-robert committed Feb 10, 2024
1 parent 1130579 commit de5443e
Show file tree
Hide file tree
Showing 14 changed files with 170 additions and 69 deletions.
11 changes: 11 additions & 0 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,22 @@
import ConnectDialogContainer from './components/connection-prompt/ConnectDialogContainer.svelte';
import { Paths, currentPath, getTitle, navigate } from './router/paths';
import HomeIcon from 'virtual:icons/ri/home-2-line';
import { btSelectMicrobitDialogOnLoad } from './script/stores/connectionStore';
import {
ConnectDialogStates,
connectionDialogState,
} from './script/stores/connectDialogStore';
onMount(() => {
const { bluetooth, usb } = get(compatibility);
// Value must switch from false to true after mount to trigger dialog transition
isCompatibilityWarningDialogOpen.set(!bluetooth && !usb);
if ($btSelectMicrobitDialogOnLoad) {
$connectionDialogState.connectionState =
ConnectDialogStates.CONNECT_TUTORIAL_BLUETOOTH;
$btSelectMicrobitDialogOnLoad = false;
}
});
let routeAnnouncementEl: HTMLDivElement | undefined;
Expand Down
1 change: 1 addition & 0 deletions src/StaticConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export enum HexOrigin {

class StaticConfiguration {
public static readonly connectTimeoutDuration: number = 10000;
public static readonly requestDeviceTimeoutDuration: number = 30000;

// After how long should we consider the connection lost if ping was not able to conclude?
public static readonly connectionLostTimeoutDuration: number = 3000;
Expand Down
4 changes: 2 additions & 2 deletions src/components/PleaseConnectFirst.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import StandardButton from './StandardButton.svelte';
const handleInputConnect = async () => {
if ($state.showReconnectHelp || Microbits.getInputMicrobit()) {
if ($state.showConnectHelp || Microbits.getInputMicrobit()) {
reconnect();
} else {
startConnectionProcess();
Expand All @@ -35,7 +35,7 @@
disabled={$state.reconnectState.reconnecting}
onClick={handleInputConnect}
>{$t(
$state.showReconnectHelp || Microbits.getInputMicrobit()
$state.showConnectHelp || Microbits.getInputMicrobit()
? 'actions.reconnect'
: 'footer.connectButton',
)}</StandardButton>
Expand Down
72 changes: 49 additions & 23 deletions src/components/ReconnectHelp.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
import { state } from '../script/stores/uiStore';
import { reconnect } from '../script/utils/reconnect';
import StandardDialog from './dialogs/StandardDialog.svelte';
import { stateOnHideReconnectHelp } from '../script/microbit-interfacing/state-updaters';
import { stateOnHideConnectHelp } from '../script/microbit-interfacing/state-updaters';
import { startConnectionProcess } from '../script/stores/connectDialogStore';
export let isOpen: boolean = false;
Expand All @@ -20,13 +21,17 @@
case 'bluetooth': {
return {
heading:
$state.showReconnectHelp === 'userTriggered'
? 'reconnectFailed.bluetoothHeading'
: 'disconnectedWarning.bluetoothHeading',
$state.showConnectHelp === 'connect'
? 'connectFailed.bluetoothHeading'
: $state.showConnectHelp === 'userReconnect'
? 'reconnectFailed.bluetoothHeading'
: 'disconnectedWarning.bluetoothHeading',
subtitle:
$state.showReconnectHelp === 'userTriggered'
? 'reconnectFailed.bluetooth1'
: 'disconnectedWarning.bluetooth1',
$state.showConnectHelp === 'connect'
? 'connectFailed.bluetooth1'
: $state.showConnectHelp === 'userReconnect'
? 'reconnectFailed.bluetooth1'
: 'disconnectedWarning.bluetooth1',
listHeading: 'disconnectedWarning.bluetooth2',
bulletOne: 'disconnectedWarning.bluetooth3',
bulletTwo: 'disconnectedWarning.bluetooth4',
Expand All @@ -35,13 +40,17 @@
case 'bridge': {
return {
heading:
$state.showReconnectHelp === 'userTriggered'
? 'reconnectFailed.bridgeHeading'
: 'disconnectedWarning.bridgeHeading',
$state.showConnectHelp === 'connect'
? 'connectFailed.bridgeHeading'
: $state.showConnectHelp === 'userReconnect'
? 'reconnectFailed.bridgeHeading'
: 'disconnectedWarning.bridgeHeading',
subtitle:
$state.showReconnectHelp === 'userTriggered'
? 'reconnectFailed.bridge1'
: 'disconnectedWarning.bridge1',
$state.showConnectHelp === 'connect'
? 'connectFailed.bridge1'
: $state.showConnectHelp === 'userReconnect'
? 'reconnectFailed.bridge1'
: 'disconnectedWarning.bridge1',
listHeading: 'connectMB.usbTryAgain.replugMicrobit2',
bulletOne: 'connectMB.usbTryAgain.replugMicrobit3',
bulletTwo: 'connectMB.usbTryAgain.replugMicrobit4',
Expand All @@ -50,13 +59,17 @@
case 'remote': {
return {
heading:
$state.showReconnectHelp === 'userTriggered'
? 'reconnectFailed.remoteHeading'
: 'disconnectedWarning.remoteHeading',
$state.showConnectHelp === 'connect'
? 'connectFailed.remoteHeading'
: $state.showConnectHelp === 'userReconnect'
? 'reconnectFailed.remoteHeading'
: 'disconnectedWarning.remoteHeading',
subtitle:
$state.showReconnectHelp === 'userTriggered'
? 'reconnectFailed.remote1'
: 'disconnectedWarning.remote1',
$state.showConnectHelp === 'connect'
? 'connectFailed.remote1'
: $state.showConnectHelp === 'userReconnect'
? 'reconnectFailed.remote1'
: 'disconnectedWarning.remote1',
listHeading: 'disconnectedWarning.bluetooth2',
bulletOne: 'disconnectedWarning.bluetooth3',
bulletTwo: 'disconnectedWarning.bluetooth4',
Expand All @@ -73,10 +86,19 @@
}
}
})();
const handleReconnect = () => {
if ($state.showConnectHelp === 'connect') {
stateOnHideConnectHelp();
startConnectionProcess();
} else {
reconnect(true);
}
};
</script>

{#if $state.reconnectState.connectionType !== 'none'}
<StandardDialog {isOpen} onClose={stateOnHideReconnectHelp} class="w-150 space-y-5">
<StandardDialog {isOpen} onClose={stateOnHideConnectHelp} class="w-150 space-y-5">
<svelte:fragment slot="heading">
{$t(content.heading)}
</svelte:fragment>
Expand All @@ -100,10 +122,14 @@
{$t('connectMB.troubleshooting')}
<ExternalLinkIcon />
</a>
<StandardButton onClick={stateOnHideReconnectHelp}
<StandardButton onClick={stateOnHideConnectHelp}
>{$t('actions.cancel')}</StandardButton>
<StandardButton type="primary" onClick={() => reconnect(true)}
>{$t('actions.reconnect')}</StandardButton>
<StandardButton type="primary" onClick={handleReconnect}
>{$t(
$state.showConnectHelp === 'connect'
? 'footer.connectButton'
: 'actions.reconnect',
)}</StandardButton>
</div>
</svelte:fragment>
</StandardDialog>
Expand Down
4 changes: 2 additions & 2 deletions src/components/bottom/ConnectedLiveGraphButtons.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
};
const handleInputConnect = async () => {
if ($state.showReconnectHelp || Microbits.getInputMicrobit()) {
if ($state.showConnectHelp || Microbits.getInputMicrobit()) {
reconnect();
} else {
startConnectionProcess();
Expand Down Expand Up @@ -55,7 +55,7 @@
disabled={$state.reconnectState.reconnecting}
size="small"
>{$t(
$state.showReconnectHelp || Microbits.getInputMicrobit()
$state.showConnectHelp || Microbits.getInputMicrobit()
? 'actions.reconnect'
: 'footer.connectButton',
)}</StandardButton>
Expand Down
11 changes: 8 additions & 3 deletions src/components/dialogs/StandardDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@
}
};
const onOpenChange: CreateDialogProps['onOpenChange'] = ({ next }) => {
if (!next) {
const onOpenChange: CreateDialogProps['onOpenChange'] = ({ curr, next }) => {
// Use curr so we don't call onCloseDialog() on page load.
if (curr && !next) {
onCloseDialog();
} else {
onOpenDialog();
Expand Down Expand Up @@ -73,9 +74,13 @@
const sync = createSync(states);
$: sync.open(isOpen, v => (isOpen = v));
let prevOpen: boolean;
$: if (isOpen) {
onOpenDialog();
} else {
prevOpen = isOpen;
} else if (prevOpen && !isOpen) {
// Use prevOpen so we don't call onCloseDialog() on page load.
prevOpen = isOpen;
onCloseDialog();
}
Expand Down
6 changes: 6 additions & 0 deletions src/messages/ui.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,17 @@
"connectMB.reconnecting": "Reconnecting…",
"connectMB.bluetooth.invalidPattern": "The pattern you have drawn is invalid.",

"connectFailed.bluetoothHeading": "Failed to connect to micro:bit",
"connectFailed.bluetooth1": "The connection to the micro:bit could not be established.",
"reconnectFailed.radioHeading": "Failed to reconnect to micro:bits",
"reconnectFailed.bluetoothHeading": "Failed to reconnect to micro:bit",
"reconnectFailed.bluetooth1": "The connection to the micro:bit could not be re-established.",
"connectFailed.remoteHeading": "Failed to connect to micro:bit 1",
"connectFailed.remote1": "The connection to the micro:bit you are wearing could not be established.",
"reconnectFailed.remoteHeading": "Failed to reconnect to micro:bit 1",
"reconnectFailed.remote1": "The connection to the micro:bit you are wearing could not be re-established.",
"connectFailed.bridgeHeading": "Failed to connect to micro:bit 2",
"connectFailed.bridge1": "The connection to the micro:bit connected to your computer could not be established.",
"reconnectFailed.bridgeHeading": "Failed to reconnect to micro:bit 2",
"reconnectFailed.bridge1": "The connection to the micro:bit connected to your computer could not be re-established.",
"reconnectFailed.subtitle": "Follow these instructions to restart the connection process.",
Expand Down
65 changes: 52 additions & 13 deletions src/script/microbit-interfacing/MicrobitBluetooth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* SPDX-License-Identifier: MIT
*/

import Bowser from 'bowser';
import StaticConfiguration from '../../StaticConfiguration';
import { outputting } from '../stores/uiStore';
import { logError, logMessage } from '../utils/logging';
Expand All @@ -22,6 +23,10 @@ import {
stateOnReady,
stateOnReconnectionAttempt,
} from './state-updaters';
import { btSelectMicrobitDialogOnLoad } from '../stores/connectionStore';

const browser = Bowser.getParser(window.navigator.userAgent);
const isWindowsOS = browser.getOSName() === 'Windows';

/**
* UART data target. For fixing type compatibility issues.
Expand Down Expand Up @@ -57,6 +62,8 @@ export class MicrobitBluetooth implements MicrobitConnection {
private gattConnectPromise: Promise<MBSpecs.MBVersion | undefined> | undefined;
private disconnectPromise: Promise<unknown> | undefined;
private connecting = false;
private isReconnect = false;
private reconnectReadyPromise: Promise<void> | undefined;

private outputWriteQueue: {
busy: boolean;
Expand All @@ -77,6 +84,10 @@ export class MicrobitBluetooth implements MicrobitConnection {
logMessage('Bluetooth connect', states);
if (this.duringExplicitConnectDisconnect) {
logMessage('Skipping connect attempt when one is already in progress');
// Wait for the gattConnectPromise while showing a "connecting" dialog.
// If the user clicks disconnect while the automatic reconnect is in progress,
// then clicks reconnect, we need to wait rather than return immediately.
await this.gattConnectPromise;
return;
}
this.duringExplicitConnectDisconnect++;
Expand Down Expand Up @@ -188,16 +199,29 @@ export class MicrobitBluetooth implements MicrobitConnection {
} finally {
this.duringExplicitConnectDisconnect--;
}
this.reconnectReadyPromise = new Promise(resolve => setTimeout(resolve, 3_500));
if (updateState) {
this.inUseAs.forEach(value =>
stateOnDisconnected(value, userTriggered, 'bluetooth'),
stateOnDisconnected(
value,
userTriggered ? false : this.isReconnect ? 'autoReconnect' : 'connect',
'bluetooth',
),
);
}
}

async reconnect(): Promise<void> {
logMessage('Bluetooth reconnect');
this.isReconnect = true;
const as = Array.from(this.inUseAs);
if (isWindowsOS) {
// On Windows, the micro:bit can take around 3 seconds to respond to gatt.disconnect().
// Attempting to reconnect before the micro:bit has responded results in another
// gattserverdisconnected event being fired. We then fail to get primaryService on a
// disconnected GATT server.
await this.reconnectReadyPromise;
}
await this.connect(...as);
}

Expand All @@ -214,7 +238,7 @@ export class MicrobitBluetooth implements MicrobitConnection {
}
} catch (e) {
logError('Bluetooth connect triggered by disconnect listener failed', e);
this.inUseAs.forEach(s => stateOnDisconnected(s, false, 'bluetooth'));
this.inUseAs.forEach(s => stateOnDisconnected(s, 'autoReconnect', 'bluetooth'));
}
};

Expand Down Expand Up @@ -486,17 +510,32 @@ export const startBluetoothConnection = async (

const requestDevice = async (name: string): Promise<BluetoothDevice | undefined> => {
try {
return navigator.bluetooth.requestDevice({
filters: [{ namePrefix: `BBC micro:bit [${name}]` }],
optionalServices: [
MBSpecs.Services.UART_SERVICE,
MBSpecs.Services.ACCEL_SERVICE,
MBSpecs.Services.DEVICE_INFO_SERVICE,
MBSpecs.Services.LED_SERVICE,
MBSpecs.Services.IO_SERVICE,
MBSpecs.Services.BUTTON_SERVICE,
],
});
// In some situations the Chrome device prompt simply doesn't appear so we time this out after 30 seconds and reload the page
const result = await Promise.race([
navigator.bluetooth.requestDevice({
filters: [{ namePrefix: `BBC micro:bit [${name}]` }],
optionalServices: [
MBSpecs.Services.UART_SERVICE,
MBSpecs.Services.ACCEL_SERVICE,
MBSpecs.Services.DEVICE_INFO_SERVICE,
MBSpecs.Services.LED_SERVICE,
MBSpecs.Services.IO_SERVICE,
MBSpecs.Services.BUTTON_SERVICE,
],
}),
new Promise<'timeout'>(resolve =>
setTimeout(
() => resolve('timeout'),
StaticConfiguration.requestDeviceTimeoutDuration,
),
),
]);
if (result === 'timeout') {
btSelectMicrobitDialogOnLoad.set(true);
window.location.reload();
return undefined;
}
return result;
} catch (e) {
logError('Bluetooth request device failed/cancelled', e);
return undefined;
Expand Down
Loading

0 comments on commit de5443e

Please sign in to comment.