incyclist-devices 3.0.25 → 3.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,8 @@ class BleAdapter extends adpater_js_1.default {
17
17
  onDeviceDisconnectHandler = this.emit.bind(this);
18
18
  onDisconnectDoneHandler = this.onDisconnectDone.bind(this);
19
19
  startTask;
20
+ startPromise;
21
+ stopPromise;
20
22
  constructor(settings, props) {
21
23
  super(settings, props);
22
24
  this.deviceData = {};
@@ -198,8 +200,11 @@ class BleAdapter extends adpater_js_1.default {
198
200
  return 'connected';
199
201
  }
200
202
  async start(startProps) {
201
- if (this.isStarting()) {
202
- await this.stop();
203
+ while (this.stopPromise !== undefined) {
204
+ await this.stopPromise;
205
+ }
206
+ if (this.isStarting() && this.startPromise !== undefined) {
207
+ return this.startPromise;
203
208
  }
204
209
  const ble = this.getBle();
205
210
  ble.once('disconnect-done', this.onDisconnectDoneHandler);
@@ -209,11 +214,20 @@ class BleAdapter extends adpater_js_1.default {
209
214
  errorOnTimeout: false,
210
215
  log: this.logEvent.bind(this)
211
216
  });
212
- const res = await this.startTask.run();
213
- if (!res) {
214
- ble.removeListener('disconnect-done', this.onDisconnectDoneHandler);
217
+ this.startPromise = this.runStartTask(ble);
218
+ return this.startPromise;
219
+ }
220
+ async runStartTask(ble) {
221
+ try {
222
+ const res = await this.startTask.run();
223
+ if (!res) {
224
+ ble.removeListener('disconnect-done', this.onDisconnectDoneHandler);
225
+ }
226
+ return res;
227
+ }
228
+ finally {
229
+ this.startPromise = undefined;
215
230
  }
216
- return res;
217
231
  }
218
232
  isStarting() {
219
233
  return this.startTask?.isRunning();
@@ -253,6 +267,8 @@ class BleAdapter extends adpater_js_1.default {
253
267
  }
254
268
  async initControl(_props) {
255
269
  }
270
+ async initControlBestEffort(_props) {
271
+ }
256
272
  getStartLogProps(props) {
257
273
  const capabilities = this.props?.capabilities;
258
274
  const { user, userWeight, bikeWeight, timeout, wheelDiameter, restart, scanOnly } = props ?? {};
@@ -288,11 +304,16 @@ class BleAdapter extends adpater_js_1.default {
288
304
  this.stopped = true;
289
305
  return false;
290
306
  }
291
- await this.waitForInitialData(timeout);
292
307
  await this.checkCapabilities();
293
308
  const skipControl = this.props.capabilities && !this.props.capabilities.includes(index_js_1.IncyclistCapability.Control);
294
- if (this.hasCapability(index_js_1.IncyclistCapability.Control) && !skipControl)
309
+ const isControllable = this.hasCapability(index_js_1.IncyclistCapability.Control) && !skipControl;
310
+ if (isControllable) {
295
311
  await this.initControl(startProps);
312
+ }
313
+ else {
314
+ await this.initControlBestEffort(startProps);
315
+ }
316
+ await this.waitForInitialData(timeout);
296
317
  this.stopped = false;
297
318
  this.started = true;
298
319
  if (wasPaused)
@@ -372,9 +393,23 @@ class BleAdapter extends adpater_js_1.default {
372
393
  return success;
373
394
  }
374
395
  async stop() {
396
+ if (this.stopPromise !== undefined) {
397
+ return this.stopPromise;
398
+ }
399
+ this.stopPromise = this.runStop();
400
+ try {
401
+ return await this.stopPromise;
402
+ }
403
+ finally {
404
+ this.stopPromise = undefined;
405
+ }
406
+ }
407
+ async runStop() {
375
408
  this.logEvent({ message: 'stopping device', device: this.getName(), interface: this.getInterface() });
376
409
  if (this.isStarting()) {
377
- await this.startTask.stop();
410
+ const task = this.startTask;
411
+ await task.stop();
412
+ await task.getUnderlyingPromise()?.catch(() => { });
378
413
  }
379
414
  this.started = false;
380
415
  this.resetData();
@@ -158,8 +158,23 @@ class BlePeripheral {
158
158
  const promise = this.discoverServicesPromise;
159
159
  const res = await promise;
160
160
  (0, utils_js_1.sleep)(0).then(() => { delete this.discoverServicesPromise; });
161
+ const isComplete = this.checkAnnouncedServices(res);
162
+ if (!isComplete) {
163
+ this.logEvent({ message: 'service data incomplete - disconnecting' });
164
+ }
161
165
  return res;
162
166
  }
167
+ checkAnnouncedServices(discovered) {
168
+ const announced = this.getAnnouncedServices().map(x => (0, utils_js_2.beautifyUUID)(x));
169
+ const toBeChecked = discovered.map(x => (0, utils_js_2.beautifyUUID)(x));
170
+ const cntAnnounced = announced.length;
171
+ let cntVerified = 0;
172
+ for (const s of announced) {
173
+ if (toBeChecked.includes(s))
174
+ cntVerified++;
175
+ }
176
+ return cntAnnounced === cntVerified;
177
+ }
163
178
  async _discoverServices() {
164
179
  if (!this.getPeripheral())
165
180
  return [];
@@ -436,25 +451,61 @@ class BlePeripheral {
436
451
  return Promise.reject(new Error('characteristic not found: ' + characteristicUUID));
437
452
  }
438
453
  }
454
+ const signal = options?.signal;
439
455
  return new Promise((resolve, reject) => {
456
+ let settled = false;
457
+ let onData;
458
+ const cleanup = () => {
459
+ if (onData) {
460
+ c.off('data', onData);
461
+ onData = undefined;
462
+ }
463
+ signal?.removeEventListener('abort', onAbort);
464
+ };
465
+ const settleResolve = (result) => {
466
+ if (settled)
467
+ return;
468
+ settled = true;
469
+ cleanup();
470
+ resolve(result);
471
+ };
472
+ const settleReject = (err) => {
473
+ if (settled)
474
+ return;
475
+ settled = true;
476
+ cleanup();
477
+ reject(err);
478
+ };
479
+ const onAbort = () => {
480
+ settleReject(new Error('aborted'));
481
+ };
482
+ if (signal) {
483
+ if (signal.aborted) {
484
+ settleReject(new Error('aborted'));
485
+ return;
486
+ }
487
+ signal.addEventListener('abort', onAbort, { once: true });
488
+ }
440
489
  const write = () => {
441
- if (this.disconnecting || !this.connected)
442
- return Promise.resolve(Buffer.from([]));
443
- c.on('data', (data) => {
444
- c.removeAllListeners('data');
445
- resolve(data);
446
- });
490
+ if (this.disconnecting || !this.connected) {
491
+ settleResolve(Buffer.from([]));
492
+ return;
493
+ }
494
+ onData = (responseData) => {
495
+ settleResolve(responseData);
496
+ };
497
+ c.on('data', onData);
447
498
  this.logEvent({ message: 'write request', characteristic: uuid, data: data.toString('hex'), withoutResponse: options?.withoutResponse === true });
448
499
  c.write(data, options?.withoutResponse === true, (err) => {
449
500
  if (err)
450
- reject(err);
501
+ settleReject(err);
451
502
  });
452
503
  if (options?.withoutResponse) {
453
- resolve(Buffer.from([]));
504
+ settleResolve(Buffer.from([]));
454
505
  }
455
506
  };
456
507
  if (!options?.withoutResponse) {
457
- this.subscribe(characteristicUUID, null).then(success => {
508
+ this.subscribe(characteristicUUID, null).then(() => {
458
509
  write();
459
510
  });
460
511
  }
@@ -12,6 +12,7 @@ const adapter_js_1 = __importDefault(require("../base/adapter.js"));
12
12
  const consts_js_1 = require("./consts.js");
13
13
  const index_js_1 = require("../../types/index.js");
14
14
  const utils_js_1 = require("../../utils/utils.js");
15
+ const task_js_1 = require("../../utils/task.js");
15
16
  const index_js_2 = require("../zwift/play/index.js");
16
17
  const index_js_3 = require("../../features/index.js");
17
18
  const fm_resistance_js_1 = __importDefault(require("../../modes/fm-resistance.js"));
@@ -21,6 +22,7 @@ class BleFmAdapter extends adapter_js_1.default {
21
22
  distanceInternal = 0;
22
23
  connectPromise;
23
24
  requestControlRetryDelay = 1000;
25
+ requestControlTimeout = 10000;
24
26
  promiseSendUpdate;
25
27
  zwiftPlay;
26
28
  isHubInitialized = false;
@@ -168,8 +170,36 @@ class BleFmAdapter extends adapter_js_1.default {
168
170
  if (!this.hasCapability(index_js_1.IncyclistCapability.Control))
169
171
  return;
170
172
  await this.establishControl();
173
+ await this.attemptStartRequest();
171
174
  await this.sendInitialRequest();
172
175
  }
176
+ async initControlBestEffort(_startProps) {
177
+ if (!this.isStarting())
178
+ return;
179
+ this.setConstants();
180
+ try {
181
+ const hasControl = await this.getSensor().requestControl();
182
+ if (hasControl) {
183
+ this.logEvent({ message: 'control granted (downgraded device)', device: this.getName(), interface: this.getInterface() });
184
+ await this.attemptStartRequest();
185
+ }
186
+ else {
187
+ this.logEvent({ message: 'could not establish control (downgraded device, non-fatal)', device: this.getName(), interface: this.getInterface() });
188
+ }
189
+ }
190
+ catch (err) {
191
+ this.logEvent({ message: 'could not establish control (downgraded device, non-fatal)', device: this.getName(), interface: this.getInterface(), error: err.message });
192
+ }
193
+ }
194
+ async attemptStartRequest() {
195
+ try {
196
+ const started = await this.getSensor().startRequest();
197
+ this.logEvent({ message: started ? 'start request acknowledged' : 'start request not acknowledged (non-fatal)', device: this.getName(), interface: this.getInterface() });
198
+ }
199
+ catch (err) {
200
+ this.logEvent({ message: 'start request failed (non-fatal)', device: this.getName(), interface: this.getInterface(), error: err.message });
201
+ }
202
+ }
173
203
  setConstants() {
174
204
  const mode = this.getCyclingMode();
175
205
  const sensor = this.getSensor();
@@ -195,30 +225,53 @@ class BleFmAdapter extends adapter_js_1.default {
195
225
  let hasControl = false;
196
226
  let tryCnt = 0;
197
227
  const sensor = this.getSensor();
198
- return new Promise((resolve) => {
228
+ let cancelled = false;
229
+ const controlAbort = new AbortController();
230
+ const abandon = () => {
231
+ if (cancelled)
232
+ return;
233
+ cancelled = true;
234
+ controlAbort.abort();
235
+ };
236
+ const controlPromise = new Promise((resolve) => {
199
237
  this.startTask.notifyOnStop(() => {
238
+ abandon();
200
239
  resolve(false);
201
240
  });
202
241
  const waitUntilControl = async () => {
203
242
  if (this.supportsVirtualShifting()) {
204
243
  await this.initVirtualShifting();
205
244
  }
206
- while (!hasControl && this.isStarting()) {
245
+ while (!hasControl && !cancelled && this.isStarting()) {
207
246
  if (tryCnt++ === 0) {
208
247
  this.logEvent({ message: 'requesting control', device: this.getName(), interface: this.getInterface() });
209
248
  }
210
- hasControl = await sensor.requestControl();
249
+ hasControl = await sensor.requestControl(controlAbort.signal);
211
250
  if (hasControl) {
212
251
  this.logEvent({ message: 'control granted', device: this.getName(), interface: this.getInterface() });
213
252
  resolve(this.isStarting());
214
253
  }
215
- else {
254
+ else if (!cancelled) {
216
255
  await (0, utils_js_1.sleep)(this.requestControlRetryDelay);
217
256
  }
218
257
  }
219
258
  };
220
259
  waitUntilControl();
221
260
  });
261
+ const waitTask = new task_js_1.InteruptableTask(controlPromise, {
262
+ timeout: this.requestControlTimeout,
263
+ name: 'establishControl',
264
+ errorOnTimeout: false,
265
+ log: this.logEvent.bind(this),
266
+ onCancel: abandon
267
+ });
268
+ const granted = await waitTask.run();
269
+ if (!granted && this.isStarting()) {
270
+ abandon();
271
+ this.logEvent({ message: 'could not establish control', device: this.getName(), interface: this.getInterface() });
272
+ throw new Error('could not establish control');
273
+ }
274
+ return granted;
222
275
  }
223
276
  async sendInitialRequest() {
224
277
  const startRequest = this.getCyclingMode().getBikeInitRequest();
@@ -105,20 +105,17 @@ class BleFitnessMachineDevice extends sensor_js_1.TBleSensor {
105
105
  this.hasControl = false;
106
106
  this.ftmsServiceDataAttempts = 0;
107
107
  }
108
- async requestControl() {
108
+ async requestControl(signal) {
109
109
  if (this.hasControl) {
110
110
  return true;
111
111
  }
112
112
  if (!this.isSubscribed())
113
113
  return false;
114
- if (this.features?.setPower === false && this.features?.setSlope === false && this.features?.setResistance === false) {
115
- return true;
116
- }
117
114
  this.logEvent({ message: 'requestControl' });
118
115
  this.isCheckingControl = true;
119
116
  const data = Buffer.alloc(1);
120
117
  data.writeUInt8(0, 0);
121
- const res = await this.writeFtmsMessage(0, data, { timeout: 5000 });
118
+ const res = await this.writeFtmsMessage(0, data, { timeout: 5000, signal });
122
119
  if (res === 1) {
123
120
  this.hasControl = true;
124
121
  }
@@ -625,9 +622,15 @@ class BleFitnessMachineDevice extends sensor_js_1.TBleSensor {
625
622
  let res;
626
623
  let tsStart = Date.now();
627
624
  if (props?.timeout) {
628
- res = await new task_js_1.InteruptableTask(this.write(consts_js_1.FTMS_CP, data, props), {
625
+ const internalAbort = new AbortController();
626
+ const combined = new AbortController();
627
+ props.signal?.addEventListener('abort', () => combined.abort(), { once: true });
628
+ internalAbort.signal.addEventListener('abort', () => combined.abort(), { once: true });
629
+ const signal = combined.signal;
630
+ res = await new task_js_1.InteruptableTask(this.write(consts_js_1.FTMS_CP, data, { ...props, signal }), {
629
631
  timeout: props.timeout ?? 800,
630
- errorOnTimeout: true
632
+ errorOnTimeout: true,
633
+ onCancel: () => internalAbort.abort()
631
634
  }).run();
632
635
  }
633
636
  else {
@@ -10,6 +10,7 @@ class InteruptableTask {
10
10
  internalEvents = new node_events_1.EventEmitter();
11
11
  promise;
12
12
  onStopNotifiers = [];
13
+ cancelNotified = false;
13
14
  constructor(promise, props) {
14
15
  this.state = (props?.state ?? {});
15
16
  this.props = props;
@@ -20,6 +21,9 @@ class InteruptableTask {
20
21
  getPromise() {
21
22
  return this.internalState.promise;
22
23
  }
24
+ getUnderlyingPromise() {
25
+ return this.promise;
26
+ }
23
27
  getState() {
24
28
  return this.state;
25
29
  }
@@ -40,6 +44,7 @@ class InteruptableTask {
40
44
  const { timeout } = this.props;
41
45
  if (timeout) {
42
46
  this.internalState.tsTimeout = this.internalState.tsStart + timeout;
47
+ this.internalState.timeoutDuration = timeout;
43
48
  this.internalState.onTimeout = this.onTimeout.bind(this);
44
49
  this.internalState.timeout = setTimeout(() => { this.internalEvents.emit('timeout'); }, timeout);
45
50
  this.internalEvents.on('timeout', this.internalState.onTimeout);
@@ -53,6 +58,7 @@ class InteruptableTask {
53
58
  this.sendStopNotification();
54
59
  if (this.getState().result === 'completed' || this.getState().result === 'error')
55
60
  return;
61
+ this.notifyCancel();
56
62
  this.getState().result = 'stopped';
57
63
  if (this.props.onDone)
58
64
  resolve(this.props.onDone(this.getState()));
@@ -97,8 +103,9 @@ class InteruptableTask {
97
103
  if (!this.internalState.timeout)
98
104
  return;
99
105
  const message = this.props.name ? `${this.props.name} timeout` : 'timeout';
100
- this.logEvent({ message, active: this.isRunning() });
106
+ this.logEvent({ message, active: this.isRunning(), duration: this.internalState?.timeoutDuration });
101
107
  this.clearTimeout();
108
+ this.notifyCancel();
102
109
  this.getState().result = 'timeout';
103
110
  const resolve = this.internalState.onDone;
104
111
  const reject = this.internalState.onError;
@@ -111,6 +118,12 @@ class InteruptableTask {
111
118
  else
112
119
  resolve(null);
113
120
  }
121
+ notifyCancel() {
122
+ if (this.cancelNotified)
123
+ return;
124
+ this.cancelNotified = true;
125
+ this.props.onCancel?.();
126
+ }
114
127
  sendStopNotification() {
115
128
  this.onStopNotifiers.forEach((cb) => {
116
129
  if (typeof cb === 'function')
@@ -12,6 +12,8 @@ export default class BleAdapter extends IncyclistDevice {
12
12
  onDeviceDisconnectHandler = this.emit.bind(this);
13
13
  onDisconnectDoneHandler = this.onDisconnectDone.bind(this);
14
14
  startTask;
15
+ startPromise;
16
+ stopPromise;
15
17
  constructor(settings, props) {
16
18
  super(settings, props);
17
19
  this.deviceData = {};
@@ -193,8 +195,11 @@ export default class BleAdapter extends IncyclistDevice {
193
195
  return 'connected';
194
196
  }
195
197
  async start(startProps) {
196
- if (this.isStarting()) {
197
- await this.stop();
198
+ while (this.stopPromise !== undefined) {
199
+ await this.stopPromise;
200
+ }
201
+ if (this.isStarting() && this.startPromise !== undefined) {
202
+ return this.startPromise;
198
203
  }
199
204
  const ble = this.getBle();
200
205
  ble.once('disconnect-done', this.onDisconnectDoneHandler);
@@ -204,11 +209,20 @@ export default class BleAdapter extends IncyclistDevice {
204
209
  errorOnTimeout: false,
205
210
  log: this.logEvent.bind(this)
206
211
  });
207
- const res = await this.startTask.run();
208
- if (!res) {
209
- ble.removeListener('disconnect-done', this.onDisconnectDoneHandler);
212
+ this.startPromise = this.runStartTask(ble);
213
+ return this.startPromise;
214
+ }
215
+ async runStartTask(ble) {
216
+ try {
217
+ const res = await this.startTask.run();
218
+ if (!res) {
219
+ ble.removeListener('disconnect-done', this.onDisconnectDoneHandler);
220
+ }
221
+ return res;
222
+ }
223
+ finally {
224
+ this.startPromise = undefined;
210
225
  }
211
- return res;
212
226
  }
213
227
  isStarting() {
214
228
  return this.startTask?.isRunning();
@@ -248,6 +262,8 @@ export default class BleAdapter extends IncyclistDevice {
248
262
  }
249
263
  async initControl(_props) {
250
264
  }
265
+ async initControlBestEffort(_props) {
266
+ }
251
267
  getStartLogProps(props) {
252
268
  const capabilities = this.props?.capabilities;
253
269
  const { user, userWeight, bikeWeight, timeout, wheelDiameter, restart, scanOnly } = props ?? {};
@@ -283,11 +299,16 @@ export default class BleAdapter extends IncyclistDevice {
283
299
  this.stopped = true;
284
300
  return false;
285
301
  }
286
- await this.waitForInitialData(timeout);
287
302
  await this.checkCapabilities();
288
303
  const skipControl = this.props.capabilities && !this.props.capabilities.includes(IncyclistCapability.Control);
289
- if (this.hasCapability(IncyclistCapability.Control) && !skipControl)
304
+ const isControllable = this.hasCapability(IncyclistCapability.Control) && !skipControl;
305
+ if (isControllable) {
290
306
  await this.initControl(startProps);
307
+ }
308
+ else {
309
+ await this.initControlBestEffort(startProps);
310
+ }
311
+ await this.waitForInitialData(timeout);
291
312
  this.stopped = false;
292
313
  this.started = true;
293
314
  if (wasPaused)
@@ -367,9 +388,23 @@ export default class BleAdapter extends IncyclistDevice {
367
388
  return success;
368
389
  }
369
390
  async stop() {
391
+ if (this.stopPromise !== undefined) {
392
+ return this.stopPromise;
393
+ }
394
+ this.stopPromise = this.runStop();
395
+ try {
396
+ return await this.stopPromise;
397
+ }
398
+ finally {
399
+ this.stopPromise = undefined;
400
+ }
401
+ }
402
+ async runStop() {
370
403
  this.logEvent({ message: 'stopping device', device: this.getName(), interface: this.getInterface() });
371
404
  if (this.isStarting()) {
372
- await this.startTask.stop();
405
+ const task = this.startTask;
406
+ await task.stop();
407
+ await task.getUnderlyingPromise()?.catch(() => { });
373
408
  }
374
409
  this.started = false;
375
410
  this.resetData();
@@ -155,8 +155,23 @@ export class BlePeripheral {
155
155
  const promise = this.discoverServicesPromise;
156
156
  const res = await promise;
157
157
  sleep(0).then(() => { delete this.discoverServicesPromise; });
158
+ const isComplete = this.checkAnnouncedServices(res);
159
+ if (!isComplete) {
160
+ this.logEvent({ message: 'service data incomplete - disconnecting' });
161
+ }
158
162
  return res;
159
163
  }
164
+ checkAnnouncedServices(discovered) {
165
+ const announced = this.getAnnouncedServices().map(x => beautifyUUID(x));
166
+ const toBeChecked = discovered.map(x => beautifyUUID(x));
167
+ const cntAnnounced = announced.length;
168
+ let cntVerified = 0;
169
+ for (const s of announced) {
170
+ if (toBeChecked.includes(s))
171
+ cntVerified++;
172
+ }
173
+ return cntAnnounced === cntVerified;
174
+ }
160
175
  async _discoverServices() {
161
176
  if (!this.getPeripheral())
162
177
  return [];
@@ -433,25 +448,61 @@ export class BlePeripheral {
433
448
  return Promise.reject(new Error('characteristic not found: ' + characteristicUUID));
434
449
  }
435
450
  }
451
+ const signal = options?.signal;
436
452
  return new Promise((resolve, reject) => {
453
+ let settled = false;
454
+ let onData;
455
+ const cleanup = () => {
456
+ if (onData) {
457
+ c.off('data', onData);
458
+ onData = undefined;
459
+ }
460
+ signal?.removeEventListener('abort', onAbort);
461
+ };
462
+ const settleResolve = (result) => {
463
+ if (settled)
464
+ return;
465
+ settled = true;
466
+ cleanup();
467
+ resolve(result);
468
+ };
469
+ const settleReject = (err) => {
470
+ if (settled)
471
+ return;
472
+ settled = true;
473
+ cleanup();
474
+ reject(err);
475
+ };
476
+ const onAbort = () => {
477
+ settleReject(new Error('aborted'));
478
+ };
479
+ if (signal) {
480
+ if (signal.aborted) {
481
+ settleReject(new Error('aborted'));
482
+ return;
483
+ }
484
+ signal.addEventListener('abort', onAbort, { once: true });
485
+ }
437
486
  const write = () => {
438
- if (this.disconnecting || !this.connected)
439
- return Promise.resolve(Buffer.from([]));
440
- c.on('data', (data) => {
441
- c.removeAllListeners('data');
442
- resolve(data);
443
- });
487
+ if (this.disconnecting || !this.connected) {
488
+ settleResolve(Buffer.from([]));
489
+ return;
490
+ }
491
+ onData = (responseData) => {
492
+ settleResolve(responseData);
493
+ };
494
+ c.on('data', onData);
444
495
  this.logEvent({ message: 'write request', characteristic: uuid, data: data.toString('hex'), withoutResponse: options?.withoutResponse === true });
445
496
  c.write(data, options?.withoutResponse === true, (err) => {
446
497
  if (err)
447
- reject(err);
498
+ settleReject(err);
448
499
  });
449
500
  if (options?.withoutResponse) {
450
- resolve(Buffer.from([]));
501
+ settleResolve(Buffer.from([]));
451
502
  }
452
503
  };
453
504
  if (!options?.withoutResponse) {
454
- this.subscribe(characteristicUUID, null).then(success => {
505
+ this.subscribe(characteristicUUID, null).then(() => {
455
506
  write();
456
507
  });
457
508
  }
@@ -7,6 +7,7 @@ import BleAdapter from '../base/adapter.js';
7
7
  import { cRR, cwABike } from './consts.js';
8
8
  import { IncyclistCapability } from '../../types/index.js';
9
9
  import { sleep } from '../../utils/utils.js';
10
+ import { InteruptableTask } from '../../utils/task.js';
10
11
  import { BleZwiftPlaySensor } from '../zwift/play/index.js';
11
12
  import { useFeatureToggle } from '../../features/index.js';
12
13
  import FMResistanceMode from '../../modes/fm-resistance.js';
@@ -16,6 +17,7 @@ export default class BleFmAdapter extends BleAdapter {
16
17
  distanceInternal = 0;
17
18
  connectPromise;
18
19
  requestControlRetryDelay = 1000;
20
+ requestControlTimeout = 10000;
19
21
  promiseSendUpdate;
20
22
  zwiftPlay;
21
23
  isHubInitialized = false;
@@ -163,8 +165,36 @@ export default class BleFmAdapter extends BleAdapter {
163
165
  if (!this.hasCapability(IncyclistCapability.Control))
164
166
  return;
165
167
  await this.establishControl();
168
+ await this.attemptStartRequest();
166
169
  await this.sendInitialRequest();
167
170
  }
171
+ async initControlBestEffort(_startProps) {
172
+ if (!this.isStarting())
173
+ return;
174
+ this.setConstants();
175
+ try {
176
+ const hasControl = await this.getSensor().requestControl();
177
+ if (hasControl) {
178
+ this.logEvent({ message: 'control granted (downgraded device)', device: this.getName(), interface: this.getInterface() });
179
+ await this.attemptStartRequest();
180
+ }
181
+ else {
182
+ this.logEvent({ message: 'could not establish control (downgraded device, non-fatal)', device: this.getName(), interface: this.getInterface() });
183
+ }
184
+ }
185
+ catch (err) {
186
+ this.logEvent({ message: 'could not establish control (downgraded device, non-fatal)', device: this.getName(), interface: this.getInterface(), error: err.message });
187
+ }
188
+ }
189
+ async attemptStartRequest() {
190
+ try {
191
+ const started = await this.getSensor().startRequest();
192
+ this.logEvent({ message: started ? 'start request acknowledged' : 'start request not acknowledged (non-fatal)', device: this.getName(), interface: this.getInterface() });
193
+ }
194
+ catch (err) {
195
+ this.logEvent({ message: 'start request failed (non-fatal)', device: this.getName(), interface: this.getInterface(), error: err.message });
196
+ }
197
+ }
168
198
  setConstants() {
169
199
  const mode = this.getCyclingMode();
170
200
  const sensor = this.getSensor();
@@ -190,30 +220,53 @@ export default class BleFmAdapter extends BleAdapter {
190
220
  let hasControl = false;
191
221
  let tryCnt = 0;
192
222
  const sensor = this.getSensor();
193
- return new Promise((resolve) => {
223
+ let cancelled = false;
224
+ const controlAbort = new AbortController();
225
+ const abandon = () => {
226
+ if (cancelled)
227
+ return;
228
+ cancelled = true;
229
+ controlAbort.abort();
230
+ };
231
+ const controlPromise = new Promise((resolve) => {
194
232
  this.startTask.notifyOnStop(() => {
233
+ abandon();
195
234
  resolve(false);
196
235
  });
197
236
  const waitUntilControl = async () => {
198
237
  if (this.supportsVirtualShifting()) {
199
238
  await this.initVirtualShifting();
200
239
  }
201
- while (!hasControl && this.isStarting()) {
240
+ while (!hasControl && !cancelled && this.isStarting()) {
202
241
  if (tryCnt++ === 0) {
203
242
  this.logEvent({ message: 'requesting control', device: this.getName(), interface: this.getInterface() });
204
243
  }
205
- hasControl = await sensor.requestControl();
244
+ hasControl = await sensor.requestControl(controlAbort.signal);
206
245
  if (hasControl) {
207
246
  this.logEvent({ message: 'control granted', device: this.getName(), interface: this.getInterface() });
208
247
  resolve(this.isStarting());
209
248
  }
210
- else {
249
+ else if (!cancelled) {
211
250
  await sleep(this.requestControlRetryDelay);
212
251
  }
213
252
  }
214
253
  };
215
254
  waitUntilControl();
216
255
  });
256
+ const waitTask = new InteruptableTask(controlPromise, {
257
+ timeout: this.requestControlTimeout,
258
+ name: 'establishControl',
259
+ errorOnTimeout: false,
260
+ log: this.logEvent.bind(this),
261
+ onCancel: abandon
262
+ });
263
+ const granted = await waitTask.run();
264
+ if (!granted && this.isStarting()) {
265
+ abandon();
266
+ this.logEvent({ message: 'could not establish control', device: this.getName(), interface: this.getInterface() });
267
+ throw new Error('could not establish control');
268
+ }
269
+ return granted;
217
270
  }
218
271
  async sendInitialRequest() {
219
272
  const startRequest = this.getCyclingMode().getBikeInitRequest();
@@ -103,20 +103,17 @@ export default class BleFitnessMachineDevice extends TBleSensor {
103
103
  this.hasControl = false;
104
104
  this.ftmsServiceDataAttempts = 0;
105
105
  }
106
- async requestControl() {
106
+ async requestControl(signal) {
107
107
  if (this.hasControl) {
108
108
  return true;
109
109
  }
110
110
  if (!this.isSubscribed())
111
111
  return false;
112
- if (this.features?.setPower === false && this.features?.setSlope === false && this.features?.setResistance === false) {
113
- return true;
114
- }
115
112
  this.logEvent({ message: 'requestControl' });
116
113
  this.isCheckingControl = true;
117
114
  const data = Buffer.alloc(1);
118
115
  data.writeUInt8(0, 0);
119
- const res = await this.writeFtmsMessage(0, data, { timeout: 5000 });
116
+ const res = await this.writeFtmsMessage(0, data, { timeout: 5000, signal });
120
117
  if (res === 1) {
121
118
  this.hasControl = true;
122
119
  }
@@ -623,9 +620,15 @@ export default class BleFitnessMachineDevice extends TBleSensor {
623
620
  let res;
624
621
  let tsStart = Date.now();
625
622
  if (props?.timeout) {
626
- res = await new InteruptableTask(this.write(FTMS_CP, data, props), {
623
+ const internalAbort = new AbortController();
624
+ const combined = new AbortController();
625
+ props.signal?.addEventListener('abort', () => combined.abort(), { once: true });
626
+ internalAbort.signal.addEventListener('abort', () => combined.abort(), { once: true });
627
+ const signal = combined.signal;
628
+ res = await new InteruptableTask(this.write(FTMS_CP, data, { ...props, signal }), {
627
629
  timeout: props.timeout ?? 800,
628
- errorOnTimeout: true
630
+ errorOnTimeout: true,
631
+ onCancel: () => internalAbort.abort()
629
632
  }).run();
630
633
  }
631
634
  else {
@@ -7,6 +7,7 @@ export class InteruptableTask {
7
7
  internalEvents = new EventEmitter();
8
8
  promise;
9
9
  onStopNotifiers = [];
10
+ cancelNotified = false;
10
11
  constructor(promise, props) {
11
12
  this.state = (props?.state ?? {});
12
13
  this.props = props;
@@ -17,6 +18,9 @@ export class InteruptableTask {
17
18
  getPromise() {
18
19
  return this.internalState.promise;
19
20
  }
21
+ getUnderlyingPromise() {
22
+ return this.promise;
23
+ }
20
24
  getState() {
21
25
  return this.state;
22
26
  }
@@ -37,6 +41,7 @@ export class InteruptableTask {
37
41
  const { timeout } = this.props;
38
42
  if (timeout) {
39
43
  this.internalState.tsTimeout = this.internalState.tsStart + timeout;
44
+ this.internalState.timeoutDuration = timeout;
40
45
  this.internalState.onTimeout = this.onTimeout.bind(this);
41
46
  this.internalState.timeout = setTimeout(() => { this.internalEvents.emit('timeout'); }, timeout);
42
47
  this.internalEvents.on('timeout', this.internalState.onTimeout);
@@ -50,6 +55,7 @@ export class InteruptableTask {
50
55
  this.sendStopNotification();
51
56
  if (this.getState().result === 'completed' || this.getState().result === 'error')
52
57
  return;
58
+ this.notifyCancel();
53
59
  this.getState().result = 'stopped';
54
60
  if (this.props.onDone)
55
61
  resolve(this.props.onDone(this.getState()));
@@ -94,8 +100,9 @@ export class InteruptableTask {
94
100
  if (!this.internalState.timeout)
95
101
  return;
96
102
  const message = this.props.name ? `${this.props.name} timeout` : 'timeout';
97
- this.logEvent({ message, active: this.isRunning() });
103
+ this.logEvent({ message, active: this.isRunning(), duration: this.internalState?.timeoutDuration });
98
104
  this.clearTimeout();
105
+ this.notifyCancel();
99
106
  this.getState().result = 'timeout';
100
107
  const resolve = this.internalState.onDone;
101
108
  const reject = this.internalState.onError;
@@ -108,6 +115,12 @@ export class InteruptableTask {
108
115
  else
109
116
  resolve(null);
110
117
  }
118
+ notifyCancel() {
119
+ if (this.cancelNotified)
120
+ return;
121
+ this.cancelNotified = true;
122
+ this.props.onCancel?.();
123
+ }
111
124
  sendStopNotification() {
112
125
  this.onStopNotifiers.forEach((cb) => {
113
126
  if (typeof cb === 'function')
@@ -15,6 +15,8 @@ export default class BleAdapter<TDeviceData extends BleDeviceData, TDevice exten
15
15
  protected onDeviceDisconnectHandler: any;
16
16
  protected onDisconnectDoneHandler: any;
17
17
  protected startTask: InteruptableTask<TaskState, boolean>;
18
+ protected startPromise?: Promise<boolean>;
19
+ protected stopPromise?: Promise<boolean>;
18
20
  constructor(settings: BleDeviceSettings, props?: DeviceProperties);
19
21
  getUniqueName(): string;
20
22
  connect(): Promise<boolean>;
@@ -43,17 +45,20 @@ export default class BleAdapter<TDeviceData extends BleDeviceData, TDevice exten
43
45
  getDefaultStartupTimeout(): number;
44
46
  startPreChecks(props: BleStartProperties): Promise<'done' | 'connected' | 'connection-failed'>;
45
47
  start(startProps?: BleStartProperties): Promise<boolean>;
48
+ protected runStartTask(ble: IBleInterface<any>): Promise<boolean>;
46
49
  protected isStarting(): boolean;
47
50
  protected hasData(): boolean;
48
51
  protected waitForInitialData(startupTimeout: any): Promise<void>;
49
52
  protected checkCapabilities(): Promise<void>;
50
53
  protected initControl(_props?: BleStartProperties): Promise<void>;
54
+ protected initControlBestEffort(_props?: BleStartProperties): Promise<void>;
51
55
  protected getStartLogProps(props: BleStartProperties): BleStartProperties;
52
56
  protected startAdapter(startProps?: BleStartProperties): Promise<boolean>;
53
57
  startSensor(): Promise<boolean>;
54
58
  protected onDisconnectDone(): Promise<void>;
55
59
  restart(pause?: number): Promise<boolean>;
56
60
  stop(): Promise<boolean>;
61
+ protected runStop(): Promise<boolean>;
57
62
  pause(): Promise<boolean>;
58
63
  resume(): Promise<boolean>;
59
64
  protected getBle(): IBleInterface<any>;
@@ -29,15 +29,16 @@ export declare class BlePeripheral implements IBlePeripheral {
29
29
  isConnected(): boolean;
30
30
  isConnecting(): boolean;
31
31
  onDisconnect(callback: () => void): void;
32
- getManufacturerData(): Buffer<ArrayBufferLike>;
32
+ getManufacturerData(): Buffer;
33
33
  getServiceData(uuid: string): Buffer | undefined;
34
34
  protected onPeripheralDisconnect(): Promise<void>;
35
35
  protected onPeripheralError(err: Error): void;
36
36
  discoverServices(): Promise<string[]>;
37
+ protected checkAnnouncedServices(discovered: string[]): boolean;
37
38
  protected _discoverServices(): Promise<string[]>;
38
39
  discoverCharacteristics(serviceUUID: string): Promise<BleCharacteristic[]>;
39
40
  protected _discoverCharacteristics(serviceUUID: string): Promise<BleCharacteristic[]>;
40
- subscribe(characteristicUUID: string, callback: (characteristicUuid: string, data: Buffer, isNotify?: any) => void): Promise<boolean>;
41
+ subscribe(characteristicUUID: string, callback: (characteristicUuid: string, data: Buffer, isNotify?: boolean) => void): Promise<boolean>;
41
42
  unsubscribe(characteristicUUID: string): Promise<boolean>;
42
43
  subscribeSelected(characteristics: string[], callback: (characteristicUuid: string, data: Buffer, isNotify?: boolean) => void): Promise<boolean>;
43
44
  discoverAllCharacteristics(): Promise<string[]>;
@@ -12,6 +12,7 @@ export default class BleFmAdapter extends BleAdapter<IndoorBikeData, BleFitnessM
12
12
  protected distanceInternal: number;
13
13
  protected connectPromise: Promise<boolean> | undefined;
14
14
  protected requestControlRetryDelay: number;
15
+ protected requestControlTimeout: number;
15
16
  protected promiseSendUpdate: Promise<UpdateRequest | void> | undefined;
16
17
  protected zwiftPlay: BleZwiftPlaySensor | undefined;
17
18
  protected isHubInitialized: boolean;
@@ -28,6 +29,8 @@ export default class BleFmAdapter extends BleAdapter<IndoorBikeData, BleFitnessM
28
29
  protected checkResume(): boolean[];
29
30
  protected initVirtualShifting(initHub?: boolean): Promise<void>;
30
31
  protected initControl(_startProps?: BleStartProperties): Promise<void>;
32
+ protected initControlBestEffort(_startProps?: BleStartProperties): Promise<void>;
33
+ protected attemptStartRequest(): Promise<void>;
31
34
  protected setConstants(): void;
32
35
  protected establishControl(): Promise<boolean>;
33
36
  protected sendInitialRequest(): Promise<void>;
@@ -35,7 +35,7 @@ export default class BleFitnessMachineDevice extends TBleSensor {
35
35
  getWindSpeed(): number;
36
36
  getSupportedSports(): Array<Sport>;
37
37
  protected onDisconnect(): void;
38
- requestControl(): Promise<boolean>;
38
+ requestControl(signal?: AbortSignal): Promise<boolean>;
39
39
  setTargetPower(power: number): Promise<boolean>;
40
40
  setTargetResistanceLevel(resistanceLevel: number): Promise<boolean>;
41
41
  setSlope(slope: number): Promise<boolean>;
@@ -123,6 +123,7 @@ export type BleCommsConnectProps = {
123
123
  export interface BleWriteProps {
124
124
  withoutResponse?: boolean;
125
125
  timeout?: number;
126
+ signal?: AbortSignal;
126
127
  }
127
128
  export interface ConnectState {
128
129
  isConnecting: boolean;
@@ -13,10 +13,12 @@ export interface TaskProps<T, P> {
13
13
  errorOnTimeout?: boolean;
14
14
  log?: (event: any) => void;
15
15
  onDone?: (state: T) => P;
16
+ onCancel?: () => void;
16
17
  }
17
18
  interface InternalTaskState<P> {
18
19
  tsStart?: number;
19
20
  tsTimeout?: number;
21
+ timeoutDuration?: number;
20
22
  isRunning: boolean;
21
23
  timeout?: NodeJS.Timeout;
22
24
  promise?: Promise<P>;
@@ -31,8 +33,10 @@ export declare class InteruptableTask<T extends TaskState, P> {
31
33
  protected internalEvents: EventEmitter<any>;
32
34
  protected promise?: Promise<P>;
33
35
  protected onStopNotifiers: Array<() => void>;
36
+ protected cancelNotified: boolean;
34
37
  constructor(promise: Promise<any>, props?: TaskProps<T, P>);
35
38
  getPromise(): Promise<P>;
39
+ getUnderlyingPromise(): Promise<any> | undefined;
36
40
  getState(): T;
37
41
  run(): Promise<P>;
38
42
  notifyOnStop(cb: () => void): void;
@@ -41,6 +45,7 @@ export declare class InteruptableTask<T extends TaskState, P> {
41
45
  isRunning(): boolean;
42
46
  protected clearTimeout(): void;
43
47
  protected onTimeout(): void;
48
+ protected notifyCancel(): void;
44
49
  protected sendStopNotification(): void;
45
50
  protected logEvent(event: any): void;
46
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incyclist-devices",
3
- "version": "3.0.25",
3
+ "version": "3.0.27",
4
4
  "scripts": {
5
5
  "lint": "eslint . --ext .ts",
6
6
  "build": "npm run build:esm && npm run build:cjs",