Skip to content

Commit

Permalink
Centralise event handling
Browse files Browse the repository at this point in the history
Queue starting/stopping notifications to avoid making a mess.
  • Loading branch information
microbit-matt-hillsdon committed Aug 2, 2024
1 parent b5d093d commit 2dcce6b
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 46 deletions.
17 changes: 10 additions & 7 deletions lib/bluetooth-device-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,7 @@ export class BluetoothDeviceWrapper {
public readonly device: BluetoothDevice,
private logging: Logging = new NullLogging(),
private dispatchTypedEvent: TypedServiceEventDispatcher,
// We recreate this for the same connection and need to re-setup notifications for the old events
private events: Record<keyof ServiceConnectionEventMap, boolean>,
private currentEvents: () => Array<keyof ServiceConnectionEventMap>,
private callbacks: ConnectCallbacks,
) {
device.addEventListener(
Expand Down Expand Up @@ -235,7 +234,7 @@ export class BluetoothDeviceWrapper {
this.connecting = false;
}

Object.keys(this.events).forEach((e) =>
this.currentEvents().forEach((e) =>
this.startNotifications(e as TypedServiceEvent),
);

Expand Down Expand Up @@ -382,13 +381,17 @@ export class BluetoothDeviceWrapper {
if (serviceInfo) {
// TODO: type cheat! why?
const service = await this.createIfNeeded(serviceInfo as any, true);
service?.startNotifications(type);
this.queueGattOperation(
async () => await service?.startNotifications(type),
);
}
}

async stopNotifications(type: TypedServiceEvent) {
const serviceInfo = this.serviceInfo.find((s) => s.events.includes(type));
serviceInfo?.get()?.stopNotifications(type);
this.queueGattOperation(
async () => await serviceInfo?.get()?.stopNotifications(type),
);
}

private disposeServices() {
Expand All @@ -401,7 +404,7 @@ export const createBluetoothDeviceWrapper = async (
device: BluetoothDevice,
logging: Logging,
dispatchTypedEvent: TypedServiceEventDispatcher,
addedServiceListeners: Record<keyof ServiceConnectionEventMap, boolean>,
currentEvents: () => Array<keyof ServiceConnectionEventMap>,
callbacks: ConnectCallbacks,
): Promise<BluetoothDeviceWrapper | undefined> => {
try {
Expand All @@ -413,7 +416,7 @@ export const createBluetoothDeviceWrapper = async (
device,
logging,
dispatchTypedEvent,
addedServiceListeners,
currentEvents,
callbacks,
);
deviceIdToWrapper.set(device.id, bluetooth);
Expand Down
32 changes: 13 additions & 19 deletions lib/bluetooth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import {
} from "./device.js";
import { TypedEventTarget } from "./events.js";
import { Logging, NullLogging } from "./logging.js";
import { ServiceConnectionEventMap } from "./service-events.js";
import {
ServiceConnectionEventMap,
TypedServiceEvent,
} from "./service-events.js";

const requestDeviceTimeoutDuration: number = 30000;

Expand All @@ -46,15 +49,6 @@ export class MicrobitWebBluetoothConnection
private logging: Logging;
connection: BluetoothDeviceWrapper | undefined;

private _addEventListener = this.addEventListener;
private _removeEventListener = this.removeEventListener;

private activeEvents = {
accelerometerdatachanged: false,
buttonachanged: false,
buttonbchanged: false,
};

private availabilityListener = (e: Event) => {
// TODO: is this called? is `value` correct?
const value = (e as any).value as boolean;
Expand All @@ -66,14 +60,14 @@ export class MicrobitWebBluetoothConnection
constructor(options: MicrobitWebBluetoothConnectionOptions = {}) {
super();
this.logging = options.logging || new NullLogging();
this.addEventListener = (type, ...args) => {
this._addEventListener(type, ...args);
this.connection?.startNotifications(type);
};
this.removeEventListener = (type, ...args) => {
this.connection?.stopNotifications(type);
this._removeEventListener(type, ...args);
};
}

protected eventActivated(type: string): void {
this.connection?.startNotifications(type as TypedServiceEvent);
}

protected eventDeactivated(type: string): void {
this.connection?.stopNotifications(type as TypedServiceEvent);
}

private log(v: any) {
Expand Down Expand Up @@ -161,7 +155,7 @@ export class MicrobitWebBluetoothConnection
device,
this.logging,
this.dispatchTypedEvent.bind(this),
this.activeEvents,
() => this.getActiveEvents() as Array<keyof ServiceConnectionEventMap>,
{
onConnecting: () => this.setStatus(ConnectionStatus.CONNECTING),
onReconnecting: () => this.setStatus(ConnectionStatus.RECONNECTING),
Expand Down
114 changes: 114 additions & 0 deletions lib/events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import { TrackingEventTarget } from "./events.js";

class TestTrackingEventTarget extends TrackingEventTarget {
constructor(
private activate: (type: string) => void,
private deactivate: (type: string) => void,
) {
super();
}
public getActiveEvents(): string[] {
return super.getActiveEvents();
}
protected eventActivated(type: string): void {
this.activate(type);
}
protected eventDeactivated(type: string): void {
this.deactivate(type);
}
}

describe("TrackingEventTarget", () => {
const listener = () => {};

it("add remove", () => {
const activate = vi.fn();
const deactivate = vi.fn();
const target = new TestTrackingEventTarget(activate, deactivate);
expect(target.getActiveEvents()).toEqual([]);

target.addEventListener("foo", listener);
expect(activate).toBeCalledTimes(1);
expect(deactivate).toBeCalledTimes(0);
expect(target.getActiveEvents()).toEqual(["foo"]);

target.removeEventListener("foo", listener);
expect(activate).toBeCalledTimes(1);
expect(deactivate).toBeCalledTimes(1);
expect(target.getActiveEvents()).toEqual([]);
});

it("callback equality", () => {
const listenerAlt = () => {};

const activate = vi.fn();
const deactivate = vi.fn();
const target = new TestTrackingEventTarget(activate, deactivate);
expect(target.getActiveEvents()).toEqual([]);

target.addEventListener("foo", listenerAlt);
target.addEventListener("foo", listener);
target.addEventListener("foo", listener);
target.removeEventListener("foo", listener);
expect(target.getActiveEvents()).toEqual(["foo"]);
target.removeEventListener("foo", listenerAlt);
expect(target.getActiveEvents()).toEqual([]);
});

it("option equality - capture", () => {
const fooListener = vi.fn();
const activate = vi.fn();
const deactivate = vi.fn();
const target = new TestTrackingEventTarget(activate, deactivate);
expect(target.getActiveEvents()).toEqual([]);

target.addEventListener("foo", fooListener, { capture: true });
target.addEventListener("foo", fooListener, false);
target.removeEventListener("foo", fooListener, true);
expect(target.getActiveEvents()).toEqual(["foo"]);
target.dispatchEvent(new Event("foo"));
expect(fooListener).toBeCalledTimes(1);
});

it("option equality", () => {
const fooListener = vi.fn();
const activate = vi.fn();
const deactivate = vi.fn();
const target = new TestTrackingEventTarget(activate, deactivate);

// Despite MDN docs claiming all options can result in another listener added
// it seems only capture counts for both add and remove
target.addEventListener("foo", fooListener, { passive: true });
target.addEventListener("foo", fooListener, { once: true });
target.addEventListener("foo", fooListener, { capture: true });
target.addEventListener("foo", fooListener, { capture: false });
target.dispatchEvent(new Event("foo"));
expect(fooListener).toBeCalledTimes(2);

target.removeEventListener("foo", fooListener, true);
expect(target.getActiveEvents()).toEqual(["foo"]);
target.dispatchEvent(new Event("foo"));
expect(fooListener).toBeCalledTimes(3);

target.removeEventListener("foo", fooListener, false);
expect(target.getActiveEvents()).toEqual([]);
target.dispatchEvent(new Event("foo"));
expect(fooListener).toBeCalledTimes(3);
});

it("once", () => {
const fooListener = vi.fn();
const activate = vi.fn();
const deactivate = vi.fn();
const target = new TestTrackingEventTarget(activate, deactivate);

target.addEventListener("foo", fooListener, { once: true });
target.dispatchEvent(new Event("foo"));
expect(fooListener).toBeCalledTimes(1);
expect(deactivate).toBeCalledTimes(1);

target.dispatchEvent(new Event("foo"));
expect(fooListener).toBeCalledTimes(1);
});
});
100 changes: 99 additions & 1 deletion lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,78 @@ export interface TypedEventTarget<M extends ValueIsEvent<M>> {
*/
dispatchEvent: (event: Event) => boolean;
}

// We've added this in to keep track of what events are active.
// Having done this it's questionable whether it's worth the reimplementation
// just to use an EventTarget API.
export class TrackingEventTarget extends EventTarget {
private activeEventTracking: Map<string, Registration[]> = new Map();

addEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean,
): void {
if (callback !== null) {
const registrations = this.activeEventTracking.get(type) ?? [];
const wasEmpty = registrations.length === 0;
const registration = new Registration(callback, options ?? false);
if (!registrations.find((r) => r.eq(registration))) {
registrations.push(registration);
this.activeEventTracking.set(type, registrations);
if (wasEmpty) {
this.eventActivated(type);
}
}
}
super.addEventListener(type, callback, options);
}

removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean,
): void {
if (callback !== null) {
const registration = new Registration(callback, options ?? false);
this.filterRegistrations(type, (r) => !r.eq(registration));
}
super.removeEventListener(type, callback, options);
}

dispatchEvent(event: Event): boolean {
const result = super.dispatchEvent(event);
this.filterRegistrations(event.type, (r) => !r.isOnce());
return result;
}

private filterRegistrations(
type: string,
predicate: (r: Registration) => boolean,
): void {
let registrations = this.activeEventTracking.get(type) ?? [];
registrations = registrations.filter(predicate);
if (registrations.length === 0) {
this.activeEventTracking.delete(type);
this.eventDeactivated(type);
} else {
this.activeEventTracking.set(type, registrations);
}
}

protected eventActivated(type: string) {}

protected eventDeactivated(type: string) {}

protected getActiveEvents(): string[] {
return [...this.activeEventTracking.keys()];
}
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class TypedEventTarget<M extends ValueIsEvent<M>> extends EventTarget {
export class TypedEventTarget<
M extends ValueIsEvent<M>,
> extends TrackingEventTarget {
/**
* Dispatches a synthetic event event to target and returns true if either
* event's cancelable attribute value is false or its preventDefault() method
Expand All @@ -120,3 +190,31 @@ export class TypedEventTarget<M extends ValueIsEvent<M>> extends EventTarget {
return super.dispatchEvent(event);
}
}

class Registration {
constructor(
private callback: EventListenerOrEventListenerObject,
private options: AddEventListenerOptions | boolean,
) {}

isOnce() {
return typeof this.options === "object" && this.options.once === true;
}

eq(other: Registration) {
return (
other.callback === this.callback &&
eqUseCapture(this.options, other.options)
);
}
}

const eqUseCapture = (
left: AddEventListenerOptions | boolean,
right: AddEventListenerOptions | boolean,
) => {
const leftValue = typeof left === "boolean" ? left : left.capture ?? false;
const rightValue =
typeof right === "boolean" ? right : right.capture ?? false;
return leftValue === rightValue;
};
21 changes: 2 additions & 19 deletions lib/usb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,6 @@ export class MicrobitWebUSBConnection

private logging: Logging;

private _addEventListener = this.addEventListener;
private _removeEventListener = this.removeEventListener;

private addedListeners: Record<string, number> = {
serialdata: 0,
};
Expand All @@ -142,20 +139,6 @@ export class MicrobitWebUSBConnection
) {
super();
this.logging = options.logging;
// TODO: this doesn't account for the rules around add/remove call equivalence
this.addEventListener = (type, ...args) => {
this._addEventListener(type, ...args);
if (++this.addedListeners[type] === 1 && !this.flashing) {
this.startNotifications(type);
}
};
this.removeEventListener = (type, ...args) => {
this._removeEventListener(type, ...args);
if (--this.addedListeners[type] <= 0) {
this.addedListeners[type] = 0;
this.stopNotifications(type);
}
};
}

private log(v: any) {
Expand Down Expand Up @@ -442,7 +425,7 @@ export class MicrobitWebUSBConnection
return this.device;
}

private async startNotifications(type: string) {
protected eventActivated(type: string): void {
switch (type as keyof DeviceConnectionEventMap) {
case "serialdata": {
this.startSerialInternal();
Expand All @@ -451,7 +434,7 @@ export class MicrobitWebUSBConnection
}
}

private async stopNotifications(type: string) {
protected async eventDeactivated(type: string) {
switch (type as keyof DeviceConnectionEventMap) {
case "serialdata": {
this.stopSerialInternal();
Expand Down

0 comments on commit 2dcce6b

Please sign in to comment.