Skip to content

Commit 393c8d6

Browse files
Centralise event handling (#23)
Queue starting/stopping notifications to avoid making a mess.
1 parent b5d093d commit 393c8d6

File tree

5 files changed

+238
-46
lines changed

5 files changed

+238
-46
lines changed

lib/bluetooth-device-wrapper.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,7 @@ export class BluetoothDeviceWrapper {
138138
public readonly device: BluetoothDevice,
139139
private logging: Logging = new NullLogging(),
140140
private dispatchTypedEvent: TypedServiceEventDispatcher,
141-
// We recreate this for the same connection and need to re-setup notifications for the old events
142-
private events: Record<keyof ServiceConnectionEventMap, boolean>,
141+
private currentEvents: () => Array<keyof ServiceConnectionEventMap>,
143142
private callbacks: ConnectCallbacks,
144143
) {
145144
device.addEventListener(
@@ -235,7 +234,7 @@ export class BluetoothDeviceWrapper {
235234
this.connecting = false;
236235
}
237236

238-
Object.keys(this.events).forEach((e) =>
237+
this.currentEvents().forEach((e) =>
239238
this.startNotifications(e as TypedServiceEvent),
240239
);
241240

@@ -382,13 +381,17 @@ export class BluetoothDeviceWrapper {
382381
if (serviceInfo) {
383382
// TODO: type cheat! why?
384383
const service = await this.createIfNeeded(serviceInfo as any, true);
385-
service?.startNotifications(type);
384+
this.queueGattOperation(
385+
async () => await service?.startNotifications(type),
386+
);
386387
}
387388
}
388389

389390
async stopNotifications(type: TypedServiceEvent) {
390391
const serviceInfo = this.serviceInfo.find((s) => s.events.includes(type));
391-
serviceInfo?.get()?.stopNotifications(type);
392+
this.queueGattOperation(
393+
async () => await serviceInfo?.get()?.stopNotifications(type),
394+
);
392395
}
393396

394397
private disposeServices() {
@@ -401,7 +404,7 @@ export const createBluetoothDeviceWrapper = async (
401404
device: BluetoothDevice,
402405
logging: Logging,
403406
dispatchTypedEvent: TypedServiceEventDispatcher,
404-
addedServiceListeners: Record<keyof ServiceConnectionEventMap, boolean>,
407+
currentEvents: () => Array<keyof ServiceConnectionEventMap>,
405408
callbacks: ConnectCallbacks,
406409
): Promise<BluetoothDeviceWrapper | undefined> => {
407410
try {
@@ -413,7 +416,7 @@ export const createBluetoothDeviceWrapper = async (
413416
device,
414417
logging,
415418
dispatchTypedEvent,
416-
addedServiceListeners,
419+
currentEvents,
417420
callbacks,
418421
);
419422
deviceIdToWrapper.set(device.id, bluetooth);

lib/bluetooth.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
} from "./device.js";
2121
import { TypedEventTarget } from "./events.js";
2222
import { Logging, NullLogging } from "./logging.js";
23-
import { ServiceConnectionEventMap } from "./service-events.js";
23+
import {
24+
ServiceConnectionEventMap,
25+
TypedServiceEvent,
26+
} from "./service-events.js";
2427

2528
const requestDeviceTimeoutDuration: number = 30000;
2629

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

49-
private _addEventListener = this.addEventListener;
50-
private _removeEventListener = this.removeEventListener;
51-
52-
private activeEvents = {
53-
accelerometerdatachanged: false,
54-
buttonachanged: false,
55-
buttonbchanged: false,
56-
};
57-
5852
private availabilityListener = (e: Event) => {
5953
// TODO: is this called? is `value` correct?
6054
const value = (e as any).value as boolean;
@@ -66,14 +60,14 @@ export class MicrobitWebBluetoothConnection
6660
constructor(options: MicrobitWebBluetoothConnectionOptions = {}) {
6761
super();
6862
this.logging = options.logging || new NullLogging();
69-
this.addEventListener = (type, ...args) => {
70-
this._addEventListener(type, ...args);
71-
this.connection?.startNotifications(type);
72-
};
73-
this.removeEventListener = (type, ...args) => {
74-
this.connection?.stopNotifications(type);
75-
this._removeEventListener(type, ...args);
76-
};
63+
}
64+
65+
protected eventActivated(type: string): void {
66+
this.connection?.startNotifications(type as TypedServiceEvent);
67+
}
68+
69+
protected eventDeactivated(type: string): void {
70+
this.connection?.stopNotifications(type as TypedServiceEvent);
7771
}
7872

7973
private log(v: any) {
@@ -161,7 +155,7 @@ export class MicrobitWebBluetoothConnection
161155
device,
162156
this.logging,
163157
this.dispatchTypedEvent.bind(this),
164-
this.activeEvents,
158+
() => this.getActiveEvents() as Array<keyof ServiceConnectionEventMap>,
165159
{
166160
onConnecting: () => this.setStatus(ConnectionStatus.CONNECTING),
167161
onReconnecting: () => this.setStatus(ConnectionStatus.RECONNECTING),

lib/events.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { TrackingEventTarget } from "./events.js";
3+
4+
class TestTrackingEventTarget extends TrackingEventTarget {
5+
constructor(
6+
private activate: (type: string) => void,
7+
private deactivate: (type: string) => void,
8+
) {
9+
super();
10+
}
11+
public getActiveEvents(): string[] {
12+
return super.getActiveEvents();
13+
}
14+
protected eventActivated(type: string): void {
15+
this.activate(type);
16+
}
17+
protected eventDeactivated(type: string): void {
18+
this.deactivate(type);
19+
}
20+
}
21+
22+
describe("TrackingEventTarget", () => {
23+
const listener = () => {};
24+
25+
it("add remove", () => {
26+
const activate = vi.fn();
27+
const deactivate = vi.fn();
28+
const target = new TestTrackingEventTarget(activate, deactivate);
29+
expect(target.getActiveEvents()).toEqual([]);
30+
31+
target.addEventListener("foo", listener);
32+
expect(activate).toBeCalledTimes(1);
33+
expect(deactivate).toBeCalledTimes(0);
34+
expect(target.getActiveEvents()).toEqual(["foo"]);
35+
36+
target.removeEventListener("foo", listener);
37+
expect(activate).toBeCalledTimes(1);
38+
expect(deactivate).toBeCalledTimes(1);
39+
expect(target.getActiveEvents()).toEqual([]);
40+
});
41+
42+
it("callback equality", () => {
43+
const listenerAlt = () => {};
44+
45+
const activate = vi.fn();
46+
const deactivate = vi.fn();
47+
const target = new TestTrackingEventTarget(activate, deactivate);
48+
expect(target.getActiveEvents()).toEqual([]);
49+
50+
target.addEventListener("foo", listenerAlt);
51+
target.addEventListener("foo", listener);
52+
target.addEventListener("foo", listener);
53+
target.removeEventListener("foo", listener);
54+
expect(target.getActiveEvents()).toEqual(["foo"]);
55+
target.removeEventListener("foo", listenerAlt);
56+
expect(target.getActiveEvents()).toEqual([]);
57+
});
58+
59+
it("option equality - capture", () => {
60+
const fooListener = vi.fn();
61+
const activate = vi.fn();
62+
const deactivate = vi.fn();
63+
const target = new TestTrackingEventTarget(activate, deactivate);
64+
expect(target.getActiveEvents()).toEqual([]);
65+
66+
target.addEventListener("foo", fooListener, { capture: true });
67+
target.addEventListener("foo", fooListener, false);
68+
target.removeEventListener("foo", fooListener, true);
69+
expect(target.getActiveEvents()).toEqual(["foo"]);
70+
target.dispatchEvent(new Event("foo"));
71+
expect(fooListener).toBeCalledTimes(1);
72+
});
73+
74+
it("option equality", () => {
75+
const fooListener = vi.fn();
76+
const activate = vi.fn();
77+
const deactivate = vi.fn();
78+
const target = new TestTrackingEventTarget(activate, deactivate);
79+
80+
// Despite MDN docs claiming all options can result in another listener added
81+
// it seems only capture counts for both add and remove
82+
target.addEventListener("foo", fooListener, { passive: true });
83+
target.addEventListener("foo", fooListener, { once: true });
84+
target.addEventListener("foo", fooListener, { capture: true });
85+
target.addEventListener("foo", fooListener, { capture: false });
86+
target.dispatchEvent(new Event("foo"));
87+
expect(fooListener).toBeCalledTimes(2);
88+
89+
target.removeEventListener("foo", fooListener, true);
90+
expect(target.getActiveEvents()).toEqual(["foo"]);
91+
target.dispatchEvent(new Event("foo"));
92+
expect(fooListener).toBeCalledTimes(3);
93+
94+
target.removeEventListener("foo", fooListener, false);
95+
expect(target.getActiveEvents()).toEqual([]);
96+
target.dispatchEvent(new Event("foo"));
97+
expect(fooListener).toBeCalledTimes(3);
98+
});
99+
100+
it("once", () => {
101+
const fooListener = vi.fn();
102+
const activate = vi.fn();
103+
const deactivate = vi.fn();
104+
const target = new TestTrackingEventTarget(activate, deactivate);
105+
106+
target.addEventListener("foo", fooListener, { once: true });
107+
target.dispatchEvent(new Event("foo"));
108+
expect(fooListener).toBeCalledTimes(1);
109+
expect(deactivate).toBeCalledTimes(1);
110+
111+
target.dispatchEvent(new Event("foo"));
112+
expect(fooListener).toBeCalledTimes(1);
113+
});
114+
});

lib/events.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,78 @@ export interface TypedEventTarget<M extends ValueIsEvent<M>> {
109109
*/
110110
dispatchEvent: (event: Event) => boolean;
111111
}
112+
113+
// We've added this in to keep track of what events are active.
114+
// Having done this it's questionable whether it's worth the reimplementation
115+
// just to use an EventTarget API.
116+
export class TrackingEventTarget extends EventTarget {
117+
private activeEventTracking: Map<string, Registration[]> = new Map();
118+
119+
addEventListener(
120+
type: string,
121+
callback: EventListenerOrEventListenerObject | null,
122+
options?: AddEventListenerOptions | boolean,
123+
): void {
124+
if (callback !== null) {
125+
const registrations = this.activeEventTracking.get(type) ?? [];
126+
const wasEmpty = registrations.length === 0;
127+
const registration = new Registration(callback, options ?? false);
128+
if (!registrations.find((r) => r.eq(registration))) {
129+
registrations.push(registration);
130+
this.activeEventTracking.set(type, registrations);
131+
if (wasEmpty) {
132+
this.eventActivated(type);
133+
}
134+
}
135+
}
136+
super.addEventListener(type, callback, options);
137+
}
138+
139+
removeEventListener(
140+
type: string,
141+
callback: EventListenerOrEventListenerObject | null,
142+
options?: EventListenerOptions | boolean,
143+
): void {
144+
if (callback !== null) {
145+
const registration = new Registration(callback, options ?? false);
146+
this.filterRegistrations(type, (r) => !r.eq(registration));
147+
}
148+
super.removeEventListener(type, callback, options);
149+
}
150+
151+
dispatchEvent(event: Event): boolean {
152+
const result = super.dispatchEvent(event);
153+
this.filterRegistrations(event.type, (r) => !r.isOnce());
154+
return result;
155+
}
156+
157+
private filterRegistrations(
158+
type: string,
159+
predicate: (r: Registration) => boolean,
160+
): void {
161+
let registrations = this.activeEventTracking.get(type) ?? [];
162+
registrations = registrations.filter(predicate);
163+
if (registrations.length === 0) {
164+
this.activeEventTracking.delete(type);
165+
this.eventDeactivated(type);
166+
} else {
167+
this.activeEventTracking.set(type, registrations);
168+
}
169+
}
170+
171+
protected eventActivated(type: string) {}
172+
173+
protected eventDeactivated(type: string) {}
174+
175+
protected getActiveEvents(): string[] {
176+
return [...this.activeEventTracking.keys()];
177+
}
178+
}
179+
112180
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
113-
export class TypedEventTarget<M extends ValueIsEvent<M>> extends EventTarget {
181+
export class TypedEventTarget<
182+
M extends ValueIsEvent<M>,
183+
> extends TrackingEventTarget {
114184
/**
115185
* Dispatches a synthetic event event to target and returns true if either
116186
* event's cancelable attribute value is false or its preventDefault() method
@@ -120,3 +190,31 @@ export class TypedEventTarget<M extends ValueIsEvent<M>> extends EventTarget {
120190
return super.dispatchEvent(event);
121191
}
122192
}
193+
194+
class Registration {
195+
constructor(
196+
private callback: EventListenerOrEventListenerObject,
197+
private options: AddEventListenerOptions | boolean,
198+
) {}
199+
200+
isOnce() {
201+
return typeof this.options === "object" && this.options.once === true;
202+
}
203+
204+
eq(other: Registration) {
205+
return (
206+
other.callback === this.callback &&
207+
eqUseCapture(this.options, other.options)
208+
);
209+
}
210+
}
211+
212+
const eqUseCapture = (
213+
left: AddEventListenerOptions | boolean,
214+
right: AddEventListenerOptions | boolean,
215+
) => {
216+
const leftValue = typeof left === "boolean" ? left : left.capture ?? false;
217+
const rightValue =
218+
typeof right === "boolean" ? right : right.capture ?? false;
219+
return leftValue === rightValue;
220+
};

lib/usb.ts

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,6 @@ export class MicrobitWebUSBConnection
130130

131131
private logging: Logging;
132132

133-
private _addEventListener = this.addEventListener;
134-
private _removeEventListener = this.removeEventListener;
135-
136133
private addedListeners: Record<string, number> = {
137134
serialdata: 0,
138135
};
@@ -142,20 +139,6 @@ export class MicrobitWebUSBConnection
142139
) {
143140
super();
144141
this.logging = options.logging;
145-
// TODO: this doesn't account for the rules around add/remove call equivalence
146-
this.addEventListener = (type, ...args) => {
147-
this._addEventListener(type, ...args);
148-
if (++this.addedListeners[type] === 1 && !this.flashing) {
149-
this.startNotifications(type);
150-
}
151-
};
152-
this.removeEventListener = (type, ...args) => {
153-
this._removeEventListener(type, ...args);
154-
if (--this.addedListeners[type] <= 0) {
155-
this.addedListeners[type] = 0;
156-
this.stopNotifications(type);
157-
}
158-
};
159142
}
160143

161144
private log(v: any) {
@@ -442,7 +425,7 @@ export class MicrobitWebUSBConnection
442425
return this.device;
443426
}
444427

445-
private async startNotifications(type: string) {
428+
protected eventActivated(type: string): void {
446429
switch (type as keyof DeviceConnectionEventMap) {
447430
case "serialdata": {
448431
this.startSerialInternal();
@@ -451,7 +434,7 @@ export class MicrobitWebUSBConnection
451434
}
452435
}
453436

454-
private async stopNotifications(type: string) {
437+
protected async eventDeactivated(type: string) {
455438
switch (type as keyof DeviceConnectionEventMap) {
456439
case "serialdata": {
457440
this.stopSerialInternal();

0 commit comments

Comments
 (0)