diff --git a/src/App.svelte b/src/App.svelte index 258691e4f..e23a1ea10 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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; diff --git a/src/StaticConfiguration.ts b/src/StaticConfiguration.ts index ef0f800ab..ff3bbd69b 100644 --- a/src/StaticConfiguration.ts +++ b/src/StaticConfiguration.ts @@ -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; diff --git a/src/components/PleaseConnectFirst.svelte b/src/components/PleaseConnectFirst.svelte index fc6cd7fb3..7b2789edc 100644 --- a/src/components/PleaseConnectFirst.svelte +++ b/src/components/PleaseConnectFirst.svelte @@ -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(); @@ -35,7 +35,7 @@ disabled={$state.reconnectState.reconnecting} onClick={handleInputConnect} >{$t( - $state.showReconnectHelp || Microbits.getInputMicrobit() + $state.showConnectHelp || Microbits.getInputMicrobit() ? 'actions.reconnect' : 'footer.connectButton', )} diff --git a/src/components/ReconnectHelp.svelte b/src/components/ReconnectHelp.svelte index 02738084f..1957c317b 100644 --- a/src/components/ReconnectHelp.svelte +++ b/src/components/ReconnectHelp.svelte @@ -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; @@ -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', @@ -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', @@ -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', @@ -73,10 +86,19 @@ } } })(); + + const handleReconnect = () => { + if ($state.showConnectHelp === 'connect') { + stateOnHideConnectHelp(); + startConnectionProcess(); + } else { + reconnect(true); + } + }; {#if $state.reconnectState.connectionType !== 'none'} - + {$t(content.heading)} @@ -100,10 +122,14 @@ {$t('connectMB.troubleshooting')} - {$t('actions.cancel')} - reconnect(true)} - >{$t('actions.reconnect')} + {$t( + $state.showConnectHelp === 'connect' + ? 'footer.connectButton' + : 'actions.reconnect', + )} diff --git a/src/components/bottom/ConnectedLiveGraphButtons.svelte b/src/components/bottom/ConnectedLiveGraphButtons.svelte index 52906ce1d..48bc0fe61 100644 --- a/src/components/bottom/ConnectedLiveGraphButtons.svelte +++ b/src/components/bottom/ConnectedLiveGraphButtons.svelte @@ -23,7 +23,7 @@ }; const handleInputConnect = async () => { - if ($state.showReconnectHelp || Microbits.getInputMicrobit()) { + if ($state.showConnectHelp || Microbits.getInputMicrobit()) { reconnect(); } else { startConnectionProcess(); @@ -55,7 +55,7 @@ disabled={$state.reconnectState.reconnecting} size="small" >{$t( - $state.showReconnectHelp || Microbits.getInputMicrobit() + $state.showConnectHelp || Microbits.getInputMicrobit() ? 'actions.reconnect' : 'footer.connectButton', )} diff --git a/src/components/dialogs/StandardDialog.svelte b/src/components/dialogs/StandardDialog.svelte index 0586e8048..61b235b6f 100644 --- a/src/components/dialogs/StandardDialog.svelte +++ b/src/components/dialogs/StandardDialog.svelte @@ -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(); @@ -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(); } diff --git a/src/messages/ui.en.json b/src/messages/ui.en.json index df18f297b..f925ee1fb 100644 --- a/src/messages/ui.en.json +++ b/src/messages/ui.en.json @@ -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.", diff --git a/src/script/microbit-interfacing/MicrobitBluetooth.ts b/src/script/microbit-interfacing/MicrobitBluetooth.ts index 409d597fa..3f91d9451 100644 --- a/src/script/microbit-interfacing/MicrobitBluetooth.ts +++ b/src/script/microbit-interfacing/MicrobitBluetooth.ts @@ -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'; @@ -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. @@ -57,6 +62,8 @@ export class MicrobitBluetooth implements MicrobitConnection { private gattConnectPromise: Promise | undefined; private disconnectPromise: Promise | undefined; private connecting = false; + private isReconnect = false; + private reconnectReadyPromise: Promise | undefined; private outputWriteQueue: { busy: boolean; @@ -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++; @@ -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 { 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); } @@ -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')); } }; @@ -486,17 +510,32 @@ export const startBluetoothConnection = async ( const requestDevice = async (name: string): Promise => { 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; diff --git a/src/script/microbit-interfacing/MicrobitSerial.ts b/src/script/microbit-interfacing/MicrobitSerial.ts index 3616d37d3..d2deccdbd 100644 --- a/src/script/microbit-interfacing/MicrobitSerial.ts +++ b/src/script/microbit-interfacing/MicrobitSerial.ts @@ -20,6 +20,9 @@ import { import StaticConfiguration from '../../StaticConfiguration'; import { ConnectionType } from '../stores/uiStore'; +class BridgeError extends Error {} +class RemoteError extends Error {} + export class MicrobitSerial implements MicrobitConnection { private responseMap = new Map< number, @@ -124,7 +127,7 @@ export class MicrobitSerial implements MicrobitConnection { remoteMbIdResponse.type === protocol.ResponseTypes.Error || remoteMbIdResponse.value !== this.remoteDeviceId ) { - throw new Error( + throw new BridgeError( `Failed to set remote micro:bit ID. Expected ${this.remoteDeviceId}, got ${remoteMbIdResponse.value}`, ); } @@ -146,7 +149,7 @@ export class MicrobitSerial implements MicrobitConnection { const startCmdResponse = await this.sendCmdWaitResponse(startCmd); if (startCmdResponse.type === protocol.ResponseTypes.Error) { - throw new Error( + throw new RemoteError( `Failed to start streaming sensors data. Error response received: ${startCmdResponse.message}`, ); } @@ -167,10 +170,7 @@ export class MicrobitSerial implements MicrobitConnection { logMessage('Serial successfully connected'); } catch (e) { logError('Failed to initialise serial protocol', e); - let reconnectHelp: ConnectionType = 'remote'; - if (typeof e === 'string' && e.includes('Handshake')) { - reconnectHelp = 'bridge'; - } + const reconnectHelp = e instanceof BridgeError ? 'bridge' : 'remote'; await this.disconnectInternal(false, reconnectHelp); throw e; } finally { @@ -200,7 +200,11 @@ export class MicrobitSerial implements MicrobitConnection { } this.responseMap.clear(); await this.usb.stopSerial(); - stateOnDisconnected(DeviceRequestStates.INPUT, userDisconnect, reconnectHelp); + stateOnDisconnected( + DeviceRequestStates.INPUT, + userDisconnect ? false : this.isReconnect ? 'autoReconnect' : 'connect', + reconnectHelp, + ); } async handleReconnect(): Promise { @@ -268,7 +272,7 @@ export class MicrobitSerial implements MicrobitConnection { // We expect some to time out, likely well after the handshake is completed. if (!resolved) { if (++failureCounter === attempts) { - reject(new Error('Handshake not completed')); + reject(new BridgeError('Handshake not completed')); } } }); @@ -277,7 +281,7 @@ export class MicrobitSerial implements MicrobitConnection { }, ); if (handshakeResult.value !== protocol.version) { - throw new Error( + throw new BridgeError( `Handshake failed. Unexpected protocol version ${protocol.version}`, ); } diff --git a/src/script/microbit-interfacing/state-updaters.ts b/src/script/microbit-interfacing/state-updaters.ts index 61e922a4b..1e99c1d9b 100644 --- a/src/script/microbit-interfacing/state-updaters.ts +++ b/src/script/microbit-interfacing/state-updaters.ts @@ -5,7 +5,7 @@ */ import { get } from 'svelte/store'; -import { ConnectionType, ModelView, state } from '../stores/uiStore'; +import { ConnectHelp, ConnectionType, ModelView, state } from '../stores/uiStore'; import { Paths, currentPath, navigate } from '../../router/paths'; import MBSpecs from './MBSpecs'; import { HexOrigin } from '../../StaticConfiguration'; @@ -17,7 +17,7 @@ export const stateOnConnected = (requestState: DeviceRequestStates) => { requestState === DeviceRequestStates.INPUT ? (s.isInputConnected = true) : (s.isOutputConnected = true); - s.showReconnectHelp = false; + s.showConnectHelp = false; s.reconnectState = { ...s.reconnectState, // This is set on disconnect. @@ -103,14 +103,14 @@ export const stateOnAssigned = ( export const stateOnDisconnected = ( requestState: DeviceRequestStates, - userDisconnect: boolean, + connectHelp: ConnectHelp, connectionType: ConnectionType, ): void => { if (requestState === DeviceRequestStates.INPUT) { state.update(s => { s.isInputConnected = false; s.isInputReady = false; - s.showReconnectHelp = !userDisconnect; + s.showConnectHelp = connectHelp; s.reconnectState = { reconnecting: false, reconnectFailed: false, @@ -123,7 +123,7 @@ export const stateOnDisconnected = ( } else { state.update(s => { s.isOutputConnected = false; - s.showReconnectHelp = !userDisconnect; + s.showConnectHelp = connectHelp; s.isOutputReady = false; s.isOutputOutdated = false; s.reconnectState = { @@ -142,7 +142,7 @@ export const stateOnFailedToConnect = (requestState: DeviceRequestStates) => { state.update(s => { s.isInputConnected = false; s.isInputReady = false; - s.showReconnectHelp = false; + s.showConnectHelp = false; s.reconnectState = { ...s.reconnectState, reconnecting: false, @@ -155,7 +155,7 @@ export const stateOnFailedToConnect = (requestState: DeviceRequestStates) => { } else { state.update(s => { s.isOutputConnected = false; - s.showReconnectHelp = false; + s.showConnectHelp = false; s.isOutputReady = false; s.reconnectState = { ...s.reconnectState, @@ -169,16 +169,16 @@ export const stateOnFailedToConnect = (requestState: DeviceRequestStates) => { } }; -export const stateOnShowReconnectHelp = (userTriggered: boolean = false) => { +export const stateOnShowConnectHelp = (userTriggered: boolean = false) => { state.update(s => { - s.showReconnectHelp = userTriggered ? 'userTriggered' : true; + s.showConnectHelp = userTriggered ? 'userReconnect' : 'autoReconnect'; return s; }); }; -export const stateOnHideReconnectHelp = () => { +export const stateOnHideConnectHelp = () => { state.update(s => { - s.showReconnectHelp = false; + s.showConnectHelp = false; return s; }); }; @@ -202,7 +202,7 @@ export const stateOnVersionIdentified = ( export const stateOnReconnectionAttempt = () => { state.update(s => { - s.showReconnectHelp = false; + s.showConnectHelp = false; s.reconnectState = { ...s.reconnectState, reconnecting: true, diff --git a/src/script/stores/connectionStore.ts b/src/script/stores/connectionStore.ts index 22b411753..7a75c8d6b 100644 --- a/src/script/stores/connectionStore.ts +++ b/src/script/stores/connectionStore.ts @@ -26,6 +26,13 @@ export const radioBridgeRemoteDeviceId = persistantWritable( -1, ); +// Show the select micro:bit dialog for Bluetooth pairing on page load. +// The previous attempt to requestDevice failed. +export const btSelectMicrobitDialogOnLoad = persistantWritable( + 'btSelectMicrobitDialogOnLoad', + false, +); + export const isInputPatternValid = () => { const pattern = get(btPatternInput); return MBSpecs.Utility.isPairingPattermValid(pattern); diff --git a/src/script/stores/uiStore.ts b/src/script/stores/uiStore.ts index fd54ed753..2799ce707 100644 --- a/src/script/stores/uiStore.ts +++ b/src/script/stores/uiStore.ts @@ -29,6 +29,8 @@ export enum ModelView { STACK, } +export type ConnectHelp = 'autoReconnect' | 'userReconnect' | 'connect' | false; + export type ConnectionType = 'none' | 'bluetooth' | 'bridge' | 'remote'; interface ReconnectState { @@ -48,7 +50,7 @@ export const state = writable<{ isOutputConnected: boolean; hasTrainedBefore: boolean; isPredicting: boolean; - showReconnectHelp: 'userTriggered' | boolean; + showConnectHelp: ConnectHelp; reconnectState: ReconnectState; isInputReady: boolean; inputHexVersion: number; @@ -70,7 +72,7 @@ export const state = writable<{ isOutputConnected: false, hasTrainedBefore: false, isPredicting: false, - showReconnectHelp: false, + showConnectHelp: false, reconnectState: { connectionType: 'none', inUseAs: new Set(), diff --git a/src/script/utils/reconnect.ts b/src/script/utils/reconnect.ts index b2e1efced..465a14826 100644 --- a/src/script/utils/reconnect.ts +++ b/src/script/utils/reconnect.ts @@ -15,7 +15,7 @@ import Microbits from '../microbit-interfacing/Microbits'; import { stateOnFailedToConnect, stateOnReconnectionAttempt, - stateOnShowReconnectHelp, + stateOnShowConnectHelp, } from '../microbit-interfacing/state-updaters'; export const reconnect = async (finalAttempt: boolean = false) => { @@ -50,7 +50,7 @@ export const reconnect = async (finalAttempt: boolean = false) => { s.connectionState = ConnectDialogStates.NONE; return s; }); - stateOnShowReconnectHelp(true); + stateOnShowConnectHelp(true); } } finally { state.update(s => { diff --git a/src/views/OverlayView.svelte b/src/views/OverlayView.svelte index 16779d69e..b4056c16a 100644 --- a/src/views/OverlayView.svelte +++ b/src/views/OverlayView.svelte @@ -69,5 +69,5 @@ {/if} - +