From 55630d7f579a9151e91cc8d721fdd729bdd4093a Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 8 Jul 2024 16:49:10 +0100 Subject: [PATCH 01/20] WIP bluetooth noodling --- lib/bluetooth.ts | 378 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 lib/bluetooth.ts diff --git a/lib/bluetooth.ts b/lib/bluetooth.ts new file mode 100644 index 0000000..25240b4 --- /dev/null +++ b/lib/bluetooth.ts @@ -0,0 +1,378 @@ +/** + * (c) 2021, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Logging, NullLogging } from "./logging"; +import { withTimeout, TimeoutError } from "./async-util"; +import { DAPWrapper } from "./dap-wrapper"; +import { PartialFlashing } from "./partial-flashing"; +import { + BoardVersion, + ConnectionStatus, + ConnectOptions, + DeviceConnection, + DeviceConnectionEventMap, + EndUSBSelect, + FlashDataSource, + FlashEvent, + HexGenerationError, + MicrobitWebUSBConnectionOptions, + SerialDataEvent, + SerialErrorEvent, + SerialResetEvent, + StartUSBSelect, + ConnectionStatusEvent, + WebUSBError, +} from "./device"; +import { TypedEventTarget } from "./events"; + +/** + * A Bluetooth connection to a micro:bit device. + */ +export class MicrobitWebBluetoothConnection + extends TypedEventTarget + implements DeviceConnection +{ + // TODO: when do we call getAvailable() ? + status: ConnectionStatus = + navigator.bluetooth + ? ConnectionStatus.NO_AUTHORIZED_DEVICE + : ConnectionStatus.NOT_SUPPORTED; + + /** + * The USB device we last connected to. + * Cleared if it is disconnected. + */ + private device: BluetoothDevice | undefined; + + private logging: Logging; + + constructor( + options: MicrobitWebUSBConnectionOptions = { logging: new NullLogging() } + ) { + super(); + this.logging = options.logging; + } + + private log(v: any) { + this.logging.log(v); + } + + async initialize(): Promise { + if (navigator.bluetooth) { + // TODO: availabilitychanged + } + } + + dispose() { + if (navigator.bluetooth) { + // TODO: availabilitychanged + } + } + + async connect(options: ConnectOptions = {}): Promise { + return this.withEnrichedErrors(async () => { + await this.connectInternal(options); + return this.status; + }); + } + + getBoardVersion(): BoardVersion | null { + if (!this.connection) { + return null; + } + const boardId = this.connection.boardSerialInfo.id; + return boardId.isV1() ? "V1" : boardId.isV2() ? "V2" : null; + } + + async flash( + dataSource: FlashDataSource, + options: { + /** + * True to use a partial flash where possible, false to force a full flash. + */ + partial: boolean; + /** + * A progress callback. Called with undefined when the process is complete or has failed. + */ + progress: (percentage: number | undefined) => void; + } + ): Promise { + this.flashing = true; + try { + const startTime = new Date().getTime(); + await this.withEnrichedErrors(() => + this.flashInternal(dataSource, options) + ); + this.dispatchTypedEvent("flash", new FlashEvent()); + + const flashTime = new Date().getTime() - startTime; + this.logging.event({ + type: "WebUSB-time", + detail: { + flashTime, + }, + }); + this.logging.log("Flash complete"); + } finally { + this.flashing = false; + } + } + + private async flashInternal( + dataSource: FlashDataSource, + options: { + partial: boolean; + progress: (percentage: number | undefined, partial: boolean) => void; + } + ): Promise { + this.log("Stopping serial before flash"); + await this.stopSerialInternal(); + this.log("Reconnecting before flash"); + await this.connectInternal({ + serial: false, + }); + if (!this.connection) { + throw new Error("Must be connected now"); + } + + const partial = options.partial; + const progress = options.progress || (() => {}); + + const boardId = this.connection.boardSerialInfo.id; + const flashing = new PartialFlashing(this.connection, this.logging); + let wasPartial: boolean = false; + try { + if (partial) { + wasPartial = await flashing.flashAsync(boardId, dataSource, progress); + } else { + await flashing.fullFlashAsync(boardId, dataSource, progress); + } + } finally { + progress(undefined, wasPartial); + + if (this.disconnectAfterFlash) { + this.log("Disconnecting after flash due to tab visibility"); + this.disconnectAfterFlash = false; + await this.disconnect(); + this.visibilityReconnect = true; + } else { + // This might not strictly be "reinstating". We should make this + // behaviour configurable when pulling out a library. + this.log("Reinstating serial after flash"); + if (this.connection.daplink) { + await this.connection.daplink.connect(); + await this.startSerialInternal(); + } + } + } + } + + private async startSerialInternal() { + if (!this.connection) { + // As connecting then starting serial are async we could disconnect between them, + // so handle this gracefully. + return; + } + if (this.serialReadInProgress) { + await this.stopSerialInternal(); + } + // This is async but won't return until we stop serial so we error handle with an event. + this.serialReadInProgress = this.connection + .startSerial(this.serialListener) + .then(() => this.log("Finished listening for serial data")) + .catch((e) => { + this.dispatchTypedEvent("serial_error", new SerialErrorEvent(e)); + }); + } + + private async stopSerialInternal() { + if (this.connection && this.serialReadInProgress) { + this.connection.stopSerial(this.serialListener); + await this.serialReadInProgress; + this.serialReadInProgress = undefined; + this.dispatchTypedEvent("serial_reset", new SerialResetEvent()); + } + } + + async disconnect(): Promise { + try { + if (this.connection) { + await this.stopSerialInternal(); + await this.connection.disconnectAsync(); + } + } catch (e) { + this.log("Error during disconnection:\r\n" + e); + this.logging.event({ + type: "WebUSB-error", + message: "error-disconnecting", + }); + } finally { + this.connection = undefined; + this.setStatus(ConnectionStatus.NOT_CONNECTED); + this.logging.log("Disconnection complete"); + this.logging.event({ + type: "WebUSB-info", + message: "disconnected", + }); + } + } + + private setStatus(newStatus: ConnectionStatus) { + this.status = newStatus; + this.visibilityReconnect = false; + this.log("Device status " + newStatus); + this.dispatchTypedEvent("status", new ConnectionStatusEvent(newStatus)); + } + + private async withEnrichedErrors(f: () => Promise): Promise { + try { + return await f(); + } catch (e: any) { + if (e instanceof HexGenerationError) { + throw e; + } + + // Log error to console for feedback + this.log("An error occurred whilst attempting to use Bluetooth."); + this.log( + "Details of the error can be found below, and may be useful when trying to replicate and debug the error." + ); + this.log(e); + + // Disconnect from the microbit. + // Any new connection reallocates all the internals. + // Use the top-level API so any listeners reflect that we're disconnected. + await this.disconnect(); + + const enriched = enrichedError(e); + // Sanitise error message, replace all special chars with '-', if last char is '-' remove it + const errorMessage = e.message + ? e.message.replace(/\W+/g, "-").replace(/\W$/, "").toLowerCase() + : ""; + + this.logging.event({ + type: "WebUSB-error", + message: e.code + "/" + errorMessage, + }); + throw enriched; + } + } + + serialWrite(data: string): Promise { + return this.withEnrichedErrors(async () => { + if (this.connection) { + // Using WebUSB/DAPJs we're limited to 64 byte packet size with a two byte header. + // https://github.com/microbit-foundation/python-editor-v3/issues/215 + const maxSerialWrite = 62; + let start = 0; + while (start < data.length) { + const end = Math.min(start + maxSerialWrite, data.length); + const chunkData = data.slice(start, end); + await this.connection.daplink.serialWrite(chunkData); + start = end; + } + } + }); + } + + private handleDisconnect = (event: Event) => { + if (event.device === this.device) { + this.connection = undefined; + this.device = undefined; + this.setStatus(ConnectionStatus.NO_AUTHORIZED_DEVICE); + } + }; + + async clearDevice(): Promise { + await this.disconnect(); + this.device = undefined; + this.setStatus(ConnectionStatus.NO_AUTHORIZED_DEVICE); + } + + private async connectInternal(options: ConnectOptions): Promise { + if (!this.connection) { + const device = await this.chooseDevice(); + this.connection = new DAPWrapper(device, this.logging); + } + await withTimeout(this.connection.reconnectAsync(), 10_000); + if (options.serial === undefined || options.serial) { + this.startSerialInternal(); + } + this.setStatus(ConnectionStatus.CONNECTED); + } + + private async chooseDevice(): Promise { + if (this.device) { + return this.device; + } + this.dispatchTypedEvent("start_usb_select", new StartUSBSelect()); + this.device = await navigator.bluetooth.requestDevice({ + filters: [{ vendorId: 0x0d28, productId: 0x0204 }], + }); + this.dispatchTypedEvent("end_usb_select", new EndUSBSelect()); + return this.device; + } +} + +const genericErrorSuggestingReconnect = (e: any) => + new WebUSBError({ + code: "reconnect-microbit", + message: e.message, + }); + +// tslint:disable-next-line: no-any +const enrichedError = (err: any): WebUSBError => { + if (err instanceof WebUSBError) { + return err; + } + if (err instanceof TimeoutError) { + return new WebUSBError({ + code: "timeout-error", + message: err.message, + }); + } + + switch (typeof err) { + case "object": + // We might get Error objects as Promise rejection arguments + if (!err.message && err.promise && err.reason) { + err = err.reason; + } + // This is somewhat fragile but worth it for scenario specific errors. + // These messages changed to be prefixed in 2023 so we've relaxed the checks. + if (/No valid interfaces found/.test(err.message)) { + // This comes from DAPjs's WebUSB open. + return new WebUSBError({ + code: "update-req", + message: err.message, + }); + } else if (/No device selected/.test(err.message)) { + return new WebUSBError({ + code: "no-device-selected", + message: err.message, + }); + } else if (/Unable to claim interface/.test(err.message)) { + return new WebUSBError({ + code: "clear-connect", + message: err.message, + }); + } else if (err.name === "device-disconnected") { + return new WebUSBError({ + code: "device-disconnected", + message: err.message, + }); + } else { + // Unhandled error. User will need to reconnect their micro:bit + return genericErrorSuggestingReconnect(err); + } + case "string": { + // Caught a string. Example case: "Flash error" from DAPjs + return genericErrorSuggestingReconnect(err); + } + default: { + return genericErrorSuggestingReconnect(err); + } + } +}; From 5278aa0261e794298735e3047a6e777228116c04 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Tue, 9 Jul 2024 13:35:15 +0100 Subject: [PATCH 02/20] WIP --- lib/MicrobitBluetooth.ts | 327 +++++++++++++++++++++++++++++++++++++++ lib/bluetooth-profile.ts | 41 +++++ lib/main.ts | 11 +- lib/radio-bridge.ts | 321 ++++++++++++++++++++++++++++++++++++++ lib/serial-protocol.ts | 228 +++++++++++++++++++++++++++ package-lock.json | 16 ++ package.json | 7 +- src/demo.css | 62 ++++++++ src/demo.ts | 32 +++- 9 files changed, 1033 insertions(+), 12 deletions(-) create mode 100644 lib/MicrobitBluetooth.ts create mode 100644 lib/bluetooth-profile.ts create mode 100644 lib/radio-bridge.ts create mode 100644 lib/serial-protocol.ts create mode 100644 src/demo.css diff --git a/lib/MicrobitBluetooth.ts b/lib/MicrobitBluetooth.ts new file mode 100644 index 0000000..3b71c4b --- /dev/null +++ b/lib/MicrobitBluetooth.ts @@ -0,0 +1,327 @@ +/** + * (c) 2023, Center for Computational Thinking and Design at Aarhus University and contributors + * + * SPDX-License-Identifier: MIT + */ + +import { profile } from "./bluetooth-profile"; +import { BoardId } from "./board-id"; +import { Logging, NullLogging } from "./logging"; + +const deviceIdToConnection: Map = new Map(); + +const connectTimeoutDuration: number = 10000; +const requestDeviceTimeoutDuration: number = 30000; +// After how long should we consider the connection lost if ping was not able to conclude? +const connectionLostTimeoutDuration: number = 3000; + +function findPlatform(): string | undefined { + const navigator: any = window.navigator; + const platform = navigator.userAgentData?.platform; + if (platform) { + return platform; + } + const isAndroid = /android/.test(navigator.userAgent.toLowerCase()); + return isAndroid ? "android" : navigator.platform ?? "unknown"; +} +const platform = findPlatform(); +const isWindowsOS = platform && /^Win/.test(platform); + +export class MicrobitBluetooth { + // Used to avoid automatic reconnection during user triggered connect/disconnect + // or reconnection itself. + private duringExplicitConnectDisconnect: number = 0; + + // On ChromeOS and Mac there's no timeout and no clear way to abort + // device.gatt.connect(), so we accept that sometimes we'll still + // be trying to connect when we'd rather not be. If it succeeds when + // we no longer intend to be connected then we disconnect at that + // point. If we try to connect when a previous connection attempt is + // still around then we wait for it for our timeout period. + // + // On Windows it times out after 7s. + // https://bugs.chromium.org/p/chromium/issues/detail?id=684073 + private gattConnectPromise: Promise | undefined; + private disconnectPromise: Promise | undefined; + private connecting = false; + private isReconnect = false; + private reconnectReadyPromise: Promise | undefined; + // Whether this is the final reconnection attempt. + private finalAttempt = false; + + constructor( + public readonly name: string, + public readonly device: BluetoothDevice, + private logging: Logging = new NullLogging() + ) { + device.addEventListener( + "gattserverdisconnected", + this.handleDisconnectEvent + ); + } + + async connect(): Promise { + const device = await requestDevice(name); + if (!device) { + return undefined; + } + try { + // Reuse our connection objects for the same device as they + // track the GATT connect promise that never resolves. + const bluetooth = + deviceIdToConnection.get(device.id) ?? + new MicrobitBluetooth(name, device); + deviceIdToConnection.set(device.id, bluetooth); + await bluetooth.connect(); + return bluetooth; + } catch (e) { + return undefined; + } + + this.logging.event({ + type: this.isReconnect ? "Reconnect" : "Connect", + message: "Bluetooth connect start", + }); + if (this.duringExplicitConnectDisconnect) { + this.logging.log( + "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++; + if (this.device.gatt === undefined) { + throw new Error( + "BluetoothRemoteGATTServer for micro:bit device is undefined" + ); + } + try { + // A previous connect might have completed in the background as a device was replugged etc. + await this.disconnectPromise; + this.gattConnectPromise = + this.gattConnectPromise ?? + this.device.gatt + .connect() + .then(async () => { + // We always do this even if we might immediately disconnect as disconnecting + // without using services causes getPrimaryService calls to hang on subsequent + // reconnect - probably a device-side issue. + const modelNumber = await this.getModelNumber(); + // This connection could be arbitrarily later when our manual timeout may have passed. + // Do we still want to be connected? + if (!this.connecting) { + this.logging.log( + "Bluetooth GATT server connect after timeout, triggering disconnect" + ); + this.disconnectPromise = (async () => { + await this.disconnectInternal(false, false); + this.disconnectPromise = undefined; + })(); + } else { + this.logging.log( + "Bluetooth GATT server connected when connecting" + ); + } + return modelNumber; + }) + .catch((e) => { + if (this.connecting) { + // Error will be logged by main connect error handling. + throw e; + } else { + this.logging.error( + "Bluetooth GATT server connect error after our timeout", + e + ); + return undefined; + } + }) + .finally(() => { + this.logging.log("Bluetooth GATT server promise field cleared"); + this.gattConnectPromise = undefined; + }); + + this.connecting = true; + let boardId: BoardId | undefined; + try { + const gattConnectResult = await Promise.race([ + this.gattConnectPromise, + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), connectTimeoutDuration) + ), + ]); + if (gattConnectResult === "timeout") { + this.logging.log("Bluetooth GATT server connect timeout"); + throw new Error("Bluetooth GATT server connect timeout"); + } + boardId = gattConnectResult; + } finally { + this.connecting = false; + } + + this.logging.event({ + type: this.isReconnect ? "Reconnect" : "Connect", + message: "Bluetooth connect success", + }); + } catch (e) { + this.logging.error("Bluetooth connect error", e); + this.logging.event({ + type: this.isReconnect ? "Reconnect" : "Connect", + message: "Bluetooth connect failed", + }); + await this.disconnectInternal(false); + throw new Error("Failed to establish a connection!"); + } finally { + this.finalAttempt = false; + this.duringExplicitConnectDisconnect--; + } + } + + async disconnect(): Promise { + return this.disconnectInternal(true); + } + + private async disconnectInternal( + userTriggered: boolean, + updateState: boolean = true + ): Promise { + this.logging.log( + `Bluetooth disconnect ${userTriggered ? "(user triggered)" : "(programmatic)"}` + ); + this.duringExplicitConnectDisconnect++; + try { + if (this.device.gatt?.connected) { + this.device.gatt?.disconnect(); + } + } catch (e) { + this.logging.error("Bluetooth GATT disconnect error (ignored)", e); + // We might have already lost the connection. + } finally { + this.duringExplicitConnectDisconnect--; + } + this.reconnectReadyPromise = new Promise((resolve) => + setTimeout(resolve, 3_500) + ); + } + + async reconnect(finalAttempt: boolean = false): Promise { + this.finalAttempt = finalAttempt; + this.logging.log("Bluetooth reconnect"); + this.isReconnect = true; + 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(); + } + + handleDisconnectEvent = async (): Promise => { + this.outputWriteQueue = { busy: false, queue: [] }; + + try { + if (!this.duringExplicitConnectDisconnect) { + this.logging.log( + "Bluetooth GATT disconnected... automatically trying reconnect" + ); + stateOnReconnectionAttempt(); + await this.reconnect(); + } else { + this.logging.log( + "Bluetooth GATT disconnect ignored during explicit disconnect" + ); + } + } catch (e) { + this.logging.error( + "Bluetooth connect triggered by disconnect listener failed", + e + ); + } + }; + + private assertGattServer(): BluetoothRemoteGATTServer { + if (!this.device.gatt?.connected) { + throw new Error("Could not listen to services, no microbit connected!"); + } + return this.device.gatt; + } + /** + * Fetches the model number of the micro:bit. + * @param {BluetoothRemoteGATTServer} gattServer The GATT server to read from. + * @return {Promise} The model number of the micro:bit. 1 for the original, 2 for the new. + */ + private async getModelNumber(): Promise { + this.assertGattServer(); + try { + const deviceInfo = await this.assertGattServer().getPrimaryService( + MBSpecs.Services.DEVICE_INFO_SERVICE + ); + const modelNumber = await deviceInfo.getCharacteristic( + MBSpecs.Characteristics.MODEL_NUMBER + ); + // Read the value and convert it to UTF-8 (as specified in the Bluetooth specification). + const modelNumberValue = await modelNumber.readValue(); + const decodedModelNumber = new TextDecoder().decode(modelNumberValue); + // The model number either reads "BBC micro:bit" or "BBC micro:bit V2.0". Still unsure if those are the only cases. + if (decodedModelNumber.toLowerCase() === "BBC micro:bit".toLowerCase()) { + return 1; + } + if ( + decodedModelNumber + .toLowerCase() + .includes("BBC micro:bit v2".toLowerCase()) + ) { + return 2; + } + throw new Error(`Unexpected model number ${decodedModelNumber}`); + } catch (e) { + this.logging.error("Could not read model number", e); + throw new Error("Could not read model number"); + } + } + + private requestDevice = async ( + name: string + ): Promise => { + try { + // In some situations the Chrome device prompt simply doesn't appear so we time this out after 30 seconds and reload the page + // TODO: give control over this to the caller + const result = await Promise.race([ + navigator.bluetooth.requestDevice({ + // TODO: this is limiting + filters: [{ namePrefix: `BBC micro:bit [${name}]` }], + optionalServices: [ + // TODO: include everything or perhaps paramterise? + profile.uart.id, + profile.accelerometer.id, + profile.deviceInformation.id, + profile.led.id, + profile.io.id, + profile.button.id, + ], + }), + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), requestDeviceTimeoutDuration) + ), + ]); + if (result === "timeout") { + // btSelectMicrobitDialogOnLoad.set(true); + window.location.reload(); + return undefined; + } + return result; + } catch (e) { + this.logging.error("Bluetooth request device failed/cancelled", e); + return undefined; + } + }; +} + +export const startBluetoothConnection = async ( + name: string +): Promise => {}; diff --git a/lib/bluetooth-profile.ts b/lib/bluetooth-profile.ts new file mode 100644 index 0000000..22946bb --- /dev/null +++ b/lib/bluetooth-profile.ts @@ -0,0 +1,41 @@ +// Very incomplete BT profile +export const profile = { + uart: { + id: "6e400001-b5a3-f393-e0a9-e50e24dcca9e", + characteristics: { + tx: { id: "6e400002-b5a3-f393-e0a9-e50e24dcca9e" }, + rx: { id: "6e400003-b5a3-f393-e0a9-e50e24dcca9e" }, + }, + }, + accelerometer: { + id: "e95d0753-251d-470a-a062-fa1922dfa9a8", + characteristics: { + data: { id: "e95dca4b-251d-470a-a062-fa1922dfa9a8" }, + }, + }, + deviceInformation: { + id: "0000180a-0000-1000-8000-00805f9b34fb", + characteristics: { + modelNumber: { id: "00002a24-0000-1000-8000-00805f9b34fb" }, + }, + }, + led: { + id: "e95dd91d-251d-470a-a062-fa1922dfa9a8", + characteristics: { + matrixState: { id: "e95d7b77-251d-470a-a062-fa1922dfa9a8" }, + }, + }, + io: { + id: "e95d127b-251d-470a-a062-fa1922dfa9a8", + characteristics: { + data: { id: "e95d8d00-251d-470a-a062-fa1922dfa9a8" }, + }, + }, + button: { + id: "e95d9882-251d-470a-a062-fa1922dfa9a8", + characteristics: { + a: { id: "e95dda90-251d-470a-a062-fa1922dfa9a8" }, + b: { id: "e95dda91-251d-470a-a062-fa1922dfa9a8" }, + }, + }, +}; diff --git a/lib/main.ts b/lib/main.ts index 51887f9..a270320 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -1,4 +1,11 @@ import { MicrobitWebUSBConnection } from "./webusb"; +import { MicrobitWebBluetoothConnection } from "./bluetooth"; +import { BoardId } from "./board-id"; +import { DeviceConnection } from "./device"; -// TODO: more! -export { MicrobitWebUSBConnection }; \ No newline at end of file +export { + MicrobitWebUSBConnection, + MicrobitWebBluetoothConnection, + BoardId, + DeviceConnection, +}; diff --git a/lib/radio-bridge.ts b/lib/radio-bridge.ts new file mode 100644 index 0000000..b1c7de6 --- /dev/null +++ b/lib/radio-bridge.ts @@ -0,0 +1,321 @@ +/** + * (c) 2023, Center for Computational Thinking and Design at Aarhus University and contributors + * + * SPDX-License-Identifier: MIT + */ + +import { MicrobitWebUSBConnection } from "./webusb"; +import * as protocol from "./serial-protocol"; +import { ConnectionType } from "../stores/uiStore"; + +class BridgeError extends Error {} +class RemoteError extends Error {} + +export class MicrobitSerial implements MicrobitConnection { + private responseMap = new Map< + number, + ( + value: protocol.MessageResponse | PromiseLike + ) => void + >(); + + // To avoid concurrent connect attempts + private isConnecting: boolean = false; + + private connectionCheckIntervalId: ReturnType | undefined; + private lastReceivedMessageTimestamp: number | undefined; + private isReconnect: boolean = false; + // Whether this is the final reconnection attempt. + private finalAttempt = false; + + constructor( + private usb: MicrobitUSB, + private remoteDeviceId: number + ) {} + + async connect(...states: DeviceRequestStates[]): Promise { + logEvent({ + type: this.isReconnect ? "Reconnect" : "Connect", + action: "Serial connect start", + states, + }); + if (this.isConnecting) { + logMessage("Skipping connect attempt when one is already in progress"); + return; + } + this.isConnecting = true; + let unprocessedData = ""; + let previousButtonState = { A: 0, B: 0 }; + let onPeriodicMessageRecieved: (() => void) | undefined; + + const handleError = (e: unknown) => { + logError("Serial error", e); + void this.disconnectInternal(false, "bridge"); + }; + const processMessage = (data: string) => { + const messages = protocol.splitMessages(unprocessedData + data); + unprocessedData = messages.remainingInput; + messages.messages.forEach(async (msg) => { + this.lastReceivedMessageTimestamp = Date.now(); + + // Messages are either periodic sensor data or command/response + const sensorData = protocol.processPeriodicMessage(msg); + if (sensorData) { + // stateOnReconnected(); + if (onPeriodicMessageRecieved) { + onPeriodicMessageRecieved(); + onPeriodicMessageRecieved = undefined; + } + onAccelerometerChange( + sensorData.accelerometerX, + sensorData.accelerometerY, + sensorData.accelerometerZ + ); + if (sensorData.buttonA !== previousButtonState.A) { + previousButtonState.A = sensorData.buttonA; + onButtonChange(sensorData.buttonA, "A"); + } + if (sensorData.buttonB !== previousButtonState.B) { + previousButtonState.B = sensorData.buttonB; + onButtonChange(sensorData.buttonB, "B"); + } + } else { + const messageResponse = protocol.processResponseMessage(msg); + if (!messageResponse) { + return; + } + const responseResolve = this.responseMap.get( + messageResponse.messageId + ); + if (responseResolve) { + this.responseMap.delete(messageResponse.messageId); + responseResolve(messageResponse); + } + } + }); + }; + try { + await this.usb.startSerial(processMessage, handleError); + await this.handshake(); + // stateOnConnected(DeviceRequestStates.INPUT); + + // Check for connection lost + if (this.connectionCheckIntervalId === undefined) { + this.connectionCheckIntervalId = setInterval(async () => { + if ( + this.lastReceivedMessageTimestamp && + Date.now() - this.lastReceivedMessageTimestamp > 1_000 + ) { + // stateOnReconnectionAttempt(); + } + if ( + this.lastReceivedMessageTimestamp && + Date.now() - this.lastReceivedMessageTimestamp > + StaticConfiguration.connectTimeoutDuration + ) { + await this.handleReconnect(); + } + }, 1000); + } + + logMessage(`Serial: using remote device id ${this.remoteDeviceId}`); + const remoteMbIdCommand = protocol.generateCmdRemoteMbId( + this.remoteDeviceId + ); + const remoteMbIdResponse = + await this.sendCmdWaitResponse(remoteMbIdCommand); + if ( + remoteMbIdResponse.type === protocol.ResponseTypes.Error || + remoteMbIdResponse.value !== this.remoteDeviceId + ) { + throw new BridgeError( + `Failed to set remote micro:bit ID. Expected ${this.remoteDeviceId}, got ${remoteMbIdResponse.value}` + ); + } + + // For now we only support input state. + if (states.includes(DeviceRequestStates.INPUT)) { + // Request the micro:bit to start sending the periodic messages + const startCmd = protocol.generateCmdStart({ + accelerometer: true, + buttons: true, + }); + const periodicMessagePromise = new Promise((resolve, reject) => { + onPeriodicMessageRecieved = resolve; + setTimeout(() => { + onPeriodicMessageRecieved = undefined; + reject(new Error("Failed to receive data from remote micro:bit")); + }, 500); + }); + + const startCmdResponse = await this.sendCmdWaitResponse(startCmd); + if (startCmdResponse.type === protocol.ResponseTypes.Error) { + throw new RemoteError( + `Failed to start streaming sensors data. Error response received: ${startCmdResponse.message}` + ); + } + + if (this.isReconnect) { + await periodicMessagePromise; + } else { + periodicMessagePromise.catch(async (e) => { + logError("Failed to initialise serial protocol", e); + await this.disconnectInternal(false, "remote"); + this.isConnecting = false; + }); + } + } + + // stateOnAssigned(DeviceRequestStates.INPUT, this.usb.getModelNumber()); + // stateOnReady(DeviceRequestStates.INPUT); + logEvent({ + type: this.isReconnect ? "Reconnect" : "Connect", + action: "Serial connect success", + states, + }); + } catch (e) { + logError("Failed to initialise serial protocol", e); + logEvent({ + type: this.isReconnect ? "Reconnect" : "Connect", + action: "Serial connect failed", + states, + }); + const reconnectHelp = e instanceof BridgeError ? "bridge" : "remote"; + await this.disconnectInternal(false, reconnectHelp); + throw e; + } finally { + this.finalAttempt = false; + this.isConnecting = false; + } + } + + async disconnect(): Promise { + return this.disconnectInternal(true, "bridge"); + } + + private stopConnectionCheck() { + clearInterval(this.connectionCheckIntervalId); + this.connectionCheckIntervalId = undefined; + this.lastReceivedMessageTimestamp = undefined; + } + + private async disconnectInternal( + userDisconnect: boolean, + reconnectHelp: ConnectionType + ): Promise { + this.stopConnectionCheck(); + try { + await this.sendCmdWaitResponse(protocol.generateCmdStop()); + } catch (e) { + // If this fails the remote micro:bit has already gone away. + } + this.responseMap.clear(); + await this.usb.stopSerial(); + // stateOnDisconnected( + // DeviceRequestStates.INPUT, + // userDisconnect || this.finalAttempt + // ? false + // : this.isReconnect + // ? "autoReconnect" + // : "connect", + // reconnectHelp + // ); + } + + async handleReconnect(): Promise { + if (this.isConnecting) { + logMessage("Serial disconnect ignored... reconnect already in progress"); + return; + } + try { + this.stopConnectionCheck(); + logMessage("Serial disconnected... automatically trying to reconnect"); + this.responseMap.clear(); + await this.usb.stopSerial(); + await this.usb.softwareReset(); + await this.reconnect(); + } catch (e) { + logError("Serial connect triggered by disconnect listener failed", e); + } finally { + this.isConnecting = false; + } + } + + async reconnect(finalAttempt: boolean = false): Promise { + this.finalAttempt = finalAttempt; + logMessage("Serial reconnect"); + this.isReconnect = true; + await this.connect(DeviceRequestStates.INPUT); + } + + private async sendCmdWaitResponse( + cmd: protocol.MessageCmd + ): Promise { + const responsePromise = new Promise( + (resolve, reject) => { + this.responseMap.set(cmd.messageId, resolve); + setTimeout(() => { + this.responseMap.delete(cmd.messageId); + reject(new Error(`Timeout waiting for response ${cmd.messageId}`)); + }, 1_000); + } + ); + await this.usb.serialWrite(cmd.message); + return responsePromise; + } + + private async handshake(): Promise { + // There is an issue where we cannot read data out from the micro:bit serial + // buffer until the buffer has been filled. + // As a workaround we can spam the micro:bit with handshake messages until + // enough responses have been queued in the buffer to fill it and the data + // starts to flow. + logMessage("Serial handshake"); + const handshakeResult = await new Promise( + async (resolve, reject) => { + const attempts = 20; + let attemptCounter = 0; + let failureCounter = 0; + let resolved = false; + while (attemptCounter < 20 && !resolved) { + attemptCounter++; + this.sendCmdWaitResponse(protocol.generateCmdHandshake()) + .then((value) => { + if (!resolved) { + resolved = true; + resolve(value); + } + }) + .catch(() => { + // We expect some to time out, likely well after the handshake is completed. + if (!resolved) { + if (++failureCounter === attempts) { + reject(new BridgeError("Handshake not completed")); + } + } + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + ); + if (handshakeResult.value !== protocol.version) { + throw new BridgeError( + `Handshake failed. Unexpected protocol version ${protocol.version}` + ); + } + } +} + +export const startSerialConnection = async ( + usb: MicrobitUSB, + requestState: DeviceRequestStates, + remoteDeviceId: number +): Promise => { + try { + const serial = new MicrobitSerial(usb, remoteDeviceId); + await serial.connect(requestState); + return serial; + } catch (e) { + return undefined; + } +}; diff --git a/lib/serial-protocol.ts b/lib/serial-protocol.ts new file mode 100644 index 0000000..7b94775 --- /dev/null +++ b/lib/serial-protocol.ts @@ -0,0 +1,228 @@ +/** + * (c) 2024, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ + +export type SplittedMessages = { + messages: string[]; + remainingInput: string; +}; + +enum MessageTypes { + Command = 'C', + Response = 'R', + Periodic = 'P', +} + +export enum CommandTypes { + Handshake = 'HS', + RadioFrequency = 'RF', + RemoteMbId = 'RMBID', + SoftwareVersion = 'SWVER', + HardwareVersion = 'HWVER', + Zstart = 'ZSTART', + Stop = 'STOP', +} + +enum ResponseExtraTypes { + Error = 'ERROR', +} + +export type ResponseTypes = CommandTypes | ResponseExtraTypes; +export const ResponseTypes = { ...CommandTypes, ...ResponseExtraTypes }; + +export type MessageCmd = { + message: string; + messageId: number; + type: CommandTypes; + value: number | string; +}; + +export type MessageResponse = { + message: string; + messageId: number; + type: ResponseTypes; + value: number | string; +}; + +// More sensors are available in the protocol, but we only support these two +export type MicrobitSensors = { + accelerometer: boolean; + buttons: boolean; +}; + +export type MicrobitSensorState = { + accelerometerX: number; + accelerometerY: number; + accelerometerZ: number; + buttonA: number; + buttonB: number; +}; + +// Currently implemented protocol version +export const version = 1; + +export const splitMessages = (message: string): SplittedMessages => { + if (!message) { + return { + messages: [], + remainingInput: '', + }; + } + let messages = message.split('\n'); + let remainingInput = messages.pop() || ''; + + // Throw away any empty messages and messages that don't start with a valid type + messages = messages.filter( + (msg: string) => + msg.length > 0 && Object.values(MessageTypes).includes(msg[0] as MessageTypes), + ); + + // Any remaining input will be the start of the next message, so if it doesn't start + // with a valid type throw it away as it'll be prepended to the next serial string + if ( + remainingInput.length > 0 && + !Object.values(MessageTypes).includes(remainingInput[0] as MessageTypes) + ) { + remainingInput = ''; + } + + return { + messages, + remainingInput, + }; +}; + +export const processResponseMessage = (message: string): MessageResponse | undefined => { + // Regex for a message response with 3 groups: + // id -> The message ID, 1-8 hex characters + // cmd -> The command type, a string, only capital letters, matching CommandTypes + // value -> The response value, empty string or a word, number, + // or version (e.g 1.2.3) depending on the command type + const responseMatch = + /^R\[(?[0-9A-Fa-f]{1,8})\](?[A-Z]+)\[(?-?[\w.]*)\]$/.exec(message); + if (!responseMatch || !responseMatch.groups) { + return undefined; + } + const messageId = parseInt(responseMatch.groups['id'], 16); + if (isNaN(messageId)) { + return undefined; + } + const responseType = responseMatch.groups['cmd'] as ResponseTypes; + if (!Object.values(ResponseTypes).includes(responseType)) { + return undefined; + } + let value: string | number = responseMatch.groups['value']; + switch (responseType) { + // Commands with numeric values + case ResponseTypes.Handshake: + case ResponseTypes.RadioFrequency: + case ResponseTypes.RemoteMbId: + case ResponseTypes.HardwareVersion: + case ResponseTypes.Error: + value = Number(value); + if (isNaN(value) || value < 0 || value > 0xffffffff) { + return undefined; + } + break; + // Commands without values + case ResponseTypes.Zstart: + case ResponseTypes.Stop: + if (value !== '') { + return undefined; + } + break; + // Semver-ish values (valid range 00.00.00 to 99.99.99) + case ResponseTypes.SoftwareVersion: + if (!/^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}$/.test(value)) { + return undefined; + } + break; + } + return { + message, + messageId, + type: responseType, + value, + }; +}; + +export const processPeriodicMessage = ( + message: string, +): MicrobitSensorState | undefined => { + // Basic checks to match the message being a compact periodic message + if (message.length !== 13 || message[0] !== MessageTypes.Periodic) { + return undefined; + } + // All characters except the first one should be hex + if (!/^[0-9A-Fa-f]+$/.test(message.substring(1))) { + return undefined; + } + + // Only the two Least Significant Bits from the buttons are used + const buttons = parseInt(message[12], 16); + if (buttons > 3) { + return undefined; + } + + return { + // The accelerometer data has been clamped to -2048 to 2047, and an offset + // added to make the values positive, so that needs to be reversed + accelerometerX: parseInt(message.substring(3, 6), 16) - 2048, + accelerometerY: parseInt(message.substring(6, 9), 16) - 2048, + accelerometerZ: parseInt(message.substring(9, 12), 16) - 2048, + // Button A is the LSB, button B is the next bit + buttonA: buttons & 1, + buttonB: (buttons >> 1) & 1, + }; +}; + +const generateCommand = (cmdType: CommandTypes, cmdData: string = ''): MessageCmd => { + // Generate an random (enough) ID with max value of 8 hex digits + const msgID = Math.floor(Math.random() * 0xffffffff); + return { + message: `C[${msgID.toString(16).toUpperCase()}]${cmdType}[${cmdData}]\n`, + messageId: msgID, + type: cmdType, + value: cmdData, + }; +}; + +export const generateCmdHandshake = (): MessageCmd => { + return generateCommand(CommandTypes.Handshake); +}; + +export const generateCmdStart = (sensors: MicrobitSensors): MessageCmd => { + let cmdData = ''; + if (sensors.accelerometer) { + cmdData += 'A'; + } + if (sensors.buttons) { + cmdData += 'B'; + } + return generateCommand(CommandTypes.Zstart, cmdData); +}; + +export const generateCmdStop = (): MessageCmd => { + return generateCommand(CommandTypes.Stop); +}; + +export const generateCmdRadioFrequency = (frequency: number): MessageCmd => { + if (frequency < 0 || frequency > 83) { + throw new Error('Radio frequency out of range'); + } + return generateCommand(CommandTypes.RadioFrequency, frequency.toString()); +}; + +export const generateCmdRemoteMbId = (remoteMicrobitId: number): MessageCmd => { + if (remoteMicrobitId < 0 || remoteMicrobitId > 0xffffffff) { + throw new Error('Remote micro:bit ID out of range'); + } + return generateCommand(CommandTypes.RemoteMbId, remoteMicrobitId.toString()); +}; + +export const generateRandomRadioFrequency = (): number => { + // The value range for radio frequencies is 0 to 83 + return Math.floor(Math.random() * 84); +}; diff --git a/package-lock.json b/package-lock.json index 26cefb2..8e45c18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@types/node": "^20.14.10", "jsdom": "^24.1.0", + "prettier": "3.3.2", "typescript": "^5.2.2", "vite": "^5.3.1", "vitest": "^2.0.0" @@ -1428,6 +1429,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz", + "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", diff --git a/package.json b/package.json index 0ad3869..76ec2e8 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "private": true, "version": "0.0.0", "type": "module", - - "files": ["dist"], + "files": [ + "dist" + ], "main": "./dist/my-lib.umd.cjs", "module": "./dist/my-lib.js", "exports": { @@ -13,7 +14,6 @@ "require": "./dist/my-lib.umd.cjs" } }, - "scripts": { "dev": "vite", "build": "tsc && vite build", @@ -23,6 +23,7 @@ "devDependencies": { "@types/node": "^20.14.10", "jsdom": "^24.1.0", + "prettier": "3.3.2", "typescript": "^5.2.2", "vite": "^5.3.1", "vitest": "^2.0.0" diff --git a/src/demo.css b/src/demo.css new file mode 100644 index 0000000..1d6285f --- /dev/null +++ b/src/demo.css @@ -0,0 +1,62 @@ +/* https://www.joshwcomeau.com/css/custom-css-reset/ */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +/* Custom styles */ + +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + margin: 1em +} + +section { + border: 2px solid grey; + border-radius: 1em; + padding: 1em; +} + +label { + display: block; +} + +*+* { + margin-top: 1rem +} \ No newline at end of file diff --git a/src/demo.ts b/src/demo.ts index d429e8e..a37da17 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -3,10 +3,28 @@ * * SPDX-License-Identifier: MIT */ -document.querySelector('#app')!.innerHTML = ` -
-

- TODO -

-
-` \ No newline at end of file +import "./demo.css"; +import { MicrobitWebUSBConnection } from "../lib/webusb"; + +document.querySelector("#app")!.innerHTML = ` +
+

WebUSB

+ + + +
+`; + +const connect = document.querySelector("#webusb > .connect")!; +const flash = document.querySelector("#webusb > .flash")!; +const connection = new MicrobitWebUSBConnection(); +connection.addEventListener("status", (event) => { + console.log(event.status); +}); + +connect.addEventListener("click", async () => { + await connection.initialize(); + await connection.connect(); +}); + +flash.addEventListener("click", async () => {}); From 4865e1a5a5cc33bea3a729d1942ce9ddcf33d82e Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Tue, 9 Jul 2024 15:20:16 +0100 Subject: [PATCH 03/20] WIP --- lib/MicrobitBluetooth.ts | 68 +++----- lib/bluetooth-device-wrapper.ts | 281 ++++++++++++++++++++++++++++++++ lib/bluetooth.ts | 57 +++++-- lib/device-version.ts | 4 + lib/logging.ts | 11 +- lib/simulator.ts | 2 +- 6 files changed, 359 insertions(+), 64 deletions(-) create mode 100644 lib/bluetooth-device-wrapper.ts create mode 100644 lib/device-version.ts diff --git a/lib/MicrobitBluetooth.ts b/lib/MicrobitBluetooth.ts index 3b71c4b..d310ac4 100644 --- a/lib/MicrobitBluetooth.ts +++ b/lib/MicrobitBluetooth.ts @@ -6,9 +6,11 @@ import { profile } from "./bluetooth-profile"; import { BoardId } from "./board-id"; +import { DeviceVersion } from "./device-version"; import { Logging, NullLogging } from "./logging"; -const deviceIdToConnection: Map = new Map(); +const deviceIdToConnection: Map = + new Map(); const connectTimeoutDuration: number = 10000; const requestDeviceTimeoutDuration: number = 30000; @@ -27,7 +29,7 @@ function findPlatform(): string | undefined { const platform = findPlatform(); const isWindowsOS = platform && /^Win/.test(platform); -export class MicrobitBluetooth { +export class MicrobitBluetoothConnection { // Used to avoid automatic reconnection during user triggered connect/disconnect // or reconnection itself. private duringExplicitConnectDisconnect: number = 0; @@ -41,7 +43,7 @@ export class MicrobitBluetooth { // // On Windows it times out after 7s. // https://bugs.chromium.org/p/chromium/issues/detail?id=684073 - private gattConnectPromise: Promise | undefined; + private gattConnectPromise: Promise | undefined; private disconnectPromise: Promise | undefined; private connecting = false; private isReconnect = false; @@ -61,23 +63,6 @@ export class MicrobitBluetooth { } async connect(): Promise { - const device = await requestDevice(name); - if (!device) { - return undefined; - } - try { - // Reuse our connection objects for the same device as they - // track the GATT connect promise that never resolves. - const bluetooth = - deviceIdToConnection.get(device.id) ?? - new MicrobitBluetooth(name, device); - deviceIdToConnection.set(device.id, bluetooth); - await bluetooth.connect(); - return bluetooth; - } catch (e) { - return undefined; - } - this.logging.event({ type: this.isReconnect ? "Reconnect" : "Connect", message: "Bluetooth connect start", @@ -109,7 +94,7 @@ export class MicrobitBluetooth { // We always do this even if we might immediately disconnect as disconnecting // without using services causes getPrimaryService calls to hang on subsequent // reconnect - probably a device-side issue. - const modelNumber = await this.getModelNumber(); + const deviceVersion = await this.getDeviceVersion(); // This connection could be arbitrarily later when our manual timeout may have passed. // Do we still want to be connected? if (!this.connecting) { @@ -125,7 +110,7 @@ export class MicrobitBluetooth { "Bluetooth GATT server connected when connecting" ); } - return modelNumber; + return deviceVersion; }) .catch((e) => { if (this.connecting) { @@ -145,7 +130,7 @@ export class MicrobitBluetooth { }); this.connecting = true; - let boardId: BoardId | undefined; + let deviceVersion: DeviceVersion | undefined; try { const gattConnectResult = await Promise.race([ this.gattConnectPromise, @@ -157,7 +142,7 @@ export class MicrobitBluetooth { this.logging.log("Bluetooth GATT server connect timeout"); throw new Error("Bluetooth GATT server connect timeout"); } - boardId = gattConnectResult; + deviceVersion = gattConnectResult; } finally { this.connecting = false; } @@ -222,14 +207,14 @@ export class MicrobitBluetooth { } handleDisconnectEvent = async (): Promise => { - this.outputWriteQueue = { busy: false, queue: [] }; + // this.outputWriteQueue = { busy: false, queue: [] }; try { if (!this.duringExplicitConnectDisconnect) { this.logging.log( "Bluetooth GATT disconnected... automatically trying reconnect" ); - stateOnReconnectionAttempt(); + // stateOnReconnectionAttempt(); await this.reconnect(); } else { this.logging.log( @@ -255,30 +240,27 @@ export class MicrobitBluetooth { * @param {BluetoothRemoteGATTServer} gattServer The GATT server to read from. * @return {Promise} The model number of the micro:bit. 1 for the original, 2 for the new. */ - private async getModelNumber(): Promise { + private async getDeviceVersion(): Promise { this.assertGattServer(); + const serviceMeta = profile.deviceInformation; try { const deviceInfo = await this.assertGattServer().getPrimaryService( - MBSpecs.Services.DEVICE_INFO_SERVICE + serviceMeta.id ); - const modelNumber = await deviceInfo.getCharacteristic( - MBSpecs.Characteristics.MODEL_NUMBER + const characteristic = await deviceInfo.getCharacteristic( + serviceMeta.characteristics.modelNumber.id ); - // Read the value and convert it to UTF-8 (as specified in the Bluetooth specification). - const modelNumberValue = await modelNumber.readValue(); - const decodedModelNumber = new TextDecoder().decode(modelNumberValue); - // The model number either reads "BBC micro:bit" or "BBC micro:bit V2.0". Still unsure if those are the only cases. - if (decodedModelNumber.toLowerCase() === "BBC micro:bit".toLowerCase()) { - return 1; + const modelNumberBytes = await characteristic.readValue(); + const modelNumber = new TextDecoder().decode(modelNumberBytes); + if (modelNumber.toLowerCase() === "BBC micro:bit".toLowerCase()) { + return DeviceVersion.V1; } if ( - decodedModelNumber - .toLowerCase() - .includes("BBC micro:bit v2".toLowerCase()) + modelNumber.toLowerCase().includes("BBC micro:bit v2".toLowerCase()) ) { - return 2; + return DeviceVersion.V2; } - throw new Error(`Unexpected model number ${decodedModelNumber}`); + throw new Error(`Unexpected model number ${modelNumber}`); } catch (e) { this.logging.error("Could not read model number", e); throw new Error("Could not read model number"); @@ -321,7 +303,3 @@ export class MicrobitBluetooth { } }; } - -export const startBluetoothConnection = async ( - name: string -): Promise => {}; diff --git a/lib/bluetooth-device-wrapper.ts b/lib/bluetooth-device-wrapper.ts new file mode 100644 index 0000000..0b57a41 --- /dev/null +++ b/lib/bluetooth-device-wrapper.ts @@ -0,0 +1,281 @@ +/** + * (c) 2023, Center for Computational Thinking and Design at Aarhus University and contributors + * + * SPDX-License-Identifier: MIT + */ + +import { profile } from "./bluetooth-profile"; +import { DeviceVersion } from "./device-version"; +import { Logging, NullLogging } from "./logging"; + +const deviceIdToWrapper: Map = new Map(); + +const connectTimeoutDuration: number = 10000; + +function findPlatform(): string | undefined { + const navigator: any = window.navigator; + const platform = navigator.userAgentData?.platform; + if (platform) { + return platform; + } + const isAndroid = /android/.test(navigator.userAgent.toLowerCase()); + return isAndroid ? "android" : navigator.platform ?? "unknown"; +} +const platform = findPlatform(); +const isWindowsOS = platform && /^Win/.test(platform); + +export class BluetoothDeviceWrapper { + // Used to avoid automatic reconnection during user triggered connect/disconnect + // or reconnection itself. + private duringExplicitConnectDisconnect: number = 0; + + // On ChromeOS and Mac there's no timeout and no clear way to abort + // device.gatt.connect(), so we accept that sometimes we'll still + // be trying to connect when we'd rather not be. If it succeeds when + // we no longer intend to be connected then we disconnect at that + // point. If we try to connect when a previous connection attempt is + // still around then we wait for it for our timeout period. + // + // On Windows it times out after 7s. + // https://bugs.chromium.org/p/chromium/issues/detail?id=684073 + private gattConnectPromise: Promise | undefined; + private disconnectPromise: Promise | undefined; + private connecting = false; + private isReconnect = false; + private reconnectReadyPromise: Promise | undefined; + // Whether this is the final reconnection attempt. + private finalAttempt = false; + + constructor( + public readonly device: BluetoothDevice, + private logging: Logging = new NullLogging() + ) { + device.addEventListener( + "gattserverdisconnected", + this.handleDisconnectEvent + ); + } + + async connect(): Promise { + this.logging.event({ + type: this.isReconnect ? "Reconnect" : "Connect", + message: "Bluetooth connect start", + }); + if (this.duringExplicitConnectDisconnect) { + this.logging.log( + "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++; + if (this.device.gatt === undefined) { + throw new Error( + "BluetoothRemoteGATTServer for micro:bit device is undefined" + ); + } + try { + // A previous connect might have completed in the background as a device was replugged etc. + await this.disconnectPromise; + this.gattConnectPromise = + this.gattConnectPromise ?? + this.device.gatt + .connect() + .then(async () => { + // We always do this even if we might immediately disconnect as disconnecting + // without using services causes getPrimaryService calls to hang on subsequent + // reconnect - probably a device-side issue. + const deviceVersion = await this.getDeviceVersion(); + // This connection could be arbitrarily later when our manual timeout may have passed. + // Do we still want to be connected? + if (!this.connecting) { + this.logging.log( + "Bluetooth GATT server connect after timeout, triggering disconnect" + ); + this.disconnectPromise = (async () => { + await this.disconnectInternal(false, false); + this.disconnectPromise = undefined; + })(); + } else { + this.logging.log( + "Bluetooth GATT server connected when connecting" + ); + } + return deviceVersion; + }) + .catch((e) => { + if (this.connecting) { + // Error will be logged by main connect error handling. + throw e; + } else { + this.logging.error( + "Bluetooth GATT server connect error after our timeout", + e + ); + return undefined; + } + }) + .finally(() => { + this.logging.log("Bluetooth GATT server promise field cleared"); + this.gattConnectPromise = undefined; + }); + + this.connecting = true; + let deviceVersion: DeviceVersion | undefined; + try { + const gattConnectResult = await Promise.race([ + this.gattConnectPromise, + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), connectTimeoutDuration) + ), + ]); + if (gattConnectResult === "timeout") { + this.logging.log("Bluetooth GATT server connect timeout"); + throw new Error("Bluetooth GATT server connect timeout"); + } + deviceVersion = gattConnectResult; + } finally { + this.connecting = false; + } + + this.logging.event({ + type: this.isReconnect ? "Reconnect" : "Connect", + message: "Bluetooth connect success", + }); + } catch (e) { + this.logging.error("Bluetooth connect error", e); + this.logging.event({ + type: this.isReconnect ? "Reconnect" : "Connect", + message: "Bluetooth connect failed", + }); + await this.disconnectInternal(false); + throw new Error("Failed to establish a connection!"); + } finally { + this.finalAttempt = false; + this.duringExplicitConnectDisconnect--; + } + } + + async disconnect(): Promise { + return this.disconnectInternal(true); + } + + private async disconnectInternal( + userTriggered: boolean, + updateState: boolean = true + ): Promise { + this.logging.log( + `Bluetooth disconnect ${userTriggered ? "(user triggered)" : "(programmatic)"}` + ); + this.duringExplicitConnectDisconnect++; + try { + if (this.device.gatt?.connected) { + this.device.gatt?.disconnect(); + } + } catch (e) { + this.logging.error("Bluetooth GATT disconnect error (ignored)", e); + // We might have already lost the connection. + } finally { + this.duringExplicitConnectDisconnect--; + } + this.reconnectReadyPromise = new Promise((resolve) => + setTimeout(resolve, 3_500) + ); + } + + async reconnect(finalAttempt: boolean = false): Promise { + this.finalAttempt = finalAttempt; + this.logging.log("Bluetooth reconnect"); + this.isReconnect = true; + 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(); + } + + handleDisconnectEvent = async (): Promise => { + // this.outputWriteQueue = { busy: false, queue: [] }; + + try { + if (!this.duringExplicitConnectDisconnect) { + this.logging.log( + "Bluetooth GATT disconnected... automatically trying reconnect" + ); + // stateOnReconnectionAttempt(); + await this.reconnect(); + } else { + this.logging.log( + "Bluetooth GATT disconnect ignored during explicit disconnect" + ); + } + } catch (e) { + this.logging.error( + "Bluetooth connect triggered by disconnect listener failed", + e + ); + } + }; + + private assertGattServer(): BluetoothRemoteGATTServer { + if (!this.device.gatt?.connected) { + throw new Error("Could not listen to services, no microbit connected!"); + } + return this.device.gatt; + } + /** + * Fetches the model number of the micro:bit. + * @param {BluetoothRemoteGATTServer} gattServer The GATT server to read from. + * @return {Promise} The model number of the micro:bit. 1 for the original, 2 for the new. + */ + private async getDeviceVersion(): Promise { + this.assertGattServer(); + const serviceMeta = profile.deviceInformation; + try { + const deviceInfo = await this.assertGattServer().getPrimaryService( + serviceMeta.id + ); + const characteristic = await deviceInfo.getCharacteristic( + serviceMeta.characteristics.modelNumber.id + ); + const modelNumberBytes = await characteristic.readValue(); + const modelNumber = new TextDecoder().decode(modelNumberBytes); + if (modelNumber.toLowerCase() === "BBC micro:bit".toLowerCase()) { + return DeviceVersion.V1; + } + if ( + modelNumber.toLowerCase().includes("BBC micro:bit v2".toLowerCase()) + ) { + return DeviceVersion.V2; + } + throw new Error(`Unexpected model number ${modelNumber}`); + } catch (e) { + this.logging.error("Could not read model number", e); + throw new Error("Could not read model number"); + } + } +} + +export const createBluetoothDeviceWrapper = async ( + device: BluetoothDevice, + logging: Logging +): Promise => { + try { + // Reuse our connection objects for the same device as they + // track the GATT connect promise that never resolves. + const bluetooth = + deviceIdToWrapper.get(device.id) ?? + new BluetoothDeviceWrapper(device, logging); + deviceIdToWrapper.set(device.id, bluetooth); + await bluetooth.connect(); + return bluetooth; + } catch (e) { + return undefined; + } +}; diff --git a/lib/bluetooth.ts b/lib/bluetooth.ts index 25240b4..7769675 100644 --- a/lib/bluetooth.ts +++ b/lib/bluetooth.ts @@ -26,6 +26,12 @@ import { WebUSBError, } from "./device"; import { TypedEventTarget } from "./events"; +import { createBluetoothDeviceWrapper } from "./bluetooth-device-wrapper"; +import { profile } from "./bluetooth-profile"; + +const requestDeviceTimeoutDuration: number = 30000; +// After how long should we consider the connection lost if ping was not able to conclude? +const connectionLostTimeoutDuration: number = 3000; /** * A Bluetooth connection to a micro:bit device. @@ -35,10 +41,9 @@ export class MicrobitWebBluetoothConnection implements DeviceConnection { // TODO: when do we call getAvailable() ? - status: ConnectionStatus = - navigator.bluetooth - ? ConnectionStatus.NO_AUTHORIZED_DEVICE - : ConnectionStatus.NOT_SUPPORTED; + status: ConnectionStatus = navigator.bluetooth + ? ConnectionStatus.NO_AUTHORIZED_DEVICE + : ConnectionStatus.NOT_SUPPORTED; /** * The USB device we last connected to. @@ -47,6 +52,7 @@ export class MicrobitWebBluetoothConnection private device: BluetoothDevice | undefined; private logging: Logging; + connection: any; constructor( options: MicrobitWebUSBConnectionOptions = { logging: new NullLogging() } @@ -294,7 +300,7 @@ export class MicrobitWebBluetoothConnection private async connectInternal(options: ConnectOptions): Promise { if (!this.connection) { const device = await this.chooseDevice(); - this.connection = new DAPWrapper(device, this.logging); + this.connection = createBluetoothDeviceWrapper(device, this.logging); } await withTimeout(this.connection.reconnectAsync(), 10_000); if (options.serial === undefined || options.serial) { @@ -303,16 +309,45 @@ export class MicrobitWebBluetoothConnection this.setStatus(ConnectionStatus.CONNECTED); } - private async chooseDevice(): Promise { + private async chooseDevice(): Promise { if (this.device) { return this.device; } this.dispatchTypedEvent("start_usb_select", new StartUSBSelect()); - this.device = await navigator.bluetooth.requestDevice({ - filters: [{ vendorId: 0x0d28, productId: 0x0204 }], - }); - this.dispatchTypedEvent("end_usb_select", new EndUSBSelect()); - return this.device; + try { + // In some situations the Chrome device prompt simply doesn't appear so we time this out after 30 seconds and reload the page + // TODO: give control over this to the caller + const result = await Promise.race([ + navigator.bluetooth.requestDevice({ + // TODO: this is limiting + filters: [{ namePrefix: `BBC micro:bit [${name}]` }], + optionalServices: [ + // TODO: include everything or perhaps parameterise? + profile.uart.id, + profile.accelerometer.id, + profile.deviceInformation.id, + profile.led.id, + profile.io.id, + profile.button.id, + ], + }), + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), requestDeviceTimeoutDuration) + ), + ]); + if (result === "timeout") { + // btSelectMicrobitDialogOnLoad.set(true); + window.location.reload(); + return undefined; + } + this.device = result; + return result; + } catch (e) { + this.logging.error("Bluetooth request device failed/cancelled", e); + return undefined; + } finally { + this.dispatchTypedEvent("end_usb_select", new EndUSBSelect()); + } } } diff --git a/lib/device-version.ts b/lib/device-version.ts new file mode 100644 index 0000000..c626a49 --- /dev/null +++ b/lib/device-version.ts @@ -0,0 +1,4 @@ +export const enum DeviceVersion { + V1, + V2, +} diff --git a/lib/logging.ts b/lib/logging.ts index 32d4f8b..522c806 100644 --- a/lib/logging.ts +++ b/lib/logging.ts @@ -12,15 +12,12 @@ export interface Event { export interface Logging { event(event: Event): void; - error(e: any): void; + error(message: string, e: unknown): void; log(e: any): void; } export class NullLogging implements Logging { - event(_event: Event): void { - } - error(_e: any): void { - } - log(_e: any): void { - } + event(_event: Event): void {} + error(_m: string, _e: unknown): void {} + log(_e: any): void {} } diff --git a/lib/simulator.ts b/lib/simulator.ts index 0cf54cc..382ce3b 100644 --- a/lib/simulator.ts +++ b/lib/simulator.ts @@ -264,7 +264,7 @@ export class SimulatorDeviceConnection } case "internal_error": { const error = event.data.error; - this.logging.error(error); + this.logging.error("Internal error", error); break; } default: { From 143cdc412839c134308e07b509b84ddf4e9116f6 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Tue, 9 Jul 2024 16:39:38 +0100 Subject: [PATCH 04/20] Strip back, make compile --- lib/MicrobitBluetooth.ts | 305 -------------------------------- lib/bluetooth-device-wrapper.ts | 24 ++- lib/bluetooth.ts | 252 +++----------------------- lib/board-id.ts | 6 + lib/device-version.ts | 4 - 5 files changed, 44 insertions(+), 547 deletions(-) delete mode 100644 lib/MicrobitBluetooth.ts delete mode 100644 lib/device-version.ts diff --git a/lib/MicrobitBluetooth.ts b/lib/MicrobitBluetooth.ts deleted file mode 100644 index d310ac4..0000000 --- a/lib/MicrobitBluetooth.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * (c) 2023, Center for Computational Thinking and Design at Aarhus University and contributors - * - * SPDX-License-Identifier: MIT - */ - -import { profile } from "./bluetooth-profile"; -import { BoardId } from "./board-id"; -import { DeviceVersion } from "./device-version"; -import { Logging, NullLogging } from "./logging"; - -const deviceIdToConnection: Map = - new Map(); - -const connectTimeoutDuration: number = 10000; -const requestDeviceTimeoutDuration: number = 30000; -// After how long should we consider the connection lost if ping was not able to conclude? -const connectionLostTimeoutDuration: number = 3000; - -function findPlatform(): string | undefined { - const navigator: any = window.navigator; - const platform = navigator.userAgentData?.platform; - if (platform) { - return platform; - } - const isAndroid = /android/.test(navigator.userAgent.toLowerCase()); - return isAndroid ? "android" : navigator.platform ?? "unknown"; -} -const platform = findPlatform(); -const isWindowsOS = platform && /^Win/.test(platform); - -export class MicrobitBluetoothConnection { - // Used to avoid automatic reconnection during user triggered connect/disconnect - // or reconnection itself. - private duringExplicitConnectDisconnect: number = 0; - - // On ChromeOS and Mac there's no timeout and no clear way to abort - // device.gatt.connect(), so we accept that sometimes we'll still - // be trying to connect when we'd rather not be. If it succeeds when - // we no longer intend to be connected then we disconnect at that - // point. If we try to connect when a previous connection attempt is - // still around then we wait for it for our timeout period. - // - // On Windows it times out after 7s. - // https://bugs.chromium.org/p/chromium/issues/detail?id=684073 - private gattConnectPromise: Promise | undefined; - private disconnectPromise: Promise | undefined; - private connecting = false; - private isReconnect = false; - private reconnectReadyPromise: Promise | undefined; - // Whether this is the final reconnection attempt. - private finalAttempt = false; - - constructor( - public readonly name: string, - public readonly device: BluetoothDevice, - private logging: Logging = new NullLogging() - ) { - device.addEventListener( - "gattserverdisconnected", - this.handleDisconnectEvent - ); - } - - async connect(): Promise { - this.logging.event({ - type: this.isReconnect ? "Reconnect" : "Connect", - message: "Bluetooth connect start", - }); - if (this.duringExplicitConnectDisconnect) { - this.logging.log( - "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++; - if (this.device.gatt === undefined) { - throw new Error( - "BluetoothRemoteGATTServer for micro:bit device is undefined" - ); - } - try { - // A previous connect might have completed in the background as a device was replugged etc. - await this.disconnectPromise; - this.gattConnectPromise = - this.gattConnectPromise ?? - this.device.gatt - .connect() - .then(async () => { - // We always do this even if we might immediately disconnect as disconnecting - // without using services causes getPrimaryService calls to hang on subsequent - // reconnect - probably a device-side issue. - const deviceVersion = await this.getDeviceVersion(); - // This connection could be arbitrarily later when our manual timeout may have passed. - // Do we still want to be connected? - if (!this.connecting) { - this.logging.log( - "Bluetooth GATT server connect after timeout, triggering disconnect" - ); - this.disconnectPromise = (async () => { - await this.disconnectInternal(false, false); - this.disconnectPromise = undefined; - })(); - } else { - this.logging.log( - "Bluetooth GATT server connected when connecting" - ); - } - return deviceVersion; - }) - .catch((e) => { - if (this.connecting) { - // Error will be logged by main connect error handling. - throw e; - } else { - this.logging.error( - "Bluetooth GATT server connect error after our timeout", - e - ); - return undefined; - } - }) - .finally(() => { - this.logging.log("Bluetooth GATT server promise field cleared"); - this.gattConnectPromise = undefined; - }); - - this.connecting = true; - let deviceVersion: DeviceVersion | undefined; - try { - const gattConnectResult = await Promise.race([ - this.gattConnectPromise, - new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), connectTimeoutDuration) - ), - ]); - if (gattConnectResult === "timeout") { - this.logging.log("Bluetooth GATT server connect timeout"); - throw new Error("Bluetooth GATT server connect timeout"); - } - deviceVersion = gattConnectResult; - } finally { - this.connecting = false; - } - - this.logging.event({ - type: this.isReconnect ? "Reconnect" : "Connect", - message: "Bluetooth connect success", - }); - } catch (e) { - this.logging.error("Bluetooth connect error", e); - this.logging.event({ - type: this.isReconnect ? "Reconnect" : "Connect", - message: "Bluetooth connect failed", - }); - await this.disconnectInternal(false); - throw new Error("Failed to establish a connection!"); - } finally { - this.finalAttempt = false; - this.duringExplicitConnectDisconnect--; - } - } - - async disconnect(): Promise { - return this.disconnectInternal(true); - } - - private async disconnectInternal( - userTriggered: boolean, - updateState: boolean = true - ): Promise { - this.logging.log( - `Bluetooth disconnect ${userTriggered ? "(user triggered)" : "(programmatic)"}` - ); - this.duringExplicitConnectDisconnect++; - try { - if (this.device.gatt?.connected) { - this.device.gatt?.disconnect(); - } - } catch (e) { - this.logging.error("Bluetooth GATT disconnect error (ignored)", e); - // We might have already lost the connection. - } finally { - this.duringExplicitConnectDisconnect--; - } - this.reconnectReadyPromise = new Promise((resolve) => - setTimeout(resolve, 3_500) - ); - } - - async reconnect(finalAttempt: boolean = false): Promise { - this.finalAttempt = finalAttempt; - this.logging.log("Bluetooth reconnect"); - this.isReconnect = true; - 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(); - } - - handleDisconnectEvent = async (): Promise => { - // this.outputWriteQueue = { busy: false, queue: [] }; - - try { - if (!this.duringExplicitConnectDisconnect) { - this.logging.log( - "Bluetooth GATT disconnected... automatically trying reconnect" - ); - // stateOnReconnectionAttempt(); - await this.reconnect(); - } else { - this.logging.log( - "Bluetooth GATT disconnect ignored during explicit disconnect" - ); - } - } catch (e) { - this.logging.error( - "Bluetooth connect triggered by disconnect listener failed", - e - ); - } - }; - - private assertGattServer(): BluetoothRemoteGATTServer { - if (!this.device.gatt?.connected) { - throw new Error("Could not listen to services, no microbit connected!"); - } - return this.device.gatt; - } - /** - * Fetches the model number of the micro:bit. - * @param {BluetoothRemoteGATTServer} gattServer The GATT server to read from. - * @return {Promise} The model number of the micro:bit. 1 for the original, 2 for the new. - */ - private async getDeviceVersion(): Promise { - this.assertGattServer(); - const serviceMeta = profile.deviceInformation; - try { - const deviceInfo = await this.assertGattServer().getPrimaryService( - serviceMeta.id - ); - const characteristic = await deviceInfo.getCharacteristic( - serviceMeta.characteristics.modelNumber.id - ); - const modelNumberBytes = await characteristic.readValue(); - const modelNumber = new TextDecoder().decode(modelNumberBytes); - if (modelNumber.toLowerCase() === "BBC micro:bit".toLowerCase()) { - return DeviceVersion.V1; - } - if ( - modelNumber.toLowerCase().includes("BBC micro:bit v2".toLowerCase()) - ) { - return DeviceVersion.V2; - } - throw new Error(`Unexpected model number ${modelNumber}`); - } catch (e) { - this.logging.error("Could not read model number", e); - throw new Error("Could not read model number"); - } - } - - private requestDevice = async ( - name: string - ): Promise => { - try { - // In some situations the Chrome device prompt simply doesn't appear so we time this out after 30 seconds and reload the page - // TODO: give control over this to the caller - const result = await Promise.race([ - navigator.bluetooth.requestDevice({ - // TODO: this is limiting - filters: [{ namePrefix: `BBC micro:bit [${name}]` }], - optionalServices: [ - // TODO: include everything or perhaps paramterise? - profile.uart.id, - profile.accelerometer.id, - profile.deviceInformation.id, - profile.led.id, - profile.io.id, - profile.button.id, - ], - }), - new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), requestDeviceTimeoutDuration) - ), - ]); - if (result === "timeout") { - // btSelectMicrobitDialogOnLoad.set(true); - window.location.reload(); - return undefined; - } - return result; - } catch (e) { - this.logging.error("Bluetooth request device failed/cancelled", e); - return undefined; - } - }; -} diff --git a/lib/bluetooth-device-wrapper.ts b/lib/bluetooth-device-wrapper.ts index 0b57a41..bb828a7 100644 --- a/lib/bluetooth-device-wrapper.ts +++ b/lib/bluetooth-device-wrapper.ts @@ -5,7 +5,7 @@ */ import { profile } from "./bluetooth-profile"; -import { DeviceVersion } from "./device-version"; +import { BoardVersion } from "./device"; import { Logging, NullLogging } from "./logging"; const deviceIdToWrapper: Map = new Map(); @@ -38,7 +38,7 @@ export class BluetoothDeviceWrapper { // // On Windows it times out after 7s. // https://bugs.chromium.org/p/chromium/issues/detail?id=684073 - private gattConnectPromise: Promise | undefined; + private gattConnectPromise: Promise | undefined; private disconnectPromise: Promise | undefined; private connecting = false; private isReconnect = false; @@ -88,7 +88,7 @@ export class BluetoothDeviceWrapper { // We always do this even if we might immediately disconnect as disconnecting // without using services causes getPrimaryService calls to hang on subsequent // reconnect - probably a device-side issue. - const deviceVersion = await this.getDeviceVersion(); + const boardVersion = await this.getBoardVersion(); // This connection could be arbitrarily later when our manual timeout may have passed. // Do we still want to be connected? if (!this.connecting) { @@ -104,7 +104,7 @@ export class BluetoothDeviceWrapper { "Bluetooth GATT server connected when connecting" ); } - return deviceVersion; + return boardVersion; }) .catch((e) => { if (this.connecting) { @@ -124,7 +124,7 @@ export class BluetoothDeviceWrapper { }); this.connecting = true; - let deviceVersion: DeviceVersion | undefined; + let boardVersion: BoardVersion | undefined; try { const gattConnectResult = await Promise.race([ this.gattConnectPromise, @@ -136,7 +136,7 @@ export class BluetoothDeviceWrapper { this.logging.log("Bluetooth GATT server connect timeout"); throw new Error("Bluetooth GATT server connect timeout"); } - deviceVersion = gattConnectResult; + boardVersion = gattConnectResult; } finally { this.connecting = false; } @@ -229,12 +229,8 @@ export class BluetoothDeviceWrapper { } return this.device.gatt; } - /** - * Fetches the model number of the micro:bit. - * @param {BluetoothRemoteGATTServer} gattServer The GATT server to read from. - * @return {Promise} The model number of the micro:bit. 1 for the original, 2 for the new. - */ - private async getDeviceVersion(): Promise { + + private async getBoardVersion(): Promise { this.assertGattServer(); const serviceMeta = profile.deviceInformation; try { @@ -247,12 +243,12 @@ export class BluetoothDeviceWrapper { const modelNumberBytes = await characteristic.readValue(); const modelNumber = new TextDecoder().decode(modelNumberBytes); if (modelNumber.toLowerCase() === "BBC micro:bit".toLowerCase()) { - return DeviceVersion.V1; + return "V1"; } if ( modelNumber.toLowerCase().includes("BBC micro:bit v2".toLowerCase()) ) { - return DeviceVersion.V2; + return "V2"; } throw new Error(`Unexpected model number ${modelNumber}`); } catch (e) { diff --git a/lib/bluetooth.ts b/lib/bluetooth.ts index 7769675..78d62e6 100644 --- a/lib/bluetooth.ts +++ b/lib/bluetooth.ts @@ -3,36 +3,32 @@ * * SPDX-License-Identifier: MIT */ -import { Logging, NullLogging } from "./logging"; -import { withTimeout, TimeoutError } from "./async-util"; -import { DAPWrapper } from "./dap-wrapper"; -import { PartialFlashing } from "./partial-flashing"; +import { withTimeout } from "./async-util"; +import { createBluetoothDeviceWrapper } from "./bluetooth-device-wrapper"; +import { profile } from "./bluetooth-profile"; import { BoardVersion, - ConnectionStatus, ConnectOptions, + ConnectionStatus, + ConnectionStatusEvent, DeviceConnection, DeviceConnectionEventMap, EndUSBSelect, FlashDataSource, - FlashEvent, - HexGenerationError, - MicrobitWebUSBConnectionOptions, - SerialDataEvent, - SerialErrorEvent, SerialResetEvent, StartUSBSelect, - ConnectionStatusEvent, - WebUSBError, } from "./device"; import { TypedEventTarget } from "./events"; -import { createBluetoothDeviceWrapper } from "./bluetooth-device-wrapper"; -import { profile } from "./bluetooth-profile"; +import { Logging, NullLogging } from "./logging"; const requestDeviceTimeoutDuration: number = 30000; // After how long should we consider the connection lost if ping was not able to conclude? const connectionLostTimeoutDuration: number = 3000; +export interface MicrobitWebBluetoothConnectionOptions { + logging?: Logging; +} + /** * A Bluetooth connection to a micro:bit device. */ @@ -53,12 +49,11 @@ export class MicrobitWebBluetoothConnection private logging: Logging; connection: any; + flashing: boolean; - constructor( - options: MicrobitWebUSBConnectionOptions = { logging: new NullLogging() } - ) { + constructor(options: MicrobitWebBluetoothConnectionOptions = {}) { super(); - this.logging = options.logging; + this.logging = options.logging || new NullLogging(); } private log(v: any) { @@ -78,10 +73,8 @@ export class MicrobitWebBluetoothConnection } async connect(options: ConnectOptions = {}): Promise { - return this.withEnrichedErrors(async () => { - await this.connectInternal(options); - return this.status; - }); + await this.connectInternal(options); + return this.status; } getBoardVersion(): BoardVersion | null { @@ -105,74 +98,7 @@ export class MicrobitWebBluetoothConnection progress: (percentage: number | undefined) => void; } ): Promise { - this.flashing = true; - try { - const startTime = new Date().getTime(); - await this.withEnrichedErrors(() => - this.flashInternal(dataSource, options) - ); - this.dispatchTypedEvent("flash", new FlashEvent()); - - const flashTime = new Date().getTime() - startTime; - this.logging.event({ - type: "WebUSB-time", - detail: { - flashTime, - }, - }); - this.logging.log("Flash complete"); - } finally { - this.flashing = false; - } - } - - private async flashInternal( - dataSource: FlashDataSource, - options: { - partial: boolean; - progress: (percentage: number | undefined, partial: boolean) => void; - } - ): Promise { - this.log("Stopping serial before flash"); - await this.stopSerialInternal(); - this.log("Reconnecting before flash"); - await this.connectInternal({ - serial: false, - }); - if (!this.connection) { - throw new Error("Must be connected now"); - } - - const partial = options.partial; - const progress = options.progress || (() => {}); - - const boardId = this.connection.boardSerialInfo.id; - const flashing = new PartialFlashing(this.connection, this.logging); - let wasPartial: boolean = false; - try { - if (partial) { - wasPartial = await flashing.flashAsync(boardId, dataSource, progress); - } else { - await flashing.fullFlashAsync(boardId, dataSource, progress); - } - } finally { - progress(undefined, wasPartial); - - if (this.disconnectAfterFlash) { - this.log("Disconnecting after flash due to tab visibility"); - this.disconnectAfterFlash = false; - await this.disconnect(); - this.visibilityReconnect = true; - } else { - // This might not strictly be "reinstating". We should make this - // behaviour configurable when pulling out a library. - this.log("Reinstating serial after flash"); - if (this.connection.daplink) { - await this.connection.daplink.connect(); - await this.startSerialInternal(); - } - } - } + throw new Error("Unsupported"); } private async startSerialInternal() { @@ -181,23 +107,12 @@ export class MicrobitWebBluetoothConnection // so handle this gracefully. return; } - if (this.serialReadInProgress) { - await this.stopSerialInternal(); - } - // This is async but won't return until we stop serial so we error handle with an event. - this.serialReadInProgress = this.connection - .startSerial(this.serialListener) - .then(() => this.log("Finished listening for serial data")) - .catch((e) => { - this.dispatchTypedEvent("serial_error", new SerialErrorEvent(e)); - }); + // TODO } private async stopSerialInternal() { - if (this.connection && this.serialReadInProgress) { - this.connection.stopSerial(this.serialListener); - await this.serialReadInProgress; - this.serialReadInProgress = undefined; + if (this.connection) { + // TODO this.dispatchTypedEvent("serial_reset", new SerialResetEvent()); } } @@ -211,7 +126,7 @@ export class MicrobitWebBluetoothConnection } catch (e) { this.log("Error during disconnection:\r\n" + e); this.logging.event({ - type: "WebUSB-error", + type: "Bluetooth-error", message: "error-disconnecting", }); } finally { @@ -219,7 +134,7 @@ export class MicrobitWebBluetoothConnection this.setStatus(ConnectionStatus.NOT_CONNECTED); this.logging.log("Disconnection complete"); this.logging.event({ - type: "WebUSB-info", + type: "Bluetooth-info", message: "disconnected", }); } @@ -227,69 +142,16 @@ export class MicrobitWebBluetoothConnection private setStatus(newStatus: ConnectionStatus) { this.status = newStatus; - this.visibilityReconnect = false; this.log("Device status " + newStatus); this.dispatchTypedEvent("status", new ConnectionStatusEvent(newStatus)); } - private async withEnrichedErrors(f: () => Promise): Promise { - try { - return await f(); - } catch (e: any) { - if (e instanceof HexGenerationError) { - throw e; - } - - // Log error to console for feedback - this.log("An error occurred whilst attempting to use Bluetooth."); - this.log( - "Details of the error can be found below, and may be useful when trying to replicate and debug the error." - ); - this.log(e); - - // Disconnect from the microbit. - // Any new connection reallocates all the internals. - // Use the top-level API so any listeners reflect that we're disconnected. - await this.disconnect(); - - const enriched = enrichedError(e); - // Sanitise error message, replace all special chars with '-', if last char is '-' remove it - const errorMessage = e.message - ? e.message.replace(/\W+/g, "-").replace(/\W$/, "").toLowerCase() - : ""; - - this.logging.event({ - type: "WebUSB-error", - message: e.code + "/" + errorMessage, - }); - throw enriched; - } - } - serialWrite(data: string): Promise { - return this.withEnrichedErrors(async () => { - if (this.connection) { - // Using WebUSB/DAPJs we're limited to 64 byte packet size with a two byte header. - // https://github.com/microbit-foundation/python-editor-v3/issues/215 - const maxSerialWrite = 62; - let start = 0; - while (start < data.length) { - const end = Math.min(start + maxSerialWrite, data.length); - const chunkData = data.slice(start, end); - await this.connection.daplink.serialWrite(chunkData); - start = end; - } - } - }); - } - - private handleDisconnect = (event: Event) => { - if (event.device === this.device) { - this.connection = undefined; - this.device = undefined; - this.setStatus(ConnectionStatus.NO_AUTHORIZED_DEVICE); + if (this.connection) { + // TODO } - }; + return Promise.resolve(); + } async clearDevice(): Promise { await this.disconnect(); @@ -300,6 +162,9 @@ export class MicrobitWebBluetoothConnection private async connectInternal(options: ConnectOptions): Promise { if (!this.connection) { const device = await this.chooseDevice(); + if (!device) { + return; + } this.connection = createBluetoothDeviceWrapper(device, this.logging); } await withTimeout(this.connection.reconnectAsync(), 10_000); @@ -350,64 +215,3 @@ export class MicrobitWebBluetoothConnection } } } - -const genericErrorSuggestingReconnect = (e: any) => - new WebUSBError({ - code: "reconnect-microbit", - message: e.message, - }); - -// tslint:disable-next-line: no-any -const enrichedError = (err: any): WebUSBError => { - if (err instanceof WebUSBError) { - return err; - } - if (err instanceof TimeoutError) { - return new WebUSBError({ - code: "timeout-error", - message: err.message, - }); - } - - switch (typeof err) { - case "object": - // We might get Error objects as Promise rejection arguments - if (!err.message && err.promise && err.reason) { - err = err.reason; - } - // This is somewhat fragile but worth it for scenario specific errors. - // These messages changed to be prefixed in 2023 so we've relaxed the checks. - if (/No valid interfaces found/.test(err.message)) { - // This comes from DAPjs's WebUSB open. - return new WebUSBError({ - code: "update-req", - message: err.message, - }); - } else if (/No device selected/.test(err.message)) { - return new WebUSBError({ - code: "no-device-selected", - message: err.message, - }); - } else if (/Unable to claim interface/.test(err.message)) { - return new WebUSBError({ - code: "clear-connect", - message: err.message, - }); - } else if (err.name === "device-disconnected") { - return new WebUSBError({ - code: "device-disconnected", - message: err.message, - }); - } else { - // Unhandled error. User will need to reconnect their micro:bit - return genericErrorSuggestingReconnect(err); - } - case "string": { - // Caught a string. Example case: "Flash error" from DAPjs - return genericErrorSuggestingReconnect(err); - } - default: { - return genericErrorSuggestingReconnect(err); - } - } -}; diff --git a/lib/board-id.ts b/lib/board-id.ts index 6df8c27..1465159 100644 --- a/lib/board-id.ts +++ b/lib/board-id.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: MIT */ +import { BoardVersion } from "./device"; + /** * Validates micro:bit board IDs. */ @@ -17,6 +19,10 @@ export class BoardId { } } + toBoardVersion(): BoardVersion { + return this.isV1() ? "V1" : "V2"; + } + isV1(): boolean { return this.id === 0x9900 || this.id === 0x9901; } diff --git a/lib/device-version.ts b/lib/device-version.ts deleted file mode 100644 index c626a49..0000000 --- a/lib/device-version.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const enum DeviceVersion { - V1, - V2, -} From 3cc433de6ea2202e711a38c36a42405268802297 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 10 Jul 2024 09:04:01 +0100 Subject: [PATCH 05/20] WIP --- lib/bluetooth-device-wrapper.ts | 9 ++++----- lib/bluetooth.ts | 35 +++++++++++++++++---------------- lib/device.ts | 32 +++++++++++------------------- lib/main.ts | 2 +- lib/mock.ts | 2 +- lib/simulator.ts | 4 ++-- lib/webusb.ts | 22 +++++++++++++-------- package.json | 1 + 8 files changed, 53 insertions(+), 54 deletions(-) diff --git a/lib/bluetooth-device-wrapper.ts b/lib/bluetooth-device-wrapper.ts index bb828a7..f90aec0 100644 --- a/lib/bluetooth-device-wrapper.ts +++ b/lib/bluetooth-device-wrapper.ts @@ -38,7 +38,7 @@ export class BluetoothDeviceWrapper { // // On Windows it times out after 7s. // https://bugs.chromium.org/p/chromium/issues/detail?id=684073 - private gattConnectPromise: Promise | undefined; + private gattConnectPromise: Promise | undefined; private disconnectPromise: Promise | undefined; private connecting = false; private isReconnect = false; @@ -46,6 +46,8 @@ export class BluetoothDeviceWrapper { // Whether this is the final reconnection attempt. private finalAttempt = false; + boardVersion: BoardVersion | null; + constructor( public readonly device: BluetoothDevice, private logging: Logging = new NullLogging() @@ -88,7 +90,7 @@ export class BluetoothDeviceWrapper { // We always do this even if we might immediately disconnect as disconnecting // without using services causes getPrimaryService calls to hang on subsequent // reconnect - probably a device-side issue. - const boardVersion = await this.getBoardVersion(); + this.boardVersion = await this.getBoardVersion(); // This connection could be arbitrarily later when our manual timeout may have passed. // Do we still want to be connected? if (!this.connecting) { @@ -104,7 +106,6 @@ export class BluetoothDeviceWrapper { "Bluetooth GATT server connected when connecting" ); } - return boardVersion; }) .catch((e) => { if (this.connecting) { @@ -124,7 +125,6 @@ export class BluetoothDeviceWrapper { }); this.connecting = true; - let boardVersion: BoardVersion | undefined; try { const gattConnectResult = await Promise.race([ this.gattConnectPromise, @@ -136,7 +136,6 @@ export class BluetoothDeviceWrapper { this.logging.log("Bluetooth GATT server connect timeout"); throw new Error("Bluetooth GATT server connect timeout"); } - boardVersion = gattConnectResult; } finally { this.connecting = false; } diff --git a/lib/bluetooth.ts b/lib/bluetooth.ts index 78d62e6..5424ef0 100644 --- a/lib/bluetooth.ts +++ b/lib/bluetooth.ts @@ -3,8 +3,10 @@ * * SPDX-License-Identifier: MIT */ -import { withTimeout } from "./async-util"; -import { createBluetoothDeviceWrapper } from "./bluetooth-device-wrapper"; +import { + BluetoothDeviceWrapper, + createBluetoothDeviceWrapper, +} from "./bluetooth-device-wrapper"; import { profile } from "./bluetooth-profile"; import { BoardVersion, @@ -13,10 +15,10 @@ import { ConnectionStatusEvent, DeviceConnection, DeviceConnectionEventMap, - EndUSBSelect, + AfterRequestDevice, FlashDataSource, SerialResetEvent, - StartUSBSelect, + BeforeRequestDevice, } from "./device"; import { TypedEventTarget } from "./events"; import { Logging, NullLogging } from "./logging"; @@ -48,7 +50,7 @@ export class MicrobitWebBluetoothConnection private device: BluetoothDevice | undefined; private logging: Logging; - connection: any; + connection: BluetoothDeviceWrapper | undefined; flashing: boolean; constructor(options: MicrobitWebBluetoothConnectionOptions = {}) { @@ -81,8 +83,7 @@ export class MicrobitWebBluetoothConnection if (!this.connection) { return null; } - const boardId = this.connection.boardSerialInfo.id; - return boardId.isV1() ? "V1" : boardId.isV2() ? "V2" : null; + return this.connection.boardVersion; } async flash( @@ -113,15 +114,14 @@ export class MicrobitWebBluetoothConnection private async stopSerialInternal() { if (this.connection) { // TODO - this.dispatchTypedEvent("serial_reset", new SerialResetEvent()); + this.dispatchTypedEvent("serialreset", new SerialResetEvent()); } } async disconnect(): Promise { try { if (this.connection) { - await this.stopSerialInternal(); - await this.connection.disconnectAsync(); + await this.connection.disconnect(); } } catch (e) { this.log("Error during disconnection:\r\n" + e); @@ -165,12 +165,13 @@ export class MicrobitWebBluetoothConnection if (!device) { return; } - this.connection = createBluetoothDeviceWrapper(device, this.logging); - } - await withTimeout(this.connection.reconnectAsync(), 10_000); - if (options.serial === undefined || options.serial) { - this.startSerialInternal(); + this.connection = await createBluetoothDeviceWrapper( + device, + this.logging + ); } + // TODO: timeout unification? + this.connection?.connect(); this.setStatus(ConnectionStatus.CONNECTED); } @@ -178,7 +179,7 @@ export class MicrobitWebBluetoothConnection if (this.device) { return this.device; } - this.dispatchTypedEvent("start_usb_select", new StartUSBSelect()); + this.dispatchTypedEvent("beforerequestdevice", new BeforeRequestDevice()); try { // In some situations the Chrome device prompt simply doesn't appear so we time this out after 30 seconds and reload the page // TODO: give control over this to the caller @@ -211,7 +212,7 @@ export class MicrobitWebBluetoothConnection this.logging.error("Bluetooth request device failed/cancelled", e); return undefined; } finally { - this.dispatchTypedEvent("end_usb_select", new EndUSBSelect()); + this.dispatchTypedEvent("afterrequestdevice", new AfterRequestDevice()); } } } diff --git a/lib/device.ts b/lib/device.ts index 6fe37e0..08821f8 100644 --- a/lib/device.ts +++ b/lib/device.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: MIT */ import { TypedEventTarget } from "./events"; -import { Logging } from "./logging"; import { BoardId } from "./board-id"; /** @@ -55,13 +54,6 @@ export class WebUSBError extends Error { } } -export interface MicrobitWebUSBConnectionOptions { - // We should copy this type when extracting a library, and make it optional. - // Coupling for now to make it easy to evolve. - - logging: Logging; -} - /** * Tracks WebUSB connection status. */ @@ -135,19 +127,19 @@ export class ConnectionStatusEvent extends Event { export class SerialDataEvent extends Event { constructor(public readonly data: string) { - super("serial_data"); + super("serialdata"); } } export class SerialResetEvent extends Event { constructor() { - super("serial_reset"); + super("serialreset"); } } export class SerialErrorEvent extends Event { constructor(public readonly error: unknown) { - super("serial_error"); + super("serialerror"); } } @@ -157,26 +149,26 @@ export class FlashEvent extends Event { } } -export class StartUSBSelect extends Event { +export class BeforeRequestDevice extends Event { constructor() { - super("start_usb_select"); + super("beforerequestdevice"); } } -export class EndUSBSelect extends Event { +export class AfterRequestDevice extends Event { constructor() { - super("end_usb_select"); + super("afterrequestdevice"); } } export class DeviceConnectionEventMap { "status": ConnectionStatusEvent; - "serial_data": SerialDataEvent; - "serial_reset": Event; - "serial_error": Event; + "serialdata": SerialDataEvent; + "serialreset": Event; + "serialerror": Event; "flash": Event; - "start_usb_select": Event; - "end_usb_select": Event; + "beforerequestdevice": Event; + "afterrequestdevice": Event; } export interface DeviceConnection diff --git a/lib/main.ts b/lib/main.ts index a270320..5cea53d 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -1,7 +1,7 @@ import { MicrobitWebUSBConnection } from "./webusb"; import { MicrobitWebBluetoothConnection } from "./bluetooth"; import { BoardId } from "./board-id"; -import { DeviceConnection } from "./device"; +import type { DeviceConnection } from "./device"; export { MicrobitWebUSBConnection, diff --git a/lib/mock.ts b/lib/mock.ts index 45267df..f13d012 100644 --- a/lib/mock.ts +++ b/lib/mock.ts @@ -41,7 +41,7 @@ export class MockDeviceConnection } mockSerialWrite(data: string) { - this.dispatchTypedEvent("serial_data", new SerialDataEvent(data)); + this.dispatchTypedEvent("serialdata", new SerialDataEvent(data)); } mockConnect(code: WebUSBErrorCode) { diff --git a/lib/simulator.ts b/lib/simulator.ts index 382ce3b..ee80bbb 100644 --- a/lib/simulator.ts +++ b/lib/simulator.ts @@ -258,7 +258,7 @@ export class SimulatorDeviceConnection case "serial_output": { const text = event.data.data; if (typeof text === "string") { - this.dispatchTypedEvent("serial_data", new SerialDataEvent(text)); + this.dispatchTypedEvent("serialdata", new SerialDataEvent(text)); } break; } @@ -329,7 +329,7 @@ export class SimulatorDeviceConnection private notifyResetComms() { // Might be nice to rework so this was all about connection state changes. - this.dispatchTypedEvent("serial_reset", new SerialResetEvent()); + this.dispatchTypedEvent("serialreset", new SerialResetEvent()); this.dispatchTypedEvent("radio_reset", new RadioResetEvent()); } diff --git a/lib/webusb.ts b/lib/webusb.ts index 3f84ef4..9e521b4 100644 --- a/lib/webusb.ts +++ b/lib/webusb.ts @@ -13,15 +13,14 @@ import { ConnectOptions, DeviceConnection, DeviceConnectionEventMap, - EndUSBSelect, + AfterRequestDevice, FlashDataSource, FlashEvent, HexGenerationError, - MicrobitWebUSBConnectionOptions, SerialDataEvent, SerialErrorEvent, SerialResetEvent, - StartUSBSelect, + BeforeRequestDevice, ConnectionStatusEvent, WebUSBError, } from "./device"; @@ -34,6 +33,13 @@ export const isChromeOS105 = (): boolean => { return /CrOS/.test(userAgent) && /Chrome\/105\b/.test(userAgent); }; +export interface MicrobitWebUSBConnectionOptions { + // We should copy this type when extracting a library, and make it optional. + // Coupling for now to make it easy to evolve. + + logging: Logging; +} + /** * A WebUSB connection to a micro:bit device. */ @@ -63,7 +69,7 @@ export class MicrobitWebUSBConnection private serialReadInProgress: Promise | undefined; private serialListener = (data: string) => { - this.dispatchTypedEvent("serial_data", new SerialDataEvent(data)); + this.dispatchTypedEvent("serialdata", new SerialDataEvent(data)); }; private flashing: boolean = false; @@ -277,7 +283,7 @@ export class MicrobitWebUSBConnection .startSerial(this.serialListener) .then(() => this.log("Finished listening for serial data")) .catch((e) => { - this.dispatchTypedEvent("serial_error", new SerialErrorEvent(e)); + this.dispatchTypedEvent("serialerror", new SerialErrorEvent(e)); }); } @@ -286,7 +292,7 @@ export class MicrobitWebUSBConnection this.connection.stopSerial(this.serialListener); await this.serialReadInProgress; this.serialReadInProgress = undefined; - this.dispatchTypedEvent("serial_reset", new SerialResetEvent()); + this.dispatchTypedEvent("serialreset", new SerialResetEvent()); } } @@ -401,11 +407,11 @@ export class MicrobitWebUSBConnection if (this.device) { return this.device; } - this.dispatchTypedEvent("start_usb_select", new StartUSBSelect()); + this.dispatchTypedEvent("beforerequestdevice", new BeforeRequestDevice()); this.device = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x0d28, productId: 0x0204 }], }); - this.dispatchTypedEvent("end_usb_select", new EndUSBSelect()); + this.dispatchTypedEvent("afterrequestdevice", new AfterRequestDevice()); return this.device; } } diff --git a/package.json b/package.json index 76ec2e8..79d149d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "ci": "npm run build && npm run test && npx prettier --check src", "test": "vitest", "preview": "vite preview" }, From 3ecfaf53138cd45d75f70a9458f3c7328e114a5b Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 10 Jul 2024 13:18:49 +0100 Subject: [PATCH 06/20] More WIP --- .github/workflows/build.yml | 29 +++++++++++++++++++++++++++++ TODO.md | 4 ++++ lib/device.ts | 5 ----- lib/hex-flash-data-source.ts | 23 +++++++++++++++++++++++ package-lock.json | 22 ++++++++++++++++++++++ package.json | 1 + 6 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 TODO.md create mode 100644 lib/hex-flash-data-source.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b02e19b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: build +on: + release: + types: [created] + push: + branches: + - "**" +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "20.x" + registry-url: "https://npm.pkg.github.com" + cache: npm + - uses: microbit-foundation/npm-package-versioner-action@v1 + - run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: npm run ci + - run: npm publish + if: false && github.ref == 'refs/heads/main' || github.event_name == 'release' && github.event.action == 'created' + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..66d05a4 --- /dev/null +++ b/TODO.md @@ -0,0 +1,4 @@ +- Figure out the interface for flash + - Remove the sim? It's unrelated really. + - Add a JS partial flashing implementation, perhaps based on the prototype app + - Consider a full flash implementation later... for now we'll need to indicate lack of support somehow with a structured error code? diff --git a/lib/device.ts b/lib/device.ts index 08821f8..a2b9e56 100644 --- a/lib/device.ts +++ b/lib/device.ts @@ -106,11 +106,6 @@ export interface FlashDataSource { * @throws HexGenerationError if we cannot generate hex data. */ fullFlashData(boardId: BoardId): Promise; - - /** - * The file system represented by file name keys and data values. - */ - files(): Promise>; } export interface ConnectOptions { diff --git a/lib/hex-flash-data-source.ts b/lib/hex-flash-data-source.ts new file mode 100644 index 0000000..e8e31fb --- /dev/null +++ b/lib/hex-flash-data-source.ts @@ -0,0 +1,23 @@ +import { BoardId } from "./board-id"; +import { FlashDataSource } from "./device"; +import { + isUniversalHex, + separateUniversalHex, +} from "@microbit/microbit-universal-hex"; + +class HexFlashDataSource implements FlashDataSource { + constructor(private hex: string) { + if (isUniversalHex(hex)) { + const parts = separateUniversalHex(hex); + // Ho hum, what do we do with this? + parts[0].hex; + } + } + + partialFlashData(boardId: BoardId): Promise { + throw new Error("Method not implemented."); + } + fullFlashData(boardId: BoardId): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/package-lock.json b/package-lock.json index 8e45c18..7141ac5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "dapjs": "^2.2.0" }, "devDependencies": { + "@microbit/microbit-universal-hex": "^0.2.2", "@types/node": "^20.14.10", "jsdom": "^24.1.0", "prettier": "3.3.2", @@ -461,6 +462,20 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microbit/microbit-universal-hex": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@microbit/microbit-universal-hex/-/microbit-universal-hex-0.2.2.tgz", + "integrity": "sha512-qyFt8ATgxAyPkNz9Yado4HXEeCctwP/8L1/v2hFLeVUqw/HFqVqV4piJbqRMmyOefMcQ9OyVPhLXjtbKn9063Q==", + "dev": true, + "engines": { + "node": ">=8.5", + "npm": ">=6.0", + "yarn": "^1.0" + }, + "peerDependencies": { + "tslib": ">=1.11.1" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", @@ -1679,6 +1694,13 @@ "node": ">=18" } }, + "node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true, + "peer": true + }, "node_modules/typescript": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", diff --git a/package.json b/package.json index 79d149d..fec52a0 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "preview": "vite preview" }, "devDependencies": { + "@microbit/microbit-universal-hex": "^0.2.2", "@types/node": "^20.14.10", "jsdom": "^24.1.0", "prettier": "3.3.2", From 6e3938f62d8cf5e068997f7abbcae16f3dcbf58c Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 10 Jul 2024 13:30:58 +0100 Subject: [PATCH 07/20] WIP --- lib/radio-bridge.ts | 80 ++++++++++++++++++++++++--------------------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/lib/radio-bridge.ts b/lib/radio-bridge.ts index b1c7de6..0135197 100644 --- a/lib/radio-bridge.ts +++ b/lib/radio-bridge.ts @@ -5,13 +5,16 @@ */ import { MicrobitWebUSBConnection } from "./webusb"; +import { DeviceConnection } from "./device"; import * as protocol from "./serial-protocol"; -import { ConnectionType } from "../stores/uiStore"; +import { Logging } from "./logging"; + +const connectTimeoutDuration: number = 10000; class BridgeError extends Error {} class RemoteError extends Error {} -export class MicrobitSerial implements MicrobitConnection { +export class MicrobitRadioBridgeConnection implements DeviceConnection { private responseMap = new Map< number, ( @@ -29,18 +32,20 @@ export class MicrobitSerial implements MicrobitConnection { private finalAttempt = false; constructor( - private usb: MicrobitUSB, + private usb: MicrobitWebUSBConnection, + private logging: Logging, private remoteDeviceId: number ) {} - async connect(...states: DeviceRequestStates[]): Promise { - logEvent({ + async connect(): Promise { + this.logging.event({ type: this.isReconnect ? "Reconnect" : "Connect", - action: "Serial connect start", - states, + message: "Serial connect start", }); if (this.isConnecting) { - logMessage("Skipping connect attempt when one is already in progress"); + this.logging.log( + "Skipping connect attempt when one is already in progress" + ); return; } this.isConnecting = true; @@ -49,7 +54,7 @@ export class MicrobitSerial implements MicrobitConnection { let onPeriodicMessageRecieved: (() => void) | undefined; const handleError = (e: unknown) => { - logError("Serial error", e); + this.logging.error("Serial error", e); void this.disconnectInternal(false, "bridge"); }; const processMessage = (data: string) => { @@ -62,23 +67,23 @@ export class MicrobitSerial implements MicrobitConnection { const sensorData = protocol.processPeriodicMessage(msg); if (sensorData) { // stateOnReconnected(); - if (onPeriodicMessageRecieved) { - onPeriodicMessageRecieved(); - onPeriodicMessageRecieved = undefined; - } - onAccelerometerChange( - sensorData.accelerometerX, - sensorData.accelerometerY, - sensorData.accelerometerZ - ); - if (sensorData.buttonA !== previousButtonState.A) { - previousButtonState.A = sensorData.buttonA; - onButtonChange(sensorData.buttonA, "A"); - } - if (sensorData.buttonB !== previousButtonState.B) { - previousButtonState.B = sensorData.buttonB; - onButtonChange(sensorData.buttonB, "B"); - } + // if (onPeriodicMessageRecieved) { + // onPeriodicMessageRecieved(); + // onPeriodicMessageRecieved = undefined; + // } + // onAccelerometerChange( + // sensorData.accelerometerX, + // sensorData.accelerometerY, + // sensorData.accelerometerZ + // ); + // if (sensorData.buttonA !== previousButtonState.A) { + // previousButtonState.A = sensorData.buttonA; + // onButtonChange(sensorData.buttonA, "A"); + // } + // if (sensorData.buttonB !== previousButtonState.B) { + // previousButtonState.B = sensorData.buttonB; + // onButtonChange(sensorData.buttonB, "B"); + // } } else { const messageResponse = protocol.processResponseMessage(msg); if (!messageResponse) { @@ -111,14 +116,14 @@ export class MicrobitSerial implements MicrobitConnection { if ( this.lastReceivedMessageTimestamp && Date.now() - this.lastReceivedMessageTimestamp > - StaticConfiguration.connectTimeoutDuration + connectTimeoutDuration ) { await this.handleReconnect(); } }, 1000); } - logMessage(`Serial: using remote device id ${this.remoteDeviceId}`); + this.logging.log(`Serial: using remote device id ${this.remoteDeviceId}`); const remoteMbIdCommand = protocol.generateCmdRemoteMbId( this.remoteDeviceId ); @@ -134,7 +139,8 @@ export class MicrobitSerial implements MicrobitConnection { } // For now we only support input state. - if (states.includes(DeviceRequestStates.INPUT)) { + // TODO: when do we do this? + if (false) { // Request the micro:bit to start sending the periodic messages const startCmd = protocol.generateCmdStart({ accelerometer: true, @@ -159,7 +165,7 @@ export class MicrobitSerial implements MicrobitConnection { await periodicMessagePromise; } else { periodicMessagePromise.catch(async (e) => { - logError("Failed to initialise serial protocol", e); + this.logging.error("Failed to initialise serial protocol", e); await this.disconnectInternal(false, "remote"); this.isConnecting = false; }); @@ -168,17 +174,15 @@ export class MicrobitSerial implements MicrobitConnection { // stateOnAssigned(DeviceRequestStates.INPUT, this.usb.getModelNumber()); // stateOnReady(DeviceRequestStates.INPUT); - logEvent({ + this.logging.event({ type: this.isReconnect ? "Reconnect" : "Connect", action: "Serial connect success", - states, }); } catch (e) { - logError("Failed to initialise serial protocol", e); - logEvent({ + this.logging.error("Failed to initialise serial protocol", e); + this.logging.event({ type: this.isReconnect ? "Reconnect" : "Connect", - action: "Serial connect failed", - states, + message: "Serial connect failed", }); const reconnectHelp = e instanceof BridgeError ? "bridge" : "remote"; await this.disconnectInternal(false, reconnectHelp); @@ -310,9 +314,9 @@ export const startSerialConnection = async ( usb: MicrobitUSB, requestState: DeviceRequestStates, remoteDeviceId: number -): Promise => { +): Promise => { try { - const serial = new MicrobitSerial(usb, remoteDeviceId); + const serial = new MicrobitRadioBridgeConnection(usb, remoteDeviceId); await serial.connect(requestState); return serial; } catch (e) { From f70047b9ff098a2ebcd92a9a01b0c47373b462e1 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 10 Jul 2024 15:07:58 +0100 Subject: [PATCH 08/20] Work towards flashing --- lib/device.ts | 9 ++++---- lib/hex-flash-data-source.ts | 45 ++++++++++++++++++++++++++++-------- lib/logging.ts | 8 +++++-- lib/main.ts | 2 ++ package-lock.json | 11 ++++++++- package.json | 3 ++- src/demo.ts | 35 +++++++++++++++++++++++++--- 7 files changed, 93 insertions(+), 20 deletions(-) diff --git a/lib/device.ts b/lib/device.ts index a2b9e56..0a93461 100644 --- a/lib/device.ts +++ b/lib/device.ts @@ -92,7 +92,9 @@ export class HexGenerationError extends Error {} export interface FlashDataSource { /** - * The data required for a partial flash. + * For now we only support partially flashing contiguous data. + * This can be generated from microbit-fs directly (via getIntelHexBytes()) + * or from an existing Intel Hex via slicePad. * * @param boardId the id of the board. * @throws HexGenerationError if we cannot generate hex data. @@ -100,12 +102,11 @@ export interface FlashDataSource { partialFlashData(boardId: BoardId): Promise; /** - * A full hex. - * * @param boardId the id of the board. + * @returns A board-specific (non-universal) Intel Hex file for the given board id. * @throws HexGenerationError if we cannot generate hex data. */ - fullFlashData(boardId: BoardId): Promise; + fullFlashData(boardId: BoardId): Promise; } export interface ConnectOptions { diff --git a/lib/hex-flash-data-source.ts b/lib/hex-flash-data-source.ts index e8e31fb..97eb9a2 100644 --- a/lib/hex-flash-data-source.ts +++ b/lib/hex-flash-data-source.ts @@ -4,20 +4,47 @@ import { isUniversalHex, separateUniversalHex, } from "@microbit/microbit-universal-hex"; +import MemoryMap from "nrf-intel-hex"; -class HexFlashDataSource implements FlashDataSource { +export class HexFlashDataSource implements FlashDataSource { constructor(private hex: string) { - if (isUniversalHex(hex)) { - const parts = separateUniversalHex(hex); - // Ho hum, what do we do with this? - parts[0].hex; - } + console.log("Universal"); + console.log(hex); } partialFlashData(boardId: BoardId): Promise { - throw new Error("Method not implemented."); + const part = this.matchingPart(boardId); + const hex = MemoryMap.fromHex(part); + const keys = Array.from(hex.keys()); + const lastKey = keys[keys.length - 1]; + if (lastKey === undefined) { + throw new Error("Empty hex"); + } + const lastPart = hex.get(lastKey); + if (!lastPart) { + throw new Error("Empty hex"); + } + const length = lastKey + lastPart.length; + const data = hex.slicePad(0, length, 0); + console.log(data); + return Promise.resolve(data); } - fullFlashData(boardId: BoardId): Promise { - throw new Error("Method not implemented."); + + fullFlashData(boardId: BoardId): Promise { + const part = this.matchingPart(boardId); + console.log(part); + return Promise.resolve(part); + } + + private matchingPart(boardId: BoardId): string { + if (isUniversalHex(this.hex)) { + const parts = separateUniversalHex(this.hex); + const matching = parts.find((p) => p.boardId == boardId.normalize().id); + if (!matching) { + throw new Error("No matching part"); + } + return matching.hex; + } + return this.hex; } } diff --git a/lib/logging.ts b/lib/logging.ts index 522c806..c9c0e13 100644 --- a/lib/logging.ts +++ b/lib/logging.ts @@ -18,6 +18,10 @@ export interface Logging { export class NullLogging implements Logging { event(_event: Event): void {} - error(_m: string, _e: unknown): void {} - log(_e: any): void {} + error(_m: string, _e: unknown): void { + console.error(_m, _e); + } + log(_e: any): void { + console.log(_e); + } } diff --git a/lib/main.ts b/lib/main.ts index 5cea53d..4d47152 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -2,10 +2,12 @@ import { MicrobitWebUSBConnection } from "./webusb"; import { MicrobitWebBluetoothConnection } from "./bluetooth"; import { BoardId } from "./board-id"; import type { DeviceConnection } from "./device"; +import { HexFlashDataSource } from "./hex-flash-data-source"; export { MicrobitWebUSBConnection, MicrobitWebBluetoothConnection, BoardId, DeviceConnection, + HexFlashDataSource, }; diff --git a/package-lock.json b/package-lock.json index 7141ac5..1fa4776 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "@types/web-bluetooth": "^0.0.20", - "dapjs": "^2.2.0" + "dapjs": "^2.2.0", + "nrf-intel-hex": "^1.4.0" }, "devDependencies": { "@microbit/microbit-universal-hex": "^0.2.2", @@ -1353,6 +1354,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nrf-intel-hex": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/nrf-intel-hex/-/nrf-intel-hex-1.4.0.tgz", + "integrity": "sha512-q3+GGRIpe0VvCjUP1zaqW5rk6IpCZzhD0lu7Sguo1bgWwFcA9kZRjsaKUb0jBQMnefyOl5o0BBGAxvqMqYx8Sg==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nwsapi": { "version": "2.2.10", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz", diff --git a/package.json b/package.json index fec52a0..8427246 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@types/web-bluetooth": "^0.0.20", - "dapjs": "^2.2.0" + "dapjs": "^2.2.0", + "nrf-intel-hex": "^1.4.0" } } diff --git a/src/demo.ts b/src/demo.ts index a37da17..885ce11 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -5,26 +5,55 @@ */ import "./demo.css"; import { MicrobitWebUSBConnection } from "../lib/webusb"; +import { HexFlashDataSource } from "../lib/hex-flash-data-source"; +import { ConnectionStatus } from "../lib/device"; document.querySelector("#app")!.innerHTML = `

WebUSB

+ +

`; const connect = document.querySelector("#webusb > .connect")!; +const disconnect = document.querySelector("#webusb > .disconnect")!; const flash = document.querySelector("#webusb > .flash")!; +const fileInput = document.querySelector( + "#webusb input[type=file]" +)! as HTMLInputElement; +const statusParagraph = document.querySelector("#webusb > .status")!; const connection = new MicrobitWebUSBConnection(); +const initialisePromise = connection.initialize(); +const displayStatus = (status: ConnectionStatus) => { + statusParagraph.textContent = status.toString(); +}; connection.addEventListener("status", (event) => { - console.log(event.status); + displayStatus(event.status); }); +displayStatus(connection.status); connect.addEventListener("click", async () => { - await connection.initialize(); + await initialisePromise; await connection.connect(); }); +disconnect.addEventListener("click", async () => { + await initialisePromise; + await connection.disconnect(); +}); -flash.addEventListener("click", async () => {}); +flash.addEventListener("click", async () => { + const file = fileInput.files?.item(0); + if (file) { + const text = await file.text(); + await connection.flash(new HexFlashDataSource(text), { + partial: false, + progress: (percentage: number | undefined) => { + console.log(percentage); + }, + }); + } +}); From 7b86b9d06ba329766ae307c8b8119c4f850fe400 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 10 Jul 2024 16:13:59 +0100 Subject: [PATCH 09/20] Fix full flash --- lib/partial-flashing.ts | 7 +- lib/simulator.ts | 429 ---------------------------------------- tsconfig.json | 4 +- 3 files changed, 7 insertions(+), 433 deletions(-) delete mode 100644 lib/simulator.ts diff --git a/lib/partial-flashing.ts b/lib/partial-flashing.ts index ce2f375..6f39759 100644 --- a/lib/partial-flashing.ts +++ b/lib/partial-flashing.ts @@ -99,7 +99,10 @@ const stackAddr = 0x20001000; * Intented to be used for a single flash with a pre-connected DAPWrapper. */ export class PartialFlashing { - constructor(private dapwrapper: DAPWrapper, private logging: Logging) {} + constructor( + private dapwrapper: DAPWrapper, + private logging: Logging + ) {} private log(v: any): void { this.logging.log(v); @@ -260,7 +263,7 @@ export class PartialFlashing { try { const data = await dataSource.fullFlashData(boardId); await this.dapwrapper.transport.open(); - await this.dapwrapper.daplink.flash(data); + await this.dapwrapper.daplink.flash(new TextEncoder().encode(data)); this.logging.event({ type: "WebUSB-info", message: "full-flash-successful", diff --git a/lib/simulator.ts b/lib/simulator.ts deleted file mode 100644 index ee80bbb..0000000 --- a/lib/simulator.ts +++ /dev/null @@ -1,429 +0,0 @@ -/** - * (c) 2021, Micro:bit Educational Foundation and contributors - * - * SPDX-License-Identifier: MIT - */ -import { TypedEventTarget } from "./events"; -import { Logging } from "./logging"; -import { - BoardVersion, - ConnectionStatus, - DeviceConnection, - DeviceConnectionEventMap, - FlashDataSource, - FlashEvent, - SerialDataEvent, - SerialResetEvent, - ConnectionStatusEvent, -} from "./device"; - -// Simulator-only events. - -export class LogDataEvent extends Event { - constructor(public readonly log: DataLog) { - super("log_data"); - } -} - -export class RadioDataEvent extends Event { - constructor(public readonly text: string) { - super("radio_data"); - } -} - -export class RadioGroupEvent extends Event { - constructor(public readonly group: number) { - super("radio_group"); - } -} - -export class RadioResetEvent extends Event { - constructor() { - super("radio_reset"); - } -} - -export class StateChangeEvent extends Event { - constructor(public readonly state: SimulatorState) { - super("state_change"); - } -} - -export class RequestFlashEvent extends Event { - constructor() { - super("request_flash"); - } -} - -// It'd be nice to publish these types from the simulator project. - -export interface RadioState { - type: "radio"; - enabled: boolean; - group: number; -} - -export interface DataLoggingState { - type: "dataLogging"; - logFull: boolean; -} - -export interface RangeSensor { - type: "range"; - id: string; - value: number; - min: number; - max: number; - unit: number; - lowThreshold?: number; - highThreshold?: number; -} - -export interface EnumSensor { - type: "enum"; - id: string; - value: string; - choices: string[]; -} - -export type Sensor = RangeSensor | EnumSensor; - -export interface LogEntry { - headings?: string[]; - data?: string[]; -} - -export interface SimulatorState { - radio: RadioState; - - dataLogging: DataLoggingState; - - accelerometerX: RangeSensor; - accelerometerY: RangeSensor; - accelerometerZ: RangeSensor; - gesture: EnumSensor; - - compassX: RangeSensor; - compassY: RangeSensor; - compassZ: RangeSensor; - compassHeading: RangeSensor; - - pin0: RangeSensor; - pin1: RangeSensor; - pin2: RangeSensor; - pinLogo: RangeSensor; - - temperature: RangeSensor; - lightLevel: RangeSensor; - soundLevel: RangeSensor; - - buttonA: RangeSensor; - buttonB: RangeSensor; -} - -export type SimulatorStateKey = keyof SimulatorState; - -export type SensorStateKey = Extract< - SimulatorStateKey, - | "accelerometerX" - | "accelerometerY" - | "accelerometerZ" - | "compassX" - | "compassY" - | "compassZ" - | "compassHeading" - | "gesture" - | "pin0" - | "pin1" - | "pin2" - | "pinLogo" - | "temperature" - | "lightLevel" - | "soundLevel" - | "buttonA" - | "buttonB" ->; - -interface Config { - language: string; - translations: Record; -} - -export interface DataLog { - headings: string[]; - data: DataLogRow[]; -} - -export interface DataLogRow { - isHeading?: boolean; - data: string[]; -} - -const initialDataLog = (): DataLog => ({ - headings: [], - data: [], -}); - -class SimulatorEventMap extends DeviceConnectionEventMap { - "log_data": LogDataEvent; - "radio_data": RadioDataEvent; - "radio_group": RadioGroupEvent; - "radio_reset": RadioResetEvent; - "state_change": StateChangeEvent; - "request_flash": RequestFlashEvent; -} - -/** - * A simulated device. - * - * This communicates with the iframe that is used to embed the simulator. - */ -export class SimulatorDeviceConnection - extends TypedEventTarget - implements DeviceConnection -{ - status: ConnectionStatus = ConnectionStatus.NO_AUTHORIZED_DEVICE; - state: SimulatorState | undefined; - - log: DataLog = initialDataLog(); - - private messageListener = (event: MessageEvent) => { - const iframe = this.iframe(); - if (!iframe || event.source !== iframe.contentWindow || !event.data.kind) { - // Not an event for us. - return; - } - switch (event.data.kind) { - case "ready": { - const newState = event.data.state; - this.state = newState; - this.dispatchTypedEvent("state_change", new StateChangeEvent(newState)); - if (this.status !== ConnectionStatus.CONNECTED) { - this.setStatus(ConnectionStatus.CONNECTED); - } - break; - } - case "request_flash": { - this.dispatchTypedEvent("request_flash", new RequestFlashEvent()); - this.logging.event({ - type: "sim-user-start", - }); - break; - } - case "state_change": { - const updated = { - ...this.state, - ...event.data.change, - }; - this.state = updated; - this.dispatchTypedEvent("state_change", new StateChangeEvent(updated)); - break; - } - case "radio_output": { - // So this is a Uint8Array that may be prefixed with 0, 1, 0 bytes to indicate that it's a "string". - // Either way we only display strings for now so convert at this layer. - // If it's really binary data then TextEncoder will put replacement characters in and we'll live with that for now. - const message = event.data.data; - const text = new TextDecoder() - .decode(message) - // eslint-disable-next-line no-control-regex - .replace(/^\x01\x00\x01/, ""); - if (message instanceof Uint8Array) { - this.dispatchTypedEvent("radio_data", new RadioDataEvent(text)); - } - break; - } - case "log_output": { - const entry: LogEntry = event.data; - const result: DataLog = { - headings: entry.headings ?? this.log.headings, - data: this.log.data, - }; - // The first row is all-time headings row so don't show the initial set. - if (entry.headings && this.log.data.length > 0) { - result.data.push({ isHeading: true, data: entry.headings }); - } - if (entry.data) { - result.data.push({ data: entry.data }); - } - this.log = result; - this.dispatchTypedEvent("log_data", new LogDataEvent(this.log)); - break; - } - case "log_delete": { - this.log = initialDataLog(); - this.dispatchTypedEvent("log_data", new LogDataEvent(this.log)); - break; - } - case "serial_output": { - const text = event.data.data; - if (typeof text === "string") { - this.dispatchTypedEvent("serialdata", new SerialDataEvent(text)); - } - break; - } - case "internal_error": { - const error = event.data.error; - this.logging.error("Internal error", error); - break; - } - default: { - // Ignore unknown message. - } - } - }; - - constructor( - private logging: Logging, - private iframe: () => HTMLIFrameElement | null, - private sensorsLogged: Record = {} - ) { - super(); - } - - private logSensor(sensorId: string): void { - if (!this.sensorsLogged[sensorId]) { - this.logging.event({ - type: `sim-user-${sensorId}`, - }); - this.sensorsLogged[sensorId] = true; - } - } - - async initialize(): Promise { - window.addEventListener("message", this.messageListener); - this.setStatus(ConnectionStatus.NOT_CONNECTED); - } - - dispose() { - window.removeEventListener("message", this.messageListener); - } - - async connect(): Promise { - this.setStatus(ConnectionStatus.CONNECTED); - return this.status; - } - - getBoardVersion(): BoardVersion | null { - return "V2"; - } - - async flash( - dataSource: FlashDataSource, - options: { - partial: boolean; - progress: (percentage: number | undefined) => void; - } - ): Promise { - this.postMessage("flash", { - filesystem: await dataSource.files(), - }); - this.notifyResetComms(); - options.progress(undefined); - this.dispatchTypedEvent("flash", new FlashEvent()); - } - - configure(config: Config): void { - this.postMessage("config", config); - } - - private notifyResetComms() { - // Might be nice to rework so this was all about connection state changes. - this.dispatchTypedEvent("serialreset", new SerialResetEvent()); - this.dispatchTypedEvent("radio_reset", new RadioResetEvent()); - } - - async disconnect(): Promise { - window.removeEventListener("message", this.messageListener); - this.setStatus(ConnectionStatus.NOT_CONNECTED); - } - - async serialWrite(data: string): Promise { - this.postMessage("serial_input", { - data, - }); - } - - radioSend(message: string) { - const kind = "radio_input"; - const data = new TextEncoder().encode(message); - const prefixed = new Uint8Array(3 + data.length); - prefixed.set([1, 0, 1]); - prefixed.set(data, 3); - this.postMessage(kind, { data: prefixed }); - this.logSensor(kind); - } - - setSimulatorValue = async ( - id: SensorStateKey, - value: number | string - ): Promise => { - if (!this.state) { - throw new Error("Simulator not ready"); - } - // We don't get notified of our own changes, so update our state and notify. - this.state = { - ...this.state, - [id]: { - // Would be good to make this safe. - ...(this.state as any)[id], - value, - }, - }; - this.dispatchTypedEvent("state_change", new StateChangeEvent(this.state)); - this.postMessage("set_value", { - id, - value, - }); - this.logSensor(id); - }; - - stop = async (): Promise => { - this.postMessage("stop", {}); - }; - - reset = async (): Promise => { - this.postMessage("reset", {}); - this.notifyResetComms(); - this.logging.event({ - type: "sim-user-reset", - }); - }; - - mute = async (): Promise => { - this.postMessage("mute", {}); - this.logging.event({ - type: "sim-user-mute", - }); - }; - - unmute = async (): Promise => { - this.postMessage("unmute", {}); - this.logging.event({ - type: "sim-user-unmute", - }); - }; - - private setStatus(newStatus: ConnectionStatus) { - this.status = newStatus; - this.dispatchTypedEvent("status", new ConnectionStatusEvent(newStatus)); - } - - clearDevice(): void { - this.setStatus(ConnectionStatus.NO_AUTHORIZED_DEVICE); - } - - private postMessage(kind: string, data: any): void { - const iframe = this.iframe(); - if (!iframe) { - throw new Error("Missing simulator iframe."); - } - iframe.contentWindow!.postMessage( - { - kind, - ...data, - }, - "*" - ); - } -} diff --git a/tsconfig.json b/tsconfig.json index 5af9feb..a48da29 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, + "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src", "lib"] } From 10d91226995b370a11fe19c3fd4e0cd57bba4dc3 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 10 Jul 2024 16:30:37 +0100 Subject: [PATCH 10/20] WIP --- lib/bluetooth-device-wrapper.ts | 15 ++----- lib/bluetooth.ts | 12 ++--- lib/device.ts | 2 +- lib/mock.ts | 2 +- ...ap-wrapper.ts => webusb-device-wrapper.ts} | 7 ++- ...ls.ts => webusb-partial-flashing-utils.ts} | 0 ...flashing.ts => webusb-partial-flashing.ts} | 4 +- ...radio-bridge.ts => webusb-radio-bridge.ts} | 44 +++++++++++-------- ...-protocol.ts => webusb-serial-protocol.ts} | 0 lib/webusb.ts | 12 ++--- tsconfig.json | 3 +- 11 files changed, 49 insertions(+), 52 deletions(-) rename lib/{dap-wrapper.ts => webusb-device-wrapper.ts} (99%) rename lib/{partial-flashing-utils.ts => webusb-partial-flashing-utils.ts} (100%) rename lib/{partial-flashing.ts => webusb-partial-flashing.ts} (99%) rename lib/{radio-bridge.ts => webusb-radio-bridge.ts} (92%) rename lib/{serial-protocol.ts => webusb-serial-protocol.ts} (100%) diff --git a/lib/bluetooth-device-wrapper.ts b/lib/bluetooth-device-wrapper.ts index f90aec0..c603e3e 100644 --- a/lib/bluetooth-device-wrapper.ts +++ b/lib/bluetooth-device-wrapper.ts @@ -43,10 +43,8 @@ export class BluetoothDeviceWrapper { private connecting = false; private isReconnect = false; private reconnectReadyPromise: Promise | undefined; - // Whether this is the final reconnection attempt. - private finalAttempt = false; - boardVersion: BoardVersion | null; + boardVersion: BoardVersion | undefined; constructor( public readonly device: BluetoothDevice, @@ -98,7 +96,7 @@ export class BluetoothDeviceWrapper { "Bluetooth GATT server connect after timeout, triggering disconnect" ); this.disconnectPromise = (async () => { - await this.disconnectInternal(false, false); + await this.disconnectInternal(false); this.disconnectPromise = undefined; })(); } else { @@ -153,7 +151,6 @@ export class BluetoothDeviceWrapper { await this.disconnectInternal(false); throw new Error("Failed to establish a connection!"); } finally { - this.finalAttempt = false; this.duringExplicitConnectDisconnect--; } } @@ -162,10 +159,7 @@ export class BluetoothDeviceWrapper { return this.disconnectInternal(true); } - private async disconnectInternal( - userTriggered: boolean, - updateState: boolean = true - ): Promise { + private async disconnectInternal(userTriggered: boolean): Promise { this.logging.log( `Bluetooth disconnect ${userTriggered ? "(user triggered)" : "(programmatic)"}` ); @@ -185,8 +179,7 @@ export class BluetoothDeviceWrapper { ); } - async reconnect(finalAttempt: boolean = false): Promise { - this.finalAttempt = finalAttempt; + async reconnect(): Promise { this.logging.log("Bluetooth reconnect"); this.isReconnect = true; if (isWindowsOS) { diff --git a/lib/bluetooth.ts b/lib/bluetooth.ts index 5424ef0..b6e662c 100644 --- a/lib/bluetooth.ts +++ b/lib/bluetooth.ts @@ -24,8 +24,6 @@ import { TypedEventTarget } from "./events"; import { Logging, NullLogging } from "./logging"; const requestDeviceTimeoutDuration: number = 30000; -// After how long should we consider the connection lost if ping was not able to conclude? -const connectionLostTimeoutDuration: number = 3000; export interface MicrobitWebBluetoothConnectionOptions { logging?: Logging; @@ -51,7 +49,6 @@ export class MicrobitWebBluetoothConnection private logging: Logging; connection: BluetoothDeviceWrapper | undefined; - flashing: boolean; constructor(options: MicrobitWebBluetoothConnectionOptions = {}) { super(); @@ -79,11 +76,8 @@ export class MicrobitWebBluetoothConnection return this.status; } - getBoardVersion(): BoardVersion | null { - if (!this.connection) { - return null; - } - return this.connection.boardVersion; + getBoardVersion(): BoardVersion | undefined { + return this.connection?.boardVersion; } async flash( @@ -102,6 +96,7 @@ export class MicrobitWebBluetoothConnection throw new Error("Unsupported"); } + // @ts-ignore private async startSerialInternal() { if (!this.connection) { // As connecting then starting serial are async we could disconnect between them, @@ -111,6 +106,7 @@ export class MicrobitWebBluetoothConnection // TODO } + // @ts-ignore private async stopSerialInternal() { if (this.connection) { // TODO diff --git a/lib/device.ts b/lib/device.ts index 0a93461..c558847 100644 --- a/lib/device.ts +++ b/lib/device.ts @@ -193,7 +193,7 @@ export interface DeviceConnection * * @returns the board version or null if there is no connection. */ - getBoardVersion(): BoardVersion | null; + getBoardVersion(): BoardVersion | undefined; /** * Flash the micro:bit. diff --git a/lib/mock.ts b/lib/mock.ts index f13d012..4c1a7fa 100644 --- a/lib/mock.ts +++ b/lib/mock.ts @@ -62,7 +62,7 @@ export class MockDeviceConnection return this.status; } - getBoardVersion(): BoardVersion | null { + getBoardVersion(): BoardVersion | undefined { return "V2"; } diff --git a/lib/dap-wrapper.ts b/lib/webusb-device-wrapper.ts similarity index 99% rename from lib/dap-wrapper.ts rename to lib/webusb-device-wrapper.ts index 59b0c31..de217e1 100644 --- a/lib/dap-wrapper.ts +++ b/lib/webusb-device-wrapper.ts @@ -18,7 +18,7 @@ import { bufferConcat, CoreRegister, regRequest, -} from "./partial-flashing-utils"; +} from "./webusb-partial-flashing-utils"; import { BoardSerialInfo } from "./board-serial-info"; export class DAPWrapper { @@ -33,7 +33,10 @@ export class DAPWrapper { private initialConnectionComplete: boolean = false; - constructor(public device: USBDevice, private logging: Logging) { + constructor( + public device: USBDevice, + private logging: Logging + ) { this.transport = new WebUSB(this.device); this.daplink = new DAPLink(this.transport); this.cortexM = new CortexM(this.transport); diff --git a/lib/partial-flashing-utils.ts b/lib/webusb-partial-flashing-utils.ts similarity index 100% rename from lib/partial-flashing-utils.ts rename to lib/webusb-partial-flashing-utils.ts diff --git a/lib/partial-flashing.ts b/lib/webusb-partial-flashing.ts similarity index 99% rename from lib/partial-flashing.ts rename to lib/webusb-partial-flashing.ts index 6f39759..f12eab8 100644 --- a/lib/partial-flashing.ts +++ b/lib/webusb-partial-flashing.ts @@ -48,7 +48,7 @@ import { DAPLink } from "dapjs"; import { Logging } from "./logging"; import { withTimeout, TimeoutError } from "./async-util"; import { BoardId } from "./board-id"; -import { DAPWrapper } from "./dap-wrapper"; +import { DAPWrapper } from "./webusb-device-wrapper"; import { FlashDataSource } from "./device"; import { CoreRegister, @@ -56,7 +56,7 @@ import { Page, pageAlignBlocks, read32FromUInt8Array, -} from "./partial-flashing-utils"; +} from "./webusb-partial-flashing-utils"; type ProgressCallback = (n: number, partial: boolean) => void; diff --git a/lib/radio-bridge.ts b/lib/webusb-radio-bridge.ts similarity index 92% rename from lib/radio-bridge.ts rename to lib/webusb-radio-bridge.ts index 0135197..9acd034 100644 --- a/lib/radio-bridge.ts +++ b/lib/webusb-radio-bridge.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * (c) 2023, Center for Computational Thinking and Design at Aarhus University and contributors * @@ -5,8 +6,7 @@ */ import { MicrobitWebUSBConnection } from "./webusb"; -import { DeviceConnection } from "./device"; -import * as protocol from "./serial-protocol"; +import * as protocol from "./webusb-serial-protocol"; import { Logging } from "./logging"; const connectTimeoutDuration: number = 10000; @@ -14,7 +14,7 @@ const connectTimeoutDuration: number = 10000; class BridgeError extends Error {} class RemoteError extends Error {} -export class MicrobitRadioBridgeConnection implements DeviceConnection { +export class MicrobitRadioBridgeConnection { private responseMap = new Map< number, ( @@ -176,7 +176,7 @@ export class MicrobitRadioBridgeConnection implements DeviceConnection { // stateOnReady(DeviceRequestStates.INPUT); this.logging.event({ type: this.isReconnect ? "Reconnect" : "Connect", - action: "Serial connect success", + message: "Serial connect success", }); } catch (e) { this.logging.error("Failed to initialise serial protocol", e); @@ -203,10 +203,7 @@ export class MicrobitRadioBridgeConnection implements DeviceConnection { this.lastReceivedMessageTimestamp = undefined; } - private async disconnectInternal( - userDisconnect: boolean, - reconnectHelp: ConnectionType - ): Promise { + private async disconnectInternal(userDisconnect: boolean): Promise { this.stopConnectionCheck(); try { await this.sendCmdWaitResponse(protocol.generateCmdStop()); @@ -228,18 +225,25 @@ export class MicrobitRadioBridgeConnection implements DeviceConnection { async handleReconnect(): Promise { if (this.isConnecting) { - logMessage("Serial disconnect ignored... reconnect already in progress"); + this.logging.log( + "Serial disconnect ignored... reconnect already in progress" + ); return; } try { this.stopConnectionCheck(); - logMessage("Serial disconnected... automatically trying to reconnect"); + this.logging.log( + "Serial disconnected... automatically trying to reconnect" + ); this.responseMap.clear(); await this.usb.stopSerial(); await this.usb.softwareReset(); await this.reconnect(); } catch (e) { - logError("Serial connect triggered by disconnect listener failed", e); + this.logging.error( + "Serial connect triggered by disconnect listener failed", + e + ); } finally { this.isConnecting = false; } @@ -247,9 +251,9 @@ export class MicrobitRadioBridgeConnection implements DeviceConnection { async reconnect(finalAttempt: boolean = false): Promise { this.finalAttempt = finalAttempt; - logMessage("Serial reconnect"); + this.logging.log("Serial reconnect"); this.isReconnect = true; - await this.connect(DeviceRequestStates.INPUT); + await this.connect(); } private async sendCmdWaitResponse( @@ -274,7 +278,7 @@ export class MicrobitRadioBridgeConnection implements DeviceConnection { // As a workaround we can spam the micro:bit with handshake messages until // enough responses have been queued in the buffer to fill it and the data // starts to flow. - logMessage("Serial handshake"); + this.logging.log("Serial handshake"); const handshakeResult = await new Promise( async (resolve, reject) => { const attempts = 20; @@ -311,13 +315,17 @@ export class MicrobitRadioBridgeConnection implements DeviceConnection { } export const startSerialConnection = async ( - usb: MicrobitUSB, - requestState: DeviceRequestStates, + logging: Logging, + usb: MicrobitWebUSBConnection, remoteDeviceId: number ): Promise => { try { - const serial = new MicrobitRadioBridgeConnection(usb, remoteDeviceId); - await serial.connect(requestState); + const serial = new MicrobitRadioBridgeConnection( + usb, + logging, + remoteDeviceId + ); + await serial.connect(); return serial; } catch (e) { return undefined; diff --git a/lib/serial-protocol.ts b/lib/webusb-serial-protocol.ts similarity index 100% rename from lib/serial-protocol.ts rename to lib/webusb-serial-protocol.ts diff --git a/lib/webusb.ts b/lib/webusb.ts index 9e521b4..eaba7d0 100644 --- a/lib/webusb.ts +++ b/lib/webusb.ts @@ -5,8 +5,8 @@ */ import { Logging, NullLogging } from "./logging"; import { withTimeout, TimeoutError } from "./async-util"; -import { DAPWrapper } from "./dap-wrapper"; -import { PartialFlashing } from "./partial-flashing"; +import { DAPWrapper } from "./webusb-device-wrapper"; +import { PartialFlashing } from "./webusb-partial-flashing"; import { BoardVersion, ConnectionStatus, @@ -178,12 +178,8 @@ export class MicrobitWebUSBConnection }); } - getBoardVersion(): BoardVersion | null { - if (!this.connection) { - return null; - } - const boardId = this.connection.boardSerialInfo.id; - return boardId.isV1() ? "V1" : boardId.isV2() ? "V2" : null; + getBoardVersion(): BoardVersion | undefined { + return this.connection?.boardSerialInfo?.id.toBoardVersion(); } async flash( diff --git a/tsconfig.json b/tsconfig.json index a48da29..7f7c055 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,8 @@ /* Linting */ "strict": true, "noUnusedLocals": true, - "noUnusedParameters": true, + /* Temporarily disabled */ + "noUnusedParameters": false, "noFallthroughCasesInSwitch": true }, "include": ["src", "lib"] From 4522f2689d124b38dfeb217111af64bb779bdd0d Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 10 Jul 2024 16:32:51 +0100 Subject: [PATCH 11/20] Prettier --- lib/async-util.test.ts | 2 +- lib/async-util.ts | 2 +- lib/bluetooth-device-wrapper.ts | 32 +++++++------- lib/bluetooth.ts | 6 +-- lib/board-serial-info.ts | 2 +- lib/device.ts | 2 +- lib/events.ts | 6 +-- lib/mock.ts | 2 +- lib/setupTests.ts | 2 +- lib/webusb-device-wrapper.ts | 26 +++++------ lib/webusb-partial-flashing-utils.ts | 9 ++-- lib/webusb-partial-flashing.ts | 24 +++++------ lib/webusb-radio-bridge.ts | 32 +++++++------- lib/webusb-serial-protocol.ts | 64 ++++++++++++++++------------ lib/webusb.test.ts | 2 +- lib/webusb.ts | 16 +++---- src/demo.css | 21 ++++++--- src/demo.ts | 2 +- vite.config.ts | 12 +++--- 19 files changed, 142 insertions(+), 122 deletions(-) diff --git a/lib/async-util.test.ts b/lib/async-util.test.ts index 3342e8f..8b56005 100644 --- a/lib/async-util.test.ts +++ b/lib/async-util.test.ts @@ -10,7 +10,7 @@ describe("withTimeout", () => { it("times out", async () => { const neverResolves = new Promise(() => {}); await expect(() => withTimeout(neverResolves, 0)).rejects.toThrowError( - TimeoutError + TimeoutError, ); }); it("returns the value", async () => { diff --git a/lib/async-util.ts b/lib/async-util.ts index 1a0b619..999f4d7 100644 --- a/lib/async-util.ts +++ b/lib/async-util.ts @@ -12,7 +12,7 @@ export class TimeoutError extends Error {} */ export async function withTimeout( actionPromise: Promise, - timeout: number + timeout: number, ): Promise { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { diff --git a/lib/bluetooth-device-wrapper.ts b/lib/bluetooth-device-wrapper.ts index c603e3e..d3a683c 100644 --- a/lib/bluetooth-device-wrapper.ts +++ b/lib/bluetooth-device-wrapper.ts @@ -48,11 +48,11 @@ export class BluetoothDeviceWrapper { constructor( public readonly device: BluetoothDevice, - private logging: Logging = new NullLogging() + private logging: Logging = new NullLogging(), ) { device.addEventListener( "gattserverdisconnected", - this.handleDisconnectEvent + this.handleDisconnectEvent, ); } @@ -63,7 +63,7 @@ export class BluetoothDeviceWrapper { }); if (this.duringExplicitConnectDisconnect) { this.logging.log( - "Skipping connect attempt when one is already in progress" + "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, @@ -74,7 +74,7 @@ export class BluetoothDeviceWrapper { this.duringExplicitConnectDisconnect++; if (this.device.gatt === undefined) { throw new Error( - "BluetoothRemoteGATTServer for micro:bit device is undefined" + "BluetoothRemoteGATTServer for micro:bit device is undefined", ); } try { @@ -93,7 +93,7 @@ export class BluetoothDeviceWrapper { // Do we still want to be connected? if (!this.connecting) { this.logging.log( - "Bluetooth GATT server connect after timeout, triggering disconnect" + "Bluetooth GATT server connect after timeout, triggering disconnect", ); this.disconnectPromise = (async () => { await this.disconnectInternal(false); @@ -101,7 +101,7 @@ export class BluetoothDeviceWrapper { })(); } else { this.logging.log( - "Bluetooth GATT server connected when connecting" + "Bluetooth GATT server connected when connecting", ); } }) @@ -112,7 +112,7 @@ export class BluetoothDeviceWrapper { } else { this.logging.error( "Bluetooth GATT server connect error after our timeout", - e + e, ); return undefined; } @@ -127,7 +127,7 @@ export class BluetoothDeviceWrapper { const gattConnectResult = await Promise.race([ this.gattConnectPromise, new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), connectTimeoutDuration) + setTimeout(() => resolve("timeout"), connectTimeoutDuration), ), ]); if (gattConnectResult === "timeout") { @@ -161,7 +161,7 @@ export class BluetoothDeviceWrapper { private async disconnectInternal(userTriggered: boolean): Promise { this.logging.log( - `Bluetooth disconnect ${userTriggered ? "(user triggered)" : "(programmatic)"}` + `Bluetooth disconnect ${userTriggered ? "(user triggered)" : "(programmatic)"}`, ); this.duringExplicitConnectDisconnect++; try { @@ -175,7 +175,7 @@ export class BluetoothDeviceWrapper { this.duringExplicitConnectDisconnect--; } this.reconnectReadyPromise = new Promise((resolve) => - setTimeout(resolve, 3_500) + setTimeout(resolve, 3_500), ); } @@ -198,19 +198,19 @@ export class BluetoothDeviceWrapper { try { if (!this.duringExplicitConnectDisconnect) { this.logging.log( - "Bluetooth GATT disconnected... automatically trying reconnect" + "Bluetooth GATT disconnected... automatically trying reconnect", ); // stateOnReconnectionAttempt(); await this.reconnect(); } else { this.logging.log( - "Bluetooth GATT disconnect ignored during explicit disconnect" + "Bluetooth GATT disconnect ignored during explicit disconnect", ); } } catch (e) { this.logging.error( "Bluetooth connect triggered by disconnect listener failed", - e + e, ); } }; @@ -227,10 +227,10 @@ export class BluetoothDeviceWrapper { const serviceMeta = profile.deviceInformation; try { const deviceInfo = await this.assertGattServer().getPrimaryService( - serviceMeta.id + serviceMeta.id, ); const characteristic = await deviceInfo.getCharacteristic( - serviceMeta.characteristics.modelNumber.id + serviceMeta.characteristics.modelNumber.id, ); const modelNumberBytes = await characteristic.readValue(); const modelNumber = new TextDecoder().decode(modelNumberBytes); @@ -252,7 +252,7 @@ export class BluetoothDeviceWrapper { export const createBluetoothDeviceWrapper = async ( device: BluetoothDevice, - logging: Logging + logging: Logging, ): Promise => { try { // Reuse our connection objects for the same device as they diff --git a/lib/bluetooth.ts b/lib/bluetooth.ts index b6e662c..ad43155 100644 --- a/lib/bluetooth.ts +++ b/lib/bluetooth.ts @@ -91,7 +91,7 @@ export class MicrobitWebBluetoothConnection * A progress callback. Called with undefined when the process is complete or has failed. */ progress: (percentage: number | undefined) => void; - } + }, ): Promise { throw new Error("Unsupported"); } @@ -163,7 +163,7 @@ export class MicrobitWebBluetoothConnection } this.connection = await createBluetoothDeviceWrapper( device, - this.logging + this.logging, ); } // TODO: timeout unification? @@ -194,7 +194,7 @@ export class MicrobitWebBluetoothConnection ], }), new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), requestDeviceTimeoutDuration) + setTimeout(() => resolve("timeout"), requestDeviceTimeoutDuration), ), ]); if (result === "timeout") { diff --git a/lib/board-serial-info.ts b/lib/board-serial-info.ts index 8651992..f0252d2 100644 --- a/lib/board-serial-info.ts +++ b/lib/board-serial-info.ts @@ -9,7 +9,7 @@ export class BoardSerialInfo { constructor( public id: BoardId, public familyId: string, - public hic: string + public hic: string, ) {} static parse(device: USBDevice, log: (msg: string) => void) { const serial = device.serialNumber; diff --git a/lib/device.ts b/lib/device.ts index c558847..f9c43ba 100644 --- a/lib/device.ts +++ b/lib/device.ts @@ -216,7 +216,7 @@ export interface DeviceConnection * The partial parameter reports the flash type currently in progress. */ progress: (percentage: number | undefined, partial: boolean) => void; - } + }, ): Promise; /** diff --git a/lib/events.ts b/lib/events.ts index df7a786..c5ca294 100644 --- a/lib/events.ts +++ b/lib/events.ts @@ -13,7 +13,7 @@ * @template T The type of event to listen for (has to be keyof `M`). */ export type TypedEventListener = ( - evt: M[T] + evt: M[T], ) => void | Promise; /** @@ -90,7 +90,7 @@ export interface TypedEventTarget> { addEventListener: ( type: T, listener: TypedEventListenerOrEventListenerObject | null, - options?: boolean | AddEventListenerOptions + options?: boolean | AddEventListenerOptions, ) => void; /** Removes the event listener in target's event listener list with the same @@ -98,7 +98,7 @@ export interface TypedEventTarget> { removeEventListener: ( type: T, callback: TypedEventListenerOrEventListenerObject | null, - options?: EventListenerOptions | boolean + options?: EventListenerOptions | boolean, ) => void; /** diff --git a/lib/mock.ts b/lib/mock.ts index 4c1a7fa..f203565 100644 --- a/lib/mock.ts +++ b/lib/mock.ts @@ -83,7 +83,7 @@ export class MockDeviceConnection * A progress callback. Called with undefined when the process is complete or has failed. */ progress: (percentage: number | undefined) => void; - } + }, ): Promise { await new Promise((resolve) => setTimeout(resolve, 100)); options.progress(0.5); diff --git a/lib/setupTests.ts b/lib/setupTests.ts index b26ea1d..7bf1ce5 100644 --- a/lib/setupTests.ts +++ b/lib/setupTests.ts @@ -2,4 +2,4 @@ * (c) 2024, Micro:bit Educational Foundation and contributors * * SPDX-License-Identifier: MIT - */ \ No newline at end of file + */ diff --git a/lib/webusb-device-wrapper.ts b/lib/webusb-device-wrapper.ts index de217e1..fbdbccc 100644 --- a/lib/webusb-device-wrapper.ts +++ b/lib/webusb-device-wrapper.ts @@ -35,7 +35,7 @@ export class DAPWrapper { constructor( public device: USBDevice, - private logging: Logging + private logging: Logging, ) { this.transport = new WebUSB(this.device); this.daplink = new DAPLink(this.transport); @@ -65,7 +65,7 @@ export class DAPWrapper { get boardSerialInfo(): BoardSerialInfo { return BoardSerialInfo.parse( this.device, - this.logging.log.bind(this.logging) + this.logging.log.bind(this.logging), ); } @@ -152,7 +152,7 @@ export class DAPWrapper { // Drawn from https://github.com/mmoskal/dapjs/blob/a32f11f54e9e76a9c61896ddd425c1cb1a29c143/src/transport/cmsis_dap.ts#L74 private async cmdNums( op: number /* DapCmd */, - data: number[] + data: number[], ): Promise { data.unshift(op); @@ -181,7 +181,7 @@ export class DAPWrapper { // Drawn from https://github.com/mmoskal/dapjs/blob/a32f11f54e9e76a9c61896ddd425c1cb1a29c143/src/dap/dap.ts#L117 private async readRegRepeat( regId: number /* Reg */, - cnt: number + cnt: number, ): Promise { const request = regRequest(regId); const sendargs = [0, cnt]; @@ -206,7 +206,7 @@ export class DAPWrapper { // Drawn from https://github.com/mmoskal/dapjs/blob/a32f11f54e9e76a9c61896ddd425c1cb1a29c143/src/dap/dap.ts#L138 private async writeRegRepeat( regId: number /* Reg */, - data: Uint32Array + data: Uint32Array, ): Promise { const request = regRequest(regId, true); const sendargs = [0, data.length, 0, request]; @@ -217,7 +217,7 @@ export class DAPWrapper { d & 0xff, (d >> 8) & 0xff, (d >> 16) & 0xff, - (d >> 24) & 0xff + (d >> 24) & 0xff, ); }); @@ -233,7 +233,7 @@ export class DAPWrapper { // Drawn from https://github.com/mmoskal/dapjs/blob/a32f11f54e9e76a9c61896ddd425c1cb1a29c143/src/memory/memory.ts#L181 private async readBlockCore( addr: number, - words: number + words: number, ): Promise { // Set up CMSIS-DAP to read/write from/to the RAM address addr using the register // ApReg.DRW to write to or read from. @@ -250,7 +250,7 @@ export class DAPWrapper { for (let i = 0; i < Math.ceil(words / 15); i++) { const b: Uint8Array = await this.readRegRepeat( apReg(ApReg.DRW, DapVal.READ), - i === blocks.length - 1 ? lastSize : 15 + i === blocks.length - 1 ? lastSize : 15, ); blocks.push(b); } @@ -262,7 +262,7 @@ export class DAPWrapper { // Drawn from https://github.com/mmoskal/dapjs/blob/a32f11f54e9e76a9c61896ddd425c1cb1a29c143/src/memory/memory.ts#L205 private async writeBlockCore( addr: number, - words: Uint32Array + words: Uint32Array, ): Promise { try { // Set up CMSIS-DAP to read/write from/to the RAM address addr using the register ApReg.DRW to write to or read from. @@ -335,7 +335,7 @@ export class DAPWrapper { ) { if (registers.length > 12) { throw new Error( - `Only 12 general purpose registers but got ${registers.length} values` + `Only 12 general purpose registers but got ${registers.length} values`, ); } @@ -355,7 +355,7 @@ export class DAPWrapper { // Recurses otherwise. private async waitForHaltCore( halted: boolean, - deadline: number + deadline: number, ): Promise { if (new Date().getTime() > deadline) { throw new Error("timeout"); @@ -379,7 +379,7 @@ export class DAPWrapper { await this.cortexM.writeMem32( CortexSpecialReg.NVIC_AIRCR, CortexSpecialReg.NVIC_AIRCR_VECTKEY | - CortexSpecialReg.NVIC_AIRCR_SYSRESETREQ + CortexSpecialReg.NVIC_AIRCR_SYSRESETREQ, ); // wait for the system to come out of reset @@ -400,7 +400,7 @@ export class DAPWrapper { const demcr = await this.cortexM.readMem32(CortexSpecialReg.DEMCR); await this.cortexM.writeMem32( CortexSpecialReg.DEMCR, - CortexSpecialReg.DEMCR | CortexSpecialReg.DEMCR_VC_CORERESET + CortexSpecialReg.DEMCR | CortexSpecialReg.DEMCR_VC_CORERESET, ); await this.softwareReset(); diff --git a/lib/webusb-partial-flashing-utils.ts b/lib/webusb-partial-flashing-utils.ts index 0fd0b2c..02859a0 100644 --- a/lib/webusb-partial-flashing-utils.ts +++ b/lib/webusb-partial-flashing-utils.ts @@ -83,7 +83,10 @@ export const regRequest = (regId: number, isWrite: boolean = false): number => { }; export class Page { - constructor(readonly targetAddr: number, readonly data: Uint8Array) {} + constructor( + readonly targetAddr: number, + readonly data: Uint8Array, + ) {} } // Split buffer into pages, each of pageSize size. @@ -91,7 +94,7 @@ export class Page { export const pageAlignBlocks = ( buffer: Uint8Array, targetAddr: number, - pageSize: number + pageSize: number, ): Page[] => { let unaligned = new Uint8Array(buffer); let pages = []; @@ -114,7 +117,7 @@ export const pageAlignBlocks = ( export const onlyChanged = ( pages: Page[], checksums: Uint8Array, - pageSize: number + pageSize: number, ): Page[] => { return pages.filter((page) => { let idx = page.targetAddr / pageSize; diff --git a/lib/webusb-partial-flashing.ts b/lib/webusb-partial-flashing.ts index f12eab8..c143a43 100644 --- a/lib/webusb-partial-flashing.ts +++ b/lib/webusb-partial-flashing.ts @@ -101,7 +101,7 @@ const stackAddr = 0x20001000; export class PartialFlashing { constructor( private dapwrapper: DAPWrapper, - private logging: Logging + private logging: Logging, ) {} private log(v: any): void { @@ -120,11 +120,11 @@ export class PartialFlashing { dataAddr, 0, this.dapwrapper.pageSize, - this.dapwrapper.numPages + this.dapwrapper.numPages, ); return this.dapwrapper.readBlockAsync( dataAddr, - this.dapwrapper.numPages * 2 + this.dapwrapper.numPages * 2, ); } @@ -136,7 +136,7 @@ export class PartialFlashing { await Promise.all([ this.dapwrapper.cortexM.writeCoreRegister( CoreRegister.PC, - loadAddr + 4 + 1 + loadAddr + 4 + 1, ), this.dapwrapper.cortexM.writeCoreRegister(CoreRegister.LR, loadAddr + 1), this.dapwrapper.cortexM.writeCoreRegister(CoreRegister.SP, stackAddr), @@ -144,7 +144,7 @@ export class PartialFlashing { this.dapwrapper.cortexM.writeCoreRegister(1, addr), this.dapwrapper.cortexM.writeCoreRegister( 2, - this.dapwrapper.pageSize >> 2 + this.dapwrapper.pageSize >> 2, ), ]); return this.dapwrapper.cortexM.resume(false); @@ -155,7 +155,7 @@ export class PartialFlashing { private async partialFlashPageAsync( page: Page, nextPage: Page, - i: number + i: number, ): Promise { // TODO: This short-circuits UICR, do we need to update this? if (page.targetAddr >= 0x10000000) { @@ -190,7 +190,7 @@ export class PartialFlashing { // Write pages of data to micro:bit ROM. private async partialFlashCoreAsync( pages: Page[], - updateProgress: ProgressCallback + updateProgress: ProgressCallback, ) { this.log("Partial flash"); for (let i = 0; i < pages.length; ++i) { @@ -206,7 +206,7 @@ export class PartialFlashing { private async partialFlashAsync( boardId: BoardId, dataSource: FlashDataSource, - updateProgress: ProgressCallback + updateProgress: ProgressCallback, ): Promise { const flashBytes = await dataSource.partialFlashData(boardId); const checksums = await this.getFlashChecksumsAsync(); @@ -252,7 +252,7 @@ export class PartialFlashing { async fullFlashAsync( boardId: BoardId, dataSource: FlashDataSource, - updateProgress: ProgressCallback + updateProgress: ProgressCallback, ) { this.log("Full flash"); @@ -271,7 +271,7 @@ export class PartialFlashing { } finally { this.dapwrapper.daplink.removeListener( DAPLink.EVENT_PROGRESS, - fullFlashProgress + fullFlashProgress, ); } } @@ -281,7 +281,7 @@ export class PartialFlashing { async flashAsync( boardId: BoardId, dataSource: FlashDataSource, - updateProgress: ProgressCallback + updateProgress: ProgressCallback, ): Promise { let resetPromise = (async () => { // Reset micro:bit to ensure interface responds correctly. @@ -303,7 +303,7 @@ export class PartialFlashing { return await this.partialFlashAsync( boardId, dataSource, - updateProgress + updateProgress, ); } catch (e) { if (e instanceof TimeoutError) { diff --git a/lib/webusb-radio-bridge.ts b/lib/webusb-radio-bridge.ts index 9acd034..e038e5f 100644 --- a/lib/webusb-radio-bridge.ts +++ b/lib/webusb-radio-bridge.ts @@ -18,7 +18,7 @@ export class MicrobitRadioBridgeConnection { private responseMap = new Map< number, ( - value: protocol.MessageResponse | PromiseLike + value: protocol.MessageResponse | PromiseLike, ) => void >(); @@ -34,7 +34,7 @@ export class MicrobitRadioBridgeConnection { constructor( private usb: MicrobitWebUSBConnection, private logging: Logging, - private remoteDeviceId: number + private remoteDeviceId: number, ) {} async connect(): Promise { @@ -44,7 +44,7 @@ export class MicrobitRadioBridgeConnection { }); if (this.isConnecting) { this.logging.log( - "Skipping connect attempt when one is already in progress" + "Skipping connect attempt when one is already in progress", ); return; } @@ -90,7 +90,7 @@ export class MicrobitRadioBridgeConnection { return; } const responseResolve = this.responseMap.get( - messageResponse.messageId + messageResponse.messageId, ); if (responseResolve) { this.responseMap.delete(messageResponse.messageId); @@ -125,7 +125,7 @@ export class MicrobitRadioBridgeConnection { this.logging.log(`Serial: using remote device id ${this.remoteDeviceId}`); const remoteMbIdCommand = protocol.generateCmdRemoteMbId( - this.remoteDeviceId + this.remoteDeviceId, ); const remoteMbIdResponse = await this.sendCmdWaitResponse(remoteMbIdCommand); @@ -134,7 +134,7 @@ export class MicrobitRadioBridgeConnection { remoteMbIdResponse.value !== this.remoteDeviceId ) { throw new BridgeError( - `Failed to set remote micro:bit ID. Expected ${this.remoteDeviceId}, got ${remoteMbIdResponse.value}` + `Failed to set remote micro:bit ID. Expected ${this.remoteDeviceId}, got ${remoteMbIdResponse.value}`, ); } @@ -157,7 +157,7 @@ export class MicrobitRadioBridgeConnection { const startCmdResponse = await this.sendCmdWaitResponse(startCmd); if (startCmdResponse.type === protocol.ResponseTypes.Error) { throw new RemoteError( - `Failed to start streaming sensors data. Error response received: ${startCmdResponse.message}` + `Failed to start streaming sensors data. Error response received: ${startCmdResponse.message}`, ); } @@ -226,14 +226,14 @@ export class MicrobitRadioBridgeConnection { async handleReconnect(): Promise { if (this.isConnecting) { this.logging.log( - "Serial disconnect ignored... reconnect already in progress" + "Serial disconnect ignored... reconnect already in progress", ); return; } try { this.stopConnectionCheck(); this.logging.log( - "Serial disconnected... automatically trying to reconnect" + "Serial disconnected... automatically trying to reconnect", ); this.responseMap.clear(); await this.usb.stopSerial(); @@ -242,7 +242,7 @@ export class MicrobitRadioBridgeConnection { } catch (e) { this.logging.error( "Serial connect triggered by disconnect listener failed", - e + e, ); } finally { this.isConnecting = false; @@ -257,7 +257,7 @@ export class MicrobitRadioBridgeConnection { } private async sendCmdWaitResponse( - cmd: protocol.MessageCmd + cmd: protocol.MessageCmd, ): Promise { const responsePromise = new Promise( (resolve, reject) => { @@ -266,7 +266,7 @@ export class MicrobitRadioBridgeConnection { this.responseMap.delete(cmd.messageId); reject(new Error(`Timeout waiting for response ${cmd.messageId}`)); }, 1_000); - } + }, ); await this.usb.serialWrite(cmd.message); return responsePromise; @@ -304,11 +304,11 @@ export class MicrobitRadioBridgeConnection { }); await new Promise((resolve) => setTimeout(resolve, 100)); } - } + }, ); if (handshakeResult.value !== protocol.version) { throw new BridgeError( - `Handshake failed. Unexpected protocol version ${protocol.version}` + `Handshake failed. Unexpected protocol version ${protocol.version}`, ); } } @@ -317,13 +317,13 @@ export class MicrobitRadioBridgeConnection { export const startSerialConnection = async ( logging: Logging, usb: MicrobitWebUSBConnection, - remoteDeviceId: number + remoteDeviceId: number, ): Promise => { try { const serial = new MicrobitRadioBridgeConnection( usb, logging, - remoteDeviceId + remoteDeviceId, ); await serial.connect(); return serial; diff --git a/lib/webusb-serial-protocol.ts b/lib/webusb-serial-protocol.ts index 7b94775..f8a859d 100644 --- a/lib/webusb-serial-protocol.ts +++ b/lib/webusb-serial-protocol.ts @@ -10,23 +10,23 @@ export type SplittedMessages = { }; enum MessageTypes { - Command = 'C', - Response = 'R', - Periodic = 'P', + Command = "C", + Response = "R", + Periodic = "P", } export enum CommandTypes { - Handshake = 'HS', - RadioFrequency = 'RF', - RemoteMbId = 'RMBID', - SoftwareVersion = 'SWVER', - HardwareVersion = 'HWVER', - Zstart = 'ZSTART', - Stop = 'STOP', + Handshake = "HS", + RadioFrequency = "RF", + RemoteMbId = "RMBID", + SoftwareVersion = "SWVER", + HardwareVersion = "HWVER", + Zstart = "ZSTART", + Stop = "STOP", } enum ResponseExtraTypes { - Error = 'ERROR', + Error = "ERROR", } export type ResponseTypes = CommandTypes | ResponseExtraTypes; @@ -67,16 +67,17 @@ export const splitMessages = (message: string): SplittedMessages => { if (!message) { return { messages: [], - remainingInput: '', + remainingInput: "", }; } - let messages = message.split('\n'); - let remainingInput = messages.pop() || ''; + let messages = message.split("\n"); + let remainingInput = messages.pop() || ""; // Throw away any empty messages and messages that don't start with a valid type messages = messages.filter( (msg: string) => - msg.length > 0 && Object.values(MessageTypes).includes(msg[0] as MessageTypes), + msg.length > 0 && + Object.values(MessageTypes).includes(msg[0] as MessageTypes), ); // Any remaining input will be the start of the next message, so if it doesn't start @@ -85,7 +86,7 @@ export const splitMessages = (message: string): SplittedMessages => { remainingInput.length > 0 && !Object.values(MessageTypes).includes(remainingInput[0] as MessageTypes) ) { - remainingInput = ''; + remainingInput = ""; } return { @@ -94,26 +95,30 @@ export const splitMessages = (message: string): SplittedMessages => { }; }; -export const processResponseMessage = (message: string): MessageResponse | undefined => { +export const processResponseMessage = ( + message: string, +): MessageResponse | undefined => { // Regex for a message response with 3 groups: // id -> The message ID, 1-8 hex characters // cmd -> The command type, a string, only capital letters, matching CommandTypes // value -> The response value, empty string or a word, number, // or version (e.g 1.2.3) depending on the command type const responseMatch = - /^R\[(?[0-9A-Fa-f]{1,8})\](?[A-Z]+)\[(?-?[\w.]*)\]$/.exec(message); + /^R\[(?[0-9A-Fa-f]{1,8})\](?[A-Z]+)\[(?-?[\w.]*)\]$/.exec( + message, + ); if (!responseMatch || !responseMatch.groups) { return undefined; } - const messageId = parseInt(responseMatch.groups['id'], 16); + const messageId = parseInt(responseMatch.groups["id"], 16); if (isNaN(messageId)) { return undefined; } - const responseType = responseMatch.groups['cmd'] as ResponseTypes; + const responseType = responseMatch.groups["cmd"] as ResponseTypes; if (!Object.values(ResponseTypes).includes(responseType)) { return undefined; } - let value: string | number = responseMatch.groups['value']; + let value: string | number = responseMatch.groups["value"]; switch (responseType) { // Commands with numeric values case ResponseTypes.Handshake: @@ -129,7 +134,7 @@ export const processResponseMessage = (message: string): MessageResponse | undef // Commands without values case ResponseTypes.Zstart: case ResponseTypes.Stop: - if (value !== '') { + if (value !== "") { return undefined; } break; @@ -178,7 +183,10 @@ export const processPeriodicMessage = ( }; }; -const generateCommand = (cmdType: CommandTypes, cmdData: string = ''): MessageCmd => { +const generateCommand = ( + cmdType: CommandTypes, + cmdData: string = "", +): MessageCmd => { // Generate an random (enough) ID with max value of 8 hex digits const msgID = Math.floor(Math.random() * 0xffffffff); return { @@ -194,12 +202,12 @@ export const generateCmdHandshake = (): MessageCmd => { }; export const generateCmdStart = (sensors: MicrobitSensors): MessageCmd => { - let cmdData = ''; + let cmdData = ""; if (sensors.accelerometer) { - cmdData += 'A'; + cmdData += "A"; } if (sensors.buttons) { - cmdData += 'B'; + cmdData += "B"; } return generateCommand(CommandTypes.Zstart, cmdData); }; @@ -210,14 +218,14 @@ export const generateCmdStop = (): MessageCmd => { export const generateCmdRadioFrequency = (frequency: number): MessageCmd => { if (frequency < 0 || frequency > 83) { - throw new Error('Radio frequency out of range'); + throw new Error("Radio frequency out of range"); } return generateCommand(CommandTypes.RadioFrequency, frequency.toString()); }; export const generateCmdRemoteMbId = (remoteMicrobitId: number): MessageCmd => { if (remoteMicrobitId < 0 || remoteMicrobitId > 0xffffffff) { - throw new Error('Remote micro:bit ID out of range'); + throw new Error("Remote micro:bit ID out of range"); } return generateCommand(CommandTypes.RemoteMbId, remoteMicrobitId.toString()); }; diff --git a/lib/webusb.test.ts b/lib/webusb.test.ts index 2d9d741..16a6163 100644 --- a/lib/webusb.test.ts +++ b/lib/webusb.test.ts @@ -13,7 +13,7 @@ import { ConnectionStatus, ConnectionStatusEvent } from "./device"; import { MicrobitWebUSBConnection } from "./webusb"; import { beforeAll, expect, vi, describe, it } from "vitest"; -vi.mock("./dap-wrapper", () => ({ +vi.mock("./webusb-device-wrapper", () => ({ DAPWrapper: class DapWrapper { startSerial = vi.fn().mockReturnValue(Promise.resolve()); reconnectAsync = vi.fn(); diff --git a/lib/webusb.ts b/lib/webusb.ts index eaba7d0..c8d41b3 100644 --- a/lib/webusb.ts +++ b/lib/webusb.ts @@ -124,14 +124,14 @@ export class MicrobitWebUSBConnection } }, assumePageIsStayingOpenDelay); }, - { once: true } + { once: true }, ); }; private logging: Logging; constructor( - options: MicrobitWebUSBConnectionOptions = { logging: new NullLogging() } + options: MicrobitWebUSBConnectionOptions = { logging: new NullLogging() }, ) { super(); this.logging = options.logging; @@ -150,7 +150,7 @@ export class MicrobitWebUSBConnection if (window.document) { window.document.addEventListener( "visibilitychange", - this.visibilityChangeListener + this.visibilityChangeListener, ); } } @@ -165,7 +165,7 @@ export class MicrobitWebUSBConnection if (window.document) { window.document.removeEventListener( "visibilitychange", - this.visibilityChangeListener + this.visibilityChangeListener, ); } } @@ -193,13 +193,13 @@ export class MicrobitWebUSBConnection * A progress callback. Called with undefined when the process is complete or has failed. */ progress: (percentage: number | undefined) => void; - } + }, ): Promise { this.flashing = true; try { const startTime = new Date().getTime(); await this.withEnrichedErrors(() => - this.flashInternal(dataSource, options) + this.flashInternal(dataSource, options), ); this.dispatchTypedEvent("flash", new FlashEvent()); @@ -221,7 +221,7 @@ export class MicrobitWebUSBConnection options: { partial: boolean; progress: (percentage: number | undefined, partial: boolean) => void; - } + }, ): Promise { this.log("Stopping serial before flash"); await this.stopSerialInternal(); @@ -333,7 +333,7 @@ export class MicrobitWebUSBConnection // Log error to console for feedback this.log("An error occurred whilst attempting to use WebUSB."); this.log( - "Details of the error can be found below, and may be useful when trying to replicate and debug the error." + "Details of the error can be found below, and may be useful when trying to replicate and debug the error.", ); this.log(e); diff --git a/src/demo.css b/src/demo.css index 1d6285f..dd93210 100644 --- a/src/demo.css +++ b/src/demo.css @@ -43,8 +43,19 @@ h6 { /* Custom styles */ body { - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - margin: 1em + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Open Sans", + "Helvetica Neue", + sans-serif; + margin: 1em; } section { @@ -57,6 +68,6 @@ label { display: block; } -*+* { - margin-top: 1rem -} \ No newline at end of file +* + * { + margin-top: 1rem; +} diff --git a/src/demo.ts b/src/demo.ts index 885ce11..49d3bf1 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -23,7 +23,7 @@ const connect = document.querySelector("#webusb > .connect")!; const disconnect = document.querySelector("#webusb > .disconnect")!; const flash = document.querySelector("#webusb > .flash")!; const fileInput = document.querySelector( - "#webusb input[type=file]" + "#webusb input[type=file]", )! as HTMLInputElement; const statusParagraph = document.querySelector("#webusb > .status")!; const connection = new MicrobitWebUSBConnection(); diff --git a/vite.config.ts b/vite.config.ts index 678578c..baafd19 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,9 +4,7 @@ * SPDX-License-Identifier: MIT */ import { resolve } from "path"; -import { - loadEnv, -} from "vite"; +import { loadEnv } from "vite"; import { configDefaults, defineConfig, UserConfig } from "vitest/config"; export default defineConfig(({ mode }) => { @@ -17,16 +15,16 @@ export default defineConfig(({ mode }) => { sourcemap: true, lib: { // Could also be a dictionary or array of multiple entry points - entry: resolve(__dirname, 'lib/main.ts'), - name: 'MicrobitConnection', + entry: resolve(__dirname, "lib/main.ts"), + name: "MicrobitConnection", // the proper extensions will be added - fileName: 'microbit-connection', + fileName: "microbit-connection", }, }, test: { exclude: [...configDefaults.exclude, "**/e2e/**"], environment: "jsdom", - setupFiles: "./src/setupTests.ts", + setupFiles: "./lib/setupTests.ts", mockReset: true, }, }; From c72fb123ffa835584b11d2d00ed5123201e5a739 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 10 Jul 2024 16:33:07 +0100 Subject: [PATCH 12/20] Prettier check fix --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8427246..5dfa29d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "ci": "npm run build && npm run test && npx prettier --check src", + "ci": "npm run build && npm run test && npx prettier --check lib src", "test": "vitest", "preview": "vite preview" }, From b3bc2bf26743e21707824693685effd34f3570b6 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 10 Jul 2024 17:18:03 +0100 Subject: [PATCH 13/20] Fill out BT profile. Demo connect/disconnect. Name filter something of a cludge --- lib/bluetooth-profile.ts | 45 +++++++++++++++++++++++++++++++++++++-- lib/bluetooth.ts | 32 ++++++++++++++++++---------- lib/device.ts | 4 +++- src/demo.ts | 46 ++++++++++++++++++++++++++++++++-------- 4 files changed, 104 insertions(+), 23 deletions(-) diff --git a/lib/bluetooth-profile.ts b/lib/bluetooth-profile.ts index 22946bb..5978a4b 100644 --- a/lib/bluetooth-profile.ts +++ b/lib/bluetooth-profile.ts @@ -11,24 +11,40 @@ export const profile = { id: "e95d0753-251d-470a-a062-fa1922dfa9a8", characteristics: { data: { id: "e95dca4b-251d-470a-a062-fa1922dfa9a8" }, + period: { id: "e95dfb24-251d-470a-a062-fa1922dfa9a8" }, }, }, deviceInformation: { id: "0000180a-0000-1000-8000-00805f9b34fb", characteristics: { modelNumber: { id: "00002a24-0000-1000-8000-00805f9b34fb" }, + serialNumber: { id: "00002a25-0000-1000-8000-00805f9b34fb" }, + firmwareRevision: { id: "00002a26-0000-1000-8000-00805f9b34fb" }, + hardwareRevision: { id: "00002a27-0000-1000-8000-00805f9b34fb" }, + manufacturer: { id: "00002a29-0000-1000-8000-00805f9b34fb" }, + }, + }, + dfuControl: { + id: "e95d93b0-251d-470a-a062-fa1922dfa9a8", + characteristics: { + control: { id: "e95d93b1-251d-470a-a062-fa1922dfa9a8" }, }, }, led: { id: "e95dd91d-251d-470a-a062-fa1922dfa9a8", characteristics: { matrixState: { id: "e95d7b77-251d-470a-a062-fa1922dfa9a8" }, + text: { id: "e95d93ee-251d-470a-a062-fa1922dfa9a8" }, + scrollingDelay: { id: "e95d0d2d-251d-470a-a062-fa1922dfa9a8" }, }, }, - io: { + ioPin: { id: "e95d127b-251d-470a-a062-fa1922dfa9a8", characteristics: { - data: { id: "e95d8d00-251d-470a-a062-fa1922dfa9a8" }, + pinData: { id: "e95d8d00-251d-470a-a062-fa1922dfa9a8" }, + pinAdConfiguration: { id: "e95d5899-251d-470a-a062-fa1922dfa9a8" }, + pinIoConfiguration: { id: "e95db9fe-251d-470a-a062-fa1922dfa9a8" }, + pwmControl: { id: "e95dd822-251d-470a-a062-fa1922dfa9a8" }, }, }, button: { @@ -38,4 +54,29 @@ export const profile = { b: { id: "e95dda91-251d-470a-a062-fa1922dfa9a8" }, }, }, + event: { + id: "e95d93af-251d-470a-a062-fa1922dfa9a8", + characteristics: { + microBitRequirements: { id: "e95db84c-251d-470a-a062-fa1922dfa9a8" }, + microBitEvent: { id: "e95d9775-251d-470a-a062-fa1922dfa9a8" }, + clientRequirements: { id: "e95d23c4-251d-470a-a062-fa1922dfa9a8" }, + clientEvent: { id: "e95d5404-251d-470a-a062-fa1922dfa9a8" }, + }, + }, + magnetometer: { + id: "e95df2d8-251d-470a-a062-fa1922dfa9a8", + characteristics: { + data: { id: "e95dfb11-251d-470a-a062-fa1922dfa9a8" }, + period: { id: "e95d386c-251d-470a-a062-fa1922dfa9a8" }, + bearing: { id: "e95d9715-251d-470a-a062-fa1922dfa9a8" }, + calibration: { id: "e95db358-251d-470a-a062-fa1922dfa9a8" }, + }, + }, + temperature: { + id: "e95d6100-251d-470a-a062-fa1922dfa9a8", + characteristics: { + data: { id: "e95d9250-251d-470a-a062-fa1922dfa9a8" }, + period: { id: "e95d1b25-251d-470a-a062-fa1922dfa9a8" }, + }, + }, }; diff --git a/lib/bluetooth.ts b/lib/bluetooth.ts index ad43155..afe8388 100644 --- a/lib/bluetooth.ts +++ b/lib/bluetooth.ts @@ -91,7 +91,7 @@ export class MicrobitWebBluetoothConnection * A progress callback. Called with undefined when the process is complete or has failed. */ progress: (percentage: number | undefined) => void; - }, + } ): Promise { throw new Error("Unsupported"); } @@ -157,13 +157,13 @@ export class MicrobitWebBluetoothConnection private async connectInternal(options: ConnectOptions): Promise { if (!this.connection) { - const device = await this.chooseDevice(); + const device = await this.chooseDevice(options); if (!device) { return; } this.connection = await createBluetoothDeviceWrapper( device, - this.logging, + this.logging ); } // TODO: timeout unification? @@ -171,7 +171,9 @@ export class MicrobitWebBluetoothConnection this.setStatus(ConnectionStatus.CONNECTED); } - private async chooseDevice(): Promise { + private async chooseDevice( + options: ConnectOptions + ): Promise { if (this.device) { return this.device; } @@ -181,20 +183,28 @@ export class MicrobitWebBluetoothConnection // TODO: give control over this to the caller const result = await Promise.race([ navigator.bluetooth.requestDevice({ - // TODO: this is limiting - filters: [{ namePrefix: `BBC micro:bit [${name}]` }], + filters: [ + { + namePrefix: options.name + ? `BBC micro:bit [${options.name}]` + : "BBC micro:bit", + }, + ], optionalServices: [ - // TODO: include everything or perhaps parameterise? - profile.uart.id, profile.accelerometer.id, + profile.button.id, profile.deviceInformation.id, + profile.dfuControl.id, + profile.event.id, + profile.ioPin.id, profile.led.id, - profile.io.id, - profile.button.id, + profile.magnetometer.id, + profile.temperature.id, + profile.uart.id, ], }), new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), requestDeviceTimeoutDuration), + setTimeout(() => resolve("timeout"), requestDeviceTimeoutDuration) ), ]); if (result === "timeout") { diff --git a/lib/device.ts b/lib/device.ts index f9c43ba..e620833 100644 --- a/lib/device.ts +++ b/lib/device.ts @@ -111,6 +111,8 @@ export interface FlashDataSource { export interface ConnectOptions { serial?: boolean; + // Name filter used for Web Bluetooth + name?: string; } export type BoardVersion = "V1" | "V2"; @@ -216,7 +218,7 @@ export interface DeviceConnection * The partial parameter reports the flash type currently in progress. */ progress: (percentage: number | undefined, partial: boolean) => void; - }, + } ): Promise; /** diff --git a/src/demo.ts b/src/demo.ts index 49d3bf1..2a7ea15 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -6,11 +6,19 @@ import "./demo.css"; import { MicrobitWebUSBConnection } from "../lib/webusb"; import { HexFlashDataSource } from "../lib/hex-flash-data-source"; -import { ConnectionStatus } from "../lib/device"; +import { ConnectionStatus, DeviceConnection } from "../lib/device"; +import { MicrobitWebBluetoothConnection } from "../lib/bluetooth"; document.querySelector("#app")!.innerHTML = ` -
-

WebUSB

+
+

Connect and flash

+ +

@@ -19,18 +27,38 @@ document.querySelector("#app")!.innerHTML = `
`; -const connect = document.querySelector("#webusb > .connect")!; -const disconnect = document.querySelector("#webusb > .disconnect")!; -const flash = document.querySelector("#webusb > .flash")!; +const transport = document.querySelector( + "#flash > .transport" +)! as HTMLSelectElement; +const connect = document.querySelector("#flash > .connect")!; +const disconnect = document.querySelector("#flash > .disconnect")!; +const flash = document.querySelector("#flash > .flash")!; const fileInput = document.querySelector( - "#webusb input[type=file]", + "#flash input[type=file]" )! as HTMLInputElement; -const statusParagraph = document.querySelector("#webusb > .status")!; -const connection = new MicrobitWebUSBConnection(); +const statusParagraph = document.querySelector("#flash > .status")!; + +const usb = new MicrobitWebUSBConnection(); +const bluetooth = new MicrobitWebBluetoothConnection(); +let connection: DeviceConnection = usb; + const initialisePromise = connection.initialize(); const displayStatus = (status: ConnectionStatus) => { statusParagraph.textContent = status.toString(); }; +transport.addEventListener("change", (e) => { + switch (transport.value) { + case "bluetooth": { + connection = bluetooth; + break; + } + case "usb": { + connection = usb; + break; + } + } +}); + connection.addEventListener("status", (event) => { displayStatus(event.status); }); From 46c58667a9f41bafd0997af2274900d07943ae74 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Thu, 11 Jul 2024 10:36:06 +0100 Subject: [PATCH 14/20] Tweak demo --- src/demo.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/demo.ts b/src/demo.ts index 2a7ea15..54c26c0 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -38,26 +38,28 @@ const fileInput = document.querySelector( )! as HTMLInputElement; const statusParagraph = document.querySelector("#flash > .status")!; -const usb = new MicrobitWebUSBConnection(); -const bluetooth = new MicrobitWebBluetoothConnection(); -let connection: DeviceConnection = usb; - -const initialisePromise = connection.initialize(); +let connection: DeviceConnection = new MicrobitWebUSBConnection(); const displayStatus = (status: ConnectionStatus) => { statusParagraph.textContent = status.toString(); }; -transport.addEventListener("change", (e) => { +const switchTransport = async () => { + await connection.disconnect(); + connection.dispose(); + switch (transport.value) { case "bluetooth": { - connection = bluetooth; + connection = new MicrobitWebBluetoothConnection(); break; } case "usb": { - connection = usb; + connection = new MicrobitWebUSBConnection(); break; } } -}); + await connection.initialize(); +}; +transport.addEventListener("change", switchTransport); +void switchTransport(); connection.addEventListener("status", (event) => { displayStatus(event.status); @@ -65,11 +67,9 @@ connection.addEventListener("status", (event) => { displayStatus(connection.status); connect.addEventListener("click", async () => { - await initialisePromise; await connection.connect(); }); disconnect.addEventListener("click", async () => { - await initialisePromise; await connection.disconnect(); }); From 469a4e8bf4031def14375bd07a0e4f975d45e1b5 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Thu, 11 Jul 2024 12:00:06 +0100 Subject: [PATCH 15/20] USB partial flashing works The interface needs rethinking but at least it makes some sense. --- TODO.md | 4 ++++ lib/device.ts | 2 ++ lib/hex-flash-data-source.ts | 21 ++++++++++----------- src/demo.ts | 2 +- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/TODO.md b/TODO.md index 66d05a4..370ab20 100644 --- a/TODO.md +++ b/TODO.md @@ -2,3 +2,7 @@ - Remove the sim? It's unrelated really. - Add a JS partial flashing implementation, perhaps based on the prototype app - Consider a full flash implementation later... for now we'll need to indicate lack of support somehow with a structured error code? + +Doc links for the memory map: +- https://microbit-micropython.readthedocs.io/en/v2-docs/devguide/hexformat.html +- https://github.com/lancaster-university/codal-microbit-v2/blob/master/docs/MemoryMap.md diff --git a/lib/device.ts b/lib/device.ts index e620833..b4c6ab2 100644 --- a/lib/device.ts +++ b/lib/device.ts @@ -96,6 +96,8 @@ export interface FlashDataSource { * This can be generated from microbit-fs directly (via getIntelHexBytes()) * or from an existing Intel Hex via slicePad. * + * This interface is quite confusing and worth revisiting. + * * @param boardId the id of the board. * @throws HexGenerationError if we cannot generate hex data. */ diff --git a/lib/hex-flash-data-source.ts b/lib/hex-flash-data-source.ts index 97eb9a2..96308ee 100644 --- a/lib/hex-flash-data-source.ts +++ b/lib/hex-flash-data-source.ts @@ -1,5 +1,5 @@ import { BoardId } from "./board-id"; -import { FlashDataSource } from "./device"; +import { FlashDataSource, HexGenerationError } from "./device"; import { isUniversalHex, separateUniversalHex, @@ -7,32 +7,31 @@ import { import MemoryMap from "nrf-intel-hex"; export class HexFlashDataSource implements FlashDataSource { - constructor(private hex: string) { - console.log("Universal"); - console.log(hex); - } + constructor(private hex: string) {} partialFlashData(boardId: BoardId): Promise { + // Perhaps this would make more sense if we actually worked with a MemoryMap? + // That's what microbit-fs is using internally. + // Then the partial flashing code could be given everything including UICR without + // passing a very large Uint8Array. const part = this.matchingPart(boardId); const hex = MemoryMap.fromHex(part); - const keys = Array.from(hex.keys()); + const keys = Array.from(hex.keys()).filter((k) => k < 0x10000000); const lastKey = keys[keys.length - 1]; if (lastKey === undefined) { - throw new Error("Empty hex"); + throw new HexGenerationError("Empty hex"); } const lastPart = hex.get(lastKey); if (!lastPart) { - throw new Error("Empty hex"); + throw new HexGenerationError("Empty hex"); } const length = lastKey + lastPart.length; const data = hex.slicePad(0, length, 0); - console.log(data); return Promise.resolve(data); } fullFlashData(boardId: BoardId): Promise { const part = this.matchingPart(boardId); - console.log(part); return Promise.resolve(part); } @@ -41,7 +40,7 @@ export class HexFlashDataSource implements FlashDataSource { const parts = separateUniversalHex(this.hex); const matching = parts.find((p) => p.boardId == boardId.normalize().id); if (!matching) { - throw new Error("No matching part"); + throw new HexGenerationError("No matching part"); } return matching.hex; } diff --git a/src/demo.ts b/src/demo.ts index 54c26c0..f8e4087 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -78,7 +78,7 @@ flash.addEventListener("click", async () => { if (file) { const text = await file.text(); await connection.flash(new HexFlashDataSource(text), { - partial: false, + partial: true, progress: (percentage: number | undefined) => { console.log(percentage); }, From 33bb63724cfe45671fea390048fbf83901a23bf0 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Thu, 11 Jul 2024 12:04:13 +0100 Subject: [PATCH 16/20] Note --- lib/hex-flash-data-source.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/hex-flash-data-source.ts b/lib/hex-flash-data-source.ts index 96308ee..0f92f30 100644 --- a/lib/hex-flash-data-source.ts +++ b/lib/hex-flash-data-source.ts @@ -4,6 +4,8 @@ import { isUniversalHex, separateUniversalHex, } from "@microbit/microbit-universal-hex"; + +// I think we'd end up with two independently bundled copies of this for clients who also depend on microbit-fs. import MemoryMap from "nrf-intel-hex"; export class HexFlashDataSource implements FlashDataSource { From afa37dba541a43216dc4ab8225c770ecfa0a6d00 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Thu, 11 Jul 2024 12:10:59 +0100 Subject: [PATCH 17/20] More pondering --- lib/hex-flash-data-source.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/hex-flash-data-source.ts b/lib/hex-flash-data-source.ts index 0f92f30..9fcb833 100644 --- a/lib/hex-flash-data-source.ts +++ b/lib/hex-flash-data-source.ts @@ -12,10 +12,12 @@ export class HexFlashDataSource implements FlashDataSource { constructor(private hex: string) {} partialFlashData(boardId: BoardId): Promise { - // Perhaps this would make more sense if we actually worked with a MemoryMap? - // That's what microbit-fs is using internally. + // Perhaps this would make more sense if we returned a MemoryMap? // Then the partial flashing code could be given everything including UICR without // passing a very large Uint8Array. + + // Or use MM inside PF and return a (partial) hex string in the microbit-fs case? + const part = this.matchingPart(boardId); const hex = MemoryMap.fromHex(part); const keys = Array.from(hex.keys()).filter((k) => k < 0x10000000); From 27cf783fafdc0efe836c96ddb567d3be52e59583 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Thu, 11 Jul 2024 13:42:30 +0100 Subject: [PATCH 18/20] Rename to match usage --- lib/hex-flash-data-source.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/hex-flash-data-source.ts b/lib/hex-flash-data-source.ts index 9fcb833..dbd68b1 100644 --- a/lib/hex-flash-data-source.ts +++ b/lib/hex-flash-data-source.ts @@ -1,5 +1,8 @@ import { BoardId } from "./board-id"; -import { FlashDataSource, HexGenerationError } from "./device"; +import { + FlashDataSource, + HexGenerationError as FlashDataError, +} from "./device"; import { isUniversalHex, separateUniversalHex, @@ -23,11 +26,11 @@ export class HexFlashDataSource implements FlashDataSource { const keys = Array.from(hex.keys()).filter((k) => k < 0x10000000); const lastKey = keys[keys.length - 1]; if (lastKey === undefined) { - throw new HexGenerationError("Empty hex"); + throw new FlashDataError("Empty hex"); } const lastPart = hex.get(lastKey); if (!lastPart) { - throw new HexGenerationError("Empty hex"); + throw new FlashDataError("Empty hex"); } const length = lastKey + lastPart.length; const data = hex.slicePad(0, length, 0); @@ -44,7 +47,7 @@ export class HexFlashDataSource implements FlashDataSource { const parts = separateUniversalHex(this.hex); const matching = parts.find((p) => p.boardId == boardId.normalize().id); if (!matching) { - throw new HexGenerationError("No matching part"); + throw new FlashDataError("No matching part"); } return matching.hex; } From 8f330a01882a1185f6e1c53bffe4a9222f6b093e Mon Sep 17 00:00:00 2001 From: Robert Knight Date: Thu, 11 Jul 2024 13:47:12 +0100 Subject: [PATCH 19/20] First pass at Web Bluetooth accelerometer data --- lib/accelerometer-service.ts | 84 +++++++++++++++++++++++++++++++++ lib/accelerometer.ts | 23 +++++++++ lib/bluetooth-device-wrapper.ts | 40 +++++++++------- lib/bluetooth.ts | 5 ++ lib/device.ts | 3 ++ lib/mock.ts | 7 ++- lib/webusb.ts | 21 +++++---- src/demo.ts | 38 +++++++++++++++ 8 files changed, 196 insertions(+), 25 deletions(-) create mode 100644 lib/accelerometer-service.ts create mode 100644 lib/accelerometer.ts diff --git a/lib/accelerometer-service.ts b/lib/accelerometer-service.ts new file mode 100644 index 0000000..523877f --- /dev/null +++ b/lib/accelerometer-service.ts @@ -0,0 +1,84 @@ +import { + Accelerometer, + AccelerometerData, + AccelerometerDataEvent, + AccelerometerEventMap, +} from "./accelerometer"; +import { profile } from "./bluetooth-profile"; +import { TypedEventTarget } from "./events"; + +export type CharacteristicDataTarget = EventTarget & { + value: DataView; +}; + +export class AccelerometerService + extends TypedEventTarget + implements Accelerometer +{ + private static accelerometerInstance: AccelerometerService | undefined; + + constructor( + private accelerometerDataCharacteristic: BluetoothRemoteGATTCharacteristic, + // @ts-ignore temporarily unused characteristic + private accelerometerPeriodCharacteristic: BluetoothRemoteGATTCharacteristic + ) { + super(); + this.accelerometerDataCharacteristic.addEventListener( + "characteristicvaluechanged", + this.dataListener + ); + } + + static async init(gattServer: BluetoothRemoteGATTServer) { + if (this.accelerometerInstance) { + return this.accelerometerInstance; + } + const accelerometerService = await gattServer.getPrimaryService( + profile.accelerometer.id + ); + const accelerometerDataCharacteristic = + await accelerometerService.getCharacteristic( + profile.accelerometer.characteristics.data.id + ); + const accelerometerPeriodCharacteristic = + await accelerometerService.getCharacteristic( + profile.accelerometer.characteristics.period.id + ); + this.accelerometerInstance = new AccelerometerService( + accelerometerDataCharacteristic, + accelerometerPeriodCharacteristic + ); + return this.accelerometerInstance; + } + + async getData(): Promise { + const dataView = await this.accelerometerDataCharacteristic.readValue(); + const data = this.dataViewToData(dataView); + return data; + } + + private dataViewToData(dataView: DataView): AccelerometerData { + return { + x: dataView.getInt16(0, true), + y: dataView.getInt16(2, true), + z: dataView.getInt16(4, true), + }; + } + + private dataListener = (event: Event) => { + const target = event.target as CharacteristicDataTarget; + const data = this.dataViewToData(target.value); + this.dispatchTypedEvent( + "accelerometerdatachanged", + new AccelerometerDataEvent(data) + ); + }; + + startNotifications() { + this.accelerometerDataCharacteristic.startNotifications(); + } + + stopNotifications() { + this.accelerometerDataCharacteristic.stopNotifications(); + } +} diff --git a/lib/accelerometer.ts b/lib/accelerometer.ts new file mode 100644 index 0000000..5205cab --- /dev/null +++ b/lib/accelerometer.ts @@ -0,0 +1,23 @@ +import { TypedEventTarget } from "./events"; + +export interface Accelerometer extends TypedEventTarget { + getData: () => Promise; + startNotifications: () => void; + stopNotifications: () => void; +} + +export class AccelerometerDataEvent extends Event { + constructor(public readonly data: AccelerometerData) { + super("accelerometerdatachanged"); + } +} + +export class AccelerometerEventMap { + "accelerometerdatachanged": AccelerometerDataEvent; +} + +export interface AccelerometerData { + x: number; + y: number; + z: number; +} diff --git a/lib/bluetooth-device-wrapper.ts b/lib/bluetooth-device-wrapper.ts index d3a683c..afa5209 100644 --- a/lib/bluetooth-device-wrapper.ts +++ b/lib/bluetooth-device-wrapper.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: MIT */ +import { Accelerometer } from "./accelerometer"; +import { AccelerometerService } from "./accelerometer-service"; import { profile } from "./bluetooth-profile"; import { BoardVersion } from "./device"; import { Logging, NullLogging } from "./logging"; @@ -48,11 +50,11 @@ export class BluetoothDeviceWrapper { constructor( public readonly device: BluetoothDevice, - private logging: Logging = new NullLogging(), + private logging: Logging = new NullLogging() ) { device.addEventListener( "gattserverdisconnected", - this.handleDisconnectEvent, + this.handleDisconnectEvent ); } @@ -63,7 +65,7 @@ export class BluetoothDeviceWrapper { }); if (this.duringExplicitConnectDisconnect) { this.logging.log( - "Skipping connect attempt when one is already in progress", + "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, @@ -74,7 +76,7 @@ export class BluetoothDeviceWrapper { this.duringExplicitConnectDisconnect++; if (this.device.gatt === undefined) { throw new Error( - "BluetoothRemoteGATTServer for micro:bit device is undefined", + "BluetoothRemoteGATTServer for micro:bit device is undefined" ); } try { @@ -93,7 +95,7 @@ export class BluetoothDeviceWrapper { // Do we still want to be connected? if (!this.connecting) { this.logging.log( - "Bluetooth GATT server connect after timeout, triggering disconnect", + "Bluetooth GATT server connect after timeout, triggering disconnect" ); this.disconnectPromise = (async () => { await this.disconnectInternal(false); @@ -101,7 +103,7 @@ export class BluetoothDeviceWrapper { })(); } else { this.logging.log( - "Bluetooth GATT server connected when connecting", + "Bluetooth GATT server connected when connecting" ); } }) @@ -112,7 +114,7 @@ export class BluetoothDeviceWrapper { } else { this.logging.error( "Bluetooth GATT server connect error after our timeout", - e, + e ); return undefined; } @@ -127,7 +129,7 @@ export class BluetoothDeviceWrapper { const gattConnectResult = await Promise.race([ this.gattConnectPromise, new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), connectTimeoutDuration), + setTimeout(() => resolve("timeout"), connectTimeoutDuration) ), ]); if (gattConnectResult === "timeout") { @@ -161,7 +163,7 @@ export class BluetoothDeviceWrapper { private async disconnectInternal(userTriggered: boolean): Promise { this.logging.log( - `Bluetooth disconnect ${userTriggered ? "(user triggered)" : "(programmatic)"}`, + `Bluetooth disconnect ${userTriggered ? "(user triggered)" : "(programmatic)"}` ); this.duringExplicitConnectDisconnect++; try { @@ -175,7 +177,7 @@ export class BluetoothDeviceWrapper { this.duringExplicitConnectDisconnect--; } this.reconnectReadyPromise = new Promise((resolve) => - setTimeout(resolve, 3_500), + setTimeout(resolve, 3_500) ); } @@ -198,19 +200,19 @@ export class BluetoothDeviceWrapper { try { if (!this.duringExplicitConnectDisconnect) { this.logging.log( - "Bluetooth GATT disconnected... automatically trying reconnect", + "Bluetooth GATT disconnected... automatically trying reconnect" ); // stateOnReconnectionAttempt(); await this.reconnect(); } else { this.logging.log( - "Bluetooth GATT disconnect ignored during explicit disconnect", + "Bluetooth GATT disconnect ignored during explicit disconnect" ); } } catch (e) { this.logging.error( "Bluetooth connect triggered by disconnect listener failed", - e, + e ); } }; @@ -227,10 +229,10 @@ export class BluetoothDeviceWrapper { const serviceMeta = profile.deviceInformation; try { const deviceInfo = await this.assertGattServer().getPrimaryService( - serviceMeta.id, + serviceMeta.id ); const characteristic = await deviceInfo.getCharacteristic( - serviceMeta.characteristics.modelNumber.id, + serviceMeta.characteristics.modelNumber.id ); const modelNumberBytes = await characteristic.readValue(); const modelNumber = new TextDecoder().decode(modelNumberBytes); @@ -248,11 +250,17 @@ export class BluetoothDeviceWrapper { throw new Error("Could not read model number"); } } + + async getAccelerometerService(): Promise { + const gattServer = this.assertGattServer(); + const accelerometer = await AccelerometerService.init(gattServer); + return accelerometer; + } } export const createBluetoothDeviceWrapper = async ( device: BluetoothDevice, - logging: Logging, + logging: Logging ): Promise => { try { // Reuse our connection objects for the same device as they diff --git a/lib/bluetooth.ts b/lib/bluetooth.ts index afe8388..2c2104d 100644 --- a/lib/bluetooth.ts +++ b/lib/bluetooth.ts @@ -3,6 +3,7 @@ * * SPDX-License-Identifier: MIT */ +import { Accelerometer } from "./accelerometer"; import { BluetoothDeviceWrapper, createBluetoothDeviceWrapper, @@ -136,6 +137,10 @@ export class MicrobitWebBluetoothConnection } } + async getAccelerometer(): Promise { + return this.connection?.getAccelerometerService(); + } + private setStatus(newStatus: ConnectionStatus) { this.status = newStatus; this.log("Device status " + newStatus); diff --git a/lib/device.ts b/lib/device.ts index b4c6ab2..5ee92d8 100644 --- a/lib/device.ts +++ b/lib/device.ts @@ -5,6 +5,7 @@ */ import { TypedEventTarget } from "./events"; import { BoardId } from "./board-id"; +import { Accelerometer } from "./accelerometer"; /** * Specific identified error types. @@ -242,4 +243,6 @@ export interface DeviceConnection * Clear device to enable chooseDevice. */ clearDevice(): void; + + getAccelerometer(): Promise; } diff --git a/lib/mock.ts b/lib/mock.ts index f203565..33574ca 100644 --- a/lib/mock.ts +++ b/lib/mock.ts @@ -16,6 +16,7 @@ import { WebUSBError, WebUSBErrorCode, } from "./device"; +import { Accelerometer } from "./accelerometer"; /** * A mock device used during end-to-end testing. @@ -83,7 +84,7 @@ export class MockDeviceConnection * A progress callback. Called with undefined when the process is complete or has failed. */ progress: (percentage: number | undefined) => void; - }, + } ): Promise { await new Promise((resolve) => setTimeout(resolve, 100)); options.progress(0.5); @@ -112,4 +113,8 @@ export class MockDeviceConnection mockWebUsbNotSupported(): void { this.setStatus(ConnectionStatus.NOT_SUPPORTED); } + + getAccelerometer(): Promise { + return Promise.resolve(undefined); + } } diff --git a/lib/webusb.ts b/lib/webusb.ts index c8d41b3..c43dfc5 100644 --- a/lib/webusb.ts +++ b/lib/webusb.ts @@ -25,6 +25,7 @@ import { WebUSBError, } from "./device"; import { TypedEventTarget } from "./events"; +import { Accelerometer } from "./accelerometer"; // Temporary workaround for ChromeOS 105 bug. // See https://bugs.chromium.org/p/chromium/issues/detail?id=1363712&q=usb&can=2 @@ -124,14 +125,14 @@ export class MicrobitWebUSBConnection } }, assumePageIsStayingOpenDelay); }, - { once: true }, + { once: true } ); }; private logging: Logging; constructor( - options: MicrobitWebUSBConnectionOptions = { logging: new NullLogging() }, + options: MicrobitWebUSBConnectionOptions = { logging: new NullLogging() } ) { super(); this.logging = options.logging; @@ -150,7 +151,7 @@ export class MicrobitWebUSBConnection if (window.document) { window.document.addEventListener( "visibilitychange", - this.visibilityChangeListener, + this.visibilityChangeListener ); } } @@ -165,7 +166,7 @@ export class MicrobitWebUSBConnection if (window.document) { window.document.removeEventListener( "visibilitychange", - this.visibilityChangeListener, + this.visibilityChangeListener ); } } @@ -193,13 +194,13 @@ export class MicrobitWebUSBConnection * A progress callback. Called with undefined when the process is complete or has failed. */ progress: (percentage: number | undefined) => void; - }, + } ): Promise { this.flashing = true; try { const startTime = new Date().getTime(); await this.withEnrichedErrors(() => - this.flashInternal(dataSource, options), + this.flashInternal(dataSource, options) ); this.dispatchTypedEvent("flash", new FlashEvent()); @@ -221,7 +222,7 @@ export class MicrobitWebUSBConnection options: { partial: boolean; progress: (percentage: number | undefined, partial: boolean) => void; - }, + } ): Promise { this.log("Stopping serial before flash"); await this.stopSerialInternal(); @@ -333,7 +334,7 @@ export class MicrobitWebUSBConnection // Log error to console for feedback this.log("An error occurred whilst attempting to use WebUSB."); this.log( - "Details of the error can be found below, and may be useful when trying to replicate and debug the error.", + "Details of the error can be found below, and may be useful when trying to replicate and debug the error." ); this.log(e); @@ -410,6 +411,10 @@ export class MicrobitWebUSBConnection this.dispatchTypedEvent("afterrequestdevice", new AfterRequestDevice()); return this.device; } + + async getAccelerometer(): Promise { + return Promise.resolve(undefined); + } } const genericErrorSuggestingReconnect = (e: any) => diff --git a/src/demo.ts b/src/demo.ts index f8e4087..47e4667 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -8,6 +8,7 @@ import { MicrobitWebUSBConnection } from "../lib/webusb"; import { HexFlashDataSource } from "../lib/hex-flash-data-source"; import { ConnectionStatus, DeviceConnection } from "../lib/device"; import { MicrobitWebBluetoothConnection } from "../lib/bluetooth"; +import { AccelerometerDataEvent } from "../lib/accelerometer"; document.querySelector("#app")!.innerHTML = `
@@ -24,6 +25,11 @@ document.querySelector("#app")!.innerHTML = `

+
+ + + +
`; @@ -37,6 +43,15 @@ const fileInput = document.querySelector( "#flash input[type=file]" )! as HTMLInputElement; const statusParagraph = document.querySelector("#flash > .status")!; +const accDataGet = document.querySelector( + "#flash > .acc-controls > .acc-data-get" +)!; +const accDataListen = document.querySelector( + "#flash > .acc-controls > .acc-data-listen" +)!; +const accDataStop = document.querySelector( + "#flash > .acc-controls > .acc-data-stop" +)!; let connection: DeviceConnection = new MicrobitWebUSBConnection(); const displayStatus = (status: ConnectionStatus) => { @@ -85,3 +100,26 @@ flash.addEventListener("click", async () => { }); } }); + +accDataGet.addEventListener("click", async () => { + const acc = await connection.getAccelerometer(); + const data = await acc?.getData(); + console.log("Get accelerometer data", data); +}); + +const accChangedListener = (event: AccelerometerDataEvent) => { + console.log(event.data); +}; + +accDataListen.addEventListener("click", async () => { + const acc = await connection.getAccelerometer(); + console.log("Stream accelerometer data"); + acc?.addEventListener("accelerometerdatachanged", accChangedListener); + acc?.startNotifications(); +}); + +accDataStop.addEventListener("click", async () => { + const acc = await connection.getAccelerometer(); + acc?.removeEventListener("accelerometerdatachanged", accChangedListener); + acc?.stopNotifications(); +}); From 8e7b5a5c0e2bd94828d9b089b4a0141d5807021b Mon Sep 17 00:00:00 2001 From: Robert Knight Date: Thu, 11 Jul 2024 14:05:39 +0100 Subject: [PATCH 20/20] Prettier --- lib/accelerometer-service.ts | 14 +++++++------- lib/bluetooth-device-wrapper.ts | 32 ++++++++++++++++---------------- lib/bluetooth.ts | 8 ++++---- lib/device.ts | 2 +- lib/mock.ts | 2 +- lib/webusb.ts | 16 ++++++++-------- src/demo.ts | 10 +++++----- 7 files changed, 42 insertions(+), 42 deletions(-) diff --git a/lib/accelerometer-service.ts b/lib/accelerometer-service.ts index 523877f..04f142c 100644 --- a/lib/accelerometer-service.ts +++ b/lib/accelerometer-service.ts @@ -20,12 +20,12 @@ export class AccelerometerService constructor( private accelerometerDataCharacteristic: BluetoothRemoteGATTCharacteristic, // @ts-ignore temporarily unused characteristic - private accelerometerPeriodCharacteristic: BluetoothRemoteGATTCharacteristic + private accelerometerPeriodCharacteristic: BluetoothRemoteGATTCharacteristic, ) { super(); this.accelerometerDataCharacteristic.addEventListener( "characteristicvaluechanged", - this.dataListener + this.dataListener, ); } @@ -34,19 +34,19 @@ export class AccelerometerService return this.accelerometerInstance; } const accelerometerService = await gattServer.getPrimaryService( - profile.accelerometer.id + profile.accelerometer.id, ); const accelerometerDataCharacteristic = await accelerometerService.getCharacteristic( - profile.accelerometer.characteristics.data.id + profile.accelerometer.characteristics.data.id, ); const accelerometerPeriodCharacteristic = await accelerometerService.getCharacteristic( - profile.accelerometer.characteristics.period.id + profile.accelerometer.characteristics.period.id, ); this.accelerometerInstance = new AccelerometerService( accelerometerDataCharacteristic, - accelerometerPeriodCharacteristic + accelerometerPeriodCharacteristic, ); return this.accelerometerInstance; } @@ -70,7 +70,7 @@ export class AccelerometerService const data = this.dataViewToData(target.value); this.dispatchTypedEvent( "accelerometerdatachanged", - new AccelerometerDataEvent(data) + new AccelerometerDataEvent(data), ); }; diff --git a/lib/bluetooth-device-wrapper.ts b/lib/bluetooth-device-wrapper.ts index afa5209..1ee3c88 100644 --- a/lib/bluetooth-device-wrapper.ts +++ b/lib/bluetooth-device-wrapper.ts @@ -50,11 +50,11 @@ export class BluetoothDeviceWrapper { constructor( public readonly device: BluetoothDevice, - private logging: Logging = new NullLogging() + private logging: Logging = new NullLogging(), ) { device.addEventListener( "gattserverdisconnected", - this.handleDisconnectEvent + this.handleDisconnectEvent, ); } @@ -65,7 +65,7 @@ export class BluetoothDeviceWrapper { }); if (this.duringExplicitConnectDisconnect) { this.logging.log( - "Skipping connect attempt when one is already in progress" + "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, @@ -76,7 +76,7 @@ export class BluetoothDeviceWrapper { this.duringExplicitConnectDisconnect++; if (this.device.gatt === undefined) { throw new Error( - "BluetoothRemoteGATTServer for micro:bit device is undefined" + "BluetoothRemoteGATTServer for micro:bit device is undefined", ); } try { @@ -95,7 +95,7 @@ export class BluetoothDeviceWrapper { // Do we still want to be connected? if (!this.connecting) { this.logging.log( - "Bluetooth GATT server connect after timeout, triggering disconnect" + "Bluetooth GATT server connect after timeout, triggering disconnect", ); this.disconnectPromise = (async () => { await this.disconnectInternal(false); @@ -103,7 +103,7 @@ export class BluetoothDeviceWrapper { })(); } else { this.logging.log( - "Bluetooth GATT server connected when connecting" + "Bluetooth GATT server connected when connecting", ); } }) @@ -114,7 +114,7 @@ export class BluetoothDeviceWrapper { } else { this.logging.error( "Bluetooth GATT server connect error after our timeout", - e + e, ); return undefined; } @@ -129,7 +129,7 @@ export class BluetoothDeviceWrapper { const gattConnectResult = await Promise.race([ this.gattConnectPromise, new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), connectTimeoutDuration) + setTimeout(() => resolve("timeout"), connectTimeoutDuration), ), ]); if (gattConnectResult === "timeout") { @@ -163,7 +163,7 @@ export class BluetoothDeviceWrapper { private async disconnectInternal(userTriggered: boolean): Promise { this.logging.log( - `Bluetooth disconnect ${userTriggered ? "(user triggered)" : "(programmatic)"}` + `Bluetooth disconnect ${userTriggered ? "(user triggered)" : "(programmatic)"}`, ); this.duringExplicitConnectDisconnect++; try { @@ -177,7 +177,7 @@ export class BluetoothDeviceWrapper { this.duringExplicitConnectDisconnect--; } this.reconnectReadyPromise = new Promise((resolve) => - setTimeout(resolve, 3_500) + setTimeout(resolve, 3_500), ); } @@ -200,19 +200,19 @@ export class BluetoothDeviceWrapper { try { if (!this.duringExplicitConnectDisconnect) { this.logging.log( - "Bluetooth GATT disconnected... automatically trying reconnect" + "Bluetooth GATT disconnected... automatically trying reconnect", ); // stateOnReconnectionAttempt(); await this.reconnect(); } else { this.logging.log( - "Bluetooth GATT disconnect ignored during explicit disconnect" + "Bluetooth GATT disconnect ignored during explicit disconnect", ); } } catch (e) { this.logging.error( "Bluetooth connect triggered by disconnect listener failed", - e + e, ); } }; @@ -229,10 +229,10 @@ export class BluetoothDeviceWrapper { const serviceMeta = profile.deviceInformation; try { const deviceInfo = await this.assertGattServer().getPrimaryService( - serviceMeta.id + serviceMeta.id, ); const characteristic = await deviceInfo.getCharacteristic( - serviceMeta.characteristics.modelNumber.id + serviceMeta.characteristics.modelNumber.id, ); const modelNumberBytes = await characteristic.readValue(); const modelNumber = new TextDecoder().decode(modelNumberBytes); @@ -260,7 +260,7 @@ export class BluetoothDeviceWrapper { export const createBluetoothDeviceWrapper = async ( device: BluetoothDevice, - logging: Logging + logging: Logging, ): Promise => { try { // Reuse our connection objects for the same device as they diff --git a/lib/bluetooth.ts b/lib/bluetooth.ts index 2c2104d..9e690c2 100644 --- a/lib/bluetooth.ts +++ b/lib/bluetooth.ts @@ -92,7 +92,7 @@ export class MicrobitWebBluetoothConnection * A progress callback. Called with undefined when the process is complete or has failed. */ progress: (percentage: number | undefined) => void; - } + }, ): Promise { throw new Error("Unsupported"); } @@ -168,7 +168,7 @@ export class MicrobitWebBluetoothConnection } this.connection = await createBluetoothDeviceWrapper( device, - this.logging + this.logging, ); } // TODO: timeout unification? @@ -177,7 +177,7 @@ export class MicrobitWebBluetoothConnection } private async chooseDevice( - options: ConnectOptions + options: ConnectOptions, ): Promise { if (this.device) { return this.device; @@ -209,7 +209,7 @@ export class MicrobitWebBluetoothConnection ], }), new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), requestDeviceTimeoutDuration) + setTimeout(() => resolve("timeout"), requestDeviceTimeoutDuration), ), ]); if (result === "timeout") { diff --git a/lib/device.ts b/lib/device.ts index 5ee92d8..ef763ba 100644 --- a/lib/device.ts +++ b/lib/device.ts @@ -221,7 +221,7 @@ export interface DeviceConnection * The partial parameter reports the flash type currently in progress. */ progress: (percentage: number | undefined, partial: boolean) => void; - } + }, ): Promise; /** diff --git a/lib/mock.ts b/lib/mock.ts index 33574ca..33da997 100644 --- a/lib/mock.ts +++ b/lib/mock.ts @@ -84,7 +84,7 @@ export class MockDeviceConnection * A progress callback. Called with undefined when the process is complete or has failed. */ progress: (percentage: number | undefined) => void; - } + }, ): Promise { await new Promise((resolve) => setTimeout(resolve, 100)); options.progress(0.5); diff --git a/lib/webusb.ts b/lib/webusb.ts index c43dfc5..9b9c0b9 100644 --- a/lib/webusb.ts +++ b/lib/webusb.ts @@ -125,14 +125,14 @@ export class MicrobitWebUSBConnection } }, assumePageIsStayingOpenDelay); }, - { once: true } + { once: true }, ); }; private logging: Logging; constructor( - options: MicrobitWebUSBConnectionOptions = { logging: new NullLogging() } + options: MicrobitWebUSBConnectionOptions = { logging: new NullLogging() }, ) { super(); this.logging = options.logging; @@ -151,7 +151,7 @@ export class MicrobitWebUSBConnection if (window.document) { window.document.addEventListener( "visibilitychange", - this.visibilityChangeListener + this.visibilityChangeListener, ); } } @@ -166,7 +166,7 @@ export class MicrobitWebUSBConnection if (window.document) { window.document.removeEventListener( "visibilitychange", - this.visibilityChangeListener + this.visibilityChangeListener, ); } } @@ -194,13 +194,13 @@ export class MicrobitWebUSBConnection * A progress callback. Called with undefined when the process is complete or has failed. */ progress: (percentage: number | undefined) => void; - } + }, ): Promise { this.flashing = true; try { const startTime = new Date().getTime(); await this.withEnrichedErrors(() => - this.flashInternal(dataSource, options) + this.flashInternal(dataSource, options), ); this.dispatchTypedEvent("flash", new FlashEvent()); @@ -222,7 +222,7 @@ export class MicrobitWebUSBConnection options: { partial: boolean; progress: (percentage: number | undefined, partial: boolean) => void; - } + }, ): Promise { this.log("Stopping serial before flash"); await this.stopSerialInternal(); @@ -334,7 +334,7 @@ export class MicrobitWebUSBConnection // Log error to console for feedback this.log("An error occurred whilst attempting to use WebUSB."); this.log( - "Details of the error can be found below, and may be useful when trying to replicate and debug the error." + "Details of the error can be found below, and may be useful when trying to replicate and debug the error.", ); this.log(e); diff --git a/src/demo.ts b/src/demo.ts index 47e4667..ad3a1d8 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -34,23 +34,23 @@ document.querySelector("#app")!.innerHTML = ` `; const transport = document.querySelector( - "#flash > .transport" + "#flash > .transport", )! as HTMLSelectElement; const connect = document.querySelector("#flash > .connect")!; const disconnect = document.querySelector("#flash > .disconnect")!; const flash = document.querySelector("#flash > .flash")!; const fileInput = document.querySelector( - "#flash input[type=file]" + "#flash input[type=file]", )! as HTMLInputElement; const statusParagraph = document.querySelector("#flash > .status")!; const accDataGet = document.querySelector( - "#flash > .acc-controls > .acc-data-get" + "#flash > .acc-controls > .acc-data-get", )!; const accDataListen = document.querySelector( - "#flash > .acc-controls > .acc-data-listen" + "#flash > .acc-controls > .acc-data-listen", )!; const accDataStop = document.querySelector( - "#flash > .acc-controls > .acc-data-stop" + "#flash > .acc-controls > .acc-data-stop", )!; let connection: DeviceConnection = new MicrobitWebUSBConnection();