Skip to content

Commit 7600802

Browse files
Centralise event handling
Try to account for options etc. - needs tests for the detail which I'll add in a follow up PR. Queue starting/stopping notifications to avoid making a mess.
1 parent b5d093d commit 7600802

File tree

4 files changed

+170
-46
lines changed

4 files changed

+170
-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.ts

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,158 @@ 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+
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.eqForAdd(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+
let registrations = this.activeEventTracking.get(type) ?? [];
146+
const registration = new Registration(callback, options ?? false);
147+
registrations = registrations.filter((r) => !r.eqForRemove(registration));
148+
this.activeEventTracking.set(type, registrations);
149+
if (registrations.length == 0) {
150+
this.eventDeactivated(type);
151+
}
152+
}
153+
}
154+
155+
dispatchEvent(event: Event): boolean {
156+
const result = super.dispatchEvent(event);
157+
let registrations = this.activeEventTracking.get(event.type) ?? [];
158+
registrations = registrations.filter((r) => !r.isOnce());
159+
this.activeEventTracking.set(event.type, registrations);
160+
if (registrations.length === 0) {
161+
this.eventDeactivated(event.type);
162+
}
163+
return result;
164+
}
165+
166+
protected eventActivated(type: string) {}
167+
168+
protected eventDeactivated(type: string) {}
169+
170+
protected getActiveEvents(): string[] {
171+
return [...this.activeEventTracking.keys()];
172+
}
173+
}
174+
112175
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
113-
export class TypedEventTarget<M extends ValueIsEvent<M>> extends EventTarget {
176+
export class TypedEventTarget<
177+
M extends ValueIsEvent<M>,
178+
> extends TrackingEventTarget {
114179
/**
115180
* Dispatches a synthetic event event to target and returns true if either
116181
* event's cancelable attribute value is false or its preventDefault() method
117182
* was not invoked, and false otherwise.
118183
*/
119184
public dispatchTypedEvent<T extends keyof M>(_type: T, event: M[T]): boolean {
185+
// Remove any "once" listeners for the event
120186
return super.dispatchEvent(event);
121187
}
122188
}
189+
190+
class Registration {
191+
constructor(
192+
private callback: EventListenerOrEventListenerObject,
193+
private options: AddEventListenerOptions | boolean,
194+
) {}
195+
196+
isOnce() {
197+
return typeof this.options === "object" && this.options.once === true;
198+
}
199+
200+
eqForAdd(other: Registration) {
201+
return (
202+
other.callback === this.callback &&
203+
eqOptionsForAdd(this.options, other.options)
204+
);
205+
}
206+
207+
eqForRemove(other: Registration) {
208+
return (
209+
other.callback === this.callback &&
210+
eqOptionsForRemove(this.options, other.options)
211+
);
212+
}
213+
}
214+
215+
const eqOptionsForAdd = (
216+
left: AddEventListenerOptions | boolean,
217+
right: AddEventListenerOptions | boolean,
218+
) => {
219+
if (!eqUseCapture(left, right)) {
220+
return false;
221+
}
222+
if (!eqOptionValue(left, right, "passive")) {
223+
return false;
224+
}
225+
if (!eqOptionValue(left, right, "signal")) {
226+
return false;
227+
}
228+
if (!eqOptionValue(left, right, "once")) {
229+
return false;
230+
}
231+
return true;
232+
};
233+
234+
const eqOptionsForRemove = (
235+
left: AddEventListenerOptions | boolean,
236+
right: AddEventListenerOptions | boolean,
237+
) => {
238+
return eqUseCapture(left, right);
239+
};
240+
241+
const eqUseCapture = (
242+
left: AddEventListenerOptions | boolean,
243+
right: AddEventListenerOptions | boolean,
244+
) => {
245+
return eqOptionValue(
246+
left,
247+
right,
248+
"capture",
249+
left as boolean,
250+
right as boolean,
251+
);
252+
};
253+
254+
const eqOptionValue = (
255+
left: AddEventListenerOptions | boolean,
256+
right: AddEventListenerOptions | boolean,
257+
key: "once" | "capture" | "passive" | "signal",
258+
valueIfBoolLeft: boolean = false,
259+
valueIfBoolRight: boolean = false,
260+
) => {
261+
const leftValue =
262+
typeof left === "boolean" ? valueIfBoolLeft : left[key] ?? false;
263+
const rightValue =
264+
typeof right === "boolean" ? valueIfBoolRight : right[key] ?? false;
265+
return leftValue === rightValue;
266+
};

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)