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..370ab20 --- /dev/null +++ b/TODO.md @@ -0,0 +1,8 @@ +- 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? + +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/accelerometer-service.ts b/lib/accelerometer-service.ts new file mode 100644 index 0000000..04f142c --- /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/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 new file mode 100644 index 0000000..1ee3c88 --- /dev/null +++ b/lib/bluetooth-device-wrapper.ts @@ -0,0 +1,277 @@ +/** + * (c) 2023, Center for Computational Thinking and Design at Aarhus University and contributors + * + * 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"; + +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; + + boardVersion: BoardVersion | undefined; + + 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. + 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) { + this.logging.log( + "Bluetooth GATT server connect after timeout, triggering disconnect", + ); + this.disconnectPromise = (async () => { + await this.disconnectInternal(false); + this.disconnectPromise = undefined; + })(); + } else { + this.logging.log( + "Bluetooth GATT server connected when connecting", + ); + } + }) + .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; + 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"); + } + } 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.duringExplicitConnectDisconnect--; + } + } + + async disconnect(): Promise { + return this.disconnectInternal(true); + } + + private async disconnectInternal(userTriggered: boolean): 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(): Promise { + 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; + } + + private async getBoardVersion(): 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 "V1"; + } + if ( + modelNumber.toLowerCase().includes("BBC micro:bit v2".toLowerCase()) + ) { + return "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"); + } + } + + async getAccelerometerService(): Promise { + const gattServer = this.assertGattServer(); + const accelerometer = await AccelerometerService.init(gattServer); + return accelerometer; + } +} + +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-profile.ts b/lib/bluetooth-profile.ts new file mode 100644 index 0000000..5978a4b --- /dev/null +++ b/lib/bluetooth-profile.ts @@ -0,0 +1,82 @@ +// 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" }, + 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" }, + }, + }, + ioPin: { + id: "e95d127b-251d-470a-a062-fa1922dfa9a8", + characteristics: { + 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: { + id: "e95d9882-251d-470a-a062-fa1922dfa9a8", + characteristics: { + a: { id: "e95dda90-251d-470a-a062-fa1922dfa9a8" }, + 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 new file mode 100644 index 0000000..9e690c2 --- /dev/null +++ b/lib/bluetooth.ts @@ -0,0 +1,229 @@ +/** + * (c) 2021, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Accelerometer } from "./accelerometer"; +import { + BluetoothDeviceWrapper, + createBluetoothDeviceWrapper, +} from "./bluetooth-device-wrapper"; +import { profile } from "./bluetooth-profile"; +import { + BoardVersion, + ConnectOptions, + ConnectionStatus, + ConnectionStatusEvent, + DeviceConnection, + DeviceConnectionEventMap, + AfterRequestDevice, + FlashDataSource, + SerialResetEvent, + BeforeRequestDevice, +} from "./device"; +import { TypedEventTarget } from "./events"; +import { Logging, NullLogging } from "./logging"; + +const requestDeviceTimeoutDuration: number = 30000; + +export interface MicrobitWebBluetoothConnectionOptions { + logging?: Logging; +} + +/** + * 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; + connection: BluetoothDeviceWrapper | undefined; + + constructor(options: MicrobitWebBluetoothConnectionOptions = {}) { + super(); + this.logging = options.logging || new NullLogging(); + } + + 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 { + await this.connectInternal(options); + return this.status; + } + + getBoardVersion(): BoardVersion | undefined { + return this.connection?.boardVersion; + } + + 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 { + throw new Error("Unsupported"); + } + + // @ts-ignore + private async startSerialInternal() { + if (!this.connection) { + // As connecting then starting serial are async we could disconnect between them, + // so handle this gracefully. + return; + } + // TODO + } + + // @ts-ignore + private async stopSerialInternal() { + if (this.connection) { + // TODO + this.dispatchTypedEvent("serialreset", new SerialResetEvent()); + } + } + + async disconnect(): Promise { + try { + if (this.connection) { + await this.connection.disconnect(); + } + } catch (e) { + this.log("Error during disconnection:\r\n" + e); + this.logging.event({ + type: "Bluetooth-error", + message: "error-disconnecting", + }); + } finally { + this.connection = undefined; + this.setStatus(ConnectionStatus.NOT_CONNECTED); + this.logging.log("Disconnection complete"); + this.logging.event({ + type: "Bluetooth-info", + message: "disconnected", + }); + } + } + + async getAccelerometer(): Promise { + return this.connection?.getAccelerometerService(); + } + + private setStatus(newStatus: ConnectionStatus) { + this.status = newStatus; + this.log("Device status " + newStatus); + this.dispatchTypedEvent("status", new ConnectionStatusEvent(newStatus)); + } + + serialWrite(data: string): Promise { + if (this.connection) { + // TODO + } + return Promise.resolve(); + } + + 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(options); + if (!device) { + return; + } + this.connection = await createBluetoothDeviceWrapper( + device, + this.logging, + ); + } + // TODO: timeout unification? + this.connection?.connect(); + this.setStatus(ConnectionStatus.CONNECTED); + } + + private async chooseDevice( + options: ConnectOptions, + ): Promise { + if (this.device) { + return this.device; + } + 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 + const result = await Promise.race([ + navigator.bluetooth.requestDevice({ + filters: [ + { + namePrefix: options.name + ? `BBC micro:bit [${options.name}]` + : "BBC micro:bit", + }, + ], + optionalServices: [ + profile.accelerometer.id, + profile.button.id, + profile.deviceInformation.id, + profile.dfuControl.id, + profile.event.id, + profile.ioPin.id, + profile.led.id, + profile.magnetometer.id, + profile.temperature.id, + profile.uart.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("afterrequestdevice", new AfterRequestDevice()); + } + } +} 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/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 6fe37e0..ef763ba 100644 --- a/lib/device.ts +++ b/lib/device.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: MIT */ import { TypedEventTarget } from "./events"; -import { Logging } from "./logging"; import { BoardId } from "./board-id"; +import { Accelerometer } from "./accelerometer"; /** * Specific identified error types. @@ -55,13 +55,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. */ @@ -100,7 +93,11 @@ 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. + * + * This interface is quite confusing and worth revisiting. * * @param boardId the id of the board. * @throws HexGenerationError if we cannot generate hex data. @@ -108,21 +105,17 @@ 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; - - /** - * The file system represented by file name keys and data values. - */ - files(): Promise>; + fullFlashData(boardId: BoardId): Promise; } export interface ConnectOptions { serial?: boolean; + // Name filter used for Web Bluetooth + name?: string; } export type BoardVersion = "V1" | "V2"; @@ -135,19 +128,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 +150,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 @@ -205,7 +198,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. @@ -228,7 +221,7 @@ export interface DeviceConnection * The partial parameter reports the flash type currently in progress. */ progress: (percentage: number | undefined, partial: boolean) => void; - } + }, ): Promise; /** @@ -250,4 +243,6 @@ export interface DeviceConnection * Clear device to enable chooseDevice. */ clearDevice(): void; + + getAccelerometer(): 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/hex-flash-data-source.ts b/lib/hex-flash-data-source.ts new file mode 100644 index 0000000..dbd68b1 --- /dev/null +++ b/lib/hex-flash-data-source.ts @@ -0,0 +1,56 @@ +import { BoardId } from "./board-id"; +import { + FlashDataSource, + HexGenerationError as FlashDataError, +} from "./device"; +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 { + constructor(private hex: string) {} + + partialFlashData(boardId: BoardId): Promise { + // 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); + const lastKey = keys[keys.length - 1]; + if (lastKey === undefined) { + throw new FlashDataError("Empty hex"); + } + const lastPart = hex.get(lastKey); + if (!lastPart) { + throw new FlashDataError("Empty hex"); + } + const length = lastKey + lastPart.length; + const data = hex.slicePad(0, length, 0); + return Promise.resolve(data); + } + + fullFlashData(boardId: BoardId): Promise { + const part = this.matchingPart(boardId); + 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 FlashDataError("No matching part"); + } + return matching.hex; + } + return this.hex; + } +} diff --git a/lib/logging.ts b/lib/logging.ts index 32d4f8b..c9c0e13 100644 --- a/lib/logging.ts +++ b/lib/logging.ts @@ -12,15 +12,16 @@ 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 { + event(_event: Event): 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 51887f9..4d47152 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -1,4 +1,13 @@ 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"; -// TODO: more! -export { MicrobitWebUSBConnection }; \ No newline at end of file +export { + MicrobitWebUSBConnection, + MicrobitWebBluetoothConnection, + BoardId, + DeviceConnection, + HexFlashDataSource, +}; diff --git a/lib/mock.ts b/lib/mock.ts index 45267df..33da997 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. @@ -41,7 +42,7 @@ export class MockDeviceConnection } mockSerialWrite(data: string) { - this.dispatchTypedEvent("serial_data", new SerialDataEvent(data)); + this.dispatchTypedEvent("serialdata", new SerialDataEvent(data)); } mockConnect(code: WebUSBErrorCode) { @@ -62,7 +63,7 @@ export class MockDeviceConnection return this.status; } - getBoardVersion(): BoardVersion | null { + getBoardVersion(): BoardVersion | undefined { return "V2"; } @@ -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/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/simulator.ts b/lib/simulator.ts deleted file mode 100644 index 0cf54cc..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("serial_data", new SerialDataEvent(text)); - } - break; - } - case "internal_error": { - const error = event.data.error; - this.logging.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("serial_reset", 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/lib/dap-wrapper.ts b/lib/webusb-device-wrapper.ts similarity index 96% rename from lib/dap-wrapper.ts rename to lib/webusb-device-wrapper.ts index 59b0c31..fbdbccc 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); @@ -62,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), ); } @@ -149,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); @@ -178,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]; @@ -203,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]; @@ -214,7 +217,7 @@ export class DAPWrapper { d & 0xff, (d >> 8) & 0xff, (d >> 16) & 0xff, - (d >> 24) & 0xff + (d >> 24) & 0xff, ); }); @@ -230,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. @@ -247,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); } @@ -259,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. @@ -332,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`, ); } @@ -352,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"); @@ -376,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 @@ -397,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/partial-flashing-utils.ts b/lib/webusb-partial-flashing-utils.ts similarity index 96% rename from lib/partial-flashing-utils.ts rename to lib/webusb-partial-flashing-utils.ts index 0fd0b2c..02859a0 100644 --- a/lib/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/partial-flashing.ts b/lib/webusb-partial-flashing.ts similarity index 94% rename from lib/partial-flashing.ts rename to lib/webusb-partial-flashing.ts index ce2f375..c143a43 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; @@ -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); @@ -117,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, ); } @@ -133,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), @@ -141,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); @@ -152,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) { @@ -187,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) { @@ -203,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(); @@ -249,7 +252,7 @@ export class PartialFlashing { async fullFlashAsync( boardId: BoardId, dataSource: FlashDataSource, - updateProgress: ProgressCallback + updateProgress: ProgressCallback, ) { this.log("Full flash"); @@ -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", @@ -268,7 +271,7 @@ export class PartialFlashing { } finally { this.dapwrapper.daplink.removeListener( DAPLink.EVENT_PROGRESS, - fullFlashProgress + fullFlashProgress, ); } } @@ -278,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. @@ -300,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 new file mode 100644 index 0000000..e038e5f --- /dev/null +++ b/lib/webusb-radio-bridge.ts @@ -0,0 +1,333 @@ +// @ts-nocheck +/** + * (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 "./webusb-serial-protocol"; +import { Logging } from "./logging"; + +const connectTimeoutDuration: number = 10000; + +class BridgeError extends Error {} +class RemoteError extends Error {} + +export class MicrobitRadioBridgeConnection { + 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: MicrobitWebUSBConnection, + private logging: Logging, + private remoteDeviceId: number, + ) {} + + async connect(): Promise { + this.logging.event({ + type: this.isReconnect ? "Reconnect" : "Connect", + message: "Serial connect start", + }); + if (this.isConnecting) { + this.logging.log( + "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) => { + this.logging.error("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 > + connectTimeoutDuration + ) { + await this.handleReconnect(); + } + }, 1000); + } + + this.logging.log(`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. + // TODO: when do we do this? + if (false) { + // 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) => { + this.logging.error("Failed to initialise serial protocol", e); + await this.disconnectInternal(false, "remote"); + this.isConnecting = false; + }); + } + } + + // stateOnAssigned(DeviceRequestStates.INPUT, this.usb.getModelNumber()); + // stateOnReady(DeviceRequestStates.INPUT); + this.logging.event({ + type: this.isReconnect ? "Reconnect" : "Connect", + message: "Serial connect success", + }); + } catch (e) { + this.logging.error("Failed to initialise serial protocol", e); + this.logging.event({ + type: this.isReconnect ? "Reconnect" : "Connect", + message: "Serial connect failed", + }); + 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): 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) { + this.logging.log( + "Serial disconnect ignored... reconnect already in progress", + ); + return; + } + try { + this.stopConnectionCheck(); + 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) { + this.logging.error( + "Serial connect triggered by disconnect listener failed", + e, + ); + } finally { + this.isConnecting = false; + } + } + + async reconnect(finalAttempt: boolean = false): Promise { + this.finalAttempt = finalAttempt; + this.logging.log("Serial reconnect"); + this.isReconnect = true; + await this.connect(); + } + + 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. + this.logging.log("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 ( + logging: Logging, + usb: MicrobitWebUSBConnection, + remoteDeviceId: number, +): Promise => { + try { + const serial = new MicrobitRadioBridgeConnection( + usb, + logging, + remoteDeviceId, + ); + await serial.connect(); + return serial; + } catch (e) { + return undefined; + } +}; diff --git a/lib/webusb-serial-protocol.ts b/lib/webusb-serial-protocol.ts new file mode 100644 index 0000000..f8a859d --- /dev/null +++ b/lib/webusb-serial-protocol.ts @@ -0,0 +1,236 @@ +/** + * (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/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 3f84ef4..9b9c0b9 100644 --- a/lib/webusb.ts +++ b/lib/webusb.ts @@ -5,27 +5,27 @@ */ 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, ConnectOptions, DeviceConnection, DeviceConnectionEventMap, - EndUSBSelect, + AfterRequestDevice, FlashDataSource, FlashEvent, HexGenerationError, - MicrobitWebUSBConnectionOptions, SerialDataEvent, SerialErrorEvent, SerialResetEvent, - StartUSBSelect, + BeforeRequestDevice, ConnectionStatusEvent, 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 @@ -34,6 +34,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 +70,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; @@ -118,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; @@ -144,7 +151,7 @@ export class MicrobitWebUSBConnection if (window.document) { window.document.addEventListener( "visibilitychange", - this.visibilityChangeListener + this.visibilityChangeListener, ); } } @@ -159,7 +166,7 @@ export class MicrobitWebUSBConnection if (window.document) { window.document.removeEventListener( "visibilitychange", - this.visibilityChangeListener + this.visibilityChangeListener, ); } } @@ -172,12 +179,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( @@ -191,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()); @@ -219,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(); @@ -277,7 +280,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 +289,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()); } } @@ -331,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); @@ -401,13 +404,17 @@ 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; } + + async getAccelerometer(): Promise { + return Promise.resolve(undefined); + } } const genericErrorSuggestingReconnect = (e: any) => diff --git a/package-lock.json b/package-lock.json index 26cefb2..1fa4776 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,14 @@ "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", "@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" @@ -460,6 +463,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", @@ -1337,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", @@ -1428,6 +1453,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", @@ -1663,6 +1703,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 0ad3869..5dfa29d 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,22 +14,25 @@ "require": "./dist/my-lib.umd.cjs" } }, - "scripts": { "dev": "vite", "build": "tsc && vite build", + "ci": "npm run build && npm run test && npx prettier --check lib src", "test": "vitest", "preview": "vite preview" }, "devDependencies": { + "@microbit/microbit-universal-hex": "^0.2.2", "@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" }, "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.css b/src/demo.css new file mode 100644 index 0000000..dd93210 --- /dev/null +++ b/src/demo.css @@ -0,0 +1,73 @@ +/* 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; +} diff --git a/src/demo.ts b/src/demo.ts index d429e8e..ad3a1d8 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -3,10 +3,123 @@ * * SPDX-License-Identifier: MIT */ -document.querySelector('#app')!.innerHTML = ` -
-

- TODO -

-
-` \ No newline at end of file +import "./demo.css"; +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 = ` +
+

Connect and 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( + "#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) => { + statusParagraph.textContent = status.toString(); +}; +const switchTransport = async () => { + await connection.disconnect(); + connection.dispose(); + + switch (transport.value) { + case "bluetooth": { + connection = new MicrobitWebBluetoothConnection(); + break; + } + case "usb": { + connection = new MicrobitWebUSBConnection(); + break; + } + } + await connection.initialize(); +}; +transport.addEventListener("change", switchTransport); +void switchTransport(); + +connection.addEventListener("status", (event) => { + displayStatus(event.status); +}); +displayStatus(connection.status); + +connect.addEventListener("click", async () => { + await connection.connect(); +}); +disconnect.addEventListener("click", async () => { + await connection.disconnect(); +}); + +flash.addEventListener("click", async () => { + const file = fileInput.files?.item(0); + if (file) { + const text = await file.text(); + await connection.flash(new HexFlashDataSource(text), { + partial: true, + progress: (percentage: number | undefined) => { + console.log(percentage); + }, + }); + } +}); + +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(); +}); diff --git a/tsconfig.json b/tsconfig.json index 5af9feb..7f7c055 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,8 +17,9 @@ /* Linting */ "strict": true, "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, + /* Temporarily disabled */ + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src", "lib"] } 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, }, };