From ec6e9129ac1f798616eef51de97bbd1e06f33b32 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 07:31:04 +0200 Subject: [PATCH 01/35] feat: allow enabling/disabling device sources and known devices Adds an optional `enabled` flag (defaults to true) to both `deviceSources` and `knownDevices` entries in the settings JSON. - DeviceSource / KnownDevice gain an `isEnabled()` accessor. - DeviceProviderManager can now hot-reload: it starts/stops individual device source providers as they are enabled/disabled/added/removed, instead of only loading them once at startup. Reload calls are serialized internally to avoid races when settings change in quick succession (e.g. disable immediately followed by re-enable). - SettingsManager's 'settingsChanged' event now triggers DeviceProviderManager.reload(), so editing settings (e.g. via PUT /settings) dynamically starts/stops device sources. - SerialDeviceProvider/BleDeviceProvider/ButtplugIoWebsocketDeviceProvider skip connecting to newly detected devices that are known but disabled, and close already-connected devices that become disabled. Devices that were skipped while disabled are retried once they become enabled again (via the new DeviceProvider.onSettingsChanged hook). - VirtualDeviceProvider's periodic discovery loop now also honors the enabled flag of virtual known devices. Closes #70 --- src/app.ts | 8 +- .../protocol/airotic/airoticDeviceProvider.ts | 11 +- .../buttplugIo/buttplugIoDeviceFactory.ts | 26 ++- .../buttplugIoWebsocketDeviceProvider.ts | 29 +++ .../estim2b/estim2bSerialDeviceProvider.ts | 4 +- .../slvCtrlPlusSerialDeviceProvider.ts | 4 +- .../protocol/virtual/virtualDeviceProvider.ts | 12 +- .../protocol/zc95/zc95SerialDeviceProvider.ts | 4 +- src/device/provider/bleDeviceProvider.ts | 52 ++++- src/device/provider/deviceProvider.ts | 12 ++ src/device/provider/deviceProviderManager.ts | 89 ++++++++- src/device/provider/serialDeviceProvider.ts | 50 ++++- src/serviceProvider/deviceServiceProvider.ts | 5 + src/settings/deviceSource.ts | 13 +- src/settings/knownDevice.ts | 13 +- src/settings/serializedTypes.ts | 2 + src/settings/settings.ts | 6 +- tests/integration/deviceEvents.spec.ts | 110 +++++++++++ .../provider/deviceProviderManager.spec.ts | 185 ++++++++++++++++++ tests/unit/settings/deviceSource.spec.ts | 49 +++++ tests/unit/settings/knownDevice.spec.ts | 56 ++++++ 21 files changed, 705 insertions(+), 35 deletions(-) create mode 100644 tests/unit/device/provider/deviceProviderManager.spec.ts create mode 100644 tests/unit/settings/deviceSource.spec.ts create mode 100644 tests/unit/settings/knownDevice.spec.ts diff --git a/src/app.ts b/src/app.ts index cd4499b4..bff1493b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -126,6 +126,10 @@ const configureWebsocket = (io: WebsocketServer, container: Container { io.emit(SettingsEventType.changed, serializer.transform(settings)); + + container.get('device.provider.loader') + .reload(settings) + .catch(e => logError(logger, 'Failed to reload device sources after settings change', e)); }); // Automation events @@ -139,10 +143,8 @@ const loadDeviceProviders = (container: Container): void => { const settings = container.get('settings'); const deviceProviderManager = container.get('device.provider.loader'); - deviceProviderManager.loadFromSettings(settings); - deviceProviderManager - .startProviders() + .reload(settings) .catch(e => logError(logger, `Loading device providers failed`, e)); serialPortObserver.start().catch(e => logError(logger, `Initializing serial port observer failed`, e)); diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index 5dd5c05c..f8a49dae 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -8,6 +8,7 @@ import { BleDeviceInfo } from '../../transport/bleObserver.js'; import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; import AiroticProtocol from './airoticProtocol.js'; import MessageResponseHandler from '../messageResponseHandler.js'; +import SettingsManager from '../../../settings/settingsManager.js'; import BleDeviceProvider from '../../provider/bleDeviceProvider.js'; import AiroticDeviceFactory from './airoticDeviceFactory.js'; @@ -20,8 +21,14 @@ export default class AiroticDeviceProvider extends BleDeviceProvider = new Map(); + private readonly pendingDisabledDevices: Map = new Map(); + private buttplugConnector: ButtplugNodeWebsocketClientConnector; private buttplugClient: ButtplugClient; @@ -61,6 +64,26 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { this.connectionIntervalRef ??= setImmediateInterval(() => void this.connectToServer(), 1000); } + public override async onSettingsChanged(settings: Settings): Promise { + for (const device of this.connectedDevices.values()) { + const knownDevice = settings.getKnownDeviceById(device.getDeviceId); + + if (undefined !== knownDevice && !knownDevice.isEnabled()) { + this.logger.info(`Closing device '${device.getDeviceId}' since it has been disabled`); + await this.removeButtplugIoDevice(device.getButtplugClientDevice); + } + } + + for (const [index, buttplugDevice] of this.pendingDisabledDevices) { + if (!this.buttplugIoDeviceFactory.isKnownDeviceEnabled(buttplugDevice, this.useDeviceNameAsId)) { + continue; + } + + this.pendingDisabledDevices.delete(index); + this.addButtplugIoDevice(buttplugDevice); + } + } + private async connectToServer(): Promise { if (this.buttplugClient.connected) { return; @@ -121,6 +144,12 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { private addButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { this.logger.info(`Device detected: ${buttplugDevice.name}`, buttplugDevice); + if (!this.buttplugIoDeviceFactory.isKnownDeviceEnabled(buttplugDevice, this.useDeviceNameAsId)) { + this.logger.debug(`Device '${buttplugDevice.name}' is disabled, not connecting to it`); + this.pendingDisabledDevices.set(buttplugDevice.index, buttplugDevice); + return; + } + try { const device = this.buttplugIoDeviceFactory.create(buttplugDevice, ButtplugIoWebsocketDeviceProvider.providerName, this.useDeviceNameAsId); diff --git a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts index e16f64c6..c203494c 100644 --- a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts +++ b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts @@ -13,6 +13,7 @@ import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportF import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import DeviceManager from '../../deviceManager.js'; import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; +import SettingsManager from '../../../settings/settingsManager.js'; export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { @@ -25,12 +26,13 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { @@ -30,12 +31,13 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide public constructor( deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, + settingsManager: SettingsManager, eventEmitter: EventEmitter, deviceFactory: SlvCtrlPlusDeviceFactory, deviceTransportFactory: SerialDeviceTransportFactory, logger: Logger ) { - super(deviceManager, serialPortFactory, eventEmitter, logger.child({ name: SlvCtrlPlusSerialDeviceProvider.name })); + super(deviceManager, serialPortFactory, settingsManager, eventEmitter, logger.child({ name: SlvCtrlPlusSerialDeviceProvider.name })); this.slvCtrlPlusDeviceFactory = deviceFactory; this.deviceTransportFactory = deviceTransportFactory; } diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index fbcaa97a..d9413051 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -77,19 +77,25 @@ export default class VirtualDeviceProvider extends DeviceProvider const virtualDevices = settings.getKnownDevicesBySource(VirtualDeviceProvider.providerName); - // Check if devices have been removed + // Check if devices have been removed or disabled for (const [k, v] of this.connectedDevices) { - if (!virtualDevices.has(k)) { + const knownDevice = virtualDevices.get(k); + + if (undefined === knownDevice || !knownDevice.isEnabled()) { await this.removeDevice(v) } } - // Load all currently configured devices + // Load all currently configured and enabled devices for (const [k, v] of virtualDevices) { if (this.stopped) { return; } + if (!v.isEnabled()) { + continue; + } + if (this.attemptedDevices.has(k) || this.connectedDevices.has(k)) { continue; } diff --git a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts index 71703ee3..f8c73e7a 100644 --- a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts +++ b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts @@ -14,6 +14,7 @@ import Zc95MessageFactory from './zc95MessageFactory.js'; import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; import DeviceManager from '../../deviceManager.js'; import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; +import SettingsManager from '../../../settings/settingsManager.js'; export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { @@ -26,12 +27,13 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider, @@ -17,11 +20,17 @@ export default abstract class BleDeviceProvider< TConfig extends AnyDeviceConfig = InferBleDeviceConfig > extends DeviceProvider { - private connectedDevices: Set = new Set(); + private connectedDevices: Map = new Map(); - protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { + private readonly pendingDisabledDevices: Map = new Map(); + + protected readonly settingsManager: SettingsManager; + + protected constructor(deviceManager: DeviceManager, settingsManager: SettingsManager, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger); + this.settingsManager = settingsManager; + this.deviceManager.on( DeviceManagerEvent.deviceDetected, asyncHandler( @@ -31,11 +40,42 @@ export default abstract class BleDeviceProvider< ); } + public override async onSettingsChanged(settings: Settings): Promise { + for (const [deviceId, device] of this.connectedDevices) { + const knownDevice = settings.getKnownDeviceById(deviceId); + + if (undefined !== knownDevice && !knownDevice.isEnabled()) { + this.logger.info(`Closing device '${deviceId}' since it has been disabled`); + await device.close(); + } + } + + for (const [deviceId, deviceInfo] of this.pendingDisabledDevices) { + const knownDevice = settings.getKnownDeviceById(deviceId); + + if (undefined !== knownDevice && !knownDevice.isEnabled()) { + continue; + } + + this.pendingDisabledDevices.delete(deviceId); + await this.handleDeviceDetection(deviceInfo); + } + } + private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { if (!this.isBleDeviceInfo(deviceInfo)) { return; } + const settings = this.settingsManager.getSettings(); + const knownDevice = settings?.getKnownDeviceById(deviceInfo.id); + + if (undefined !== knownDevice && !knownDevice.isEnabled()) { + this.logger.debug(`Device '${deviceInfo.id}' is disabled, not connecting to it`); + this.pendingDisabledDevices.set(deviceInfo.id, deviceInfo); + return; + } + this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); @@ -53,7 +93,8 @@ export default abstract class BleDeviceProvider< return; } - this.connectedDevices.add(device); + this.connectedDevices.set(device.getDeviceId, device); + device.on(DeviceEvent.deviceDisconnected, (d) => this.connectedDevices.delete(d.getDeviceId)); this.deviceManager.addDevice(device); this.deviceManager.claimDetectedDevice(deviceInfo.id); } catch (e: unknown) { @@ -64,10 +105,11 @@ export default abstract class BleDeviceProvider< } public override async stop(): Promise { - for (const device of this.connectedDevices) { + for (const device of this.connectedDevices.values()) { await device.close(); } this.connectedDevices.clear(); + this.pendingDisabledDevices.clear(); } private isBleDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo { diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index 5292c4ad..7e8ef444 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -1,6 +1,7 @@ import EventEmitter from 'events'; import Logger from '../../logging/Logger.js'; import DeviceManager from '../deviceManager.js'; +import Settings from '../../settings/settings.js'; export default abstract class DeviceProvider { @@ -23,4 +24,15 @@ export default abstract class DeviceProvider public async stop(): Promise { return Promise.resolve(); } + + /** + * Called whenever the settings have changed (e.g. via the `PUT /settings` endpoint or any + * mutation of the settings object). Providers can override this to react to known devices + * being enabled/disabled: closing devices that just got disabled and retrying connections + * for devices that just got (re-)enabled. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async onSettingsChanged(settings: Settings): Promise { + return Promise.resolve(); + } } diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index 1620a8e1..0c5c3b46 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -2,6 +2,7 @@ import Settings from '../../settings/settings.js'; import DeviceProviderFactory from './deviceProviderFactory.js'; import Logger from '../../logging/Logger.js'; import DeviceProvider from './deviceProvider.js'; +import { logError } from '../../util/error.js'; export default class DeviceProviderManager { @@ -9,7 +10,16 @@ export default class DeviceProviderManager private readonly logger: Logger; - private providers: DeviceProvider[] = []; + private readonly providers: Map = new Map(); + + /** + * `reload()` and `stopProviders()` mutate the shared `providers` map asynchronously. + * Since settings can change in rapid succession (e.g. a device source being disabled and + * immediately re-enabled), overlapping calls need to be serialized to avoid racing on that + * map, otherwise a later call could observe a half-finished earlier one and reach the wrong + * conclusion about whether a provider is already running. + */ + private operationChain: Promise = Promise.resolve(); public constructor( factories: Map>, @@ -19,14 +29,63 @@ export default class DeviceProviderManager this.logger = logger.child({ name: DeviceProviderManager.name }); } - public loadFromSettings(settings: Settings): void + /** + * (Re-)synchronizes the running device providers with the given settings: providers for + * device sources that were removed or disabled are stopped, providers for device sources + * that are new or were (re-)enabled are created and started. Already running providers for + * device sources that are still enabled are left untouched, but are notified about the + * settings change so they can react to devices being enabled/disabled. + */ + public reload(settings: Settings): Promise + { + return this.enqueue(() => this.doReload(settings)); + } + + public stopProviders(): Promise { + return this.enqueue(() => this.doStopProviders()); + } + + private enqueue(operation: () => Promise): Promise { + const result = this.operationChain.then(operation, operation); + + // Swallow rejections in the chain itself (each caller still gets the real + // rejection via `result`), so a failed operation doesn't permanently wedge the queue. + this.operationChain = result.catch(() => undefined); + + return result; + } + + private async doReload(settings: Settings): Promise { const configuredDeviceSources = settings.getDeviceSources(); this.logger.debug(`Found ${configuredDeviceSources.size} configured device source(s)`); + for (const [id, provider] of this.providers) { + const deviceSource = configuredDeviceSources.get(id); + + if (undefined !== deviceSource && deviceSource.isEnabled()) { + continue; + } + + const reason = undefined === deviceSource ? 'removed from config' : 'disabled'; + this.logger.info(`Stopping device source '${id}' (${reason})`); + + try { + await provider.stop(); + } catch (error: unknown) { + logError(this.logger, `Failed to stop device provider for device source '${id}'`, error); + } + + this.providers.delete(id); + } + for (const [id, deviceSource] of configuredDeviceSources) { - const factory = this.factories.get(deviceSource.type) + if (!deviceSource.isEnabled() || this.providers.has(id)) { + continue; + } + + const factory = this.factories.get(deviceSource.type); if (undefined === factory) { this.logger.warn(`Device source with id ${id} and type ${deviceSource.type} is not supported`); @@ -35,20 +94,28 @@ export default class DeviceProviderManager const provider = factory.create(deviceSource.config); - this.providers.push(provider); + this.providers.set(id, provider); + + try { + await provider.init(); + } catch (error: unknown) { + logError(this.logger, `Failed to start device provider for device source '${id}'`, error); + } } - } - public async startProviders(): Promise { - for (const provider of this.providers) { - await provider.init(); + for (const provider of this.providers.values()) { + try { + await provider.onSettingsChanged(settings); + } catch (error: unknown) { + logError(this.logger, `Device provider failed to handle settings change`, error); + } } } - public async stopProviders(): Promise { + private async doStopProviders(): Promise { const errors: unknown[] = []; - for (const provider of this.providers) { + for (const [, provider] of this.providers) { try { await provider.stop(); } catch (error: unknown) { @@ -57,6 +124,8 @@ export default class DeviceProviderManager } } + this.providers.clear(); + if (errors.length > 0) { throw new Error(`Failed to stop ${errors.length} device provider(s)`); } diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index c3d0769e..8b36721e 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -14,6 +14,9 @@ import { SerialDeviceInfo } from '../transport/serialPortObserver.js'; import PeripheralDevice, { InferPeripheralDeviceAttributes, InferPeripheralDeviceConfig } from '../peripheralDevice.js'; import { DeviceAttributes } from '../device.js'; import { AnyDeviceConfig } from '../deviceConfig.js'; +import SettingsManager from '../../settings/settingsManager.js'; +import Settings from '../../settings/settings.js'; +import { DeviceId } from '../deviceId.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; @@ -25,14 +28,25 @@ export default abstract class SerialDeviceProvider< { private readonly serialPortFactory: SerialPortFactory; - private connectedDevices: Map = new Map(); + private readonly settingsManager: SettingsManager; + + private connectedDevices: Map = new Map(); + + private readonly pendingDisabledDevices: Map = new Map(); private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; - protected constructor(deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, eventEmitter: EventEmitter, logger: Logger) { + protected constructor( + deviceManager: DeviceManager, + serialPortFactory: SerialPortFactory, + settingsManager: SettingsManager, + eventEmitter: EventEmitter, + logger: Logger + ) { super(deviceManager, eventEmitter, logger); this.serialPortFactory = serialPortFactory; + this.settingsManager = settingsManager; this.deviceDetectedListener = asyncHandler( this.handleDeviceDetection.bind(this), @@ -42,11 +56,42 @@ export default abstract class SerialDeviceProvider< this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); } + public override async onSettingsChanged(settings: Settings): Promise { + for (const [deviceId, device] of this.connectedDevices) { + const knownDevice = settings.getKnownDeviceById(deviceId); + + if (undefined !== knownDevice && !knownDevice.isEnabled()) { + this.logger.info(`Closing device '${deviceId}' since it has been disabled`); + await device.close(); + } + } + + for (const [deviceId, deviceInfo] of this.pendingDisabledDevices) { + const knownDevice = settings.getKnownDeviceById(deviceId); + + if (undefined !== knownDevice && !knownDevice.isEnabled()) { + continue; + } + + this.pendingDisabledDevices.delete(deviceId); + await this.handleDeviceDetection(deviceInfo); + } + } + private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { if (!this.isSerialDeviceInfo(deviceInfo)) { return; } + const settings = this.settingsManager.getSettings(); + const knownDevice = settings?.getKnownDeviceById(deviceInfo.id); + + if (undefined !== knownDevice && !knownDevice.isEnabled()) { + this.logger.debug(`Device '${deviceInfo.id}' is disabled, not connecting to it`); + this.pendingDisabledDevices.set(deviceInfo.id, deviceInfo); + return; + } + this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); @@ -144,6 +189,7 @@ export default abstract class SerialDeviceProvider< public override async stop(): Promise { this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + this.pendingDisabledDevices.clear(); } protected abstract connectSerialDevice(deviceInfo: DeviceInfo, port: SerialPortStream): Promise; diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index 698e4310..64348717 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -56,6 +56,7 @@ export default class DeviceServiceProvider implements ServiceProvider { await deviceDisconnected; }, 1000); + it('disabling a known device closes it and re-enabling it reconnects it', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = app.container.get('device.manager'); + const settingsManager = app.container.get('settings.manager'); + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + + const disabledSettings = new Settings(); + disabledSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 })); + disabledSettings.addKnownDevice( + new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 }, false) + ); + + const deviceDisconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); + const listener = (device: Device) => { + if (device.getDeviceId === TEST_DEVICE_ID) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceDisconnected, listener); + }); + + settingsManager.replace(disabledSettings); + await deviceDisconnected; + + expect(deviceManager.getConnectedDevices()).toHaveLength(0); + + const enabledSettings = new Settings(); + enabledSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 })); + enabledSettings.addKnownDevice( + new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 }, true) + ); + + const deviceReconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to reconnect')), 1000); + const listener = (device: Device) => { + if (device.getDeviceId === TEST_DEVICE_ID) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceConnected, listener); + }); + + settingsManager.replace(enabledSettings); + await deviceReconnected; + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + }); + + it('disabling a device source stops its provider and removes its devices', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = app.container.get('device.manager'); + const settingsManager = app.container.get('settings.manager'); + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + + const disabledSourceSettings = new Settings(); + disabledSourceSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 }, false)); + disabledSourceSettings.addKnownDevice( + new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 }) + ); + + const deviceDisconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); + const listener = (device: Device) => { + if (device.getDeviceId === TEST_DEVICE_ID) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceDisconnected, listener); + }); + + settingsManager.replace(disabledSourceSettings); + await deviceDisconnected; + + expect(deviceManager.getConnectedDevices()).toHaveLength(0); + + const reenabledSourceSettings = new Settings(); + reenabledSourceSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 }, true)); + reenabledSourceSettings.addKnownDevice( + new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 }) + ); + + const deviceReconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to reconnect')), 1000); + const listener = (device: Device) => { + if (device.getDeviceId === TEST_DEVICE_ID) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceConnected, listener); + }); + + settingsManager.replace(reenabledSourceSettings); + await deviceReconnected; + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + }); + it('virtual device disconnected', async () => { await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); diff --git a/tests/unit/device/provider/deviceProviderManager.spec.ts b/tests/unit/device/provider/deviceProviderManager.spec.ts new file mode 100644 index 00000000..c02efb7f --- /dev/null +++ b/tests/unit/device/provider/deviceProviderManager.spec.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import EventEmitter from 'events'; +import DeviceProviderManager from '../../../../src/device/provider/deviceProviderManager.js'; +import DeviceProviderFactory from '../../../../src/device/provider/deviceProviderFactory.js'; +import DeviceProvider from '../../../../src/device/provider/deviceProvider.js'; +import DeviceManager from '../../../../src/device/deviceManager.js'; +import Logger from '../../../../src/logging/Logger.js'; +import Settings from '../../../../src/settings/settings.js'; +import DeviceSource from '../../../../src/settings/deviceSource.js'; +import { JsonObject } from '../../../../src/types.js'; + +class RecordingDeviceProvider extends DeviceProvider +{ + public initCalls = 0; + public stopCalls = 0; + public stopped = false; + + // Allows tests to control when init()/stop() resolve, to simulate slow-running operations. + private initGate: Promise = Promise.resolve(); + private stopGate: Promise = Promise.resolve(); + + public constructor() { + super(mock(), new EventEmitter(), mock()); + } + + public setInitGate(gate: Promise): void { + this.initGate = gate; + } + + public setStopGate(gate: Promise): void { + this.stopGate = gate; + } + + public override async init(): Promise { + this.initCalls++; + await this.initGate; + } + + public override async stop(): Promise { + this.stopCalls++; + await this.stopGate; + this.stopped = true; + } +} + +function makeLogger(): Logger { + const logger = mock(); + logger.child.mockReturnValue(logger); + return logger; +} + +function makeSettings(sources: { id: string, type: string, enabled?: boolean }[]): Settings { + const settings = new Settings(); + + for (const source of sources) { + settings.addDeviceSource(new DeviceSource(source.id, source.type, {}, source.enabled ?? true)); + } + + return settings; +} + +function makeFactoryMap(providersById: Record): Map> { + const factories = new Map>(); + + for (const [type, provider] of Object.entries(providersById)) { + factories.set(type, { + create: (_config: JsonObject) => provider, + }); + } + + return factories; +} + +describe('DeviceProviderManager', () => { + it('creates and initializes a provider for an enabled device source', async () => { + const provider = new RecordingDeviceProvider(); + const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); + + await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + + expect(provider.initCalls).toBe(1); + expect(provider.stopCalls).toBe(0); + }); + + it('does not create a provider for a disabled device source', async () => { + const provider = new RecordingDeviceProvider(); + const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); + + await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); + + expect(provider.initCalls).toBe(0); + }); + + it('stops a running provider once its device source is disabled', async () => { + const provider = new RecordingDeviceProvider(); + const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); + + await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + expect(provider.initCalls).toBe(1); + + await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); + expect(provider.stopCalls).toBe(1); + }); + + it('stops a running provider once its device source is removed from config', async () => { + const provider = new RecordingDeviceProvider(); + const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); + + await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + expect(provider.initCalls).toBe(1); + + await manager.reload(makeSettings([])); + expect(provider.stopCalls).toBe(1); + }); + + it('notifies running providers about settings changes', async () => { + const provider = new RecordingDeviceProvider(); + let receivedSettings: Settings | undefined; + provider.onSettingsChanged = async (settings: Settings): Promise => { + receivedSettings = settings; + }; + + const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); + const settings = makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }]); + + await manager.reload(settings); + + expect(receivedSettings).toBe(settings); + }); + + it('serializes overlapping reload() calls so a disable immediately followed by a re-enable ends up running', async () => { + // Use two distinct provider instances so we can tell which one ends up "running" and + // reproduce the manager's internal bookkeeping the same way distinct factory.create() + // calls would in production. + const providerA = new RecordingDeviceProvider(); + const providerB = new RecordingDeviceProvider(); + + let creationCount = 0; + const factories = new Map>([ + ['virtual', { create: (): RecordingDeviceProvider => (creationCount++ === 0 ? providerA : providerB) }], + ]); + + const manager = new DeviceProviderManager(factories, makeLogger()); + + await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + expect(providerA.initCalls).toBe(1); + + // Make the disabling reload()'s stop() call slow, so it's still in-flight when the very + // next reload() (re-enabling the same source) is triggered without awaiting the first. + let releaseStop: () => void = () => undefined; + providerA.setStopGate(new Promise((resolve) => { releaseStop = resolve; })); + + const disablePromise = manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); + const reenablePromise = manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + + // Let the slow stop() call finish now that both reload() calls have been queued. + releaseStop(); + + await disablePromise; + await reenablePromise; + + expect(providerA.stopCalls).toBe(1); + // Without serialization, the re-enable reload() would have (incorrectly) assumed + // providerA was still valid and never created providerB. + expect(providerB.initCalls).toBe(1); + }); + + it('stopProviders stops all running providers and clears internal state', async () => { + const provider = new RecordingDeviceProvider(); + const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); + + await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + await manager.stopProviders(); + + expect(provider.stopped).toBe(true); + + // After stopProviders(), a subsequent reload() with the same enabled source must create + // a fresh provider rather than assuming one is already running. + const provider2 = new RecordingDeviceProvider(); + const manager2 = new DeviceProviderManager(makeFactoryMap({ virtual: provider2 }), makeLogger()); + await manager2.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + expect(provider2.initCalls).toBe(1); + }); +}); diff --git a/tests/unit/settings/deviceSource.spec.ts b/tests/unit/settings/deviceSource.spec.ts new file mode 100644 index 00000000..e429ae25 --- /dev/null +++ b/tests/unit/settings/deviceSource.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { plainToInstance } from 'class-transformer'; +import DeviceSource from '../../../src/settings/deviceSource.js'; + +describe('DeviceSource', () => { + it('is enabled by default when constructed without an explicit value', () => { + const deviceSource = new DeviceSource('test-id', 'virtual', {}); + + expect(deviceSource.isEnabled()).toBe(true); + }); + + it('can be constructed as disabled', () => { + const deviceSource = new DeviceSource('test-id', 'virtual', {}, false); + + expect(deviceSource.isEnabled()).toBe(false); + }); + + it('is enabled by default when deserialized from plain JSON without an "enabled" property', () => { + const deviceSource = plainToInstance(DeviceSource, { + id: 'test-id', + type: 'virtual', + config: {}, + }); + + expect(deviceSource.isEnabled()).toBe(true); + }); + + it('is disabled when deserialized from plain JSON with "enabled": false', () => { + const deviceSource = plainToInstance(DeviceSource, { + id: 'test-id', + type: 'virtual', + config: {}, + enabled: false, + }); + + expect(deviceSource.isEnabled()).toBe(false); + }); + + it('is enabled when deserialized from plain JSON with "enabled": true', () => { + const deviceSource = plainToInstance(DeviceSource, { + id: 'test-id', + type: 'virtual', + config: {}, + enabled: true, + }); + + expect(deviceSource.isEnabled()).toBe(true); + }); +}); diff --git a/tests/unit/settings/knownDevice.spec.ts b/tests/unit/settings/knownDevice.spec.ts new file mode 100644 index 00000000..b35b731e --- /dev/null +++ b/tests/unit/settings/knownDevice.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { plainToInstance } from 'class-transformer'; +import KnownDevice from '../../../src/settings/knownDevice.js'; +import { DeviceId } from '../../../src/device/deviceId.js'; + +describe('KnownDevice', () => { + it('is enabled by default when constructed without an explicit value', () => { + const knownDevice = new KnownDevice(DeviceId.create('test'), 'Test Device', 'randomGenerator', 'virtual'); + + expect(knownDevice.isEnabled()).toBe(true); + }); + + it('can be constructed as disabled', () => { + const knownDevice = new KnownDevice(DeviceId.create('test'), 'Test Device', 'randomGenerator', 'virtual', {}, false); + + expect(knownDevice.isEnabled()).toBe(false); + }); + + it('is enabled by default when deserialized from plain JSON without an "enabled" property', () => { + const knownDevice = plainToInstance(KnownDevice, { + id: DeviceId.create('test'), + name: 'Test Device', + type: 'randomGenerator', + source: 'virtual', + config: {}, + }); + + expect(knownDevice.isEnabled()).toBe(true); + }); + + it('is disabled when deserialized from plain JSON with "enabled": false', () => { + const knownDevice = plainToInstance(KnownDevice, { + id: DeviceId.create('test'), + name: 'Test Device', + type: 'randomGenerator', + source: 'virtual', + config: {}, + enabled: false, + }); + + expect(knownDevice.isEnabled()).toBe(false); + }); + + it('is enabled when deserialized from plain JSON with "enabled": true', () => { + const knownDevice = plainToInstance(KnownDevice, { + id: DeviceId.create('test'), + name: 'Test Device', + type: 'randomGenerator', + source: 'virtual', + config: {}, + enabled: true, + }); + + expect(knownDevice.isEnabled()).toBe(true); + }); +}); From 2586e6f9653c68c8f16dbf5d04434b9081951d6c Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 08:32:17 +0200 Subject: [PATCH 02/35] Some improvements, still wip --- .../protocol/buttplugIo/buttplugIoDeviceFactory.ts | 2 +- .../buttplugIo/buttplugIoWebsocketDeviceProvider.ts | 2 +- src/device/protocol/virtual/virtualDeviceProvider.ts | 8 ++------ src/device/provider/bleDeviceProvider.ts | 6 +++--- src/device/provider/deviceProviderManager.ts | 4 ++-- src/device/provider/serialDeviceProvider.ts | 6 +++--- src/settings/deviceSource.ts | 2 +- src/settings/knownDevice.ts | 2 +- tests/unit/settings/deviceSource.spec.ts | 10 +++++----- tests/unit/settings/knownDevice.spec.ts | 10 +++++----- 10 files changed, 24 insertions(+), 28 deletions(-) diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index dc1cc53c..9a0963b4 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -131,7 +131,7 @@ export default class ButtplugIoDeviceFactory public isKnownDeviceEnabled(buttplugDevice: ButtplugClientDevice, useDeviceNameAsId: boolean): boolean { const deviceId = this.computeDeviceId(buttplugDevice, useDeviceNameAsId); - return this.settings.getKnownDeviceById(deviceId)?.isEnabled() ?? true; + return this.settings.getKnownDeviceById(deviceId)?.enabled ?? true; } private resolveKnownDevice(buttplugDevice: ButtplugClientDevice, provider: string, useDeviceNameAsId: boolean): KnownDevice { diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index de8b70f8..871a90fd 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -68,7 +68,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { for (const device of this.connectedDevices.values()) { const knownDevice = settings.getKnownDeviceById(device.getDeviceId); - if (undefined !== knownDevice && !knownDevice.isEnabled()) { + if (undefined !== knownDevice && !knownDevice.enabled) { this.logger.info(`Closing device '${device.getDeviceId}' since it has been disabled`); await this.removeButtplugIoDevice(device.getButtplugClientDevice); } diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index d9413051..5c727005 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -81,7 +81,7 @@ export default class VirtualDeviceProvider extends DeviceProvider for (const [k, v] of this.connectedDevices) { const knownDevice = virtualDevices.get(k); - if (undefined === knownDevice || !knownDevice.isEnabled()) { + if (undefined === knownDevice || !knownDevice.enabled) { await this.removeDevice(v) } } @@ -92,11 +92,7 @@ export default class VirtualDeviceProvider extends DeviceProvider return; } - if (!v.isEnabled()) { - continue; - } - - if (this.attemptedDevices.has(k) || this.connectedDevices.has(k)) { + if (!v.enabled || this.attemptedDevices.has(k) || this.connectedDevices.has(k)) { continue; } diff --git a/src/device/provider/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts index f172422d..893cfbf5 100644 --- a/src/device/provider/bleDeviceProvider.ts +++ b/src/device/provider/bleDeviceProvider.ts @@ -44,7 +44,7 @@ export default abstract class BleDeviceProvider< for (const [deviceId, device] of this.connectedDevices) { const knownDevice = settings.getKnownDeviceById(deviceId); - if (undefined !== knownDevice && !knownDevice.isEnabled()) { + if (undefined !== knownDevice && !knownDevice.enabled) { this.logger.info(`Closing device '${deviceId}' since it has been disabled`); await device.close(); } @@ -53,7 +53,7 @@ export default abstract class BleDeviceProvider< for (const [deviceId, deviceInfo] of this.pendingDisabledDevices) { const knownDevice = settings.getKnownDeviceById(deviceId); - if (undefined !== knownDevice && !knownDevice.isEnabled()) { + if (undefined !== knownDevice && !knownDevice.enabled) { continue; } @@ -70,7 +70,7 @@ export default abstract class BleDeviceProvider< const settings = this.settingsManager.getSettings(); const knownDevice = settings?.getKnownDeviceById(deviceInfo.id); - if (undefined !== knownDevice && !knownDevice.isEnabled()) { + if (undefined !== knownDevice && !knownDevice.enabled) { this.logger.debug(`Device '${deviceInfo.id}' is disabled, not connecting to it`); this.pendingDisabledDevices.set(deviceInfo.id, deviceInfo); return; diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index 0c5c3b46..959aa6f5 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -64,7 +64,7 @@ export default class DeviceProviderManager for (const [id, provider] of this.providers) { const deviceSource = configuredDeviceSources.get(id); - if (undefined !== deviceSource && deviceSource.isEnabled()) { + if (undefined !== deviceSource && deviceSource.enabled) { continue; } @@ -81,7 +81,7 @@ export default class DeviceProviderManager } for (const [id, deviceSource] of configuredDeviceSources) { - if (!deviceSource.isEnabled() || this.providers.has(id)) { + if (!deviceSource.enabled || this.providers.has(id)) { continue; } diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 8b36721e..6842bfdf 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -60,7 +60,7 @@ export default abstract class SerialDeviceProvider< for (const [deviceId, device] of this.connectedDevices) { const knownDevice = settings.getKnownDeviceById(deviceId); - if (undefined !== knownDevice && !knownDevice.isEnabled()) { + if (undefined !== knownDevice && !knownDevice.enabled) { this.logger.info(`Closing device '${deviceId}' since it has been disabled`); await device.close(); } @@ -69,7 +69,7 @@ export default abstract class SerialDeviceProvider< for (const [deviceId, deviceInfo] of this.pendingDisabledDevices) { const knownDevice = settings.getKnownDeviceById(deviceId); - if (undefined !== knownDevice && !knownDevice.isEnabled()) { + if (undefined !== knownDevice && !knownDevice.enabled) { continue; } @@ -86,7 +86,7 @@ export default abstract class SerialDeviceProvider< const settings = this.settingsManager.getSettings(); const knownDevice = settings?.getKnownDeviceById(deviceInfo.id); - if (undefined !== knownDevice && !knownDevice.isEnabled()) { + if (undefined !== knownDevice && !knownDevice.enabled) { this.logger.debug(`Device '${deviceInfo.id}' is disabled, not connecting to it`); this.pendingDisabledDevices.set(deviceInfo.id, deviceInfo); return; diff --git a/src/settings/deviceSource.ts b/src/settings/deviceSource.ts index b15d1f75..ac5fd32b 100644 --- a/src/settings/deviceSource.ts +++ b/src/settings/deviceSource.ts @@ -35,7 +35,7 @@ export default class DeviceSource return this._config; } - public isEnabled(): boolean { + public get enabled(): boolean { // class-transformer bypasses the constructor when deserializing from plain JSON, so a // missing 'enabled' property in the settings file results in `_enabled` being `undefined` // at runtime despite the constructor's default parameter. Treat that as enabled (default). diff --git a/src/settings/knownDevice.ts b/src/settings/knownDevice.ts index cd02f4c3..4f3ae7cf 100644 --- a/src/settings/knownDevice.ts +++ b/src/settings/knownDevice.ts @@ -54,7 +54,7 @@ export default class KnownDevice return this._config; } - public isEnabled(): boolean { + public get enabled(): boolean { // class-transformer bypasses the constructor when deserializing from plain JSON, so a // missing 'enabled' property in the settings file results in `_enabled` being `undefined` // at runtime despite the constructor's default parameter. Treat that as enabled (default). diff --git a/tests/unit/settings/deviceSource.spec.ts b/tests/unit/settings/deviceSource.spec.ts index e429ae25..d38df26d 100644 --- a/tests/unit/settings/deviceSource.spec.ts +++ b/tests/unit/settings/deviceSource.spec.ts @@ -6,13 +6,13 @@ describe('DeviceSource', () => { it('is enabled by default when constructed without an explicit value', () => { const deviceSource = new DeviceSource('test-id', 'virtual', {}); - expect(deviceSource.isEnabled()).toBe(true); + expect(deviceSource.enabled).toBe(true); }); it('can be constructed as disabled', () => { const deviceSource = new DeviceSource('test-id', 'virtual', {}, false); - expect(deviceSource.isEnabled()).toBe(false); + expect(deviceSource.enabled).toBe(false); }); it('is enabled by default when deserialized from plain JSON without an "enabled" property', () => { @@ -22,7 +22,7 @@ describe('DeviceSource', () => { config: {}, }); - expect(deviceSource.isEnabled()).toBe(true); + expect(deviceSource.enabled).toBe(true); }); it('is disabled when deserialized from plain JSON with "enabled": false', () => { @@ -33,7 +33,7 @@ describe('DeviceSource', () => { enabled: false, }); - expect(deviceSource.isEnabled()).toBe(false); + expect(deviceSource.enabled).toBe(false); }); it('is enabled when deserialized from plain JSON with "enabled": true', () => { @@ -44,6 +44,6 @@ describe('DeviceSource', () => { enabled: true, }); - expect(deviceSource.isEnabled()).toBe(true); + expect(deviceSource.enabled).toBe(true); }); }); diff --git a/tests/unit/settings/knownDevice.spec.ts b/tests/unit/settings/knownDevice.spec.ts index b35b731e..2bb3ee2d 100644 --- a/tests/unit/settings/knownDevice.spec.ts +++ b/tests/unit/settings/knownDevice.spec.ts @@ -7,13 +7,13 @@ describe('KnownDevice', () => { it('is enabled by default when constructed without an explicit value', () => { const knownDevice = new KnownDevice(DeviceId.create('test'), 'Test Device', 'randomGenerator', 'virtual'); - expect(knownDevice.isEnabled()).toBe(true); + expect(knownDevice.enabled).toBe(true); }); it('can be constructed as disabled', () => { const knownDevice = new KnownDevice(DeviceId.create('test'), 'Test Device', 'randomGenerator', 'virtual', {}, false); - expect(knownDevice.isEnabled()).toBe(false); + expect(knownDevice.enabled).toBe(false); }); it('is enabled by default when deserialized from plain JSON without an "enabled" property', () => { @@ -25,7 +25,7 @@ describe('KnownDevice', () => { config: {}, }); - expect(knownDevice.isEnabled()).toBe(true); + expect(knownDevice.enabled).toBe(true); }); it('is disabled when deserialized from plain JSON with "enabled": false', () => { @@ -38,7 +38,7 @@ describe('KnownDevice', () => { enabled: false, }); - expect(knownDevice.isEnabled()).toBe(false); + expect(knownDevice.enabled).toBe(false); }); it('is enabled when deserialized from plain JSON with "enabled": true', () => { @@ -51,6 +51,6 @@ describe('KnownDevice', () => { enabled: true, }); - expect(knownDevice.isEnabled()).toBe(true); + expect(knownDevice.enabled).toBe(true); }); }); From 6b3088e1fa76235646438df00f28753498899a0e Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 09:21:03 +0200 Subject: [PATCH 03/35] Centralize device enable/disable handling in DeviceManager DeviceManager is now the single authoritative place deciding whether a known device may connect. It owns the enabled check, the pending-retry map for disabled devices, and closing devices that get disabled at runtime, exposed via isDeviceEnabled(), addDevice(deviceInfo, device) and onSettingsChanged(). Providers no longer duplicate this: BLE, serial and buttplug.io all route detection through announceDetectedDevice()/deviceDetected and just react to addDevice()'s result. The per-provider onSettingsChanged() hook is gone. VirtualDeviceProvider now reacts to settings changes instead of polling, dropping the scanIntervalMs config entirely. --- src/app.ts | 4 + src/device/deviceManager.ts | 105 ++++++- .../protocol/airotic/airoticDeviceProvider.ts | 4 +- .../buttplugIo/buttplugIoDeviceFactory.ts | 11 - .../buttplugIoWebsocketDeviceProvider.ts | 87 ++++-- .../estim2b/estim2bSerialDeviceProvider.ts | 4 +- .../slvCtrlPlusSerialDeviceProvider.ts | 4 +- .../protocol/virtual/virtualDeviceProvider.ts | 117 ++++--- .../virtual/virtualDeviceProviderFactory.ts | 15 +- .../protocol/zc95/zc95SerialDeviceProvider.ts | 4 +- src/device/provider/bleDeviceProvider.ts | 51 +--- src/device/provider/deviceProvider.ts | 12 - src/device/provider/deviceProviderManager.ts | 12 +- src/device/provider/serialDeviceProvider.ts | 71 ++--- src/serviceProvider/deviceServiceProvider.ts | 6 +- src/settings/settingsManager.ts | 6 + tests/integration/deviceEvents.spec.ts | 8 +- tests/integration/helpers/appHelper.ts | 4 +- tests/unit/device/deviceManager.spec.ts | 286 ++++++++++++++++-- .../provider/deviceProviderManager.spec.ts | 15 - 20 files changed, 556 insertions(+), 270 deletions(-) diff --git a/src/app.ts b/src/app.ts index bff1493b..c1043cfb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -127,6 +127,10 @@ const configureWebsocket = (io: WebsocketServer, container: Container { io.emit(SettingsEventType.changed, serializer.transform(settings)); + deviceManager + .onSettingsChanged() + .catch(e => logError(logger, 'Failed to apply device enabled/disabled changes', e)); + container.get('device.provider.loader') .reload(settings) .catch(e => logError(logger, 'Failed to reload device sources after settings change', e)); diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 44082578..0f2adcb8 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -6,6 +6,7 @@ import Logger from '../logging/Logger.js'; import { AnyDeviceConfig } from './deviceConfig.js'; import { logError } from '../util/error.js'; import { DeviceId } from './deviceId.js'; +import SettingsManager from '../settings/settingsManager.js'; export type DeviceInfo = { type: string; @@ -42,10 +43,39 @@ export default class DeviceManager private readonly connectedDevices: Map>; - public constructor(eventEmitter: EventEmitter, connectedDevices: Map, logger: Logger) { + private readonly settingsManager: SettingsManager; + + /** + * Devices that were announced as detected while belonging to a disabled known device (or + * whose registration was rejected by `addDevice()` after connecting, for protocols where the + * final device id can only be determined post-handshake). Re-announced once their known + * device gets (re-)enabled, see `onSettingsChanged()`. + */ + private readonly pendingDisabledDevices: Map = new Map(); + + public constructor( + eventEmitter: EventEmitter, + connectedDevices: Map, + settingsManager: SettingsManager, + logger: Logger + ) { this.eventEmitter = eventEmitter; this.logger = logger.child({ name: DeviceManager.name }); this.connectedDevices = connectedDevices; + this.settingsManager = settingsManager; + } + + /** + * This is the single, authoritative place deciding whether a known device is currently + * allowed to be connected. Any protocol-specific id assigned before a device's identity is + * fully resolved (e.g. during a handshake) may differ from its final, canonical device id + * (`Device.getDeviceId`), so this check is only truly reliable once called with that final + * id - which is exactly what `addDevice()` does below. Callers with only a preliminary id + * (e.g. providers deciding whether it's worth attempting a connection at all) may still use + * this as a best-effort optimization, but must not treat a resulting `true` as a guarantee. + */ + public isDeviceEnabled(deviceId: DeviceId): boolean { + return this.settingsManager.getSettings()?.getKnownDeviceById(deviceId)?.enabled ?? true; } public announceDetectedDevice(deviceInfo: DeviceInfo): void @@ -59,6 +89,12 @@ export default class DeviceManager return; } + if (!this.isDeviceEnabled(deviceInfo.id)) { + this.logger.debug(`Device with id '${deviceInfo.id}' is disabled, not announcing it as detected`); + this.pendingDisabledDevices.set(deviceInfo.id, deviceInfo); + return; + } + this.logger.info(`Detected new device with id ${deviceInfo.id}`); this.detectedDeviceAcquireQueue.set(deviceInfo.id, []); @@ -116,10 +152,30 @@ export default class DeviceManager deviceQueue[0]?.resolve({ successful: true }); } + /** + * Registers a fully connected device, unless the known device it belongs to (identified by + * its final `getDeviceId`) has been disabled - in that case, the device is closed right away + * and never registered. Returns whether the device was actually added. + * + * `deviceInfo` is the original info this device was detected with (as passed to + * `announceDetectedDevice()`), used to resolve that pipeline's bookkeeping: claiming it on + * success, or releasing it and registering it for retry on rejection. + */ public addDevice( + deviceInfo: DeviceInfo, device: Device - ): void + ): boolean { + if (!this.isDeviceEnabled(device.getDeviceId)) { + this.logger.info(`Not adding device '${device.getDeviceId}' since it is disabled`); + void device.close().catch((e: unknown) => logError(this.logger, `Failed to close disabled device '${device.getDeviceId}'`, e)); + + this.registerPendingRetry(deviceInfo); + this.releaseDetectedDevice(deviceInfo.id); + + return false; + } + this.connectedDevices.set(device.getDeviceId, device); device.on(DeviceEvent.deviceRefreshed, (d) => this.refreshDevice(d)); @@ -129,6 +185,49 @@ export default class DeviceManager this.initDeviceRefresher(device); this.eventEmitter.emit(DeviceManagerEvent.deviceConnected, device); + + this.claimDetectedDevice(deviceInfo.id); + + return true; + } + + /** + * Registers a device for retry once its known device gets (re-)enabled. Used internally by + * `addDevice()` when it rejects a device whose known device turned out to be disabled. + */ + private registerPendingRetry(deviceInfo: DeviceInfo): void { + this.pendingDisabledDevices.set(deviceInfo.id, deviceInfo); + } + + /** + * Closes any currently connected device whose known device has since been disabled, and + * re-announces any previously rejected device whose known device has since been (re-)enabled + * - letting it run through the exact same detection pipeline as a brand new device. Should be + * called whenever settings change. + */ + public async onSettingsChanged(): Promise { + for (const device of this.connectedDevices.values()) { + if (this.isDeviceEnabled(device.getDeviceId)) { + continue; + } + + this.logger.info(`Closing device '${device.getDeviceId}' since it has been disabled`); + + try { + await device.close(); + } catch (e: unknown) { + logError(this.logger, `Failed to close device '${device.getDeviceId}'`, e); + } + } + + for (const [deviceId, deviceInfo] of this.pendingDisabledDevices) { + if (!this.isDeviceEnabled(deviceId)) { + continue; + } + + this.pendingDisabledDevices.delete(deviceId); + this.announceDetectedDevice(deviceInfo); + } } public claimDetectedDevice(deviceId: DeviceId): void @@ -183,6 +282,8 @@ export default class DeviceManager this.clearDetectedDeviceAcquireQueue(deviceId, 'Device manager reset'); } + this.pendingDisabledDevices.clear(); + if (undefined !== closeError) { throw closeError; } diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index f8a49dae..c4171972 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -8,7 +8,6 @@ import { BleDeviceInfo } from '../../transport/bleObserver.js'; import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; import AiroticProtocol from './airoticProtocol.js'; import MessageResponseHandler from '../messageResponseHandler.js'; -import SettingsManager from '../../../settings/settingsManager.js'; import BleDeviceProvider from '../../provider/bleDeviceProvider.js'; import AiroticDeviceFactory from './airoticDeviceFactory.js'; @@ -24,11 +23,10 @@ export default class AiroticDeviceProvider extends BleDeviceProvider = new Map(); - private readonly pendingDisabledDevices: Map = new Map(); - private buttplugConnector: ButtplugNodeWebsocketClientConnector; private buttplugClient: ButtplugClient; @@ -53,37 +56,25 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { this.handleLostConnection.bind(this, url), (e: unknown) => logError(this.logger, `Error in disconnect handler`, e) )); - this.buttplugClient.on('deviceadded', this.addButtplugIoDevice.bind(this)); + this.buttplugClient.on('deviceadded', this.announceButtplugIoDevice.bind(this)); this.buttplugClient.on('deviceremoved', asyncHandler( this.removeButtplugIoDevice.bind(this), (e: unknown) => logError(this.logger, `Error in deviceremoved handler`, e) )); + + this.deviceManager.on( + DeviceManagerEvent.deviceDetected, + asyncHandler( + this.handleDeviceDetection.bind(this), + (err: unknown) => logError(this.logger, 'Error in device detection handler', err) + ) + ); } public override async init(): Promise { this.connectionIntervalRef ??= setImmediateInterval(() => void this.connectToServer(), 1000); } - public override async onSettingsChanged(settings: Settings): Promise { - for (const device of this.connectedDevices.values()) { - const knownDevice = settings.getKnownDeviceById(device.getDeviceId); - - if (undefined !== knownDevice && !knownDevice.enabled) { - this.logger.info(`Closing device '${device.getDeviceId}' since it has been disabled`); - await this.removeButtplugIoDevice(device.getButtplugClientDevice); - } - } - - for (const [index, buttplugDevice] of this.pendingDisabledDevices) { - if (!this.buttplugIoDeviceFactory.isKnownDeviceEnabled(buttplugDevice, this.useDeviceNameAsId)) { - continue; - } - - this.pendingDisabledDevices.delete(index); - this.addButtplugIoDevice(buttplugDevice); - } - } - private async connectToServer(): Promise { if (this.buttplugClient.connected) { return; @@ -141,26 +132,60 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { }, 30000); } - private addButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { + /** + * Announces a device reported by the Buttplug.io server to the device manager, which runs + * the enabled/disabled check centrally and takes care of retrying once a currently disabled + * device gets re-enabled - see `handleDeviceDetection()` below for the rest of the flow. + */ + private announceButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { this.logger.info(`Device detected: ${buttplugDevice.name}`, buttplugDevice); - if (!this.buttplugIoDeviceFactory.isKnownDeviceEnabled(buttplugDevice, this.useDeviceNameAsId)) { - this.logger.debug(`Device '${buttplugDevice.name}' is disabled, not connecting to it`); - this.pendingDisabledDevices.set(buttplugDevice.index, buttplugDevice); + const deviceId = this.buttplugIoDeviceFactory.computeDeviceId(buttplugDevice, this.useDeviceNameAsId); + + const deviceInfo: ButtplugIoDeviceInfo = { type: 'buttplugIo', id: deviceId, buttplugClientDevice: buttplugDevice }; + + this.deviceManager.announceDetectedDevice(deviceInfo); + } + + private isButtplugIoDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is ButtplugIoDeviceInfo { + return deviceInfo.type === 'buttplugIo'; + } + + private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { + if (!this.isButtplugIoDeviceInfo(deviceInfo)) { + return; + } + + const buttplugDevice = deviceInfo.buttplugClientDevice; + + const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); + + if (!acquireResult.successful) { + this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); return; } try { const device = this.buttplugIoDeviceFactory.create(buttplugDevice, ButtplugIoWebsocketDeviceProvider.providerName, this.useDeviceNameAsId); - this.connectedDevices.set(buttplugDevice.index, device); + // Keep local bookkeeping in sync regardless of what closes the device (e.g. the + // device manager closing it right away because it has been disabled in the meantime). + device.on(DeviceEvent.deviceDisconnected, () => this.connectedDevices.delete(buttplugDevice.index)); - this.deviceManager.addDevice(device); + if (!this.deviceManager.addDevice(deviceInfo, device)) { + // The device turned out to belong to a disabled known device after all - + // addDevice() has already closed it, released it from the acquire queue, and + // registered it for retry once re-enabled. + return; + } + + this.connectedDevices.set(buttplugDevice.index, device); this.logger.debug(`Assigned device id: ${device.getDeviceId} (${buttplugDevice.name}@${buttplugDevice.index})`); this.logger.info(`Connected devices: ${this.connectedDevices.size}`); } catch (e: unknown) { logError(this.logger, `Could not connect to device '${buttplugDevice.name}'`, e); + this.deviceManager.releaseDetectedDevice(deviceInfo.id); } } diff --git a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts index c203494c..e16f64c6 100644 --- a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts +++ b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts @@ -13,7 +13,6 @@ import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportF import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import DeviceManager from '../../deviceManager.js'; import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; -import SettingsManager from '../../../settings/settingsManager.js'; export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { @@ -26,13 +25,12 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { @@ -31,13 +30,12 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide public constructor( deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, - settingsManager: SettingsManager, eventEmitter: EventEmitter, deviceFactory: SlvCtrlPlusDeviceFactory, deviceTransportFactory: SerialDeviceTransportFactory, logger: Logger ) { - super(deviceManager, serialPortFactory, settingsManager, eventEmitter, logger.child({ name: SlvCtrlPlusSerialDeviceProvider.name })); + super(deviceManager, serialPortFactory, eventEmitter, logger.child({ name: SlvCtrlPlusSerialDeviceProvider.name })); this.slvCtrlPlusDeviceFactory = deviceFactory; this.deviceTransportFactory = deviceTransportFactory; } diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 5c727005..988a3b17 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -4,26 +4,30 @@ import Logger from '../../../logging/Logger.js'; import VirtualDevice from './virtualDevice.js'; import KnownDevice from '../../../settings/knownDevice.js'; import SettingsManager from '../../../settings/settingsManager.js'; -import Device from '../../device.js'; +import SettingsEventType from '../../../settings/settingsEventType.js'; +import type Settings from '../../../settings/settings.js'; +import Device, { DeviceEvent } from '../../device.js'; import VirtualDeviceFactory from './virtualDeviceFactory.js'; -import DeviceManager from '../../deviceManager.js'; -import { asyncHandler, setImmediateInterval } from '../../../util/async.js'; +import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../../deviceManager.js'; +import { asyncHandler } from '../../../util/async.js'; import { logError } from '../../../util/error.js'; +export type VirtualDeviceInfo = DeviceInfo & { + type: 'virtual'; + knownDevice: KnownDevice; +}; + export default class VirtualDeviceProvider extends DeviceProvider { public static readonly providerName = 'virtual'; - private attemptedDevices: Set = new Set(); private connectedDevices: Map> = new Map(); private readonly deviceFactory: VirtualDeviceFactory; private readonly settingsManager: SettingsManager; - private readonly scanIntervalMs: number; - - private discoveryInterval?: NodeJS.Timeout; + private readonly settingsChangedListener: (settings: Settings) => void; private stopped: boolean = false; @@ -32,37 +36,54 @@ export default class VirtualDeviceProvider extends DeviceProvider eventEmitter: EventEmitter, deviceFactory: VirtualDeviceFactory, settingsManager: SettingsManager, - logger: Logger, - scanIntervalMs: number + logger: Logger ) { super(deviceManager, eventEmitter, logger.child({ name: VirtualDeviceProvider.name })); this.deviceFactory = deviceFactory; this.settingsManager = settingsManager; - this.scanIntervalMs = scanIntervalMs; + + this.deviceManager.on( + DeviceManagerEvent.deviceDetected, + asyncHandler( + this.handleDeviceDetection.bind(this), + (err: unknown) => logError(this.logger, 'Error in device detection handler', err) + ) + ); + + this.settingsChangedListener = asyncHandler( + async (): Promise => this.discoverVirtualDevices(), + (e: unknown) => logError(this.logger, 'Error while scanning for virtual devices after a settings change', e) + ); } public override async init(): Promise { this.stopped = false; - this.discoveryInterval ??= setImmediateInterval(asyncHandler( - this.discoverVirtualDevices.bind(this), - (e: unknown) => this.logger.error('Error while scanning for new virtual devices', e) - ), this.scanIntervalMs); + this.settingsManager.on(SettingsEventType.changed, this.settingsChangedListener); + + // Load whatever is already configured once, without waiting for the first settings + // change. Further additions/removals are picked up reactively via settingsChangedListener. + await this.discoverVirtualDevices(); } public override async stop(): Promise { this.stopped = true; - if (this.discoveryInterval !== undefined) { - clearInterval(this.discoveryInterval); - this.discoveryInterval = undefined; - } + this.settingsManager.off(SettingsEventType.changed, this.settingsChangedListener); for (const device of this.connectedDevices.values()) { await this.removeDevice(device); } } + /** + * Virtual devices only exist as long as their known device is configured, unlike physical + * devices where removing the known device entry just makes it "unknown" again (defaulting + * back to enabled). That's a virtual-specific concern the device manager can't detect on its + * own, so this still needs to be checked whenever settings change. Enabling/disabling an + * already-configured device, on the other hand, is handled centrally by + * `DeviceManager.onSettingsChanged()`. + */ private async discoverVirtualDevices(): Promise { if (this.stopped) { return; @@ -77,49 +98,76 @@ export default class VirtualDeviceProvider extends DeviceProvider const virtualDevices = settings.getKnownDevicesBySource(VirtualDeviceProvider.providerName); - // Check if devices have been removed or disabled + // Check if devices have been removed from the configuration entirely for (const [k, v] of this.connectedDevices) { - const knownDevice = virtualDevices.get(k); - - if (undefined === knownDevice || !knownDevice.enabled) { - await this.removeDevice(v) + if (!virtualDevices.has(k)) { + await this.removeDevice(v); } } - // Load all currently configured and enabled devices + // Announce all currently configured devices that aren't connected yet - the device + // manager takes care of skipping disabled ones (and re-announcing them once re-enabled) + // as well as ones already being connected. for (const [k, v] of virtualDevices) { if (this.stopped) { return; } - if (!v.enabled || this.attemptedDevices.has(k) || this.connectedDevices.has(k)) { + if (this.connectedDevices.has(k)) { continue; } - this.attemptedDevices.add(k); + const deviceInfo: VirtualDeviceInfo = { type: 'virtual', id: v.id, knownDevice: v }; - await this.addDevice(v); + this.deviceManager.announceDetectedDevice(deviceInfo); } } - private async addDevice(knowDevice: KnownDevice): Promise { - this.logger.info(`Virtual device detected: ${knowDevice.name}`, knowDevice); + private isVirtualDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is VirtualDeviceInfo { + return deviceInfo.type === 'virtual'; + } + + private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { + if (!this.isVirtualDeviceInfo(deviceInfo)) { + return; + } + + const knownDevice = deviceInfo.knownDevice; + + this.logger.info(`Virtual device detected: ${knownDevice.name}`, knownDevice); + + const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); + + if (!acquireResult.successful) { + this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); + return; + } try { - const device = await this.deviceFactory.create(knowDevice, VirtualDeviceProvider.providerName); + const device = await this.deviceFactory.create(knownDevice, VirtualDeviceProvider.providerName); if (this.stopped) { await device.close(); - this.attemptedDevices.delete(knowDevice.id); + this.deviceManager.releaseDetectedDevice(deviceInfo.id); + return; + } + + // Keep local bookkeeping in sync regardless of what closes the device (e.g. the + // device manager closing it right away because it has been disabled in the meantime). + device.on(DeviceEvent.deviceDisconnected, (d) => this.connectedDevices.delete(d.getDeviceId)); + + if (!this.deviceManager.addDevice(deviceInfo, device)) { + // addDevice() has already closed it, released it from the acquire queue, and + // registered it for retry once re-enabled. return; } - this.deviceManager.addDevice(device); - this.connectedDevices.set(knowDevice.id, device); + this.connectedDevices.set(knownDevice.id, device); this.logger.info(`Connected virtual devices: ${this.connectedDevices.size}`); } catch (e: unknown) { - logError(this.logger, `Could not initiate virtual device '${knowDevice.id}'`, e); + logError(this.logger, `Could not initiate virtual device '${knownDevice.id}'`, e); + this.deviceManager.releaseDetectedDevice(deviceInfo.id); } } @@ -130,7 +178,6 @@ export default class VirtualDeviceProvider extends DeviceProvider await device.close(); } finally { this.connectedDevices.delete(deviceId); - this.attemptedDevices.delete(deviceId); } this.logger.info(`Device removed: ${deviceId} (${device.getDeviceName})`); diff --git a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts index a3e6a419..7ab737d5 100644 --- a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts +++ b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts @@ -5,15 +5,10 @@ import SettingsManager from '../../../settings/settingsManager.js'; import VirtualDeviceFactory from './virtualDeviceFactory.js'; import DeviceManager from '../../deviceManager.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; - -type VirtualDeviceProviderConfig = { - scanIntervalMs: number, -} +import { JsonObject } from '../../../types.js'; export default class VirtualDeviceProviderFactory implements DeviceProviderFactory { - private static readonly DEFAULT_SCAN_INTERVAL_MS = 3000; - private readonly deviceManager: DeviceManager; private readonly eventEmitterFactory: EventEmitterFactory; @@ -38,18 +33,14 @@ export default class VirtualDeviceProviderFactory implements DeviceProviderFacto this.logger = logger; } - public create(config: VirtualDeviceProviderConfig): VirtualDeviceProvider { - const scanIntervalMs = typeof config.scanIntervalMs === 'number' && config.scanIntervalMs > 0 - ? config.scanIntervalMs - : VirtualDeviceProviderFactory.DEFAULT_SCAN_INTERVAL_MS; - + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public create(config: JsonObject): VirtualDeviceProvider { return new VirtualDeviceProvider( this.deviceManager, this.eventEmitterFactory.create(), this.deviceFactory, this.settingsManager, this.logger, - scanIntervalMs, ); } } diff --git a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts index f8c73e7a..71703ee3 100644 --- a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts +++ b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts @@ -14,7 +14,6 @@ import Zc95MessageFactory from './zc95MessageFactory.js'; import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; import DeviceManager from '../../deviceManager.js'; import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; -import SettingsManager from '../../../settings/settingsManager.js'; export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { @@ -27,13 +26,12 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider = new Map(); - private readonly pendingDisabledDevices: Map = new Map(); - - protected readonly settingsManager: SettingsManager; - - protected constructor(deviceManager: DeviceManager, settingsManager: SettingsManager, eventEmitter: EventEmitter, logger: Logger) { + protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger); - this.settingsManager = settingsManager; - this.deviceManager.on( DeviceManagerEvent.deviceDetected, asyncHandler( @@ -40,42 +32,11 @@ export default abstract class BleDeviceProvider< ); } - public override async onSettingsChanged(settings: Settings): Promise { - for (const [deviceId, device] of this.connectedDevices) { - const knownDevice = settings.getKnownDeviceById(deviceId); - - if (undefined !== knownDevice && !knownDevice.enabled) { - this.logger.info(`Closing device '${deviceId}' since it has been disabled`); - await device.close(); - } - } - - for (const [deviceId, deviceInfo] of this.pendingDisabledDevices) { - const knownDevice = settings.getKnownDeviceById(deviceId); - - if (undefined !== knownDevice && !knownDevice.enabled) { - continue; - } - - this.pendingDisabledDevices.delete(deviceId); - await this.handleDeviceDetection(deviceInfo); - } - } - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { if (!this.isBleDeviceInfo(deviceInfo)) { return; } - const settings = this.settingsManager.getSettings(); - const knownDevice = settings?.getKnownDeviceById(deviceInfo.id); - - if (undefined !== knownDevice && !knownDevice.enabled) { - this.logger.debug(`Device '${deviceInfo.id}' is disabled, not connecting to it`); - this.pendingDisabledDevices.set(deviceInfo.id, deviceInfo); - return; - } - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); @@ -93,10 +54,15 @@ export default abstract class BleDeviceProvider< return; } + if (!this.deviceManager.addDevice(deviceInfo, device)) { + // The device's final id (assigned during connect/handshake) turned out to belong + // to a disabled known device - addDevice() has already closed it, released it + // from the acquire queue, and registered it for retry once re-enabled. + return; + } + this.connectedDevices.set(device.getDeviceId, device); device.on(DeviceEvent.deviceDisconnected, (d) => this.connectedDevices.delete(d.getDeviceId)); - this.deviceManager.addDevice(device); - this.deviceManager.claimDetectedDevice(deviceInfo.id); } catch (e: unknown) { logError(this.logger, 'Error while connecting to BLE device', e); this.deviceManager.releaseDetectedDevice(deviceInfo.id); @@ -109,7 +75,6 @@ export default abstract class BleDeviceProvider< await device.close(); } this.connectedDevices.clear(); - this.pendingDisabledDevices.clear(); } private isBleDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo { diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index 7e8ef444..5292c4ad 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -1,7 +1,6 @@ import EventEmitter from 'events'; import Logger from '../../logging/Logger.js'; import DeviceManager from '../deviceManager.js'; -import Settings from '../../settings/settings.js'; export default abstract class DeviceProvider { @@ -24,15 +23,4 @@ export default abstract class DeviceProvider public async stop(): Promise { return Promise.resolve(); } - - /** - * Called whenever the settings have changed (e.g. via the `PUT /settings` endpoint or any - * mutation of the settings object). Providers can override this to react to known devices - * being enabled/disabled: closing devices that just got disabled and retrying connections - * for devices that just got (re-)enabled. - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async onSettingsChanged(settings: Settings): Promise { - return Promise.resolve(); - } } diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index 959aa6f5..3022278e 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -33,8 +33,8 @@ export default class DeviceProviderManager * (Re-)synchronizes the running device providers with the given settings: providers for * device sources that were removed or disabled are stopped, providers for device sources * that are new or were (re-)enabled are created and started. Already running providers for - * device sources that are still enabled are left untouched, but are notified about the - * settings change so they can react to devices being enabled/disabled. + * device sources that are still enabled are left untouched. Known devices being individually + * enabled/disabled is handled centrally by `DeviceManager`, not here. */ public reload(settings: Settings): Promise { @@ -102,14 +102,6 @@ export default class DeviceProviderManager logError(this.logger, `Failed to start device provider for device source '${id}'`, error); } } - - for (const provider of this.providers.values()) { - try { - await provider.onSettingsChanged(settings); - } catch (error: unknown) { - logError(this.logger, `Device provider failed to handle settings change`, error); - } - } } private async doStopProviders(): Promise { diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 6842bfdf..7cc6275d 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -12,10 +12,8 @@ import { asyncHandler } from '../../util/async.js'; import { logError } from '../../util/error.js'; import { SerialDeviceInfo } from '../transport/serialPortObserver.js'; import PeripheralDevice, { InferPeripheralDeviceAttributes, InferPeripheralDeviceConfig } from '../peripheralDevice.js'; -import { DeviceAttributes } from '../device.js'; +import { DeviceAttributes, DeviceEvent } from '../device.js'; import { AnyDeviceConfig } from '../deviceConfig.js'; -import SettingsManager from '../../settings/settingsManager.js'; -import Settings from '../../settings/settings.js'; import { DeviceId } from '../deviceId.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; @@ -28,25 +26,19 @@ export default abstract class SerialDeviceProvider< { private readonly serialPortFactory: SerialPortFactory; - private readonly settingsManager: SettingsManager; - private connectedDevices: Map = new Map(); - private readonly pendingDisabledDevices: Map = new Map(); - private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; protected constructor( deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, - settingsManager: SettingsManager, eventEmitter: EventEmitter, logger: Logger ) { super(deviceManager, eventEmitter, logger); this.serialPortFactory = serialPortFactory; - this.settingsManager = settingsManager; this.deviceDetectedListener = asyncHandler( this.handleDeviceDetection.bind(this), @@ -56,42 +48,11 @@ export default abstract class SerialDeviceProvider< this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); } - public override async onSettingsChanged(settings: Settings): Promise { - for (const [deviceId, device] of this.connectedDevices) { - const knownDevice = settings.getKnownDeviceById(deviceId); - - if (undefined !== knownDevice && !knownDevice.enabled) { - this.logger.info(`Closing device '${deviceId}' since it has been disabled`); - await device.close(); - } - } - - for (const [deviceId, deviceInfo] of this.pendingDisabledDevices) { - const knownDevice = settings.getKnownDeviceById(deviceId); - - if (undefined !== knownDevice && !knownDevice.enabled) { - continue; - } - - this.pendingDisabledDevices.delete(deviceId); - await this.handleDeviceDetection(deviceInfo); - } - } - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { if (!this.isSerialDeviceInfo(deviceInfo)) { return; } - const settings = this.settingsManager.getSettings(); - const knownDevice = settings?.getKnownDeviceById(deviceInfo.id); - - if (undefined !== knownDevice && !knownDevice.enabled) { - this.logger.debug(`Device '${deviceInfo.id}' is disabled, not connecting to it`); - this.pendingDisabledDevices.set(deviceInfo.id, deviceInfo); - return; - } - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); @@ -109,8 +70,17 @@ export default abstract class SerialDeviceProvider< return; } - this.deviceManager.addDevice(device); - this.deviceManager.claimDetectedDevice(deviceInfo.id); + if (!this.deviceManager.addDevice(deviceInfo, device)) { + // The device's final id (assigned during connect/handshake) turned out to belong + // to a disabled known device - addDevice() has already closed it, released it + // from the acquire queue, and registered it for retry once re-enabled. + return; + } + + this.connectedDevices.set(device.getDeviceId, device); + device.on(DeviceEvent.deviceDisconnected, (d) => this.connectedDevices.delete(d.getDeviceId)); + + this.logger.info(`Connected devices: ${this.connectedDevices.size}`); } catch (e: unknown) { logError(this.logger, `Error while connecting to device`, e); this.deviceManager.releaseDetectedDevice(deviceInfo.id); @@ -165,18 +135,7 @@ export default abstract class SerialDeviceProvider< this.logger.info(`Could not connect to serial device '${portInfo.path}': ${attemptFailureReason}`); } else { this.logger.info(`Successfully connected to serial device '${portInfo.path}'`); - - this.connectedDevices.set(device.getDeviceId, device); - this.logger.debug(`Assigned device id: ${device.getDeviceId} (${portInfo.path})`); - this.logger.info(`Connected devices: ${this.connectedDevices.size}`); - - port.on('close', () => { - this.connectedDevices.delete(device.getDeviceId); - - this.logger.info(`Lost serial device: ${device.getDeviceId}`); - this.logger.info(`Connected devices: ${this.connectedDevices.size}`); - }); } return device; @@ -189,7 +148,11 @@ export default abstract class SerialDeviceProvider< public override async stop(): Promise { this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); - this.pendingDisabledDevices.clear(); + + for (const device of this.connectedDevices.values()) { + await device.close(); + } + this.connectedDevices.clear(); } protected abstract connectSerialDevice(deviceInfo: DeviceInfo, port: SerialPortStream): Promise; diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index 64348717..a84ce992 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -56,7 +56,6 @@ export default class DeviceServiceProvider implements ServiceProvider(), + container.get('settings.manager'), container.get('logger.default') ); }); @@ -110,7 +110,6 @@ export default class DeviceServiceProvider implements ServiceProvider (event: E, listener: SettingsEvents[E]): this + { + this.eventEmitter.off(event, listener); + return this; + } + public getSettings(): Settings|undefined { return this.settings; } diff --git a/tests/integration/deviceEvents.spec.ts b/tests/integration/deviceEvents.spec.ts index 2aead5a8..071c6803 100644 --- a/tests/integration/deviceEvents.spec.ts +++ b/tests/integration/deviceEvents.spec.ts @@ -133,7 +133,7 @@ describe('Device events', () => { expect(deviceManager.getConnectedDevices()).toHaveLength(1); const disabledSettings = new Settings(); - disabledSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 })); + disabledSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); disabledSettings.addKnownDevice( new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 }, false) ); @@ -156,7 +156,7 @@ describe('Device events', () => { expect(deviceManager.getConnectedDevices()).toHaveLength(0); const enabledSettings = new Settings(); - enabledSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 })); + enabledSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); enabledSettings.addKnownDevice( new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 }, true) ); @@ -188,7 +188,7 @@ describe('Device events', () => { expect(deviceManager.getConnectedDevices()).toHaveLength(1); const disabledSourceSettings = new Settings(); - disabledSourceSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 }, false)); + disabledSourceSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {}, false)); disabledSourceSettings.addKnownDevice( new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 }) ); @@ -211,7 +211,7 @@ describe('Device events', () => { expect(deviceManager.getConnectedDevices()).toHaveLength(0); const reenabledSourceSettings = new Settings(); - reenabledSourceSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 }, true)); + reenabledSourceSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {}, true)); reenabledSourceSettings.addKnownDevice( new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 }) ); diff --git a/tests/integration/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts index f6e251a7..2e1123be 100644 --- a/tests/integration/helpers/appHelper.ts +++ b/tests/integration/helpers/appHelper.ts @@ -27,7 +27,7 @@ export type DeviceSpec = { id: DeviceId, name: string, config?: { min: number, m function makeBaseSettings(): Settings { const settings = new Settings(); - settings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 })); + settings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); return settings; } @@ -37,7 +37,7 @@ const baseSettingsJson = { [TEST_SOURCE_ID]: { id: TEST_SOURCE_ID, type: 'virtual', - config: { scanIntervalMs: 50 }, + config: {}, }, }, }; diff --git a/tests/unit/device/deviceManager.spec.ts b/tests/unit/device/deviceManager.spec.ts index 3e4c0e22..db0c4071 100644 --- a/tests/unit/device/deviceManager.spec.ts +++ b/tests/unit/device/deviceManager.spec.ts @@ -6,8 +6,14 @@ import Device from "../../../src/device/device.js"; import TestDevice from "./testDevice.js"; import Logger from "../../../src/logging/Logger.js"; import { DeviceId } from "../../../src/device/deviceId.js"; +import SettingsManager from "../../../src/settings/settingsManager.js"; +import Settings from "../../../src/settings/settings.js"; +import KnownDevice from "../../../src/settings/knownDevice.js"; describe('deviceManager', () => { + // Returns `undefined` settings by default, which makes `isDeviceEnabled()` treat every + // device as enabled - the desired default for tests unrelated to the enable/disable feature. + const mockedSettingsManager = mock(); it('it adds device to managed devices and emits an event', async () => { @@ -16,14 +22,15 @@ describe('deviceManager', () => { const mockedLogger = mock(); mockedLogger.child.mockReturnValue(mockedLogger); - const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, new Map(), mockedLogger); + const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, new Map(), mockedSettingsManager, mockedLogger); - const device = new TestDevice(DeviceId.create('test-device-id'), 'Foo', new Date(), false, new EventEmitter()); + const deviceId = DeviceId.create('test-device-id'); + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); // New device connected expect(deviceManager.getConnectedDevices().length).toBe(0); - deviceManager.addDevice(device); + deviceManager.addDevice({ type: 'test', id: deviceId }, device); let actualDevices = deviceManager.getConnectedDevices(); @@ -39,16 +46,17 @@ describe('deviceManager', () => { it('it removes device from managed devices and emits event on disconnect', async () => { const connectedDevices = new Map(); - const device = new TestDevice(DeviceId.create('test-device-id'), 'Foo', new Date(), false, new EventEmitter()); + const deviceId = DeviceId.create('test-device-id'); + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); const mockedDeviceManagerEventEmitter = mock(); const mockedLogger = mock(); mockedLogger.child.mockReturnValue(mockedLogger); - const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedLogger); + const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedSettingsManager, mockedLogger); - deviceManager.addDevice(device); + deviceManager.addDevice({ type: 'test', id: deviceId }, device); // Connected device refreshed await device.refresh(); @@ -64,16 +72,17 @@ describe('deviceManager', () => { it('it emits an event on device update', async () => { const connectedDevices = new Map(); - const device = new TestDevice(DeviceId.create('test-device-id'), 'Foo', new Date(), false, new EventEmitter()); + const deviceId = DeviceId.create('test-device-id'); + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); const mockedDeviceManagerEventEmitter = mock(); const mockedLogger = mock(); mockedLogger.child.mockReturnValue(mockedLogger); - const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedLogger); + const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedSettingsManager, mockedLogger); - deviceManager.addDevice(device); + deviceManager.addDevice({ type: 'test', id: deviceId }, device); // Connected device closed await device.close(); @@ -98,13 +107,13 @@ describe('deviceManager', () => { const uuid = 'known-device-uuid'; const device = mock(); const connectedDevices = new Map([[uuid, device]]); - const manager = new DeviceManager(mock(), connectedDevices, mockedLogger); + const manager = new DeviceManager(mock(), connectedDevices, mockedSettingsManager, mockedLogger); expect(manager.getConnectedDevice(uuid)).toBe(device); }); it('returns null when device is not found', () => { - const manager = new DeviceManager(mock(), new Map(), mockedLogger); + const manager = new DeviceManager(mock(), new Map(), mockedSettingsManager, mockedLogger); expect(manager.getConnectedDevice('unknown-uuid')).toBeNull(); }); @@ -124,7 +133,7 @@ describe('deviceManager', () => { it('emits deviceDetected event for a newly seen device', () => { mockedEventEmitter.emit.mockReturnValue(true); - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); @@ -133,7 +142,7 @@ describe('deviceManager', () => { it('does not re-announce a device already in the acquire queue', () => { mockedEventEmitter.emit.mockReturnValue(true); - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); manager.announceDetectedDevice(deviceInfo); @@ -144,7 +153,7 @@ describe('deviceManager', () => { it('does not emit event when device is already connected', () => { const connectedDevices = new Map([[deviceId, mock()]]); - const manager = new DeviceManager(mockedEventEmitter, connectedDevices, mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, connectedDevices, mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); @@ -153,13 +162,28 @@ describe('deviceManager', () => { it('removes device from queue when no listeners respond to deviceDetected', async () => { mockedEventEmitter.emit.mockReturnValue(false); - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); const result = await manager.acquireDetectedDevice(deviceId); expect(result.successful).toBe(false); }); + + it('does not emit deviceDetected for a device belonging to a disabled known device', () => { + mockedEventEmitter.emit.mockReturnValue(true); + + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + + expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + }); }); describe('acquireDetectedDevice', () => { @@ -176,7 +200,7 @@ describe('deviceManager', () => { }); it('returns failure when device is not in the detect queue', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); const result = await manager.acquireDetectedDevice(deviceId); @@ -184,7 +208,7 @@ describe('deviceManager', () => { }); it('resolves immediately with success for the first caller', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); const result = await manager.acquireDetectedDevice(deviceId); @@ -193,7 +217,7 @@ describe('deviceManager', () => { }); it('queues the second caller until the first releases', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); await manager.acquireDetectedDevice(deviceId); @@ -219,13 +243,13 @@ describe('deviceManager', () => { }); it('is a no-op when device is not in the acquire queue', () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); expect(() => manager.releaseDetectedDevice(DeviceId.create('unknown'))).not.toThrow(); }); it('removes device from queue after the only waiter releases', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); await manager.acquireDetectedDevice(deviceId); @@ -250,7 +274,7 @@ describe('deviceManager', () => { }); it('resolves a pending second caller with failure', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); await manager.acquireDetectedDevice(deviceId); // first caller holds const pendingPromise = manager.acquireDetectedDevice(deviceId); // second waits @@ -276,7 +300,7 @@ describe('deviceManager', () => { }); it('resolves a pending caller with failure', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); await manager.acquireDetectedDevice(deviceId); // first caller holds const pendingPromise = manager.acquireDetectedDevice(deviceId); // second waits @@ -287,4 +311,222 @@ describe('deviceManager', () => { expect(result.successful).toBe(false); }); }); + + describe('isDeviceEnabled', () => { + let mockedLogger: ReturnType>; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + }); + + it('treats an unknown device as enabled', () => { + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(new Settings()); + + const manager = new DeviceManager(mock(), new Map(), settingsManager, mockedLogger); + + expect(manager.isDeviceEnabled(DeviceId.create('unknown'))).toBe(true); + }); + + it('treats a device as enabled when settings have not been loaded yet', () => { + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(undefined); + + const manager = new DeviceManager(mock(), new Map(), settingsManager, mockedLogger); + + expect(manager.isDeviceEnabled(DeviceId.create('unknown'))).toBe(true); + }); + + it('reflects a known device\'s enabled state', () => { + const deviceId = DeviceId.create('known-device'); + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const manager = new DeviceManager(mock(), new Map(), settingsManager, mockedLogger); + + expect(manager.isDeviceEnabled(deviceId)).toBe(false); + }); + }); + + describe('addDevice - disabled devices', () => { + let mockedLogger: ReturnType>; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + }); + + it('does not register a device belonging to a disabled known device and closes it', () => { + const deviceId = DeviceId.create('disabled-device'); + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const connectedDevices = new Map(); + const manager = new DeviceManager(mock(), connectedDevices, settingsManager, mockedLogger); + + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + + const added = manager.addDevice({ type: 'test', id: deviceId }, device); + + expect(added).toBe(false); + expect(manager.getConnectedDevices()).toHaveLength(0); + }); + + it('registers a device belonging to an enabled known device', () => { + const deviceId = DeviceId.create('enabled-device'); + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const manager = new DeviceManager(mock(), new Map(), settingsManager, mockedLogger); + + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + + const added = manager.addDevice({ type: 'test', id: deviceId }, device); + + expect(added).toBe(true); + expect(manager.getConnectedDevices()).toHaveLength(1); + }); + }); + + describe('onSettingsChanged', () => { + let mockedLogger: ReturnType>; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + }); + + it('closes connected devices whose known device has been disabled', async () => { + const deviceId = DeviceId.create('device-to-disable'); + const enabledSettings = new Settings(); + enabledSettings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(enabledSettings); + + const connectedDevices = new Map(); + const manager = new DeviceManager(mock(), connectedDevices, settingsManager, mockedLogger); + + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + manager.addDevice({ type: 'test', id: deviceId }, device); + expect(manager.getConnectedDevices()).toHaveLength(1); + + const disabledSettings = new Settings(); + disabledSettings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + settingsManager.getSettings.mockReturnValue(disabledSettings); + + await manager.onSettingsChanged(); + + expect(manager.getConnectedDevices()).toHaveLength(0); + }); + + it('leaves devices belonging to still-enabled known devices connected', async () => { + const deviceId = DeviceId.create('device-still-enabled'); + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const connectedDevices = new Map(); + const manager = new DeviceManager(mock(), connectedDevices, settingsManager, mockedLogger); + + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + manager.addDevice({ type: 'test', id: deviceId }, device); + + await manager.onSettingsChanged(); + + expect(manager.getConnectedDevices()).toHaveLength(1); + }); + + it('re-announces a device rejected by announceDetectedDevice once its known device gets re-enabled', async () => { + const deviceId = DeviceId.create('device-pending-1'); + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + + const disabledSettings = new Settings(); + disabledSettings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(disabledSettings); + + const mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + + const enabledSettings = new Settings(); + enabledSettings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + settingsManager.getSettings.mockReturnValue(enabledSettings); + + await manager.onSettingsChanged(); + + expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + }); + + it('re-announces a device rejected by addDevice() once its known device gets re-enabled', async () => { + const deviceId = DeviceId.create('device-pending-2'); + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); + + // Simulate a provider that connected a device via the detected-device pipeline whose + // final id (only known after connecting) turns out to belong to a disabled device. + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + const added = manager.addDevice(deviceInfo, device); + expect(added).toBe(false); + + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + + await manager.onSettingsChanged(); + + expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + }); + + it('does not re-announce a still-disabled pending device', async () => { + const deviceId = DeviceId.create('device-pending-3'); + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); + + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + manager.addDevice(deviceInfo, device); + + mockClear(mockedEventEmitter); + + await manager.onSettingsChanged(); + + expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/device/provider/deviceProviderManager.spec.ts b/tests/unit/device/provider/deviceProviderManager.spec.ts index c02efb7f..ce97838a 100644 --- a/tests/unit/device/provider/deviceProviderManager.spec.ts +++ b/tests/unit/device/provider/deviceProviderManager.spec.ts @@ -114,21 +114,6 @@ describe('DeviceProviderManager', () => { expect(provider.stopCalls).toBe(1); }); - it('notifies running providers about settings changes', async () => { - const provider = new RecordingDeviceProvider(); - let receivedSettings: Settings | undefined; - provider.onSettingsChanged = async (settings: Settings): Promise => { - receivedSettings = settings; - }; - - const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); - const settings = makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }]); - - await manager.reload(settings); - - expect(receivedSettings).toBe(settings); - }); - it('serializes overlapping reload() calls so a disable immediately followed by a re-enable ends up running', async () => { // Use two distinct provider instances so we can tell which one ends up "running" and // reproduce the manager's internal bookkeeping the same way distinct factory.create() From a0489e618d65f17f7af8b6b78b23403b1ac90c69 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 09:34:05 +0200 Subject: [PATCH 04/35] Clear pending retry on device disappearance, align virtual provider revokeDetectedDevice() now also drops the device from the pending-retry map, so a disabled device that physically disappears is not resurrected when its known device is later re-enabled. Buttplug's removeButtplugIoDevice now revokes not-yet-connected devices instead of just logging a warning. VirtualDeviceProvider's detection listener lifecycle now matches the serial provider (registered in init(), removed in stop()), so the stopped flag is only needed for the genuinely async device-creation window. --- src/device/deviceManager.ts | 3 +++ .../buttplugIoWebsocketDeviceProvider.ts | 18 +++++++------- .../protocol/virtual/virtualDeviceProvider.ts | 24 ++++++++----------- tests/unit/device/deviceManager.spec.ts | 23 ++++++++++++++++++ 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 0f2adcb8..3dbec951 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -110,6 +110,9 @@ export default class DeviceManager public revokeDetectedDevice(deviceInfo: DeviceInfo): void { + // A device that has physically disappeared should no longer be retried once its known + // device gets re-enabled, so drop any pending-retry entry alongside the acquire queue. + this.pendingDisabledDevices.delete(deviceInfo.id); this.clearDetectedDeviceAcquireQueue(deviceInfo.id, `Device with id '${deviceInfo.id}' has disappeared`); } diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index c003d134..b791753a 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -132,6 +132,12 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { }, 30000); } + private toDeviceInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceInfo { + const deviceId = this.buttplugIoDeviceFactory.computeDeviceId(buttplugDevice, this.useDeviceNameAsId); + + return { type: 'buttplugIo', id: deviceId, buttplugClientDevice: buttplugDevice }; + } + /** * Announces a device reported by the Buttplug.io server to the device manager, which runs * the enabled/disabled check centrally and takes care of retrying once a currently disabled @@ -140,11 +146,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { private announceButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { this.logger.info(`Device detected: ${buttplugDevice.name}`, buttplugDevice); - const deviceId = this.buttplugIoDeviceFactory.computeDeviceId(buttplugDevice, this.useDeviceNameAsId); - - const deviceInfo: ButtplugIoDeviceInfo = { type: 'buttplugIo', id: deviceId, buttplugClientDevice: buttplugDevice }; - - this.deviceManager.announceDetectedDevice(deviceInfo); + this.deviceManager.announceDetectedDevice(this.toDeviceInfo(buttplugDevice)); } private isButtplugIoDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is ButtplugIoDeviceInfo { @@ -193,9 +195,9 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { const device = this.connectedDevices.get(buttplugDevice.index); if (undefined === device) { - this.logger.warn( - `Could not find device to remove: ${buttplugDevice.name}@${buttplugDevice.index}` - ); + // Not locally connected - it may still be sitting in the device manager as a + // detected-but-disabled device awaiting retry, so revoke it there to avoid leaking it. + this.deviceManager.revokeDetectedDevice(this.toDeviceInfo(buttplugDevice)); return; } diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 988a3b17..04960d17 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -27,8 +27,13 @@ export default class VirtualDeviceProvider extends DeviceProvider private readonly settingsManager: SettingsManager; + private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; + private readonly settingsChangedListener: (settings: Settings) => void; + // Guards the async gap in handleDeviceDetection(): unlike physical providers, virtual device + // creation is asynchronous, so stop() can run while a device is still being built. Without + // this, such an in-flight device would be added to an already-stopped provider. private stopped: boolean = false; public constructor( @@ -42,12 +47,9 @@ export default class VirtualDeviceProvider extends DeviceProvider this.deviceFactory = deviceFactory; this.settingsManager = settingsManager; - this.deviceManager.on( - DeviceManagerEvent.deviceDetected, - asyncHandler( - this.handleDeviceDetection.bind(this), - (err: unknown) => logError(this.logger, 'Error in device detection handler', err) - ) + this.deviceDetectedListener = asyncHandler( + this.handleDeviceDetection.bind(this), + (err: unknown) => logError(this.logger, 'Error in device detection handler', err) ); this.settingsChangedListener = asyncHandler( @@ -59,6 +61,7 @@ export default class VirtualDeviceProvider extends DeviceProvider public override async init(): Promise { this.stopped = false; + this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); this.settingsManager.on(SettingsEventType.changed, this.settingsChangedListener); // Load whatever is already configured once, without waiting for the first settings @@ -69,6 +72,7 @@ export default class VirtualDeviceProvider extends DeviceProvider public override async stop(): Promise { this.stopped = true; + this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); this.settingsManager.off(SettingsEventType.changed, this.settingsChangedListener); for (const device of this.connectedDevices.values()) { @@ -85,10 +89,6 @@ export default class VirtualDeviceProvider extends DeviceProvider * `DeviceManager.onSettingsChanged()`. */ private async discoverVirtualDevices(): Promise { - if (this.stopped) { - return; - } - const settings = this.settingsManager.getSettings(); if (undefined === settings) { @@ -109,10 +109,6 @@ export default class VirtualDeviceProvider extends DeviceProvider // manager takes care of skipping disabled ones (and re-announcing them once re-enabled) // as well as ones already being connected. for (const [k, v] of virtualDevices) { - if (this.stopped) { - return; - } - if (this.connectedDevices.has(k)) { continue; } diff --git a/tests/unit/device/deviceManager.spec.ts b/tests/unit/device/deviceManager.spec.ts index db0c4071..62ad6c38 100644 --- a/tests/unit/device/deviceManager.spec.ts +++ b/tests/unit/device/deviceManager.spec.ts @@ -284,6 +284,29 @@ describe('deviceManager', () => { const result = await pendingPromise; expect(result.successful).toBe(false); }); + + it('drops a disabled device from pending retry so it is not re-announced after re-enabling', async () => { + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); + + // Announced while disabled -> parked in pending retry, no deviceDetected emitted. + manager.announceDetectedDevice(deviceInfo); + expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + + // Device physically disappears while still disabled. + manager.revokeDetectedDevice(deviceInfo); + + // Re-enabling it must NOT resurrect the gone device. + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + await manager.onSettingsChanged(); + + expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + }); }); describe('claimDetectedDevice', () => { From f18c5997aa76e440c0a86df779448ae852b9aded Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 12:13:01 +0200 Subject: [PATCH 05/35] Generalize device provider detection flow into a base class Extract the shared acquire -> create -> addDevice -> release flow that BLE, serial and buttplug.io all implemented near-identically into a new DetectedDeviceProvider base. Subclasses now only implement supportsDeviceInfo() and createDevice(), plus an optional onConnectFailed() hook, and get the connected-device bookkeeping and stop() for free. Buttplug.io keys its connected devices by their final DeviceId (via the factory's computeDeviceId) instead of the Intiface index, so it no longer needs a separate index map. Align ButtplugIoDevice.setAttribute with the single-type-parameter shape used by EStim2bDevice so the concrete device satisfies the base class' Device constraint. --- .../protocol/buttplugIo/buttplugIoDevice.ts | 7 +- .../buttplugIoWebsocketDeviceProvider.ts | 93 +++++------- src/device/provider/bleDeviceProvider.ts | 70 ++------- src/device/provider/detectedDeviceProvider.ts | 133 ++++++++++++++++++ src/device/provider/serialDeviceProvider.ts | 74 +--------- 5 files changed, 189 insertions(+), 188 deletions(-) create mode 100644 src/device/provider/detectedDeviceProvider.ts diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index a1b69bb5..177bfc81 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -18,6 +18,8 @@ export type ButtplugIoDeviceAttributes = Record< IntRangeDeviceAttribute|BoolDeviceAttribute|IntDeviceAttribute >; +type AttributeValue = AttributeValueOf; + @Exclude() export default class ButtplugIoDevice extends Device { @@ -53,9 +55,8 @@ export default class ButtplugIoDevice extends Device } public async setAttribute< - K extends AttributeKeyOf, - V extends AttributeValueOf - >(attributeName: K, value: V): Promise { + K extends AttributeKeyOf + >(attributeName: K, value: AttributeValue): Promise> { const attribute = this.attributes[attributeName]; if (undefined === attribute) { diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index b791753a..664d4632 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -1,13 +1,14 @@ import { ButtplugClientDevice, ButtplugClient, ButtplugNodeWebsocketClientConnector } from 'buttplug' import EventEmitter from 'events'; -import ButtplugIoDevice from './buttplugIoDevice.js'; -import { DeviceEvent } from '../../device.js'; -import DeviceProvider from '../../provider/deviceProvider.js'; +import ButtplugIoDevice, { ButtplugIoDeviceAttributes } from './buttplugIoDevice.js'; +import { NoDeviceConfig } from '../../deviceConfig.js'; +import { NoDeviceNotifications } from '../../device.js'; +import DetectedDeviceProvider from '../../provider/detectedDeviceProvider.js'; import ButtplugIoDeviceFactory from './buttplugIoDeviceFactory.js'; import Logger from '../../../logging/Logger.js'; import { asyncHandler, setImmediateInterval } from '../../../util/async.js'; import SlvCtrlPlusButtplugWebsocketClientConnector from './slvCtrlPlusButtplugWebsocketClientConnector.js'; -import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../../deviceManager.js'; +import DeviceManager, { DeviceInfo } from '../../deviceManager.js'; import { logError } from '../../../util/error.js'; import { hasProperty } from '../../../util/objects.js'; @@ -16,11 +17,15 @@ export type ButtplugIoDeviceInfo = DeviceInfo & { buttplugClientDevice: ButtplugClientDevice; }; -export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { +export default class ButtplugIoWebsocketDeviceProvider extends DetectedDeviceProvider< + ButtplugIoDeviceInfo, + ButtplugIoDeviceAttributes, + NoDeviceNotifications, + NoDeviceConfig, + ButtplugIoDevice +> { public static readonly providerName = 'buttplugIoWebsocket'; - private connectedDevices: Map = new Map(); - private buttplugConnector: ButtplugNodeWebsocketClientConnector; private buttplugClient: ButtplugClient; @@ -61,20 +66,23 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { this.removeButtplugIoDevice.bind(this), (e: unknown) => logError(this.logger, `Error in deviceremoved handler`, e) )); - - this.deviceManager.on( - DeviceManagerEvent.deviceDetected, - asyncHandler( - this.handleDeviceDetection.bind(this), - (err: unknown) => logError(this.logger, 'Error in device detection handler', err) - ) - ); } public override async init(): Promise { this.connectionIntervalRef ??= setImmediateInterval(() => void this.connectToServer(), 1000); } + public override async stop(): Promise { + clearInterval(this.connectionIntervalRef); + this.connectionIntervalRef = undefined; + + clearInterval(this.autoScanningIntervalRef); + this.autoScanningIntervalRef = undefined; + + // Closes and clears all devices this provider registered (see DetectedDeviceProvider). + await super.stop(); + } + private async connectToServer(): Promise { if (this.buttplugClient.connected) { return; @@ -102,7 +110,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { // As the whole websocket connection is lost there aren't any 'deviceremoved' events for the // connected Buttplug.io devices. They need to be removed manually instead. - for (const device of this.connectedDevices.values()) { + for (const device of this.getConnectedDevices()) { await this.removeButtplugIoDevice(device.getButtplugClientDevice); } @@ -141,7 +149,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { /** * Announces a device reported by the Buttplug.io server to the device manager, which runs * the enabled/disabled check centrally and takes care of retrying once a currently disabled - * device gets re-enabled - see `handleDeviceDetection()` below for the rest of the flow. + * device gets re-enabled - see `createDevice()` for the actual construction step. */ private announceButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { this.logger.info(`Device detected: ${buttplugDevice.name}`, buttplugDevice); @@ -149,50 +157,23 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { this.deviceManager.announceDetectedDevice(this.toDeviceInfo(buttplugDevice)); } - private isButtplugIoDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is ButtplugIoDeviceInfo { + protected override supportsDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is ButtplugIoDeviceInfo { return deviceInfo.type === 'buttplugIo'; } - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { - if (!this.isButtplugIoDeviceInfo(deviceInfo)) { - return; - } - - const buttplugDevice = deviceInfo.buttplugClientDevice; - - const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - - if (!acquireResult.successful) { - this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); - return; - } - - try { - const device = this.buttplugIoDeviceFactory.create(buttplugDevice, ButtplugIoWebsocketDeviceProvider.providerName, this.useDeviceNameAsId); - - // Keep local bookkeeping in sync regardless of what closes the device (e.g. the - // device manager closing it right away because it has been disabled in the meantime). - device.on(DeviceEvent.deviceDisconnected, () => this.connectedDevices.delete(buttplugDevice.index)); - - if (!this.deviceManager.addDevice(deviceInfo, device)) { - // The device turned out to belong to a disabled known device after all - - // addDevice() has already closed it, released it from the acquire queue, and - // registered it for retry once re-enabled. - return; - } - - this.connectedDevices.set(buttplugDevice.index, device); + protected override createDevice(deviceInfo: ButtplugIoDeviceInfo): Promise { + const device = this.buttplugIoDeviceFactory.create( + deviceInfo.buttplugClientDevice, + ButtplugIoWebsocketDeviceProvider.providerName, + this.useDeviceNameAsId + ); - this.logger.debug(`Assigned device id: ${device.getDeviceId} (${buttplugDevice.name}@${buttplugDevice.index})`); - this.logger.info(`Connected devices: ${this.connectedDevices.size}`); - } catch (e: unknown) { - logError(this.logger, `Could not connect to device '${buttplugDevice.name}'`, e); - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - } + return Promise.resolve(device); } private async removeButtplugIoDevice(buttplugDevice: ButtplugClientDevice): Promise { - const device = this.connectedDevices.get(buttplugDevice.index); + const deviceId = this.buttplugIoDeviceFactory.computeDeviceId(buttplugDevice, this.useDeviceNameAsId); + const device = this.getConnectedDevice(deviceId); if (undefined === device) { // Not locally connected - it may still be sitting in the device manager as a @@ -203,13 +184,9 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { try { await device.close(); - this.connectedDevices.delete(buttplugDevice.index); - this.logger.info(`Device removed: ${device.getDeviceId} (${buttplugDevice.name}@${buttplugDevice.index})`); } catch (e: unknown) { logError(this.logger, `Could not remove device '${device.getDeviceId}' (${buttplugDevice.name}@${buttplugDevice.index})`, e); } - - this.logger.info(`Connected devices: ${this.connectedDevices.size}`); } } diff --git a/src/device/provider/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts index 8e5bf60f..284e2862 100644 --- a/src/device/provider/bleDeviceProvider.ts +++ b/src/device/provider/bleDeviceProvider.ts @@ -1,84 +1,36 @@ import EventEmitter from 'events'; import { Peripheral } from '@stoprocent/noble'; -import DeviceProvider from './deviceProvider.js'; -import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager.js'; +import DetectedDeviceProvider from './detectedDeviceProvider.js'; +import DeviceManager, { DeviceInfo } from '../deviceManager.js'; import Logger from '../../logging/Logger.js'; -import { asyncHandler, promiseWithTimeout } from '../../util/async.js'; +import { promiseWithTimeout } from '../../util/async.js'; import { logError } from '../../util/error.js'; import { BleDeviceInfo } from '../transport/bleObserver.js'; import BleDevice, { InferBleDeviceAttributes, InferBleDeviceConfig } from '../bleDevice.js'; -import { DeviceAttributes, DeviceEvent, DeviceNotifications, InferDeviceNotifications } from '../device.js'; +import { DeviceAttributes, DeviceNotifications, InferDeviceNotifications } from '../device.js'; import { AnyDeviceConfig } from '../deviceConfig.js'; -import { DeviceId } from '../deviceId.js'; export default abstract class BleDeviceProvider< D extends BleDevice, TAttributes extends DeviceAttributes = InferBleDeviceAttributes, TNotifications extends DeviceNotifications = InferDeviceNotifications, TConfig extends AnyDeviceConfig = InferBleDeviceConfig -> extends DeviceProvider +> extends DetectedDeviceProvider { - private connectedDevices: Map = new Map(); - protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger); - - this.deviceManager.on( - DeviceManagerEvent.deviceDetected, - asyncHandler( - this.handleDeviceDetection.bind(this), - (err: unknown) => logError(this.logger, 'Error in device detection handler', err) - ) - ); } - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { - if (!this.isBleDeviceInfo(deviceInfo)) { - return; - } - - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); - - const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - - if (!acquireResult.successful) { - this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); - return; - } - - try { - const device = await this.connectBleDevice(deviceInfo); - - if (undefined === device) { - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - return; - } - - if (!this.deviceManager.addDevice(deviceInfo, device)) { - // The device's final id (assigned during connect/handshake) turned out to belong - // to a disabled known device - addDevice() has already closed it, released it - // from the acquire queue, and registered it for retry once re-enabled. - return; - } - - this.connectedDevices.set(device.getDeviceId, device); - device.on(DeviceEvent.deviceDisconnected, (d) => this.connectedDevices.delete(d.getDeviceId)); - } catch (e: unknown) { - logError(this.logger, 'Error while connecting to BLE device', e); - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - await this.disconnectPeripheral(deviceInfo.peripheral); - } + protected override supportsDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo { + return deviceInfo.type === 'ble'; } - public override async stop(): Promise { - for (const device of this.connectedDevices.values()) { - await device.close(); - } - this.connectedDevices.clear(); + protected override createDevice(deviceInfo: BleDeviceInfo): Promise { + return this.connectBleDevice(deviceInfo); } - private isBleDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo { - return deviceInfo.type === 'ble'; + protected override async onConnectFailed(deviceInfo: BleDeviceInfo): Promise { + await this.disconnectPeripheral(deviceInfo.peripheral); } private async disconnectPeripheral(peripheral: Peripheral): Promise { diff --git a/src/device/provider/detectedDeviceProvider.ts b/src/device/provider/detectedDeviceProvider.ts new file mode 100644 index 00000000..e5c17345 --- /dev/null +++ b/src/device/provider/detectedDeviceProvider.ts @@ -0,0 +1,133 @@ +import EventEmitter from 'events'; +import DeviceProvider from './deviceProvider.js'; +import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager.js'; +import Logger from '../../logging/Logger.js'; +import { asyncHandler } from '../../util/async.js'; +import { logError } from '../../util/error.js'; +import Device, { DeviceAttributes, DeviceEvent, DeviceNotifications } from '../device.js'; +import { AnyDeviceConfig } from '../deviceConfig.js'; +import { DeviceId } from '../deviceId.js'; + +/** + * Base class for providers that discover devices through the device manager's detection pipeline + * (`announceDetectedDevice` -> `deviceDetected` -> acquire -> add). It owns the common flow so + * subclasses only have to say which `DeviceInfo` they handle and how to turn it into a `Device`: + * + * 1. filter the `deviceDetected` event to the infos this provider handles (`supportsDeviceInfo`) + * 2. acquire the device from the manager (losing the race to another provider is fine) + * 3. create the actual device (`createDevice`) - the one genuinely provider-specific step + * 4. hand it to `DeviceManager.addDevice()`, which owns the enabled/disabled decision and all the + * acquire-queue bookkeeping (claim on success, release + retry on rejection) + * + * Connected devices are tracked locally (keyed by their final `getDeviceId`) so `stop()` can close + * exactly the devices this provider owns, and are removed again automatically on disconnect. + */ +export default abstract class DetectedDeviceProvider< + DI extends DeviceInfo, + TAttributes extends DeviceAttributes, + TNotifications extends DeviceNotifications, + TConfig extends AnyDeviceConfig, + D extends Device +> extends DeviceProvider +{ + private readonly connectedDevices: Map = new Map(); + + private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; + + protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { + super(deviceManager, eventEmitter, logger); + + this.deviceDetectedListener = asyncHandler( + this.handleDeviceDetection.bind(this), + (err: unknown) => logError(this.logger, 'Error in device detection handler', err) + ); + + this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + } + + public override async stop(): Promise { + this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + + for (const device of this.connectedDevices.values()) { + await device.close(); + } + this.connectedDevices.clear(); + } + + protected getConnectedDevices(): IterableIterator { + return this.connectedDevices.values(); + } + + protected getConnectedDevice(deviceId: DeviceId): D | undefined { + return this.connectedDevices.get(deviceId); + } + + private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { + if (!this.supportsDeviceInfo(deviceInfo)) { + return; + } + + this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); + + const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); + + if (!acquireResult.successful) { + this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); + return; + } + + let device: D | undefined; + + try { + device = await this.createDevice(deviceInfo); + } catch (e: unknown) { + logError(this.logger, `Error while connecting to device '${deviceInfo.id}'`, e); + this.deviceManager.releaseDetectedDevice(deviceInfo.id); + await this.onConnectFailed(deviceInfo); + return; + } + + if (undefined === device) { + this.deviceManager.releaseDetectedDevice(deviceInfo.id); + await this.onConnectFailed(deviceInfo); + return; + } + + // Keep local bookkeeping in sync regardless of what closes the device, e.g. the device + // manager closing it right away because it turned out to belong to a disabled device. + device.on(DeviceEvent.deviceDisconnected, (d) => this.connectedDevices.delete(d.getDeviceId)); + + if (!this.deviceManager.addDevice(deviceInfo, device)) { + // The device's final id (only known after connecting) belongs to a disabled known + // device - addDevice() has already closed it, released it from the acquire queue, and + // registered it for retry once re-enabled. + return; + } + + this.connectedDevices.set(device.getDeviceId, device); + + this.logger.info(`Connected devices: ${this.connectedDevices.size}`); + } + + /** + * Type guard selecting the `DeviceInfo`s this provider is responsible for. Detection events + * for infos of other providers are ignored. + */ + protected abstract supportsDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is DI; + + /** + * Turns a detected device info into an actual connected `Device`, or `undefined` if it could + * not be connected/identified (e.g. a failed handshake). Throwing is also allowed and treated + * the same as returning `undefined`, additionally invoking `onConnectFailed()`. + */ + protected abstract createDevice(deviceInfo: DI): Promise; + + /** + * Called after a failed or aborted connection attempt so subclasses can release any + * transport-level resources they hold (e.g. disconnecting a BLE peripheral). No-op by default. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async onConnectFailed(deviceInfo: DI): Promise { + return Promise.resolve(); + } +} diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 7cc6275d..c55defe1 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -1,4 +1,4 @@ -import DeviceProvider from './deviceProvider.js'; +import DetectedDeviceProvider from './detectedDeviceProvider.js'; import EventEmitter from 'events'; import Logger from '../../logging/Logger.js'; import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; @@ -7,14 +7,12 @@ import { SerialPortStream } from '@serialport/stream'; import SerialPortFactory from '../../factory/serialPortFactory.js'; import { AutoDetectTypes } from '@serialport/bindings-cpp'; import BaseError from 'modern-errors'; -import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager.js'; -import { asyncHandler } from '../../util/async.js'; +import DeviceManager, { DeviceInfo } from '../deviceManager.js'; import { logError } from '../../util/error.js'; import { SerialDeviceInfo } from '../transport/serialPortObserver.js'; import PeripheralDevice, { InferPeripheralDeviceAttributes, InferPeripheralDeviceConfig } from '../peripheralDevice.js'; -import { DeviceAttributes, DeviceEvent } from '../device.js'; +import { DeviceAttributes, InferDeviceNotifications } from '../device.js'; import { AnyDeviceConfig } from '../deviceConfig.js'; -import { DeviceId } from '../deviceId.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; @@ -22,14 +20,10 @@ export default abstract class SerialDeviceProvider< D extends PeripheralDevice, TAttributes extends DeviceAttributes = InferPeripheralDeviceAttributes, TConfig extends AnyDeviceConfig = InferPeripheralDeviceConfig -> extends DeviceProvider +> extends DetectedDeviceProvider, TConfig, D> { private readonly serialPortFactory: SerialPortFactory; - private connectedDevices: Map = new Map(); - - private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; - protected constructor( deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, @@ -39,60 +33,13 @@ export default abstract class SerialDeviceProvider< super(deviceManager, eventEmitter, logger); this.serialPortFactory = serialPortFactory; - - this.deviceDetectedListener = asyncHandler( - this.handleDeviceDetection.bind(this), - (err: unknown) => logError(this.logger, 'Error in device detection handler', err) - ); - - this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); - } - - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { - if (!this.isSerialDeviceInfo(deviceInfo)) { - return; - } - - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); - - const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - - if (false === acquireResult.successful) { - this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); - return; - } - - try { - const device = await this.connectToDevice(deviceInfo); - - if (undefined === device) { - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - return; - } - - if (!this.deviceManager.addDevice(deviceInfo, device)) { - // The device's final id (assigned during connect/handshake) turned out to belong - // to a disabled known device - addDevice() has already closed it, released it - // from the acquire queue, and registered it for retry once re-enabled. - return; - } - - this.connectedDevices.set(device.getDeviceId, device); - device.on(DeviceEvent.deviceDisconnected, (d) => this.connectedDevices.delete(d.getDeviceId)); - - this.logger.info(`Connected devices: ${this.connectedDevices.size}`); - } catch (e: unknown) { - logError(this.logger, `Error while connecting to device`, e); - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - } } - private isSerialDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is SerialDeviceInfo - { + protected override supportsDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is SerialDeviceInfo { return deviceInfo.type === 'serial'; } - private async connectToDevice(deviceInfo: SerialDeviceInfo): Promise { + protected override async createDevice(deviceInfo: SerialDeviceInfo): Promise { const portInfo = deviceInfo.portInfo; this.logger.info(`Connection attempt for serial device '${portInfo.path}' (s/n: ${portInfo.serialNumber})`); @@ -146,15 +93,6 @@ export default abstract class SerialDeviceProvider< return Promise.resolve(); } - public override async stop(): Promise { - this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); - - for (const device of this.connectedDevices.values()) { - await device.close(); - } - this.connectedDevices.clear(); - } - protected abstract connectSerialDevice(deviceInfo: DeviceInfo, port: SerialPortStream): Promise; protected abstract getSerialDeviceProviderPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; From 68da23765891efe9656262e41af72529de62f020 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 13:12:10 +0200 Subject: [PATCH 06/35] Introduce AnyDevice to erase setAttribute bivariance at device boundaries Add AnyDevice = Omit & wide setAttribute. Concrete devices are not assignable to Device<...> because their narrowing setAttribute override trips method parameter bivariance; erasing that one method and re-adding a wide, string-keyed version restores assignability while still requiring the full remaining Device surface (so non-devices are rejected). Use AnyDevice everywhere a concrete device type is irrelevant - the device manager, repository, updater and automation runtime - which lets addDevice drop its generics and collapses the device providers from five type parameters to a single D extends AnyDevice. The concrete devices keep their strict setAttribute for their own call sites. Removes the now-unused Infer* helper families. --- src/app.ts | 10 +++---- src/automation/scriptRuntime.ts | 6 ++-- src/device/bleDevice.ts | 6 ---- src/device/device.ts | 24 +++++++++------ src/device/deviceManager.ts | 30 ++++++++----------- src/device/genericDeviceUpdater.ts | 4 +-- src/device/peripheralDevice.ts | 6 ---- .../buttplugIoWebsocketDeviceProvider.ts | 7 +---- src/device/provider/bleDeviceProvider.ts | 11 ++----- src/device/provider/detectedDeviceProvider.ts | 8 ++--- src/device/provider/serialDeviceProvider.ts | 10 ++----- src/device/updater/abstractDeviceUpdater.ts | 4 +-- src/device/updater/bufferedDeviceUpdater.ts | 6 ++-- src/device/updater/deviceUpdaterInterface.ts | 4 +-- src/entity/deviceList.ts | 6 ++-- src/repository/connectedDeviceRepository.ts | 6 ++-- src/repository/deviceRepositoryInterface.ts | 6 ++-- src/serviceProvider/deviceServiceProvider.ts | 4 +-- tests/integration/deviceEvents.spec.ts | 16 +++++----- tests/integration/helpers/appHelper.ts | 12 ++++---- 20 files changed, 79 insertions(+), 107 deletions(-) diff --git a/src/app.ts b/src/app.ts index c1043cfb..84b31808 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,7 +14,7 @@ import { ClientToServerEvents, ServerToClientEvents, WebsocketServer } from './s import { SerializedDevice } from './device/serializedTypes.js'; import { SerializedSettings } from './settings/serializedTypes.js'; import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js'; -import Device from './device/device.js'; +import { AnyDevice } from './device/device.js'; import WebSocketEvent from './device/webSocketEvent.js'; import AutomationEventType from './automation/automationEventType.js'; import LoggerServiceProvider from './serviceProvider/loggerServiceProvider.js'; @@ -104,22 +104,22 @@ const configureWebsocket = (io: WebsocketServer, container: Container deviceUpdateHandler.handle(data)); }); - deviceManager.on(DeviceManagerEvent.deviceConnected, (device: Device) => { + deviceManager.on(DeviceManagerEvent.deviceConnected, (device: AnyDevice) => { io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceConnected, device, args: [] }); }); - deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: Device) => { + deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: AnyDevice) => { io.emit(WebSocketEvent.deviceDisconnected, serializer.transform(device, deviceDiscriminator)); void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceDisconnected, device, args: [] }); }); - deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: Device) => { + deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: AnyDevice) => { io.emit(WebSocketEvent.deviceRefreshed, serializer.transform(device, deviceDiscriminator)); void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceRefreshed, device, args: [] }); }); - deviceManager.on(DeviceManagerEvent.deviceNotification, (device: Device, notification) => { + deviceManager.on(DeviceManagerEvent.deviceNotification, (device: AnyDevice, notification) => { io.emit(WebSocketEvent.deviceNotification, serializer.transform(device, deviceDiscriminator), notification); void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceNotification, device, args: [notification] }); }); diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 5e4a0d7b..8f065c82 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -1,6 +1,6 @@ import ivm from 'isolated-vm'; import { transform } from 'sucrase'; -import Device, { DeviceNotification } from '../device/device.js'; +import { AnyDevice, DeviceNotification } from '../device/device.js'; import DeviceRepositoryInterface from '../repository/deviceRepositoryInterface.js'; import fs, { WriteStream } from 'fs'; import readLastLines from 'read-last-lines/dist/index.js'; @@ -11,8 +11,8 @@ import { AttributeValue } from '../device/attribute/deviceAttribute.js'; import Logger from '../logging/Logger.js'; export type SupportedDeviceEvent = - | { type: DeviceManagerEvent.deviceConnected | DeviceManagerEvent.deviceDisconnected | DeviceManagerEvent.deviceRefreshed; device: Device; args: [] } - | { type: DeviceManagerEvent.deviceNotification; device: Device; args: [notification: DeviceNotification] }; + | { type: DeviceManagerEvent.deviceConnected | DeviceManagerEvent.deviceDisconnected | DeviceManagerEvent.deviceRefreshed; device: AnyDevice; args: [] } + | { type: DeviceManagerEvent.deviceNotification; device: AnyDevice; args: [notification: DeviceNotification] }; type ScriptRuntimeEvents = { [AutomationEventType.consoleLog]: (data: string) => void, diff --git a/src/device/bleDevice.ts b/src/device/bleDevice.ts index 6420918a..0e4ab507 100644 --- a/src/device/bleDevice.ts +++ b/src/device/bleDevice.ts @@ -9,12 +9,6 @@ import { logError } from '../util/error.js'; import Logger from '../logging/Logger.js'; import { asyncHandler, promiseWithTimeout } from '../util/async.js'; -export type InferBleDeviceAttributes> = - D extends BleDevice ? TAttrs : DeviceAttributes; - -export type InferBleDeviceConfig> = - D extends BleDevice ? TCfg : AnyDeviceConfig; - export default abstract class BleDevice< TAttributes extends DeviceAttributes = DeviceAttributes, TNotifications extends DeviceNotifications = NoDeviceNotifications, diff --git a/src/device/device.ts b/src/device/device.ts index 14fef55e..d502df2d 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -7,15 +7,6 @@ import type { DeviceId } from './deviceId.js'; import type { JsonObject } from '../types.js'; import { DropFirst } from '../types.js'; -export type InferDeviceAttributes> = - D extends Device ? TAttrs : DeviceAttributes; - -export type InferDeviceNotifications> = - D extends Device ? TNotifs : AnyDeviceNotifications; - -export type InferDeviceConfig> = - D extends Device ? TCfg : AnyDeviceConfig; - // An attribute value can be DeviceAttribute or undefined because we want to allow Partial<> export type DeviceAttributes = Record; @@ -215,3 +206,18 @@ export default abstract class Device< return attr !== null && typeof attr === 'object' && 'name' in attr && Object.keys(this.attributes).includes(attr.name); } } + +/** + * A concrete-device-agnostic view of a `Device`, for the places that handle "some device" without + * caring about its attribute types (the device manager, repository, updater, automation runtime). + * + * A concrete `Device` is NOT assignable to `Device` (or even `Device`): its `setAttribute>` override narrows a parameter, + * which TypeScript rejects as a supertype (method parameter bivariance). Erasing that one method + * and re-adding a wide, string-keyed version restores assignability from every concrete device + * while still requiring the full remaining `Device` surface, so non-device types are still + * rejected. The concrete devices keep their strict `setAttribute` for their own call sites. + */ +export type AnyDevice = Omit & { + setAttribute(attributeName: string, value: unknown): Promise; +}; diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 3dbec951..cdc8c5ef 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -1,9 +1,8 @@ -import Device, { DeviceAttributes, DeviceEvent, DeviceNotification, DeviceNotifications } from './device.js'; +import { AnyDevice, DeviceEvent, DeviceNotification } from './device.js'; import EventEmitter from 'events'; import DeviceState from './deviceState.js'; import { setIntervalAsync } from '../util/async.js'; import Logger from '../logging/Logger.js'; -import { AnyDeviceConfig } from './deviceConfig.js'; import { logError } from '../util/error.js'; import { DeviceId } from './deviceId.js'; import SettingsManager from '../settings/settingsManager.js'; @@ -26,11 +25,11 @@ type AcquireResult = | { successful: false, reason: string }; type DeviceManagerEventMap = { - [DeviceManagerEvent.deviceConnected]: [device: Device]; - [DeviceManagerEvent.deviceDisconnected]: [device: Device]; - [DeviceManagerEvent.deviceRefreshed]: [device: Device]; + [DeviceManagerEvent.deviceConnected]: [device: AnyDevice]; + [DeviceManagerEvent.deviceDisconnected]: [device: AnyDevice]; + [DeviceManagerEvent.deviceRefreshed]: [device: AnyDevice]; [DeviceManagerEvent.deviceDetected]: [deviceInfo: DeviceInfo]; - [DeviceManagerEvent.deviceNotification]: [device: Device, notification: DeviceNotification]; + [DeviceManagerEvent.deviceNotification]: [device: AnyDevice, notification: DeviceNotification]; } export default class DeviceManager @@ -41,7 +40,7 @@ export default class DeviceManager private readonly detectedDeviceAcquireQueue: Map void }[]> = new Map(); - private readonly connectedDevices: Map>; + private readonly connectedDevices: Map; private readonly settingsManager: SettingsManager; @@ -55,7 +54,7 @@ export default class DeviceManager public constructor( eventEmitter: EventEmitter, - connectedDevices: Map, + connectedDevices: Map, settingsManager: SettingsManager, logger: Logger ) { @@ -164,10 +163,7 @@ export default class DeviceManager * `announceDetectedDevice()`), used to resolve that pipeline's bookkeeping: claiming it on * success, or releasing it and registering it for retry on rejection. */ - public addDevice( - deviceInfo: DeviceInfo, - device: Device - ): boolean + public addDevice(deviceInfo: DeviceInfo, device: AnyDevice): boolean { if (!this.isDeviceEnabled(device.getDeviceId)) { this.logger.info(`Not adding device '${device.getDeviceId}' since it is disabled`); @@ -238,12 +234,12 @@ export default class DeviceManager this.clearDetectedDeviceAcquireQueue(deviceId, `Device with id '${deviceId}' has been claimed by another provider`); } - public getConnectedDevices(): Device[] + public getConnectedDevices(): AnyDevice[] { return Array.from(this.connectedDevices.values()); } - public getConnectedDevice(deviceId: string): Device|null + public getConnectedDevice(deviceId: string): AnyDevice|null { const device = this.connectedDevices.get(deviceId); @@ -301,7 +297,7 @@ export default class DeviceManager this.detectedDeviceAcquireQueue.delete(deviceId); } - private initDeviceRefresher(device: Device): void { + private initDeviceRefresher(device: AnyDevice): void { this.logger.info(`Initializing refresher for device '${device.getDeviceName}' (id: ${device.getDeviceId})`); const deviceRefreshIntervalMs = device.getRefreshInterval; @@ -329,13 +325,13 @@ export default class DeviceManager device.on(DeviceEvent.deviceDisconnected, () => deviceRefreshInterval.clear()); } - private removeDevice(device: Device): void + private removeDevice(device: AnyDevice): void { this.connectedDevices.delete(device.getDeviceId); this.eventEmitter.emit(DeviceManagerEvent.deviceDisconnected, device); } - private refreshDevice(device: Device): void + private refreshDevice(device: AnyDevice): void { this.eventEmitter.emit(DeviceManagerEvent.deviceRefreshed, device); } diff --git a/src/device/genericDeviceUpdater.ts b/src/device/genericDeviceUpdater.ts index 700c4945..e8acec94 100644 --- a/src/device/genericDeviceUpdater.ts +++ b/src/device/genericDeviceUpdater.ts @@ -1,6 +1,6 @@ import AbstractDeviceUpdater from './updater/abstractDeviceUpdater.js'; import PlainToClassSerializer from '../serialization/plainToClassSerializer.js'; -import Device, { DeviceData } from './device.js'; +import { AnyDevice, DeviceData } from './device.js'; import Logger from '../logging/Logger.js'; import { getTypedKeys } from '../util/objects.js'; import { logError } from '../util/error.js'; @@ -17,7 +17,7 @@ export default class GenericDeviceUpdater extends AbstractDeviceUpdater this.logger = logger.child({ name: GenericDeviceUpdater.name }); } - public async update(device: Device, rawData: DeviceData): Promise { + public async update(device: AnyDevice, rawData: DeviceData): Promise { let hadFailure = false; // Queue update for later to not reject if device is busy diff --git a/src/device/peripheralDevice.ts b/src/device/peripheralDevice.ts index 6afd3e8b..2001e0db 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -5,12 +5,6 @@ import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import EventEmitter from 'events'; import { DeviceId } from './deviceId.js'; -export type InferPeripheralDeviceAttributes> = - D extends PeripheralDevice ? TAttrs : DeviceAttributes; - -export type InferPeripheralDeviceConfig> = - D extends PeripheralDevice ? TCfg : AnyDeviceConfig; - export default abstract class PeripheralDevice< TProtocol extends DeviceProtocol>, TAttributes extends DeviceAttributes = DeviceAttributes, diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 664d4632..0a7b031d 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -1,8 +1,6 @@ import { ButtplugClientDevice, ButtplugClient, ButtplugNodeWebsocketClientConnector } from 'buttplug' import EventEmitter from 'events'; -import ButtplugIoDevice, { ButtplugIoDeviceAttributes } from './buttplugIoDevice.js'; -import { NoDeviceConfig } from '../../deviceConfig.js'; -import { NoDeviceNotifications } from '../../device.js'; +import ButtplugIoDevice from './buttplugIoDevice.js'; import DetectedDeviceProvider from '../../provider/detectedDeviceProvider.js'; import ButtplugIoDeviceFactory from './buttplugIoDeviceFactory.js'; import Logger from '../../../logging/Logger.js'; @@ -19,9 +17,6 @@ export type ButtplugIoDeviceInfo = DeviceInfo & { export default class ButtplugIoWebsocketDeviceProvider extends DetectedDeviceProvider< ButtplugIoDeviceInfo, - ButtplugIoDeviceAttributes, - NoDeviceNotifications, - NoDeviceConfig, ButtplugIoDevice > { public static readonly providerName = 'buttplugIoWebsocket'; diff --git a/src/device/provider/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts index 284e2862..f94039c5 100644 --- a/src/device/provider/bleDeviceProvider.ts +++ b/src/device/provider/bleDeviceProvider.ts @@ -6,16 +6,11 @@ import Logger from '../../logging/Logger.js'; import { promiseWithTimeout } from '../../util/async.js'; import { logError } from '../../util/error.js'; import { BleDeviceInfo } from '../transport/bleObserver.js'; -import BleDevice, { InferBleDeviceAttributes, InferBleDeviceConfig } from '../bleDevice.js'; -import { DeviceAttributes, DeviceNotifications, InferDeviceNotifications } from '../device.js'; -import { AnyDeviceConfig } from '../deviceConfig.js'; +import { AnyDevice } from '../device.js'; export default abstract class BleDeviceProvider< - D extends BleDevice, - TAttributes extends DeviceAttributes = InferBleDeviceAttributes, - TNotifications extends DeviceNotifications = InferDeviceNotifications, - TConfig extends AnyDeviceConfig = InferBleDeviceConfig -> extends DetectedDeviceProvider + D extends AnyDevice = AnyDevice +> extends DetectedDeviceProvider { protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger); diff --git a/src/device/provider/detectedDeviceProvider.ts b/src/device/provider/detectedDeviceProvider.ts index e5c17345..63e104d0 100644 --- a/src/device/provider/detectedDeviceProvider.ts +++ b/src/device/provider/detectedDeviceProvider.ts @@ -4,8 +4,7 @@ import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager. import Logger from '../../logging/Logger.js'; import { asyncHandler } from '../../util/async.js'; import { logError } from '../../util/error.js'; -import Device, { DeviceAttributes, DeviceEvent, DeviceNotifications } from '../device.js'; -import { AnyDeviceConfig } from '../deviceConfig.js'; +import { AnyDevice, DeviceEvent } from '../device.js'; import { DeviceId } from '../deviceId.js'; /** @@ -24,10 +23,7 @@ import { DeviceId } from '../deviceId.js'; */ export default abstract class DetectedDeviceProvider< DI extends DeviceInfo, - TAttributes extends DeviceAttributes, - TNotifications extends DeviceNotifications, - TConfig extends AnyDeviceConfig, - D extends Device + D extends AnyDevice = AnyDevice > extends DeviceProvider { private readonly connectedDevices: Map = new Map(); diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index c55defe1..00718364 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -10,17 +10,13 @@ import BaseError from 'modern-errors'; import DeviceManager, { DeviceInfo } from '../deviceManager.js'; import { logError } from '../../util/error.js'; import { SerialDeviceInfo } from '../transport/serialPortObserver.js'; -import PeripheralDevice, { InferPeripheralDeviceAttributes, InferPeripheralDeviceConfig } from '../peripheralDevice.js'; -import { DeviceAttributes, InferDeviceNotifications } from '../device.js'; -import { AnyDeviceConfig } from '../deviceConfig.js'; +import { AnyDevice } from '../device.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; export default abstract class SerialDeviceProvider< - D extends PeripheralDevice, - TAttributes extends DeviceAttributes = InferPeripheralDeviceAttributes, - TConfig extends AnyDeviceConfig = InferPeripheralDeviceConfig -> extends DetectedDeviceProvider, TConfig, D> + D extends AnyDevice = AnyDevice +> extends DetectedDeviceProvider { private readonly serialPortFactory: SerialPortFactory; diff --git a/src/device/updater/abstractDeviceUpdater.ts b/src/device/updater/abstractDeviceUpdater.ts index 6c396499..a41bb352 100644 --- a/src/device/updater/abstractDeviceUpdater.ts +++ b/src/device/updater/abstractDeviceUpdater.ts @@ -1,6 +1,6 @@ import PlainToClassSerializer from '../../serialization/plainToClassSerializer.js'; import DeviceUpdaterInterface from './deviceUpdaterInterface.js'; -import Device, { DeviceData } from '../device.js'; +import { AnyDevice, DeviceData } from '../device.js'; export default abstract class AbstractDeviceUpdater implements DeviceUpdaterInterface { @@ -11,5 +11,5 @@ export default abstract class AbstractDeviceUpdater implements DeviceUpdaterInte this.serializer = serializer; } - public abstract update(device: Device, deviceData: DeviceData): Promise; + public abstract update(device: AnyDevice, deviceData: DeviceData): Promise; } diff --git a/src/device/updater/bufferedDeviceUpdater.ts b/src/device/updater/bufferedDeviceUpdater.ts index c38e8a71..1f21669c 100644 --- a/src/device/updater/bufferedDeviceUpdater.ts +++ b/src/device/updater/bufferedDeviceUpdater.ts @@ -1,4 +1,4 @@ -import Device, { DeviceData } from '../device.js'; +import { AnyDevice, DeviceData } from '../device.js'; import DeviceUpdaterInterface from './deviceUpdaterInterface.js'; import { SequentialTaskQueue } from 'sequential-task-queue'; @@ -13,14 +13,14 @@ export default class BufferedDeviceUpdater implements DeviceUpdaterInterface this.queue = new SequentialTaskQueue(); } - public async update(device: Device, deviceData: DeviceData): Promise { + public async update(device: AnyDevice, deviceData: DeviceData): Promise { await this.queue.push(BufferedDeviceUpdater.handleUpdate, { args: [this.decoratedDeviceUpdater, device, deviceData] }); } private static async handleUpdate( this: void, deviceUpdater: DeviceUpdaterInterface, - device: Device, + device: AnyDevice, deviceData: DeviceData ): Promise { await deviceUpdater.update(device, deviceData); diff --git a/src/device/updater/deviceUpdaterInterface.ts b/src/device/updater/deviceUpdaterInterface.ts index b0551f65..60f14596 100644 --- a/src/device/updater/deviceUpdaterInterface.ts +++ b/src/device/updater/deviceUpdaterInterface.ts @@ -1,6 +1,6 @@ -import Device, { DeviceData } from '../device.js'; +import { AnyDevice, DeviceData } from '../device.js'; export default interface DeviceUpdaterInterface { - update(device: Device, rawData: DeviceData): Promise; + update(device: AnyDevice, rawData: DeviceData): Promise; } diff --git a/src/entity/deviceList.ts b/src/entity/deviceList.ts index 19a3e236..34a345bc 100644 --- a/src/entity/deviceList.ts +++ b/src/entity/deviceList.ts @@ -1,11 +1,11 @@ import { Exclude, Type } from 'class-transformer'; -import Device from '../device/device.js'; +import Device, { AnyDevice } from '../device/device.js'; import List from './list.js'; import DeviceDiscriminator from '../serialization/discriminator/deviceDiscriminator.js'; @Exclude() -export default class DeviceList extends List +export default class DeviceList extends List { @Type(() => Device, DeviceDiscriminator.createClassTransformerTypeDiscriminator('type')) - protected readonly declare items: Device[]; + protected readonly declare items: AnyDevice[]; } diff --git a/src/repository/connectedDeviceRepository.ts b/src/repository/connectedDeviceRepository.ts index 5ad090fe..285d05e6 100644 --- a/src/repository/connectedDeviceRepository.ts +++ b/src/repository/connectedDeviceRepository.ts @@ -1,6 +1,6 @@ import DeviceRepositoryInterface from './deviceRepositoryInterface.js'; import DeviceManager from '../device/deviceManager.js'; -import Device from '../device/device.js'; +import { AnyDevice } from '../device/device.js'; import { DeviceId } from '../device/deviceId.js'; export default class ConnectedDeviceRepository implements DeviceRepositoryInterface @@ -11,12 +11,12 @@ export default class ConnectedDeviceRepository implements DeviceRepositoryInterf this.deviceManager = deviceManager; } - public getAll(): Device[] + public getAll(): AnyDevice[] { return this.deviceManager.getConnectedDevices(); } - public getById(uuid: DeviceId): Device | null { + public getById(uuid: DeviceId): AnyDevice | null { return this.deviceManager.getConnectedDevice(uuid); } } diff --git a/src/repository/deviceRepositoryInterface.ts b/src/repository/deviceRepositoryInterface.ts index 0e568db3..d0cc3aa0 100644 --- a/src/repository/deviceRepositoryInterface.ts +++ b/src/repository/deviceRepositoryInterface.ts @@ -1,8 +1,8 @@ -import Device from '../device/device.js'; +import { AnyDevice } from '../device/device.js'; export default interface DeviceRepositoryInterface { - getAll(): Device[]; + getAll(): AnyDevice[]; - getById(uuid: string): Device|null; + getById(uuid: string): AnyDevice|null; } diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index a84ce992..48fb6d69 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -7,7 +7,7 @@ import { starWarsNouns } from '../util/dictionary.js'; import BufferedDeviceUpdater from '../device/updater/bufferedDeviceUpdater.js'; import GenericDeviceUpdater from '../device/genericDeviceUpdater.js'; import SerialDeviceTransportFactory from '../device/transport/serialDeviceTransportFactory.js'; -import Device from '../device/device.js'; +import { AnyDevice } from '../device/device.js'; import DeviceProviderManager from '../device/provider/deviceProviderManager.js'; import SlvCtrlPlusSerialDeviceProvider from '../device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; import ButtplugIoWebsocketDeviceProvider from '../device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; @@ -76,7 +76,7 @@ export default class DeviceServiceProvider implements ServiceProvider { return new DeviceManager( container.get('factory.eventEmitter').create(), - new Map(), + new Map(), container.get('settings.manager'), container.get('logger.default') ); diff --git a/tests/integration/deviceEvents.spec.ts b/tests/integration/deviceEvents.spec.ts index 071c6803..f372f639 100644 --- a/tests/integration/deviceEvents.spec.ts +++ b/tests/integration/deviceEvents.spec.ts @@ -1,6 +1,6 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; -import Device from '../../src/device/device.js'; +import { AnyDevice } from '../../src/device/device.js'; import Settings from '../../src/settings/settings.js'; import KnownDevice from '../../src/settings/knownDevice.js'; import DeviceSource from '../../src/settings/deviceSource.js'; @@ -47,8 +47,8 @@ describe('Device events', () => { const deviceManager = app.container.get('device.manager'); const device = deviceManager.getConnectedDevices()[0]; - let observedValue: number | undefined; - let changedValue: number | undefined; + let observedValue: unknown; + let changedValue: unknown; await new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -106,7 +106,7 @@ describe('Device events', () => { const deviceDisconnected = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); - const listener = (device: Device) => { + const listener = (device: AnyDevice) => { if (device.getDeviceId === NEW_DEVICE_ID) { clearTimeout(timeout); deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); @@ -140,7 +140,7 @@ describe('Device events', () => { const deviceDisconnected = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); - const listener = (device: Device) => { + const listener = (device: AnyDevice) => { if (device.getDeviceId === TEST_DEVICE_ID) { clearTimeout(timeout); deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); @@ -163,7 +163,7 @@ describe('Device events', () => { const deviceReconnected = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to reconnect')), 1000); - const listener = (device: Device) => { + const listener = (device: AnyDevice) => { if (device.getDeviceId === TEST_DEVICE_ID) { clearTimeout(timeout); deviceManager.off(DeviceManagerEvent.deviceConnected, listener); @@ -195,7 +195,7 @@ describe('Device events', () => { const deviceDisconnected = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); - const listener = (device: Device) => { + const listener = (device: AnyDevice) => { if (device.getDeviceId === TEST_DEVICE_ID) { clearTimeout(timeout); deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); @@ -218,7 +218,7 @@ describe('Device events', () => { const deviceReconnected = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to reconnect')), 1000); - const listener = (device: Device) => { + const listener = (device: AnyDevice) => { if (device.getDeviceId === TEST_DEVICE_ID) { clearTimeout(timeout); deviceManager.off(DeviceManagerEvent.deviceConnected, listener); diff --git a/tests/integration/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts index 2e1123be..b333980d 100644 --- a/tests/integration/helpers/appHelper.ts +++ b/tests/integration/helpers/appHelper.ts @@ -3,7 +3,7 @@ import os from 'os'; import path from 'path'; import { io as ioClient } from 'socket.io-client'; import { createApp, AppInstance, createContainer, AppOptions } from '../../../src/app.js'; -import Device from '../../../src/device/device.js'; +import { AnyDevice } from '../../../src/device/device.js'; import { DeviceManagerEvent } from '../../../src/device/deviceManager.js'; import { ServerToClientEvents } from '../../../src/socket/types.js'; type WsEmitCall = { [E in keyof ServerToClientEvents]: [E, ...Parameters] }[keyof ServerToClientEvents]; @@ -145,9 +145,9 @@ export const resetTestApp = async (app: TestApp): Promise => { export function getConnectedDevice( container: Container, - predicate: (device: Device) => boolean, + predicate: (device: AnyDevice) => boolean, description: string, -): Device { +): AnyDevice { const device = container.get('device.manager').getConnectedDevices().find(predicate); if (undefined === device) { throw new Error(`No connected device found: ${description}`); @@ -155,17 +155,17 @@ export function getConnectedDevice( return device; } -export function waitForNDevicesConnected(container: Container, deviceCount: number, timeoutMs = 5000): Promise { +export function waitForNDevicesConnected(container: Container, deviceCount: number, timeoutMs = 5000): Promise { return new Promise((resolve, reject) => { const deviceManager = container.get('device.manager'); - const connected: Device[] = []; + const connected: AnyDevice[] = []; const timeout = setTimeout(() => { deviceManager.off(DeviceManagerEvent.deviceConnected, listener); reject(new Error(`Timed out waiting for ${deviceCount} device(s) to connect (>${timeoutMs}ms), got ${connected.length}`)); }, timeoutMs); - const listener = (device: Device): void => { + const listener = (device: AnyDevice): void => { connected.push(device); if (connected.length >= deviceCount) { clearTimeout(timeout); From cd3e0dbe30d2a74ce991e9d2be4cc50a01691c0e Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 13:17:50 +0200 Subject: [PATCH 07/35] Type AnyDevice.setAttribute with AttributeValue instead of unknown The value parameter and return of a device's setAttribute are always an AttributeValue, so use that in the erased AnyDevice signature rather than unknown. Concrete devices remain assignable and callers at the AnyDevice boundaries (automation runtime, generic updater) now get a real value type. --- src/device/device.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/device/device.ts b/src/device/device.ts index d502df2d..3d255b68 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -1,6 +1,6 @@ import { Exclude, Expose } from 'class-transformer'; import DeviceState from './deviceState.js'; -import DeviceAttribute from './attribute/deviceAttribute.js'; +import DeviceAttribute, { AttributeValue } from './attribute/deviceAttribute.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import { EventEmitter } from 'events'; import type { DeviceId } from './deviceId.js'; @@ -219,5 +219,5 @@ export default abstract class Device< * rejected. The concrete devices keep their strict `setAttribute` for their own call sites. */ export type AnyDevice = Omit & { - setAttribute(attributeName: string, value: unknown): Promise; + setAttribute(attributeName: string, value: AttributeValue): Promise; }; From d25ea810f184fd390dcb1903eaf8ec36f606c68e Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 13:33:02 +0200 Subject: [PATCH 08/35] Restore device-family type safety for BLE/serial providers The AnyDevice erasure made all device families structurally identical (their transport members are private/protected, so Omit strips their nominal identity), which let a BleDeviceProvider be parameterized with a non-BLE device. Expose a distinguishing public member per family - getPeripheral() on BleDevice, getTransport() on PeripheralDevice - and define AnyBleDevice / AnyPeripheralDevice erasing setAttribute like AnyDevice but retaining that member. Constrain the two providers on those, so the wrong device family is rejected again while keeping the single-generic, bivariance-free flow. --- src/device/bleDevice.ts | 14 ++++++++++++++ src/device/peripheralDevice.ts | 14 ++++++++++++++ src/device/provider/bleDeviceProvider.ts | 4 ++-- src/device/provider/serialDeviceProvider.ts | 4 ++-- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/device/bleDevice.ts b/src/device/bleDevice.ts index 0e4ab507..5f7d6374 100644 --- a/src/device/bleDevice.ts +++ b/src/device/bleDevice.ts @@ -2,6 +2,7 @@ import { Peripheral } from '@stoprocent/noble'; import BaseError from 'modern-errors'; import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications } from './device.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; +import { AttributeValue } from './attribute/deviceAttribute.js'; import { Expose } from 'class-transformer'; import { EventEmitter } from 'events'; import { DeviceId } from './deviceId.js'; @@ -9,6 +10,15 @@ import { logError } from '../util/error.js'; import Logger from '../logging/Logger.js'; import { asyncHandler, promiseWithTimeout } from '../util/async.js'; +/** + * Concrete-attribute-agnostic view of a BLE device, analogous to `AnyDevice` but retaining the + * BLE-specific surface (`getPeripheral`) so it stays distinguishable from other device families. + * See `AnyDevice` for why `setAttribute` has to be erased and widened. + */ +export type AnyBleDevice = Omit & { + setAttribute(attributeName: string, value: AttributeValue): Promise; +}; + export default abstract class BleDevice< TAttributes extends DeviceAttributes = DeviceAttributes, TNotifications extends DeviceNotifications = NoDeviceNotifications, @@ -72,6 +82,10 @@ export default abstract class BleDevice< this.peripheral.on('disconnect', this.reconnectHandler); } + public getPeripheral(): Peripheral { + return this.peripheral; + } + private async requestRssiUpdate(): Promise { if (this.closing || this.peripheral.state === 'disconnected') { return; diff --git a/src/device/peripheralDevice.ts b/src/device/peripheralDevice.ts index 2001e0db..72863690 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -2,9 +2,19 @@ import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications } import BidirectionalDeviceTransport from './transport/deviceBidirectionalTransport.js'; import DeviceProtocol, { MessageWithResponse } from './protocol/deviceProtocol.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; +import { AttributeValue } from './attribute/deviceAttribute.js'; import EventEmitter from 'events'; import { DeviceId } from './deviceId.js'; +/** + * Concrete-attribute-agnostic view of a serial/peripheral device, analogous to `AnyDevice` but + * retaining the peripheral-specific surface (`getTransport`) so it stays distinguishable from + * other device families. See `AnyDevice` for why `setAttribute` has to be erased and widened. + */ +export type AnyPeripheralDevice = Omit>>, 'setAttribute'> & { + setAttribute(attributeName: string, value: AttributeValue): Promise; +}; + export default abstract class PeripheralDevice< TProtocol extends DeviceProtocol>, TAttributes extends DeviceAttributes = DeviceAttributes, @@ -36,6 +46,10 @@ export default abstract class PeripheralDevice< this.transport.onClose(async () => await this.close()); } + public getTransport(): BidirectionalDeviceTransport { + return this.transport; + } + protected override async doClose(): Promise { if (this.transport.isOpen()) { await this.transport.close(); diff --git a/src/device/provider/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts index f94039c5..43a78837 100644 --- a/src/device/provider/bleDeviceProvider.ts +++ b/src/device/provider/bleDeviceProvider.ts @@ -6,10 +6,10 @@ import Logger from '../../logging/Logger.js'; import { promiseWithTimeout } from '../../util/async.js'; import { logError } from '../../util/error.js'; import { BleDeviceInfo } from '../transport/bleObserver.js'; -import { AnyDevice } from '../device.js'; +import { AnyBleDevice } from '../bleDevice.js'; export default abstract class BleDeviceProvider< - D extends AnyDevice = AnyDevice + D extends AnyBleDevice = AnyBleDevice > extends DetectedDeviceProvider { protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 00718364..a7799d23 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -10,12 +10,12 @@ import BaseError from 'modern-errors'; import DeviceManager, { DeviceInfo } from '../deviceManager.js'; import { logError } from '../../util/error.js'; import { SerialDeviceInfo } from '../transport/serialPortObserver.js'; -import { AnyDevice } from '../device.js'; +import { AnyPeripheralDevice } from '../peripheralDevice.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; export default abstract class SerialDeviceProvider< - D extends AnyDevice = AnyDevice + D extends AnyPeripheralDevice = AnyPeripheralDevice > extends DetectedDeviceProvider { private readonly serialPortFactory: SerialPortFactory; From bd140d615721b09a638d9dca76fb907f2ac12f60 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 13:44:30 +0200 Subject: [PATCH 09/35] Tighten test attribute-value typings from unknown to AttributeValue Now that AnyDevice.getAttribute()/setAttribute expose AttributeValue, the device-value locals in the deviceEvents integration test and the StubDevice setAttribute recorder can use AttributeValue instead of unknown. --- tests/integration/deviceEvents.spec.ts | 5 +++-- tests/unit/automation/scriptRuntime.spec.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/integration/deviceEvents.spec.ts b/tests/integration/deviceEvents.spec.ts index f372f639..4d8af9f3 100644 --- a/tests/integration/deviceEvents.spec.ts +++ b/tests/integration/deviceEvents.spec.ts @@ -1,6 +1,7 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; import { AnyDevice } from '../../src/device/device.js'; +import { AttributeValue } from '../../src/device/attribute/deviceAttribute.js'; import Settings from '../../src/settings/settings.js'; import KnownDevice from '../../src/settings/knownDevice.js'; import DeviceSource from '../../src/settings/deviceSource.js'; @@ -47,8 +48,8 @@ describe('Device events', () => { const deviceManager = app.container.get('device.manager'); const device = deviceManager.getConnectedDevices()[0]; - let observedValue: unknown; - let changedValue: unknown; + let observedValue: AttributeValue; + let changedValue: AttributeValue; await new Promise((resolve, reject) => { const timeout = setTimeout(() => { diff --git a/tests/unit/automation/scriptRuntime.spec.ts b/tests/unit/automation/scriptRuntime.spec.ts index 43045fe2..61383daa 100644 --- a/tests/unit/automation/scriptRuntime.spec.ts +++ b/tests/unit/automation/scriptRuntime.spec.ts @@ -6,7 +6,7 @@ import ScriptRuntime, { SupportedDeviceEvent } from '../../../src/automation/scr import AutomationEventType from '../../../src/automation/automationEventType.js'; import { DeviceManagerEvent } from '../../../src/device/deviceManager.js'; import Device, { AttributeKeyOf, AttributeValueOf, DeviceAttributes } from '../../../src/device/device.js'; -import { DeviceAttributeModifier } from '../../../src/device/attribute/deviceAttribute.js'; +import { AttributeValue, DeviceAttributeModifier } from '../../../src/device/attribute/deviceAttribute.js'; import DeviceRepositoryInterface from '../../../src/repository/deviceRepositoryInterface.js'; import StrDeviceAttribute from '../../../src/device/attribute/strDeviceAttribute.js'; import Logger from '../../../src/logging/Logger.js'; @@ -17,7 +17,7 @@ import { DeviceId } from '../../../src/device/deviceId.js'; // --------------------------------------------------------------------------- class StubDevice extends Device { - public readonly setAttributeCalls: Array<[string, unknown]> = []; + public readonly setAttributeCalls: Array<[string, AttributeValue]> = []; public constructor(id: DeviceId, name: string) { super( From c267f46d09b4b9bb6e3638c45d8aa75e4f0f71d5 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 17:31:12 +0200 Subject: [PATCH 10/35] Address code review findings on device lifecycle and settings - deviceManager: gate a pending-retry on the device's canonical id (known only after connecting) instead of the preliminary detection id, so a disabled device isn't retried on every unrelated settings change. - buttplug provider: stop() now removes the client's listeners and disconnects, so an in-flight disconnect can't reinitialize the provider after shutdown. - DetectedDeviceProvider: guard against a detection that completes after stop(), and run transport cleanup (onConnectFailed) before releasing the acquire claim so the next provider can't overlap the previous transport. - deviceProviderManager: only mutate the provider map after a lifecycle operation succeeds - keep failed-to-stop providers, drop half-started ones - without aborting the rest of the reload. - KnownDevice/DeviceSource: serialize the normalized 'enabled' getter (toPlainOnly) while still reading the raw field (toClassOnly), so the serialized payload always carries a real boolean for legacy entries. - Tests: exercise distinct detected/canonical ids and reuse the same manager to prove stopProviders() clears its state. --- src/device/deviceManager.ts | 31 ++++++++++------ .../buttplugIoWebsocketDeviceProvider.ts | 24 +++++++++++-- src/device/provider/detectedDeviceProvider.ts | 35 ++++++++++++++++--- src/device/provider/deviceProviderManager.ts | 24 +++++++++---- src/settings/deviceSource.ts | 6 +++- src/settings/knownDevice.ts | 6 +++- tests/unit/device/deviceManager.spec.ts | 23 ++++++++---- .../provider/deviceProviderManager.spec.ts | 15 ++++---- 8 files changed, 124 insertions(+), 40 deletions(-) diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index cdc8c5ef..64bea6e0 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -49,8 +49,13 @@ export default class DeviceManager * whose registration was rejected by `addDevice()` after connecting, for protocols where the * final device id can only be determined post-handshake). Re-announced once their known * device gets (re-)enabled, see `onSettingsChanged()`. + * + * `enablementId` is the id whose enablement gates the retry: it is the device's final, + * canonical id (which may differ from the preliminary `deviceInfo.id` for protocols that only + * learn their real id during a handshake), so a retry only happens once *that* device is + * enabled - not on every unrelated settings change. `deviceInfo` is what gets re-announced. */ - private readonly pendingDisabledDevices: Map = new Map(); + private readonly pendingDisabledDevices: Map = new Map(); public constructor( eventEmitter: EventEmitter, @@ -90,7 +95,9 @@ export default class DeviceManager if (!this.isDeviceEnabled(deviceInfo.id)) { this.logger.debug(`Device with id '${deviceInfo.id}' is disabled, not announcing it as detected`); - this.pendingDisabledDevices.set(deviceInfo.id, deviceInfo); + // At announcement time no connection has happened yet, so the preliminary id is the + // only id we have; it also doubles as the enablement id here. + this.registerPendingRetry(deviceInfo, deviceInfo.id); return; } @@ -111,6 +118,7 @@ export default class DeviceManager { // A device that has physically disappeared should no longer be retried once its known // device gets re-enabled, so drop any pending-retry entry alongside the acquire queue. + // The pending map is keyed by the preliminary detection id (deviceInfo.id). this.pendingDisabledDevices.delete(deviceInfo.id); this.clearDetectedDeviceAcquireQueue(deviceInfo.id, `Device with id '${deviceInfo.id}' has disappeared`); } @@ -169,7 +177,9 @@ export default class DeviceManager this.logger.info(`Not adding device '${device.getDeviceId}' since it is disabled`); void device.close().catch((e: unknown) => logError(this.logger, `Failed to close disabled device '${device.getDeviceId}'`, e)); - this.registerPendingRetry(deviceInfo); + // The final, canonical id (device.getDeviceId) is the one that was found disabled and + // must therefore gate the retry - not the preliminary detection id. + this.registerPendingRetry(deviceInfo, device.getDeviceId); this.releaseDetectedDevice(deviceInfo.id); return false; @@ -191,11 +201,12 @@ export default class DeviceManager } /** - * Registers a device for retry once its known device gets (re-)enabled. Used internally by - * `addDevice()` when it rejects a device whose known device turned out to be disabled. + * Registers a device for retry once the known device identified by `enablementId` gets + * (re-)enabled. Keyed by the preliminary detection id so `revokeDetectedDevice()` (which only + * has that id) can still drop it when the device disappears. */ - private registerPendingRetry(deviceInfo: DeviceInfo): void { - this.pendingDisabledDevices.set(deviceInfo.id, deviceInfo); + private registerPendingRetry(deviceInfo: DeviceInfo, enablementId: DeviceId): void { + this.pendingDisabledDevices.set(deviceInfo.id, { deviceInfo, enablementId }); } /** @@ -219,12 +230,12 @@ export default class DeviceManager } } - for (const [deviceId, deviceInfo] of this.pendingDisabledDevices) { - if (!this.isDeviceEnabled(deviceId)) { + for (const [detectionId, { deviceInfo, enablementId }] of this.pendingDisabledDevices) { + if (!this.isDeviceEnabled(enablementId)) { continue; } - this.pendingDisabledDevices.delete(deviceId); + this.pendingDisabledDevices.delete(detectionId); this.announceDetectedDevice(deviceInfo); } } diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 0a7b031d..906e6625 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -68,18 +68,30 @@ export default class ButtplugIoWebsocketDeviceProvider extends DetectedDevicePro } public override async stop(): Promise { + // Marks the provider stopped (isStopped()) and closes/clears the registered devices. + await super.stop(); + clearInterval(this.connectionIntervalRef); this.connectionIntervalRef = undefined; clearInterval(this.autoScanningIntervalRef); this.autoScanningIntervalRef = undefined; - // Closes and clears all devices this provider registered (see DetectedDeviceProvider). - await super.stop(); + // Drop the buttplug client's own listeners before disconnecting so the resulting + // 'disconnect' event can't run handleLostConnection() and bring the provider back up. + this.buttplugClient.removeAllListeners(); + + if (this.buttplugClient.connected) { + try { + await this.buttplugClient.disconnect(); + } catch (e: unknown) { + logError(this.logger, 'Could not disconnect from buttplug.io server', e); + } + } } private async connectToServer(): Promise { - if (this.buttplugClient.connected) { + if (this.isStopped() || this.buttplugClient.connected) { return; } @@ -112,6 +124,12 @@ export default class ButtplugIoWebsocketDeviceProvider extends DetectedDevicePro clearInterval(this.autoScanningIntervalRef); this.autoScanningIntervalRef = undefined; + // Don't reconnect if we're shutting down - stop() removes the listeners, but a disconnect + // may already be in flight when it runs. + if (this.isStopped()) { + return; + } + await this.init(); } diff --git a/src/device/provider/detectedDeviceProvider.ts b/src/device/provider/detectedDeviceProvider.ts index 63e104d0..bac190a2 100644 --- a/src/device/provider/detectedDeviceProvider.ts +++ b/src/device/provider/detectedDeviceProvider.ts @@ -30,6 +30,16 @@ export default abstract class DetectedDeviceProvider< private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; + // Removing the `deviceDetected` listener only blocks *new* detections; a `handleDeviceDetection` + // call already awaiting acquisition/creation can still complete after `stop()`. This flag lets + // that in-flight handler bail out and clean up instead of registering a device on a stopped + // provider. Subclasses may read it (via `isStopped()`) to guard their own async work. + private stopped: boolean = false; + + protected isStopped(): boolean { + return this.stopped; + } + protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger); @@ -42,6 +52,8 @@ export default abstract class DetectedDeviceProvider< } public override async stop(): Promise { + this.stopped = true; + this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); for (const device of this.connectedDevices.values()) { @@ -78,14 +90,17 @@ export default abstract class DetectedDeviceProvider< device = await this.createDevice(deviceInfo); } catch (e: unknown) { logError(this.logger, `Error while connecting to device '${deviceInfo.id}'`, e); - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - await this.onConnectFailed(deviceInfo); + await this.abortDetection(deviceInfo); return; } - if (undefined === device) { - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - await this.onConnectFailed(deviceInfo); + // The provider may have been stopped while `createDevice` was in flight. Don't register a + // device on a stopped provider - close it and release the claim instead. + if (undefined === device || this.stopped) { + if (undefined !== device) { + await device.close(); + } + await this.abortDetection(deviceInfo); return; } @@ -105,6 +120,16 @@ export default abstract class DetectedDeviceProvider< this.logger.info(`Connected devices: ${this.connectedDevices.size}`); } + /** + * Cleans up after a failed/aborted connection attempt. Transport-level cleanup runs *before* + * the acquire claim is released, so the next provider in the queue cannot begin a new attempt + * against a transport this provider is still tearing down. + */ + private async abortDetection(deviceInfo: DI): Promise { + await this.onConnectFailed(deviceInfo); + this.deviceManager.releaseDetectedDevice(deviceInfo.id); + } + /** * Type guard selecting the `DeviceInfo`s this provider is responsible for. Detection events * for infos of other providers are ignored. diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index 3022278e..0c12b0da 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -73,11 +73,13 @@ export default class DeviceProviderManager try { await provider.stop(); + // Only forget the provider once it actually stopped. A provider that failed to + // stop may still be running, so keeping it recorded prevents a duplicate from + // being started for the same source on a later reload. + this.providers.delete(id); } catch (error: unknown) { logError(this.logger, `Failed to stop device provider for device source '${id}'`, error); } - - this.providers.delete(id); } for (const [id, deviceSource] of configuredDeviceSources) { @@ -94,12 +96,19 @@ export default class DeviceProviderManager const provider = factory.create(deviceSource.config); - this.providers.set(id, provider); - try { await provider.init(); + // Only record the provider once it initialized successfully, so a failed start + // doesn't leave a stuck entry that blocks all future retries for this source. + this.providers.set(id, provider); } catch (error: unknown) { logError(this.logger, `Failed to start device provider for device source '${id}'`, error); + + try { + await provider.stop(); + } catch (cleanupError: unknown) { + logError(this.logger, `Failed to clean up half-started device provider for device source '${id}'`, cleanupError); + } } } } @@ -107,17 +116,18 @@ export default class DeviceProviderManager private async doStopProviders(): Promise { const errors: unknown[] = []; - for (const [, provider] of this.providers) { + for (const [id, provider] of this.providers) { try { await provider.stop(); + // Remove only providers that actually stopped; a failed stop stays recorded so it + // isn't mistaken for a free slot on a later reload. + this.providers.delete(id); } catch (error: unknown) { errors.push(error); this.logger.error('Failed to stop device provider', error); } } - this.providers.clear(); - if (errors.length > 0) { throw new Error(`Failed to stop ${errors.length} device provider(s)`); } diff --git a/src/settings/deviceSource.ts b/src/settings/deviceSource.ts index ac5fd32b..cdf925bd 100644 --- a/src/settings/deviceSource.ts +++ b/src/settings/deviceSource.ts @@ -13,7 +13,10 @@ export default class DeviceSource @Expose({ name: 'config' }) private readonly _config: JsonObject; - @Expose({ name: 'enabled' }) + // Read the persisted value on the way in only. On the way out we expose the normalized getter + // below instead, so the serialized payload always carries a real boolean (never undefined for + // legacy entries that predate this field). + @Expose({ name: 'enabled', toClassOnly: true }) private readonly _enabled: boolean; public constructor(id: string, type: string, config: JsonObject, enabled: boolean = true) { @@ -35,6 +38,7 @@ export default class DeviceSource return this._config; } + @Expose({ name: 'enabled', toPlainOnly: true }) public get enabled(): boolean { // class-transformer bypasses the constructor when deserializing from plain JSON, so a // missing 'enabled' property in the settings file results in `_enabled` being `undefined` diff --git a/src/settings/knownDevice.ts b/src/settings/knownDevice.ts index 4f3ae7cf..e3a7161e 100644 --- a/src/settings/knownDevice.ts +++ b/src/settings/knownDevice.ts @@ -20,7 +20,10 @@ export default class KnownDevice @Expose({ name: 'config' }) private readonly _config: JsonObject; - @Expose({ name: 'enabled' }) + // Read the persisted value on the way in only. On the way out we expose the normalized getter + // below instead, so the serialized payload always carries a real boolean (never undefined for + // legacy entries that predate this field). + @Expose({ name: 'enabled', toClassOnly: true }) private readonly _enabled: boolean; public constructor( @@ -54,6 +57,7 @@ export default class KnownDevice return this._config; } + @Expose({ name: 'enabled', toPlainOnly: true }) public get enabled(): boolean { // class-transformer bypasses the constructor when deserializing from plain JSON, so a // missing 'enabled' property in the settings file results in `_enabled` being `undefined` diff --git a/tests/unit/device/deviceManager.spec.ts b/tests/unit/device/deviceManager.spec.ts index 62ad6c38..596669a3 100644 --- a/tests/unit/device/deviceManager.spec.ts +++ b/tests/unit/device/deviceManager.spec.ts @@ -499,12 +499,16 @@ describe('deviceManager', () => { expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); }); - it('re-announces a device rejected by addDevice() once its known device gets re-enabled', async () => { - const deviceId = DeviceId.create('device-pending-2'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + it('re-announces a device rejected by addDevice() only once its canonical known device gets re-enabled', async () => { + // The device is detected under a preliminary id, but its final/canonical id (only + // known after connecting, e.g. a serial number read during a handshake) is different. + const detectionId = DeviceId.create('device-pending-2-detected'); + const canonicalId = DeviceId.create('device-pending-2-canonical'); + const deviceInfo: DeviceInfo = { type: 'test', id: detectionId }; const settings = new Settings(); - settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + // Only the canonical device is a known, disabled device. + settings.addKnownDevice(new KnownDevice(canonicalId, 'Foo', 'test', 'test', {}, false)); const settingsManager = mock(); settingsManager.getSettings.mockReturnValue(settings); @@ -515,13 +519,18 @@ describe('deviceManager', () => { const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); // Simulate a provider that connected a device via the detected-device pipeline whose - // final id (only known after connecting) turns out to belong to a disabled device. - const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + // final id turns out to belong to a disabled device. + const device = new TestDevice(canonicalId, 'Foo', new Date(), false, new EventEmitter()); const added = manager.addDevice(deviceInfo, device); expect(added).toBe(false); - settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + // An unrelated settings change while the canonical device is still disabled must NOT + // retry it (it would if the retry were gated by the still-unknown detection id). + await manager.onSettingsChanged(); + expect(mockedEventEmitter.emit).not.toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + // Enabling the canonical device does re-announce, under the original detection info. + settings.addKnownDevice(new KnownDevice(canonicalId, 'Foo', 'test', 'test', {}, true)); await manager.onSettingsChanged(); expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); diff --git a/tests/unit/device/provider/deviceProviderManager.spec.ts b/tests/unit/device/provider/deviceProviderManager.spec.ts index ce97838a..803df42e 100644 --- a/tests/unit/device/provider/deviceProviderManager.spec.ts +++ b/tests/unit/device/provider/deviceProviderManager.spec.ts @@ -153,18 +153,21 @@ describe('DeviceProviderManager', () => { it('stopProviders stops all running providers and clears internal state', async () => { const provider = new RecordingDeviceProvider(); - const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); + const provider2 = new RecordingDeviceProvider(); + let creationCount = 0; + const factories = new Map>([ + ['virtual', { create: (): RecordingDeviceProvider => (creationCount++ === 0 ? provider : provider2) }], + ]); + const manager = new DeviceProviderManager(factories, makeLogger()); await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); await manager.stopProviders(); expect(provider.stopped).toBe(true); - // After stopProviders(), a subsequent reload() with the same enabled source must create - // a fresh provider rather than assuming one is already running. - const provider2 = new RecordingDeviceProvider(); - const manager2 = new DeviceProviderManager(makeFactoryMap({ virtual: provider2 }), makeLogger()); - await manager2.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + // After stopProviders(), a subsequent reload() on the SAME manager with the same enabled + // source must create a fresh provider - proving stopProviders() cleared its internal state. + await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); expect(provider2.initCalls).toBe(1); }); }); From 0f9d1d3501f8ccd11c19df73b6f04976f854b3d6 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 19:23:59 +0200 Subject: [PATCH 11/35] Merge DetectedDeviceProvider into DeviceProvider, route virtual through it Now that VirtualDeviceProvider also discovers via announceDetectedDevice(), every provider goes through the detection pipeline, leaving DeviceProvider with a single subclass. Fold the two base classes into one: - VirtualDeviceProvider extends the common base and drops its duplicated detection flow (listener, handleDeviceDetection, connected-device map, stopped guard) - keeping only its settings-driven discovery. - DetectedDeviceProvider is removed; DeviceProvider now owns the pipeline. - No generic defaults anywhere in the provider hierarchy, so every subclass must deliberately declare the DeviceInfo/Device it supports. - Add AnyDeviceProvider = DeviceProvider for the type-agnostic boundaries (manager + factories). - Delete the unused TestDeviceProvider test double. --- .../buttplugIoWebsocketDeviceProvider.ts | 4 +- .../protocol/virtual/virtualDeviceProvider.ts | 125 +++----------- src/device/provider/bleDeviceProvider.ts | 6 +- src/device/provider/detectedDeviceProvider.ts | 154 ------------------ src/device/provider/deviceProvider.ts | 154 +++++++++++++++++- src/device/provider/deviceProviderFactory.ts | 4 +- src/device/provider/deviceProviderManager.ts | 4 +- .../provider/genericDeviceProviderFactory.ts | 4 +- src/device/provider/serialDeviceProvider.ts | 6 +- .../provider/deviceProviderManager.spec.ts | 20 ++- tests/unit/device/testDeviceProvider.ts | 18 -- 11 files changed, 204 insertions(+), 295 deletions(-) delete mode 100644 src/device/provider/detectedDeviceProvider.ts delete mode 100644 tests/unit/device/testDeviceProvider.ts diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 906e6625..00c25059 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -1,7 +1,7 @@ import { ButtplugClientDevice, ButtplugClient, ButtplugNodeWebsocketClientConnector } from 'buttplug' import EventEmitter from 'events'; import ButtplugIoDevice from './buttplugIoDevice.js'; -import DetectedDeviceProvider from '../../provider/detectedDeviceProvider.js'; +import DeviceProvider from '../../provider/deviceProvider.js'; import ButtplugIoDeviceFactory from './buttplugIoDeviceFactory.js'; import Logger from '../../../logging/Logger.js'; import { asyncHandler, setImmediateInterval } from '../../../util/async.js'; @@ -15,7 +15,7 @@ export type ButtplugIoDeviceInfo = DeviceInfo & { buttplugClientDevice: ButtplugClientDevice; }; -export default class ButtplugIoWebsocketDeviceProvider extends DetectedDeviceProvider< +export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< ButtplugIoDeviceInfo, ButtplugIoDevice > { diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 04960d17..aab92260 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -6,9 +6,9 @@ import KnownDevice from '../../../settings/knownDevice.js'; import SettingsManager from '../../../settings/settingsManager.js'; import SettingsEventType from '../../../settings/settingsEventType.js'; import type Settings from '../../../settings/settings.js'; -import Device, { DeviceEvent } from '../../device.js'; +import { DeviceInfo } from '../../deviceManager.js'; import VirtualDeviceFactory from './virtualDeviceFactory.js'; -import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../../deviceManager.js'; +import DeviceManager from '../../deviceManager.js'; import { asyncHandler } from '../../../util/async.js'; import { logError } from '../../../util/error.js'; @@ -17,25 +17,16 @@ export type VirtualDeviceInfo = DeviceInfo & { knownDevice: KnownDevice; }; -export default class VirtualDeviceProvider extends DeviceProvider +export default class VirtualDeviceProvider extends DeviceProvider> { public static readonly providerName = 'virtual'; - private connectedDevices: Map> = new Map(); - private readonly deviceFactory: VirtualDeviceFactory; private readonly settingsManager: SettingsManager; - private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; - private readonly settingsChangedListener: (settings: Settings) => void; - // Guards the async gap in handleDeviceDetection(): unlike physical providers, virtual device - // creation is asynchronous, so stop() can run while a device is still being built. Without - // this, such an in-flight device would be added to an already-stopped provider. - private stopped: boolean = false; - public constructor( deviceManager: DeviceManager, eventEmitter: EventEmitter, @@ -47,11 +38,6 @@ export default class VirtualDeviceProvider extends DeviceProvider this.deviceFactory = deviceFactory; this.settingsManager = settingsManager; - this.deviceDetectedListener = asyncHandler( - this.handleDeviceDetection.bind(this), - (err: unknown) => logError(this.logger, 'Error in device detection handler', err) - ); - this.settingsChangedListener = asyncHandler( async (): Promise => this.discoverVirtualDevices(), (e: unknown) => logError(this.logger, 'Error while scanning for virtual devices after a settings change', e) @@ -59,9 +45,6 @@ export default class VirtualDeviceProvider extends DeviceProvider } public override async init(): Promise { - this.stopped = false; - - this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); this.settingsManager.on(SettingsEventType.changed, this.settingsChangedListener); // Load whatever is already configured once, without waiting for the first settings @@ -70,14 +53,20 @@ export default class VirtualDeviceProvider extends DeviceProvider } public override async stop(): Promise { - this.stopped = true; - - this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); this.settingsManager.off(SettingsEventType.changed, this.settingsChangedListener); - for (const device of this.connectedDevices.values()) { - await this.removeDevice(device); - } + // Detaches the detection listener and closes/clears the connected devices. + await super.stop(); + } + + protected override supportsDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is VirtualDeviceInfo { + return deviceInfo.type === 'virtual'; + } + + protected override createDevice(deviceInfo: VirtualDeviceInfo): Promise | undefined> { + this.logger.info(`Virtual device detected: ${deviceInfo.knownDevice.name}`, deviceInfo.knownDevice); + + return this.deviceFactory.create(deviceInfo.knownDevice, VirtualDeviceProvider.providerName); } /** @@ -98,85 +87,21 @@ export default class VirtualDeviceProvider extends DeviceProvider const virtualDevices = settings.getKnownDevicesBySource(VirtualDeviceProvider.providerName); - // Check if devices have been removed from the configuration entirely - for (const [k, v] of this.connectedDevices) { - if (!virtualDevices.has(k)) { - await this.removeDevice(v); - } - } - - // Announce all currently configured devices that aren't connected yet - the device - // manager takes care of skipping disabled ones (and re-announcing them once re-enabled) - // as well as ones already being connected. - for (const [k, v] of virtualDevices) { - if (this.connectedDevices.has(k)) { - continue; - } - - const deviceInfo: VirtualDeviceInfo = { type: 'virtual', id: v.id, knownDevice: v }; - - this.deviceManager.announceDetectedDevice(deviceInfo); - } - } - - private isVirtualDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is VirtualDeviceInfo { - return deviceInfo.type === 'virtual'; - } - - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { - if (!this.isVirtualDeviceInfo(deviceInfo)) { - return; - } - - const knownDevice = deviceInfo.knownDevice; - - this.logger.info(`Virtual device detected: ${knownDevice.name}`, knownDevice); - - const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - - if (!acquireResult.successful) { - this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); - return; - } - - try { - const device = await this.deviceFactory.create(knownDevice, VirtualDeviceProvider.providerName); - - if (this.stopped) { + // Close devices whose known device has been removed from the configuration entirely. + // Devices for merely disabled known devices are closed centrally by the device manager. + // Snapshot first, since closing a device mutates the underlying connected-devices map. + for (const device of [...this.getConnectedDevices()]) { + if (!virtualDevices.has(device.getDeviceId)) { await device.close(); - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - return; - } - - // Keep local bookkeeping in sync regardless of what closes the device (e.g. the - // device manager closing it right away because it has been disabled in the meantime). - device.on(DeviceEvent.deviceDisconnected, (d) => this.connectedDevices.delete(d.getDeviceId)); - - if (!this.deviceManager.addDevice(deviceInfo, device)) { - // addDevice() has already closed it, released it from the acquire queue, and - // registered it for retry once re-enabled. - return; } - - this.connectedDevices.set(knownDevice.id, device); - - this.logger.info(`Connected virtual devices: ${this.connectedDevices.size}`); - } catch (e: unknown) { - logError(this.logger, `Could not initiate virtual device '${knownDevice.id}'`, e); - this.deviceManager.releaseDetectedDevice(deviceInfo.id); } - } - private async removeDevice(device: Device): Promise { - const deviceId = device.getDeviceId; + // Announce all currently configured devices - the device manager takes care of skipping + // disabled ones (and re-announcing them once re-enabled) as well as ones already connected. + for (const knownDevice of virtualDevices.values()) { + const deviceInfo: VirtualDeviceInfo = { type: 'virtual', id: knownDevice.id, knownDevice }; - try { - await device.close(); - } finally { - this.connectedDevices.delete(deviceId); + this.deviceManager.announceDetectedDevice(deviceInfo); } - - this.logger.info(`Device removed: ${deviceId} (${device.getDeviceName})`); - this.logger.info(`Connected virtual devices: ${this.connectedDevices.size}`); } } diff --git a/src/device/provider/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts index 43a78837..121c655e 100644 --- a/src/device/provider/bleDeviceProvider.ts +++ b/src/device/provider/bleDeviceProvider.ts @@ -1,6 +1,6 @@ import EventEmitter from 'events'; import { Peripheral } from '@stoprocent/noble'; -import DetectedDeviceProvider from './detectedDeviceProvider.js'; +import DeviceProvider from './deviceProvider.js'; import DeviceManager, { DeviceInfo } from '../deviceManager.js'; import Logger from '../../logging/Logger.js'; import { promiseWithTimeout } from '../../util/async.js'; @@ -8,9 +8,7 @@ import { logError } from '../../util/error.js'; import { BleDeviceInfo } from '../transport/bleObserver.js'; import { AnyBleDevice } from '../bleDevice.js'; -export default abstract class BleDeviceProvider< - D extends AnyBleDevice = AnyBleDevice -> extends DetectedDeviceProvider +export default abstract class BleDeviceProvider extends DeviceProvider { protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger); diff --git a/src/device/provider/detectedDeviceProvider.ts b/src/device/provider/detectedDeviceProvider.ts deleted file mode 100644 index bac190a2..00000000 --- a/src/device/provider/detectedDeviceProvider.ts +++ /dev/null @@ -1,154 +0,0 @@ -import EventEmitter from 'events'; -import DeviceProvider from './deviceProvider.js'; -import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager.js'; -import Logger from '../../logging/Logger.js'; -import { asyncHandler } from '../../util/async.js'; -import { logError } from '../../util/error.js'; -import { AnyDevice, DeviceEvent } from '../device.js'; -import { DeviceId } from '../deviceId.js'; - -/** - * Base class for providers that discover devices through the device manager's detection pipeline - * (`announceDetectedDevice` -> `deviceDetected` -> acquire -> add). It owns the common flow so - * subclasses only have to say which `DeviceInfo` they handle and how to turn it into a `Device`: - * - * 1. filter the `deviceDetected` event to the infos this provider handles (`supportsDeviceInfo`) - * 2. acquire the device from the manager (losing the race to another provider is fine) - * 3. create the actual device (`createDevice`) - the one genuinely provider-specific step - * 4. hand it to `DeviceManager.addDevice()`, which owns the enabled/disabled decision and all the - * acquire-queue bookkeeping (claim on success, release + retry on rejection) - * - * Connected devices are tracked locally (keyed by their final `getDeviceId`) so `stop()` can close - * exactly the devices this provider owns, and are removed again automatically on disconnect. - */ -export default abstract class DetectedDeviceProvider< - DI extends DeviceInfo, - D extends AnyDevice = AnyDevice -> extends DeviceProvider -{ - private readonly connectedDevices: Map = new Map(); - - private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; - - // Removing the `deviceDetected` listener only blocks *new* detections; a `handleDeviceDetection` - // call already awaiting acquisition/creation can still complete after `stop()`. This flag lets - // that in-flight handler bail out and clean up instead of registering a device on a stopped - // provider. Subclasses may read it (via `isStopped()`) to guard their own async work. - private stopped: boolean = false; - - protected isStopped(): boolean { - return this.stopped; - } - - protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { - super(deviceManager, eventEmitter, logger); - - this.deviceDetectedListener = asyncHandler( - this.handleDeviceDetection.bind(this), - (err: unknown) => logError(this.logger, 'Error in device detection handler', err) - ); - - this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); - } - - public override async stop(): Promise { - this.stopped = true; - - this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); - - for (const device of this.connectedDevices.values()) { - await device.close(); - } - this.connectedDevices.clear(); - } - - protected getConnectedDevices(): IterableIterator { - return this.connectedDevices.values(); - } - - protected getConnectedDevice(deviceId: DeviceId): D | undefined { - return this.connectedDevices.get(deviceId); - } - - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { - if (!this.supportsDeviceInfo(deviceInfo)) { - return; - } - - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); - - const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - - if (!acquireResult.successful) { - this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); - return; - } - - let device: D | undefined; - - try { - device = await this.createDevice(deviceInfo); - } catch (e: unknown) { - logError(this.logger, `Error while connecting to device '${deviceInfo.id}'`, e); - await this.abortDetection(deviceInfo); - return; - } - - // The provider may have been stopped while `createDevice` was in flight. Don't register a - // device on a stopped provider - close it and release the claim instead. - if (undefined === device || this.stopped) { - if (undefined !== device) { - await device.close(); - } - await this.abortDetection(deviceInfo); - return; - } - - // Keep local bookkeeping in sync regardless of what closes the device, e.g. the device - // manager closing it right away because it turned out to belong to a disabled device. - device.on(DeviceEvent.deviceDisconnected, (d) => this.connectedDevices.delete(d.getDeviceId)); - - if (!this.deviceManager.addDevice(deviceInfo, device)) { - // The device's final id (only known after connecting) belongs to a disabled known - // device - addDevice() has already closed it, released it from the acquire queue, and - // registered it for retry once re-enabled. - return; - } - - this.connectedDevices.set(device.getDeviceId, device); - - this.logger.info(`Connected devices: ${this.connectedDevices.size}`); - } - - /** - * Cleans up after a failed/aborted connection attempt. Transport-level cleanup runs *before* - * the acquire claim is released, so the next provider in the queue cannot begin a new attempt - * against a transport this provider is still tearing down. - */ - private async abortDetection(deviceInfo: DI): Promise { - await this.onConnectFailed(deviceInfo); - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - } - - /** - * Type guard selecting the `DeviceInfo`s this provider is responsible for. Detection events - * for infos of other providers are ignored. - */ - protected abstract supportsDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is DI; - - /** - * Turns a detected device info into an actual connected `Device`, or `undefined` if it could - * not be connected/identified (e.g. a failed handshake). Throwing is also allowed and treated - * the same as returning `undefined`, additionally invoking `onConnectFailed()`. - */ - protected abstract createDevice(deviceInfo: DI): Promise; - - /** - * Called after a failed or aborted connection attempt so subclasses can release any - * transport-level resources they hold (e.g. disconnecting a BLE peripheral). No-op by default. - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected async onConnectFailed(deviceInfo: DI): Promise { - return Promise.resolve(); - } -} diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index 5292c4ad..8d3883f8 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -1,8 +1,33 @@ import EventEmitter from 'events'; +import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager.js'; import Logger from '../../logging/Logger.js'; -import DeviceManager from '../deviceManager.js'; +import { asyncHandler } from '../../util/async.js'; +import { logError } from '../../util/error.js'; +import { AnyDevice, DeviceEvent } from '../device.js'; +import { DeviceId } from '../deviceId.js'; -export default abstract class DeviceProvider +/** + * Base class for all device providers. Providers discover devices through the device manager's + * detection pipeline (`announceDetectedDevice` -> `deviceDetected` -> acquire -> add) and only + * have to say which `DeviceInfo` they handle and how to turn it into a `Device`: + * + * 1. filter the `deviceDetected` event to the infos this provider handles (`supportsDeviceInfo`) + * 2. acquire the device from the manager (losing the race to another provider is fine) + * 3. create the actual device (`createDevice`) - the one genuinely provider-specific step + * 4. hand it to `DeviceManager.addDevice()`, which owns the enabled/disabled decision and all the + * acquire-queue bookkeeping (claim on success, release + retry on rejection) + * + * Connected devices are tracked locally (keyed by their final `getDeviceId`) so `stop()` can close + * exactly the devices this provider owns, and are removed again automatically on disconnect. + * + * Providers that discover devices by some other means (e.g. reacting to settings changes) still + * feed those devices through the same pipeline by calling `announceDetectedDevice()`, so the + * central enabled/disabled handling applies uniformly. + */ +export default abstract class DeviceProvider< + DI extends DeviceInfo, + D extends AnyDevice +> { protected readonly deviceManager: DeviceManager; @@ -10,10 +35,27 @@ export default abstract class DeviceProvider protected readonly logger: Logger; + private readonly connectedDevices: Map = new Map(); + + private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; + + // Removing the `deviceDetected` listener only blocks *new* detections; a `handleDeviceDetection` + // call already awaiting acquisition/creation can still complete after `stop()`. This flag lets + // that in-flight handler bail out and clean up instead of registering a device on a stopped + // provider. Subclasses may read it (via `isStopped()`) to guard their own async work. + private stopped: boolean = false; + protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { this.deviceManager = deviceManager; this.eventEmitter = eventEmitter; this.logger = logger; + + this.deviceDetectedListener = asyncHandler( + this.handleDeviceDetection.bind(this), + (err: unknown) => logError(this.logger, 'Error in device detection handler', err) + ); + + this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); } public async init(): Promise { @@ -21,6 +63,114 @@ export default abstract class DeviceProvider } public async stop(): Promise { + this.stopped = true; + + this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + + for (const device of this.connectedDevices.values()) { + await device.close(); + } + this.connectedDevices.clear(); + } + + protected isStopped(): boolean { + return this.stopped; + } + + protected getConnectedDevices(): IterableIterator { + return this.connectedDevices.values(); + } + + protected getConnectedDevice(deviceId: DeviceId): D | undefined { + return this.connectedDevices.get(deviceId); + } + + private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { + if (!this.supportsDeviceInfo(deviceInfo)) { + return; + } + + this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); + + const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); + + if (!acquireResult.successful) { + this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); + return; + } + + let device: D | undefined; + + try { + device = await this.createDevice(deviceInfo); + } catch (e: unknown) { + logError(this.logger, `Error while connecting to device '${deviceInfo.id}'`, e); + await this.abortDetection(deviceInfo); + return; + } + + // The provider may have been stopped while `createDevice` was in flight. Don't register a + // device on a stopped provider - close it and release the claim instead. + if (undefined === device || this.stopped) { + if (undefined !== device) { + await device.close(); + } + await this.abortDetection(deviceInfo); + return; + } + + // Keep local bookkeeping in sync regardless of what closes the device, e.g. the device + // manager closing it right away because it turned out to belong to a disabled device. + device.on(DeviceEvent.deviceDisconnected, (d) => this.connectedDevices.delete(d.getDeviceId)); + + if (!this.deviceManager.addDevice(deviceInfo, device)) { + // The device's final id (only known after connecting) belongs to a disabled known + // device - addDevice() has already closed it, released it from the acquire queue, and + // registered it for retry once re-enabled. + return; + } + + this.connectedDevices.set(device.getDeviceId, device); + + this.logger.info(`Connected devices: ${this.connectedDevices.size}`); + } + + /** + * Cleans up after a failed/aborted connection attempt. Transport-level cleanup runs *before* + * the acquire claim is released, so the next provider in the queue cannot begin a new attempt + * against a transport this provider is still tearing down. + */ + private async abortDetection(deviceInfo: DI): Promise { + await this.onConnectFailed(deviceInfo); + this.deviceManager.releaseDetectedDevice(deviceInfo.id); + } + + /** + * Type guard selecting the `DeviceInfo`s this provider is responsible for. Detection events + * for infos of other providers are ignored. + */ + protected abstract supportsDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is DI; + + /** + * Turns a detected device info into an actual connected `Device`, or `undefined` if it could + * not be connected/identified (e.g. a failed handshake). Throwing is also allowed and treated + * the same as returning `undefined`, additionally invoking `onConnectFailed()`. + */ + protected abstract createDevice(deviceInfo: DI): Promise; + + /** + * Called after a failed or aborted connection attempt so subclasses can release any + * transport-level resources they hold (e.g. disconnecting a BLE peripheral). No-op by default. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async onConnectFailed(deviceInfo: DI): Promise { return Promise.resolve(); } } + +/** + * A device-info- and device-type-agnostic view of a `DeviceProvider`, for the places that handle + * "some provider" without caring about the concrete `DeviceInfo`/`Device` it produces (the + * provider manager and factories). + */ +export type AnyDeviceProvider = DeviceProvider; diff --git a/src/device/provider/deviceProviderFactory.ts b/src/device/provider/deviceProviderFactory.ts index 4aa6c595..a6e38cd0 100644 --- a/src/device/provider/deviceProviderFactory.ts +++ b/src/device/provider/deviceProviderFactory.ts @@ -1,7 +1,7 @@ -import DeviceProvider from './deviceProvider.js'; +import { AnyDeviceProvider } from './deviceProvider.js'; import { JsonObject } from '../../types.js'; -export default interface DeviceProviderFactory +export default interface DeviceProviderFactory { create(config: JsonObject): DP; } diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index 0c12b0da..69449a96 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -1,7 +1,7 @@ import Settings from '../../settings/settings.js'; import DeviceProviderFactory from './deviceProviderFactory.js'; import Logger from '../../logging/Logger.js'; -import DeviceProvider from './deviceProvider.js'; +import { AnyDeviceProvider } from './deviceProvider.js'; import { logError } from '../../util/error.js'; export default class DeviceProviderManager @@ -10,7 +10,7 @@ export default class DeviceProviderManager private readonly logger: Logger; - private readonly providers: Map = new Map(); + private readonly providers: Map = new Map(); /** * `reload()` and `stopProviders()` mutate the shared `providers` map asynchronously. diff --git a/src/device/provider/genericDeviceProviderFactory.ts b/src/device/provider/genericDeviceProviderFactory.ts index abcfb9f6..8ecaa71e 100644 --- a/src/device/provider/genericDeviceProviderFactory.ts +++ b/src/device/provider/genericDeviceProviderFactory.ts @@ -1,10 +1,10 @@ -import DeviceProvider from './deviceProvider.js'; +import { AnyDeviceProvider } from './deviceProvider.js'; import DeviceProviderFactory from './deviceProviderFactory.js'; type ConcreteCtor = new (...args: any[]) => T; export default class GenericDeviceProviderFactory< - DP extends DeviceProvider + DP extends AnyDeviceProvider > implements DeviceProviderFactory { private readonly ctor: ConcreteCtor; diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index a7799d23..c2fd0669 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -1,4 +1,4 @@ -import DetectedDeviceProvider from './detectedDeviceProvider.js'; +import DeviceProvider from './deviceProvider.js'; import EventEmitter from 'events'; import Logger from '../../logging/Logger.js'; import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; @@ -14,9 +14,7 @@ import { AnyPeripheralDevice } from '../peripheralDevice.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; -export default abstract class SerialDeviceProvider< - D extends AnyPeripheralDevice = AnyPeripheralDevice -> extends DetectedDeviceProvider +export default abstract class SerialDeviceProvider extends DeviceProvider { private readonly serialPortFactory: SerialPortFactory; diff --git a/tests/unit/device/provider/deviceProviderManager.spec.ts b/tests/unit/device/provider/deviceProviderManager.spec.ts index 803df42e..6423b7bb 100644 --- a/tests/unit/device/provider/deviceProviderManager.spec.ts +++ b/tests/unit/device/provider/deviceProviderManager.spec.ts @@ -4,17 +4,18 @@ import EventEmitter from 'events'; import DeviceProviderManager from '../../../../src/device/provider/deviceProviderManager.js'; import DeviceProviderFactory from '../../../../src/device/provider/deviceProviderFactory.js'; import DeviceProvider from '../../../../src/device/provider/deviceProvider.js'; -import DeviceManager from '../../../../src/device/deviceManager.js'; +import DeviceManager, { DeviceInfo } from '../../../../src/device/deviceManager.js'; +import { AnyDevice } from '../../../../src/device/device.js'; import Logger from '../../../../src/logging/Logger.js'; import Settings from '../../../../src/settings/settings.js'; import DeviceSource from '../../../../src/settings/deviceSource.js'; import { JsonObject } from '../../../../src/types.js'; -class RecordingDeviceProvider extends DeviceProvider +class RecordingDeviceProvider extends DeviceProvider { public initCalls = 0; public stopCalls = 0; - public stopped = false; + public stopResolved = false; // Allows tests to control when init()/stop() resolve, to simulate slow-running operations. private initGate: Promise = Promise.resolve(); @@ -40,7 +41,16 @@ class RecordingDeviceProvider extends DeviceProvider public override async stop(): Promise { this.stopCalls++; await this.stopGate; - this.stopped = true; + this.stopResolved = true; + } + + // This test double never actually detects devices; it only exercises the lifecycle. + protected supportsDeviceInfo(_deviceInfo: DeviceInfo): _deviceInfo is DeviceInfo { + return false; + } + + protected createDevice(_deviceInfo: DeviceInfo): Promise { + return Promise.resolve(undefined); } } @@ -163,7 +173,7 @@ describe('DeviceProviderManager', () => { await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); await manager.stopProviders(); - expect(provider.stopped).toBe(true); + expect(provider.stopResolved).toBe(true); // After stopProviders(), a subsequent reload() on the SAME manager with the same enabled // source must create a fresh provider - proving stopProviders() cleared its internal state. diff --git a/tests/unit/device/testDeviceProvider.ts b/tests/unit/device/testDeviceProvider.ts deleted file mode 100644 index 712baae1..00000000 --- a/tests/unit/device/testDeviceProvider.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {EventEmitter} from "events"; -import DeviceProvider from "../../../src/device/provider/deviceProvider.js"; -import Logger from "../../../src/logging/Logger.js"; -import DeviceManager from "../../../src/device/deviceManager.js"; - -export default class TestDeviceProvider extends DeviceProvider -{ - public constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) - { - super(deviceManager, eventEmitter, logger); - } - - public override init(): Promise - { - // noop - return new Promise((resolve) => resolve()); - } -} From 00088eaace90454d8ea71a89fc2937afa7d5e09d Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 20:49:55 +0200 Subject: [PATCH 12/35] Drive buttplug.io scanning off 'scanningfinished' instead of fixed timers The provider previously ran two independent guessed timers: kick off a scan every 60s, and blindly stop it 30s later. Both numbers were arbitrary and ignored what the server was actually doing. A buttplug.io scan is a bounded operation - the server ends it on its own and reports back via the 'scanningfinished' event. Use that event to drive the loop: start a scan on connect, and when the server reports the scan finished, schedule the next one after a short cooldown. This removes the guessed scan duration entirely and syncs the cadence to real server state instead of a fixed clock. - Replace AUTO_SCAN_INTERVAL_MS/SCAN_DURATION_MS with a single RESCAN_COOLDOWN_MS, and name the connect-retry interval too. - Cooldown is injectable (defaulting to the constant) so tests can exercise the re-scan loop without waiting the full 30s. - Simulator tracks StartScanning requests and exposes waitForScanCount(); the buttplug lifecycle app now runs with autoScan enabled so the loop is actually covered. --- .../buttplugIoWebsocketDeviceProvider.ts | 56 +++++++++++++------ ...uttplugIoWebsocketDeviceProviderFactory.ts | 6 +- .../devices/buttplugIoDevice.spec.ts | 10 +++- .../helpers/buttplugIoServerSimulator.ts | 24 ++++++++ 4 files changed, 75 insertions(+), 21 deletions(-) diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 00c25059..fce1fb23 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -21,6 +21,14 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< > { public static readonly providerName = 'buttplugIoWebsocket'; + // How often to (re)attempt connecting to the Intiface/buttplug.io server while disconnected. + private static readonly CONNECT_RETRY_INTERVAL_MS = 1_000; + + // A buttplug.io scan is a bounded operation: the server scans, then emits 'scanningfinished'. + // To keep discovering devices that appear later we re-scan, but pause briefly between runs + // so we don't hammer the server's BLE adapter with back-to-back scans. + private static readonly RESCAN_COOLDOWN_MS = 30_000; + private buttplugConnector: ButtplugNodeWebsocketClientConnector; private buttplugClient: ButtplugClient; @@ -29,9 +37,10 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< private readonly websocketAddress: string; private readonly autoScan: boolean; private readonly useDeviceNameAsId: boolean; + private readonly rescanCooldownMs: number; private connectionIntervalRef?: NodeJS.Timeout; - private autoScanningIntervalRef?: NodeJS.Timeout; + private rescanTimeoutRef?: NodeJS.Timeout; public constructor( deviceManager: DeviceManager, @@ -40,13 +49,15 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< websocketAddress: string, autoScan: boolean, useDeviceNameAsId: boolean, - logger: Logger + logger: Logger, + rescanCooldownMs: number = ButtplugIoWebsocketDeviceProvider.RESCAN_COOLDOWN_MS ) { super(deviceManager, eventEmitter, logger.child({ name: ButtplugIoWebsocketDeviceProvider.name })); this.buttplugIoDeviceFactory = deviceFactory; this.websocketAddress = websocketAddress; this.autoScan = autoScan; this.useDeviceNameAsId = useDeviceNameAsId; + this.rescanCooldownMs = rescanCooldownMs; const url = `ws://${this.websocketAddress}/buttplug`; @@ -61,10 +72,11 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.removeButtplugIoDevice.bind(this), (e: unknown) => logError(this.logger, `Error in deviceremoved handler`, e) )); + this.buttplugClient.on('scanningfinished', this.handleScanningFinished.bind(this)); } public override async init(): Promise { - this.connectionIntervalRef ??= setImmediateInterval(() => void this.connectToServer(), 1000); + this.connectionIntervalRef ??= setImmediateInterval(() => void this.connectToServer(), ButtplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS); } public override async stop(): Promise { @@ -74,8 +86,8 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< clearInterval(this.connectionIntervalRef); this.connectionIntervalRef = undefined; - clearInterval(this.autoScanningIntervalRef); - this.autoScanningIntervalRef = undefined; + clearTimeout(this.rescanTimeoutRef); + this.rescanTimeoutRef = undefined; // Drop the buttplug client's own listeners before disconnecting so the resulting // 'disconnect' event can't run handleLostConnection() and bring the provider back up. @@ -105,7 +117,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.connectionIntervalRef = undefined; if (this.autoScan) { - this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, 60000); + this.startScanning(); } } catch (e: unknown) { logError(this.logger, `Could not connect to buttplug.io server (${url})`, hasProperty(e, 'message') ? e.message : 'unknown'); @@ -121,8 +133,8 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< await this.removeButtplugIoDevice(device.getButtplugClientDevice); } - clearInterval(this.autoScanningIntervalRef); - this.autoScanningIntervalRef = undefined; + clearTimeout(this.rescanTimeoutRef); + this.rescanTimeoutRef = undefined; // Don't reconnect if we're shutting down - stop() removes the listeners, but a disconnect // may already be in flight when it runs. @@ -133,24 +145,32 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< await this.init(); } - private discoverButtplugIoDevices(): void { - if (!this.buttplugClient.connected) { + /** + * Kicks off a single device scan. The buttplug.io server ends the scan on its own and reports + * back via the 'scanningfinished' event, which `handleScanningFinished()` uses to schedule the + * next run - so this is a self-perpetuating loop, not a one-shot. + */ + private startScanning(): void { + if (this.isStopped() || !this.buttplugClient.connected) { return; } this.buttplugClient.startScanning() .then(() => this.logger.info('Start scanning for Buttplug.io devices')) .catch((e: unknown) => this.logger.error(`Could not start scanning for buttplug.io devices`, e)); + } - setTimeout(() => { - if (undefined === this.buttplugClient || !this.buttplugClient.isScanning) { - return; - } + private handleScanningFinished(): void { + this.logger.info('Finished scanning for Buttplug.io devices'); + + if (this.isStopped() || !this.autoScan || !this.buttplugClient.connected) { + return; + } - this.buttplugClient.stopScanning() - .then(() => this.logger.info('Stop scanning for Buttplug.io devices')) - .catch((e: unknown) => this.logger.error(`Could not stop scanning for buttplug.io devices`, e)); - }, 30000); + this.rescanTimeoutRef ??= setTimeout(() => { + this.rescanTimeoutRef = undefined; + this.startScanning(); + }, this.rescanCooldownMs); } private toDeviceInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceInfo { diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts index 8f7064d6..59a55897 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts @@ -8,7 +8,8 @@ import DeviceManager from '../../deviceManager.js'; type ButtplugIoWebsocketConfig = { address: string, autoScan: boolean, - useDeviceNameAsId: boolean + useDeviceNameAsId: boolean, + rescanCooldownMs?: number } export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceProviderFactory @@ -42,7 +43,8 @@ export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceP config.address, config.autoScan, config.useDeviceNameAsId, - this.logger + this.logger, + config.rescanCooldownMs ); } } diff --git a/tests/integration/devices/buttplugIoDevice.spec.ts b/tests/integration/devices/buttplugIoDevice.spec.ts index 5b6c7312..44405210 100644 --- a/tests/integration/devices/buttplugIoDevice.spec.ts +++ b/tests/integration/devices/buttplugIoDevice.spec.ts @@ -19,7 +19,9 @@ function makeButtplugSettings(port: number): object { type: ButtplugIoWebsocketDeviceProvider.providerName, config: { address: `127.0.0.1:${port}`, - autoScan: false, + autoScan: true, + // Short cooldown so the re-scan loop runs within the test instead of the 30s default. + rescanCooldownMs: 20, useDeviceNameAsId: true, }, }, @@ -192,4 +194,10 @@ describe('Buttplug.io device lifecycle', () => { expect(res.status).toBe(200); expect(res.body.count).toBe(0); }); + + it('scans on connect and keeps re-scanning after each scan finishes', async () => { + // First scan is triggered on connect; further scans only happen if the provider + // reacts to 'scanningfinished' and schedules another run after the cooldown. + await simulator.waitForScanCount(2); + }); }); diff --git a/tests/integration/helpers/buttplugIoServerSimulator.ts b/tests/integration/helpers/buttplugIoServerSimulator.ts index 93881bf4..7bffb524 100644 --- a/tests/integration/helpers/buttplugIoServerSimulator.ts +++ b/tests/integration/helpers/buttplugIoServerSimulator.ts @@ -72,6 +72,10 @@ export class ButtplugIoServerSimulator { scalar: number; }> = []; + /** Number of StartScanning messages received so far. */ + private scanStartCount = 0; + private scanStartResolvers: Array<{ target: number; resolve: () => void }> = []; + public async start(): Promise { return new Promise((resolve, reject) => { this.server = createServer(); @@ -113,6 +117,21 @@ export class ButtplugIoServerSimulator { }); } + /** Resolves once the client has issued at least `target` StartScanning requests. */ + public waitForScanCount(target: number, timeoutMs = 5000): Promise { + if (this.scanStartCount >= target) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Timed out waiting for ${target} scan(s), saw ${this.scanStartCount} (>${timeoutMs}ms)`)), + timeoutMs + ); + this.scanStartResolvers.push({ target, resolve: () => { clearTimeout(timer); resolve(); } }); + }); + } + public async stop(): Promise { for (const ws of this.connectedClients) { ws.close(); @@ -189,8 +208,13 @@ export class ButtplugIoServerSimulator { for (const resolve of this.clientReadyResolvers) resolve(); this.clientReadyResolvers = []; } else if (msg instanceof StartScanning) { + this.scanStartCount++; ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); ws.send(`[{"ScanningFinished":{"Id":0}}]`); + for (const waiter of this.scanStartResolvers) { + if (this.scanStartCount >= waiter.target) waiter.resolve(); + } + this.scanStartResolvers = this.scanStartResolvers.filter(w => this.scanStartCount < w.target); } else if (msg instanceof StopScanning) { ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); } else if (msg instanceof ScalarCmd) { From bb40c9d8af82af90488bb620a6b01236c9cd19ac Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 21:06:19 +0200 Subject: [PATCH 13/35] Revert "Drive buttplug.io scanning off 'scanningfinished' instead of fixed timers" This reverts commit 00088eaace90454d8ea71a89fc2937afa7d5e09d. --- .../buttplugIoWebsocketDeviceProvider.ts | 56 ++++++------------- ...uttplugIoWebsocketDeviceProviderFactory.ts | 6 +- .../devices/buttplugIoDevice.spec.ts | 10 +--- .../helpers/buttplugIoServerSimulator.ts | 24 -------- 4 files changed, 21 insertions(+), 75 deletions(-) diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index fce1fb23..00c25059 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -21,14 +21,6 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< > { public static readonly providerName = 'buttplugIoWebsocket'; - // How often to (re)attempt connecting to the Intiface/buttplug.io server while disconnected. - private static readonly CONNECT_RETRY_INTERVAL_MS = 1_000; - - // A buttplug.io scan is a bounded operation: the server scans, then emits 'scanningfinished'. - // To keep discovering devices that appear later we re-scan, but pause briefly between runs - // so we don't hammer the server's BLE adapter with back-to-back scans. - private static readonly RESCAN_COOLDOWN_MS = 30_000; - private buttplugConnector: ButtplugNodeWebsocketClientConnector; private buttplugClient: ButtplugClient; @@ -37,10 +29,9 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< private readonly websocketAddress: string; private readonly autoScan: boolean; private readonly useDeviceNameAsId: boolean; - private readonly rescanCooldownMs: number; private connectionIntervalRef?: NodeJS.Timeout; - private rescanTimeoutRef?: NodeJS.Timeout; + private autoScanningIntervalRef?: NodeJS.Timeout; public constructor( deviceManager: DeviceManager, @@ -49,15 +40,13 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< websocketAddress: string, autoScan: boolean, useDeviceNameAsId: boolean, - logger: Logger, - rescanCooldownMs: number = ButtplugIoWebsocketDeviceProvider.RESCAN_COOLDOWN_MS + logger: Logger ) { super(deviceManager, eventEmitter, logger.child({ name: ButtplugIoWebsocketDeviceProvider.name })); this.buttplugIoDeviceFactory = deviceFactory; this.websocketAddress = websocketAddress; this.autoScan = autoScan; this.useDeviceNameAsId = useDeviceNameAsId; - this.rescanCooldownMs = rescanCooldownMs; const url = `ws://${this.websocketAddress}/buttplug`; @@ -72,11 +61,10 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.removeButtplugIoDevice.bind(this), (e: unknown) => logError(this.logger, `Error in deviceremoved handler`, e) )); - this.buttplugClient.on('scanningfinished', this.handleScanningFinished.bind(this)); } public override async init(): Promise { - this.connectionIntervalRef ??= setImmediateInterval(() => void this.connectToServer(), ButtplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS); + this.connectionIntervalRef ??= setImmediateInterval(() => void this.connectToServer(), 1000); } public override async stop(): Promise { @@ -86,8 +74,8 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< clearInterval(this.connectionIntervalRef); this.connectionIntervalRef = undefined; - clearTimeout(this.rescanTimeoutRef); - this.rescanTimeoutRef = undefined; + clearInterval(this.autoScanningIntervalRef); + this.autoScanningIntervalRef = undefined; // Drop the buttplug client's own listeners before disconnecting so the resulting // 'disconnect' event can't run handleLostConnection() and bring the provider back up. @@ -117,7 +105,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.connectionIntervalRef = undefined; if (this.autoScan) { - this.startScanning(); + this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, 60000); } } catch (e: unknown) { logError(this.logger, `Could not connect to buttplug.io server (${url})`, hasProperty(e, 'message') ? e.message : 'unknown'); @@ -133,8 +121,8 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< await this.removeButtplugIoDevice(device.getButtplugClientDevice); } - clearTimeout(this.rescanTimeoutRef); - this.rescanTimeoutRef = undefined; + clearInterval(this.autoScanningIntervalRef); + this.autoScanningIntervalRef = undefined; // Don't reconnect if we're shutting down - stop() removes the listeners, but a disconnect // may already be in flight when it runs. @@ -145,32 +133,24 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< await this.init(); } - /** - * Kicks off a single device scan. The buttplug.io server ends the scan on its own and reports - * back via the 'scanningfinished' event, which `handleScanningFinished()` uses to schedule the - * next run - so this is a self-perpetuating loop, not a one-shot. - */ - private startScanning(): void { - if (this.isStopped() || !this.buttplugClient.connected) { + private discoverButtplugIoDevices(): void { + if (!this.buttplugClient.connected) { return; } this.buttplugClient.startScanning() .then(() => this.logger.info('Start scanning for Buttplug.io devices')) .catch((e: unknown) => this.logger.error(`Could not start scanning for buttplug.io devices`, e)); - } - private handleScanningFinished(): void { - this.logger.info('Finished scanning for Buttplug.io devices'); - - if (this.isStopped() || !this.autoScan || !this.buttplugClient.connected) { - return; - } + setTimeout(() => { + if (undefined === this.buttplugClient || !this.buttplugClient.isScanning) { + return; + } - this.rescanTimeoutRef ??= setTimeout(() => { - this.rescanTimeoutRef = undefined; - this.startScanning(); - }, this.rescanCooldownMs); + this.buttplugClient.stopScanning() + .then(() => this.logger.info('Stop scanning for Buttplug.io devices')) + .catch((e: unknown) => this.logger.error(`Could not stop scanning for buttplug.io devices`, e)); + }, 30000); } private toDeviceInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceInfo { diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts index 59a55897..8f7064d6 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts @@ -8,8 +8,7 @@ import DeviceManager from '../../deviceManager.js'; type ButtplugIoWebsocketConfig = { address: string, autoScan: boolean, - useDeviceNameAsId: boolean, - rescanCooldownMs?: number + useDeviceNameAsId: boolean } export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceProviderFactory @@ -43,8 +42,7 @@ export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceP config.address, config.autoScan, config.useDeviceNameAsId, - this.logger, - config.rescanCooldownMs + this.logger ); } } diff --git a/tests/integration/devices/buttplugIoDevice.spec.ts b/tests/integration/devices/buttplugIoDevice.spec.ts index 44405210..5b6c7312 100644 --- a/tests/integration/devices/buttplugIoDevice.spec.ts +++ b/tests/integration/devices/buttplugIoDevice.spec.ts @@ -19,9 +19,7 @@ function makeButtplugSettings(port: number): object { type: ButtplugIoWebsocketDeviceProvider.providerName, config: { address: `127.0.0.1:${port}`, - autoScan: true, - // Short cooldown so the re-scan loop runs within the test instead of the 30s default. - rescanCooldownMs: 20, + autoScan: false, useDeviceNameAsId: true, }, }, @@ -194,10 +192,4 @@ describe('Buttplug.io device lifecycle', () => { expect(res.status).toBe(200); expect(res.body.count).toBe(0); }); - - it('scans on connect and keeps re-scanning after each scan finishes', async () => { - // First scan is triggered on connect; further scans only happen if the provider - // reacts to 'scanningfinished' and schedules another run after the cooldown. - await simulator.waitForScanCount(2); - }); }); diff --git a/tests/integration/helpers/buttplugIoServerSimulator.ts b/tests/integration/helpers/buttplugIoServerSimulator.ts index 7bffb524..93881bf4 100644 --- a/tests/integration/helpers/buttplugIoServerSimulator.ts +++ b/tests/integration/helpers/buttplugIoServerSimulator.ts @@ -72,10 +72,6 @@ export class ButtplugIoServerSimulator { scalar: number; }> = []; - /** Number of StartScanning messages received so far. */ - private scanStartCount = 0; - private scanStartResolvers: Array<{ target: number; resolve: () => void }> = []; - public async start(): Promise { return new Promise((resolve, reject) => { this.server = createServer(); @@ -117,21 +113,6 @@ export class ButtplugIoServerSimulator { }); } - /** Resolves once the client has issued at least `target` StartScanning requests. */ - public waitForScanCount(target: number, timeoutMs = 5000): Promise { - if (this.scanStartCount >= target) { - return Promise.resolve(); - } - - return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error(`Timed out waiting for ${target} scan(s), saw ${this.scanStartCount} (>${timeoutMs}ms)`)), - timeoutMs - ); - this.scanStartResolvers.push({ target, resolve: () => { clearTimeout(timer); resolve(); } }); - }); - } - public async stop(): Promise { for (const ws of this.connectedClients) { ws.close(); @@ -208,13 +189,8 @@ export class ButtplugIoServerSimulator { for (const resolve of this.clientReadyResolvers) resolve(); this.clientReadyResolvers = []; } else if (msg instanceof StartScanning) { - this.scanStartCount++; ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); ws.send(`[{"ScanningFinished":{"Id":0}}]`); - for (const waiter of this.scanStartResolvers) { - if (this.scanStartCount >= waiter.target) waiter.resolve(); - } - this.scanStartResolvers = this.scanStartResolvers.filter(w => this.scanStartCount < w.target); } else if (msg instanceof StopScanning) { ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); } else if (msg instanceof ScalarCmd) { From a03632adf0a52520153a0cc5109e6e1a454b4f64 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 21:31:54 +0200 Subject: [PATCH 14/35] Name buttplug.io scan timing constants and cover the scan duty-cycle Keep the existing StartScanning/StopScanning duty-cycle (it matches how the desktop websocket server actually behaves - it scans until told to stop), but pull the three magic numbers out into named constants so their intent is clear: CONNECT_RETRY_INTERVAL_MS, AUTO_SCAN_INTERVAL_MS and SCAN_DURATION_MS. The scan interval and duration are also injectable via the constructor (defaulting to those constants) and threaded through the provider factory config, so tests can shrink them without waiting the full 60s/30s. Adds a dedicated auto-scanning test that constructs the provider directly against the simulator - no full app, so it neither touches the shared BLE teardown nor lets its repeated scanning interfere with the lifecycle suite. The simulator now records StartScanning/StopScanning counts and no longer echoes ScanningFinished, modelling the desktop server that scans until stopped. --- .../buttplugIoWebsocketDeviceProvider.ts | 25 ++++++++-- ...uttplugIoWebsocketDeviceProviderFactory.ts | 8 +++- .../devices/buttplugIoDevice.spec.ts | 48 +++++++++++++++++++ .../helpers/buttplugIoServerSimulator.ts | 44 ++++++++++++++++- 4 files changed, 118 insertions(+), 7 deletions(-) diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 00c25059..62a97d50 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -21,6 +21,17 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< > { public static readonly providerName = 'buttplugIoWebsocket'; + // How often to (re)attempt connecting to the Intiface/buttplug.io server while disconnected. + private static readonly CONNECT_RETRY_INTERVAL_MS = 1_000; + + // How often a fresh scan cycle is kicked off while `autoScan` is enabled and we're connected. + private static readonly AUTO_SCAN_INTERVAL_MS = 60_000; + + // How long a scan window stays open before we stop it again. For the desktop websocket setup + // the server keeps scanning until told to stop, so we bound each scan ourselves rather than + // relying on a server-sent 'scanningfinished' (which that setup does not reliably emit). + private static readonly SCAN_DURATION_MS = 30_000; + private buttplugConnector: ButtplugNodeWebsocketClientConnector; private buttplugClient: ButtplugClient; @@ -29,6 +40,8 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< private readonly websocketAddress: string; private readonly autoScan: boolean; private readonly useDeviceNameAsId: boolean; + private readonly autoScanIntervalMs: number; + private readonly scanDurationMs: number; private connectionIntervalRef?: NodeJS.Timeout; private autoScanningIntervalRef?: NodeJS.Timeout; @@ -40,13 +53,17 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< websocketAddress: string, autoScan: boolean, useDeviceNameAsId: boolean, - logger: Logger + logger: Logger, + autoScanIntervalMs: number = ButtplugIoWebsocketDeviceProvider.AUTO_SCAN_INTERVAL_MS, + scanDurationMs: number = ButtplugIoWebsocketDeviceProvider.SCAN_DURATION_MS ) { super(deviceManager, eventEmitter, logger.child({ name: ButtplugIoWebsocketDeviceProvider.name })); this.buttplugIoDeviceFactory = deviceFactory; this.websocketAddress = websocketAddress; this.autoScan = autoScan; this.useDeviceNameAsId = useDeviceNameAsId; + this.autoScanIntervalMs = autoScanIntervalMs; + this.scanDurationMs = scanDurationMs; const url = `ws://${this.websocketAddress}/buttplug`; @@ -64,7 +81,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< } public override async init(): Promise { - this.connectionIntervalRef ??= setImmediateInterval(() => void this.connectToServer(), 1000); + this.connectionIntervalRef ??= setImmediateInterval(() => void this.connectToServer(), ButtplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS); } public override async stop(): Promise { @@ -105,7 +122,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.connectionIntervalRef = undefined; if (this.autoScan) { - this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, 60000); + this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, this.autoScanIntervalMs); } } catch (e: unknown) { logError(this.logger, `Could not connect to buttplug.io server (${url})`, hasProperty(e, 'message') ? e.message : 'unknown'); @@ -150,7 +167,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.buttplugClient.stopScanning() .then(() => this.logger.info('Stop scanning for Buttplug.io devices')) .catch((e: unknown) => this.logger.error(`Could not stop scanning for buttplug.io devices`, e)); - }, 30000); + }, this.scanDurationMs); } private toDeviceInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceInfo { diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts index 8f7064d6..7c6de59c 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts @@ -8,7 +8,9 @@ import DeviceManager from '../../deviceManager.js'; type ButtplugIoWebsocketConfig = { address: string, autoScan: boolean, - useDeviceNameAsId: boolean + useDeviceNameAsId: boolean, + autoScanIntervalMs?: number, + scanDurationMs?: number } export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceProviderFactory @@ -42,7 +44,9 @@ export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceP config.address, config.autoScan, config.useDeviceNameAsId, - this.logger + this.logger, + config.autoScanIntervalMs, + config.scanDurationMs ); } } diff --git a/tests/integration/devices/buttplugIoDevice.spec.ts b/tests/integration/devices/buttplugIoDevice.spec.ts index 5b6c7312..7a424309 100644 --- a/tests/integration/devices/buttplugIoDevice.spec.ts +++ b/tests/integration/devices/buttplugIoDevice.spec.ts @@ -1,4 +1,6 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import EventEmitter from 'events'; import request from 'supertest'; import { io as ioClient } from 'socket.io-client'; import WebSocketEvent from '../../../src/device/webSocketEvent.js'; @@ -7,6 +9,9 @@ import { ButtplugIoServerSimulator } from '../helpers/buttplugIoServerSimulator. import { ActuatorType, SensorType } from 'buttplug'; import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; import ButtplugIoWebsocketDeviceProvider from '../../../src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; +import ButtplugIoDeviceFactory from '../../../src/device/protocol/buttplugIo/buttplugIoDeviceFactory.js'; +import DeviceManager from '../../../src/device/deviceManager.js'; +import Logger from '../../../src/logging/Logger.js'; const BUTTPLUG_SOURCE_ID = 'd5e6f7a8-5678-4321-abcd-ef1234567894'; @@ -193,3 +198,46 @@ describe('Buttplug.io device lifecycle', () => { expect(res.body.count).toBe(0); }); }); + +describe('Buttplug.io auto scanning', () => { + let simulator: ButtplugIoServerSimulator; + let provider: ButtplugIoWebsocketDeviceProvider; + + beforeAll(async () => { + simulator = new ButtplugIoServerSimulator(); + const simulatorPort = await simulator.start(); + + const logger = mock(); + logger.child.mockReturnValue(logger); + + // Construct the provider directly (no full app) so this test needs neither the shared + // BLE/noble teardown nor the device-lifecycle app, and its repeated scanning can't + // interfere with the other suite's timing. Tiny timers keep the duty-cycle fast. + provider = new ButtplugIoWebsocketDeviceProvider( + mock(), + new EventEmitter(), + mock(), + `127.0.0.1:${simulatorPort}`, + true, + true, + logger, + 40, + 15, + ); + + await provider.init(); + }); + + afterAll(async () => { + await provider.stop(); + await simulator.stop(); + }); + + it('runs a repeating start/stop scan duty-cycle', async () => { + // The provider should keep cycling: start a scan, stop it after the scan duration, then + // start another after the interval. Seeing multiple starts *and* stops proves the whole + // duty-cycle is looping rather than firing once. + await simulator.waitForScanStartCount(2); + await simulator.waitForScanStopCount(2); + }); +}); diff --git a/tests/integration/helpers/buttplugIoServerSimulator.ts b/tests/integration/helpers/buttplugIoServerSimulator.ts index 93881bf4..6f7a9a82 100644 --- a/tests/integration/helpers/buttplugIoServerSimulator.ts +++ b/tests/integration/helpers/buttplugIoServerSimulator.ts @@ -72,6 +72,11 @@ export class ButtplugIoServerSimulator { scalar: number; }> = []; + /** StartScanning / StopScanning requests seen so far, plus waiters targeting a given count. */ + private scanStartCount = 0; + private scanStopCount = 0; + private scanWaiters: Array<{ kind: 'start' | 'stop'; target: number; resolve: () => void }> = []; + public async start(): Promise { return new Promise((resolve, reject) => { this.server = createServer(); @@ -113,6 +118,38 @@ export class ButtplugIoServerSimulator { }); } + /** Resolves once the client has issued at least `target` StartScanning requests. */ + public waitForScanStartCount(target: number, timeoutMs = 5000): Promise { + return this.waitForScanCount('start', target, timeoutMs); + } + + /** Resolves once the client has issued at least `target` StopScanning requests. */ + public waitForScanStopCount(target: number, timeoutMs = 5000): Promise { + return this.waitForScanCount('stop', target, timeoutMs); + } + + private waitForScanCount(kind: 'start' | 'stop', target: number, timeoutMs: number): Promise { + const current = kind === 'start' ? this.scanStartCount : this.scanStopCount; + if (current >= target) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const seen = kind === 'start' ? this.scanStartCount : this.scanStopCount; + reject(new Error(`Timed out waiting for ${target} ${kind}-scan(s), saw ${seen} (>${timeoutMs}ms)`)); + }, timeoutMs); + this.scanWaiters.push({ kind, target, resolve: () => { clearTimeout(timer); resolve(); } }); + }); + } + + private resolveScanWaiters(kind: 'start' | 'stop', count: number): void { + for (const waiter of this.scanWaiters) { + if (waiter.kind === kind && count >= waiter.target) waiter.resolve(); + } + this.scanWaiters = this.scanWaiters.filter(w => w.kind !== kind || count < w.target); + } + public async stop(): Promise { for (const ws of this.connectedClients) { ws.close(); @@ -189,10 +226,15 @@ export class ButtplugIoServerSimulator { for (const resolve of this.clientReadyResolvers) resolve(); this.clientReadyResolvers = []; } else if (msg instanceof StartScanning) { + this.scanStartCount++; ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); - ws.send(`[{"ScanningFinished":{"Id":0}}]`); + // Model the desktop websocket server: scanning runs until StopScanning is sent, so we + // do NOT emit ScanningFinished here (that is the WebBluetooth/WASM behaviour instead). + this.resolveScanWaiters('start', this.scanStartCount); } else if (msg instanceof StopScanning) { + this.scanStopCount++; ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); + this.resolveScanWaiters('stop', this.scanStopCount); } else if (msg instanceof ScalarCmd) { for (const s of msg.Scalars) { this.receivedScalarCmds.push({ From 9fea5d7c6f8a812ef11b531a9acb85feacf91d3b Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 21:39:35 +0200 Subject: [PATCH 15/35] Drop the auto-scan duty-cycle test and its injectable timers The dedicated auto-scan test surfaced a pre-existing flake in the 'device refreshes' test that is worth investigating on its own, so remove the new test (and the injectable interval/duration params + simulator scan-tracking that only it used) for now. The named scan-timing constants stay. The refresh flake will be looked at in a separate PR. --- .../buttplugIoWebsocketDeviceProvider.ts | 12 ++--- ...uttplugIoWebsocketDeviceProviderFactory.ts | 8 +--- .../devices/buttplugIoDevice.spec.ts | 48 ------------------- .../helpers/buttplugIoServerSimulator.ts | 44 +---------------- 4 files changed, 6 insertions(+), 106 deletions(-) diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 62a97d50..940dda39 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -40,8 +40,6 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< private readonly websocketAddress: string; private readonly autoScan: boolean; private readonly useDeviceNameAsId: boolean; - private readonly autoScanIntervalMs: number; - private readonly scanDurationMs: number; private connectionIntervalRef?: NodeJS.Timeout; private autoScanningIntervalRef?: NodeJS.Timeout; @@ -53,17 +51,13 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< websocketAddress: string, autoScan: boolean, useDeviceNameAsId: boolean, - logger: Logger, - autoScanIntervalMs: number = ButtplugIoWebsocketDeviceProvider.AUTO_SCAN_INTERVAL_MS, - scanDurationMs: number = ButtplugIoWebsocketDeviceProvider.SCAN_DURATION_MS + logger: Logger ) { super(deviceManager, eventEmitter, logger.child({ name: ButtplugIoWebsocketDeviceProvider.name })); this.buttplugIoDeviceFactory = deviceFactory; this.websocketAddress = websocketAddress; this.autoScan = autoScan; this.useDeviceNameAsId = useDeviceNameAsId; - this.autoScanIntervalMs = autoScanIntervalMs; - this.scanDurationMs = scanDurationMs; const url = `ws://${this.websocketAddress}/buttplug`; @@ -122,7 +116,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.connectionIntervalRef = undefined; if (this.autoScan) { - this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, this.autoScanIntervalMs); + this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, ButtplugIoWebsocketDeviceProvider.AUTO_SCAN_INTERVAL_MS); } } catch (e: unknown) { logError(this.logger, `Could not connect to buttplug.io server (${url})`, hasProperty(e, 'message') ? e.message : 'unknown'); @@ -167,7 +161,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.buttplugClient.stopScanning() .then(() => this.logger.info('Stop scanning for Buttplug.io devices')) .catch((e: unknown) => this.logger.error(`Could not stop scanning for buttplug.io devices`, e)); - }, this.scanDurationMs); + }, ButtplugIoWebsocketDeviceProvider.SCAN_DURATION_MS); } private toDeviceInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceInfo { diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts index 7c6de59c..8f7064d6 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts @@ -8,9 +8,7 @@ import DeviceManager from '../../deviceManager.js'; type ButtplugIoWebsocketConfig = { address: string, autoScan: boolean, - useDeviceNameAsId: boolean, - autoScanIntervalMs?: number, - scanDurationMs?: number + useDeviceNameAsId: boolean } export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceProviderFactory @@ -44,9 +42,7 @@ export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceP config.address, config.autoScan, config.useDeviceNameAsId, - this.logger, - config.autoScanIntervalMs, - config.scanDurationMs + this.logger ); } } diff --git a/tests/integration/devices/buttplugIoDevice.spec.ts b/tests/integration/devices/buttplugIoDevice.spec.ts index 7a424309..5b6c7312 100644 --- a/tests/integration/devices/buttplugIoDevice.spec.ts +++ b/tests/integration/devices/buttplugIoDevice.spec.ts @@ -1,6 +1,4 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import EventEmitter from 'events'; import request from 'supertest'; import { io as ioClient } from 'socket.io-client'; import WebSocketEvent from '../../../src/device/webSocketEvent.js'; @@ -9,9 +7,6 @@ import { ButtplugIoServerSimulator } from '../helpers/buttplugIoServerSimulator. import { ActuatorType, SensorType } from 'buttplug'; import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; import ButtplugIoWebsocketDeviceProvider from '../../../src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; -import ButtplugIoDeviceFactory from '../../../src/device/protocol/buttplugIo/buttplugIoDeviceFactory.js'; -import DeviceManager from '../../../src/device/deviceManager.js'; -import Logger from '../../../src/logging/Logger.js'; const BUTTPLUG_SOURCE_ID = 'd5e6f7a8-5678-4321-abcd-ef1234567894'; @@ -198,46 +193,3 @@ describe('Buttplug.io device lifecycle', () => { expect(res.body.count).toBe(0); }); }); - -describe('Buttplug.io auto scanning', () => { - let simulator: ButtplugIoServerSimulator; - let provider: ButtplugIoWebsocketDeviceProvider; - - beforeAll(async () => { - simulator = new ButtplugIoServerSimulator(); - const simulatorPort = await simulator.start(); - - const logger = mock(); - logger.child.mockReturnValue(logger); - - // Construct the provider directly (no full app) so this test needs neither the shared - // BLE/noble teardown nor the device-lifecycle app, and its repeated scanning can't - // interfere with the other suite's timing. Tiny timers keep the duty-cycle fast. - provider = new ButtplugIoWebsocketDeviceProvider( - mock(), - new EventEmitter(), - mock(), - `127.0.0.1:${simulatorPort}`, - true, - true, - logger, - 40, - 15, - ); - - await provider.init(); - }); - - afterAll(async () => { - await provider.stop(); - await simulator.stop(); - }); - - it('runs a repeating start/stop scan duty-cycle', async () => { - // The provider should keep cycling: start a scan, stop it after the scan duration, then - // start another after the interval. Seeing multiple starts *and* stops proves the whole - // duty-cycle is looping rather than firing once. - await simulator.waitForScanStartCount(2); - await simulator.waitForScanStopCount(2); - }); -}); diff --git a/tests/integration/helpers/buttplugIoServerSimulator.ts b/tests/integration/helpers/buttplugIoServerSimulator.ts index 6f7a9a82..93881bf4 100644 --- a/tests/integration/helpers/buttplugIoServerSimulator.ts +++ b/tests/integration/helpers/buttplugIoServerSimulator.ts @@ -72,11 +72,6 @@ export class ButtplugIoServerSimulator { scalar: number; }> = []; - /** StartScanning / StopScanning requests seen so far, plus waiters targeting a given count. */ - private scanStartCount = 0; - private scanStopCount = 0; - private scanWaiters: Array<{ kind: 'start' | 'stop'; target: number; resolve: () => void }> = []; - public async start(): Promise { return new Promise((resolve, reject) => { this.server = createServer(); @@ -118,38 +113,6 @@ export class ButtplugIoServerSimulator { }); } - /** Resolves once the client has issued at least `target` StartScanning requests. */ - public waitForScanStartCount(target: number, timeoutMs = 5000): Promise { - return this.waitForScanCount('start', target, timeoutMs); - } - - /** Resolves once the client has issued at least `target` StopScanning requests. */ - public waitForScanStopCount(target: number, timeoutMs = 5000): Promise { - return this.waitForScanCount('stop', target, timeoutMs); - } - - private waitForScanCount(kind: 'start' | 'stop', target: number, timeoutMs: number): Promise { - const current = kind === 'start' ? this.scanStartCount : this.scanStopCount; - if (current >= target) { - return Promise.resolve(); - } - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const seen = kind === 'start' ? this.scanStartCount : this.scanStopCount; - reject(new Error(`Timed out waiting for ${target} ${kind}-scan(s), saw ${seen} (>${timeoutMs}ms)`)); - }, timeoutMs); - this.scanWaiters.push({ kind, target, resolve: () => { clearTimeout(timer); resolve(); } }); - }); - } - - private resolveScanWaiters(kind: 'start' | 'stop', count: number): void { - for (const waiter of this.scanWaiters) { - if (waiter.kind === kind && count >= waiter.target) waiter.resolve(); - } - this.scanWaiters = this.scanWaiters.filter(w => w.kind !== kind || count < w.target); - } - public async stop(): Promise { for (const ws of this.connectedClients) { ws.close(); @@ -226,15 +189,10 @@ export class ButtplugIoServerSimulator { for (const resolve of this.clientReadyResolvers) resolve(); this.clientReadyResolvers = []; } else if (msg instanceof StartScanning) { - this.scanStartCount++; ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); - // Model the desktop websocket server: scanning runs until StopScanning is sent, so we - // do NOT emit ScanningFinished here (that is the WebBluetooth/WASM behaviour instead). - this.resolveScanWaiters('start', this.scanStartCount); + ws.send(`[{"ScanningFinished":{"Id":0}}]`); } else if (msg instanceof StopScanning) { - this.scanStopCount++; ws.send(`[{"Ok":{"Id":${msg.Id}}}]`); - this.resolveScanWaiters('stop', this.scanStopCount); } else if (msg instanceof ScalarCmd) { for (const s of msg.Scalars) { this.receivedScalarCmds.push({ From 150ed88c5e2fd9b6b6232c8024edaee720e95537 Mon Sep 17 00:00:00 2001 From: HRS Date: Sat, 18 Jul 2026 22:08:15 +0200 Subject: [PATCH 16/35] Remove unnecessary comments, rename some things for clarity --- src/app.ts | 4 +- .../buttplugIo/buttplugIoDeviceFactory.ts | 4 +- .../buttplugIoWebsocketDeviceProvider.ts | 14 +++-- .../protocol/virtual/virtualDeviceProvider.ts | 13 ----- src/device/provider/deviceProvider.ts | 53 ++----------------- src/device/provider/deviceProviderManager.ts | 14 ++--- .../provider/deviceProviderManager.spec.ts | 22 ++++---- 7 files changed, 32 insertions(+), 92 deletions(-) diff --git a/src/app.ts b/src/app.ts index 84b31808..36c0cc38 100644 --- a/src/app.ts +++ b/src/app.ts @@ -132,7 +132,7 @@ const configureWebsocket = (io: WebsocketServer, container: Container logError(logger, 'Failed to apply device enabled/disabled changes', e)); container.get('device.provider.loader') - .reload(settings) + .loadFromSettings(settings) .catch(e => logError(logger, 'Failed to reload device sources after settings change', e)); }); @@ -148,7 +148,7 @@ const loadDeviceProviders = (container: Container): void => { const deviceProviderManager = container.get('device.provider.loader'); deviceProviderManager - .reload(settings) + .loadFromSettings(settings) .catch(e => logError(logger, `Loading device providers failed`, e)); serialPortObserver.start().catch(e => logError(logger, `Initializing serial port observer failed`, e)); diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index 9dd4a4d7..d6f24e93 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -115,7 +115,7 @@ export default class ButtplugIoDeviceFactory return attributes; } - public computeDeviceId(buttplugDevice: ButtplugClientDevice, useDeviceNameAsId: boolean): DeviceId { + public static computeDeviceId(buttplugDevice: ButtplugClientDevice, useDeviceNameAsId: boolean): DeviceId { // Since we don't get a unique identifier for the Bluetooth device from Intiface, // we need to use the index assigned to the device by Intiface. It's the best we have. // or the name if using Intiface-engine without id persistence @@ -124,7 +124,7 @@ export default class ButtplugIoDeviceFactory } private resolveKnownDevice(buttplugDevice: ButtplugClientDevice, provider: string, useDeviceNameAsId: boolean): KnownDevice { - const deviceId = this.computeDeviceId(buttplugDevice, useDeviceNameAsId); + const deviceId = ButtplugIoDeviceFactory.computeDeviceId(buttplugDevice, useDeviceNameAsId); return this.knownDeviceRegistry.resolve( deviceId, diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 940dda39..a7a30c81 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -21,7 +21,6 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< > { public static readonly providerName = 'buttplugIoWebsocket'; - // How often to (re)attempt connecting to the Intiface/buttplug.io server while disconnected. private static readonly CONNECT_RETRY_INTERVAL_MS = 1_000; // How often a fresh scan cycle is kicked off while `autoScan` is enabled and we're connected. @@ -75,7 +74,10 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< } public override async init(): Promise { - this.connectionIntervalRef ??= setImmediateInterval(() => void this.connectToServer(), ButtplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS); + this.connectionIntervalRef ??= setImmediateInterval( + () => void this.connectToServer(), + ButtplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS + ); } public override async stop(): Promise { @@ -126,8 +128,6 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< private async handleLostConnection(url: string): Promise { this.logger.info(`Lost connection to buttplug.io server (${url})`); - // As the whole websocket connection is lost there aren't any 'deviceremoved' events for the - // connected Buttplug.io devices. They need to be removed manually instead. for (const device of this.getConnectedDevices()) { await this.removeButtplugIoDevice(device.getButtplugClientDevice); } @@ -135,8 +135,6 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< clearInterval(this.autoScanningIntervalRef); this.autoScanningIntervalRef = undefined; - // Don't reconnect if we're shutting down - stop() removes the listeners, but a disconnect - // may already be in flight when it runs. if (this.isStopped()) { return; } @@ -165,7 +163,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< } private toDeviceInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceInfo { - const deviceId = this.buttplugIoDeviceFactory.computeDeviceId(buttplugDevice, this.useDeviceNameAsId); + const deviceId = ButtplugIoDeviceFactory.computeDeviceId(buttplugDevice, this.useDeviceNameAsId); return { type: 'buttplugIo', id: deviceId, buttplugClientDevice: buttplugDevice }; } @@ -196,7 +194,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< } private async removeButtplugIoDevice(buttplugDevice: ButtplugClientDevice): Promise { - const deviceId = this.buttplugIoDeviceFactory.computeDeviceId(buttplugDevice, this.useDeviceNameAsId); + const deviceId = ButtplugIoDeviceFactory.computeDeviceId(buttplugDevice, this.useDeviceNameAsId); const device = this.getConnectedDevice(deviceId); if (undefined === device) { diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index aab92260..9f413a49 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -47,15 +47,12 @@ export default class VirtualDeviceProvider extends DeviceProvider { this.settingsManager.on(SettingsEventType.changed, this.settingsChangedListener); - // Load whatever is already configured once, without waiting for the first settings - // change. Further additions/removals are picked up reactively via settingsChangedListener. await this.discoverVirtualDevices(); } public override async stop(): Promise { this.settingsManager.off(SettingsEventType.changed, this.settingsChangedListener); - // Detaches the detection listener and closes/clears the connected devices. await super.stop(); } @@ -69,26 +66,16 @@ export default class VirtualDeviceProvider extends DeviceProvider { const settings = this.settingsManager.getSettings(); if (undefined === settings) { - // Settings not loaded yet return; } const virtualDevices = settings.getKnownDevicesBySource(VirtualDeviceProvider.providerName); // Close devices whose known device has been removed from the configuration entirely. - // Devices for merely disabled known devices are closed centrally by the device manager. // Snapshot first, since closing a device mutates the underlying connected-devices map. for (const device of [...this.getConnectedDevices()]) { if (!virtualDevices.has(device.getDeviceId)) { diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index 8d3883f8..d35817a8 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -6,24 +6,8 @@ import { logError } from '../../util/error.js'; import { AnyDevice, DeviceEvent } from '../device.js'; import { DeviceId } from '../deviceId.js'; -/** - * Base class for all device providers. Providers discover devices through the device manager's - * detection pipeline (`announceDetectedDevice` -> `deviceDetected` -> acquire -> add) and only - * have to say which `DeviceInfo` they handle and how to turn it into a `Device`: - * - * 1. filter the `deviceDetected` event to the infos this provider handles (`supportsDeviceInfo`) - * 2. acquire the device from the manager (losing the race to another provider is fine) - * 3. create the actual device (`createDevice`) - the one genuinely provider-specific step - * 4. hand it to `DeviceManager.addDevice()`, which owns the enabled/disabled decision and all the - * acquire-queue bookkeeping (claim on success, release + retry on rejection) - * - * Connected devices are tracked locally (keyed by their final `getDeviceId`) so `stop()` can close - * exactly the devices this provider owns, and are removed again automatically on disconnect. - * - * Providers that discover devices by some other means (e.g. reacting to settings changes) still - * feed those devices through the same pipeline by calling `announceDetectedDevice()`, so the - * central enabled/disabled handling applies uniformly. - */ +export type AnyDeviceProvider = DeviceProvider; + export default abstract class DeviceProvider< DI extends DeviceInfo, D extends AnyDevice @@ -39,10 +23,6 @@ export default abstract class DeviceProvider< private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; - // Removing the `deviceDetected` listener only blocks *new* detections; a `handleDeviceDetection` - // call already awaiting acquisition/creation can still complete after `stop()`. This flag lets - // that in-flight handler bail out and clean up instead of registering a device on a stopped - // provider. Subclasses may read it (via `isStopped()`) to guard their own async work. private stopped: boolean = false; protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { @@ -109,8 +89,6 @@ export default abstract class DeviceProvider< return; } - // The provider may have been stopped while `createDevice` was in flight. Don't register a - // device on a stopped provider - close it and release the claim instead. if (undefined === device || this.stopped) { if (undefined !== device) { await device.close(); @@ -119,14 +97,11 @@ export default abstract class DeviceProvider< return; } - // Keep local bookkeeping in sync regardless of what closes the device, e.g. the device - // manager closing it right away because it turned out to belong to a disabled device. device.on(DeviceEvent.deviceDisconnected, (d) => this.connectedDevices.delete(d.getDeviceId)); if (!this.deviceManager.addDevice(deviceInfo, device)) { - // The device's final id (only known after connecting) belongs to a disabled known - // device - addDevice() has already closed it, released it from the acquire queue, and - // registered it for retry once re-enabled. + // The device has not been added by the device manager. + // For example, it may be a disabled device. return; } @@ -145,32 +120,12 @@ export default abstract class DeviceProvider< this.deviceManager.releaseDetectedDevice(deviceInfo.id); } - /** - * Type guard selecting the `DeviceInfo`s this provider is responsible for. Detection events - * for infos of other providers are ignored. - */ protected abstract supportsDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is DI; - /** - * Turns a detected device info into an actual connected `Device`, or `undefined` if it could - * not be connected/identified (e.g. a failed handshake). Throwing is also allowed and treated - * the same as returning `undefined`, additionally invoking `onConnectFailed()`. - */ protected abstract createDevice(deviceInfo: DI): Promise; - /** - * Called after a failed or aborted connection attempt so subclasses can release any - * transport-level resources they hold (e.g. disconnecting a BLE peripheral). No-op by default. - */ // eslint-disable-next-line @typescript-eslint/no-unused-vars protected async onConnectFailed(deviceInfo: DI): Promise { return Promise.resolve(); } } - -/** - * A device-info- and device-type-agnostic view of a `DeviceProvider`, for the places that handle - * "some provider" without caring about the concrete `DeviceInfo`/`Device` it produces (the - * provider manager and factories). - */ -export type AnyDeviceProvider = DeviceProvider; diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index 69449a96..f76a7e5b 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -19,7 +19,7 @@ export default class DeviceProviderManager * map, otherwise a later call could observe a half-finished earlier one and reach the wrong * conclusion about whether a provider is already running. */ - private operationChain: Promise = Promise.resolve(); + private operationQueue: Promise = Promise.resolve(); public constructor( factories: Map>, @@ -36,21 +36,21 @@ export default class DeviceProviderManager * device sources that are still enabled are left untouched. Known devices being individually * enabled/disabled is handled centrally by `DeviceManager`, not here. */ - public reload(settings: Settings): Promise + public loadFromSettings(settings: Settings): Promise { - return this.enqueue(() => this.doReload(settings)); + return this.enqueueOperation(() => this.doReload(settings)); } public stopProviders(): Promise { - return this.enqueue(() => this.doStopProviders()); + return this.enqueueOperation(() => this.doStopProviders()); } - private enqueue(operation: () => Promise): Promise { - const result = this.operationChain.then(operation, operation); + private enqueueOperation(operation: () => Promise): Promise { + const result = this.operationQueue.then(operation, operation); // Swallow rejections in the chain itself (each caller still gets the real // rejection via `result`), so a failed operation doesn't permanently wedge the queue. - this.operationChain = result.catch(() => undefined); + this.operationQueue = result.catch(() => undefined); return result; } diff --git a/tests/unit/device/provider/deviceProviderManager.spec.ts b/tests/unit/device/provider/deviceProviderManager.spec.ts index 6423b7bb..46e0fcee 100644 --- a/tests/unit/device/provider/deviceProviderManager.spec.ts +++ b/tests/unit/device/provider/deviceProviderManager.spec.ts @@ -87,7 +87,7 @@ describe('DeviceProviderManager', () => { const provider = new RecordingDeviceProvider(); const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); - await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); expect(provider.initCalls).toBe(1); expect(provider.stopCalls).toBe(0); @@ -97,7 +97,7 @@ describe('DeviceProviderManager', () => { const provider = new RecordingDeviceProvider(); const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); - await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); expect(provider.initCalls).toBe(0); }); @@ -106,10 +106,10 @@ describe('DeviceProviderManager', () => { const provider = new RecordingDeviceProvider(); const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); - await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); expect(provider.initCalls).toBe(1); - await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); expect(provider.stopCalls).toBe(1); }); @@ -117,10 +117,10 @@ describe('DeviceProviderManager', () => { const provider = new RecordingDeviceProvider(); const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); - await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); expect(provider.initCalls).toBe(1); - await manager.reload(makeSettings([])); + await manager.loadFromSettings(makeSettings([])); expect(provider.stopCalls).toBe(1); }); @@ -138,7 +138,7 @@ describe('DeviceProviderManager', () => { const manager = new DeviceProviderManager(factories, makeLogger()); - await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); expect(providerA.initCalls).toBe(1); // Make the disabling reload()'s stop() call slow, so it's still in-flight when the very @@ -146,8 +146,8 @@ describe('DeviceProviderManager', () => { let releaseStop: () => void = () => undefined; providerA.setStopGate(new Promise((resolve) => { releaseStop = resolve; })); - const disablePromise = manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); - const reenablePromise = manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + const disablePromise = manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); + const reenablePromise = manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); // Let the slow stop() call finish now that both reload() calls have been queued. releaseStop(); @@ -170,14 +170,14 @@ describe('DeviceProviderManager', () => { ]); const manager = new DeviceProviderManager(factories, makeLogger()); - await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); await manager.stopProviders(); expect(provider.stopResolved).toBe(true); // After stopProviders(), a subsequent reload() on the SAME manager with the same enabled // source must create a fresh provider - proving stopProviders() cleared its internal state. - await manager.reload(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); expect(provider2.initCalls).toBe(1); }); }); From 4de46dfc1ac500bf39cc4e1d2741ab7c6ba2bc44 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 09:12:35 +0200 Subject: [PATCH 17/35] Rename DeviceInfo to DeviceDetectionInfo, disambiguate detection vs canonical device ids The detection-time device id and the final/canonical device id (device.getDeviceId, which can differ for protocols that only learn their real id post-handshake, e.g. zc95 firmware >=2.0) were both loosely called 'id', with only the retry-bookkeeping field carrying a distinguishing name (enablementId) - backwards, since that field held the more authoritative of the two. Rename for clarity: - DeviceInfo -> DeviceDetectionInfo (and its BleDeviceInfo/SerialDeviceInfo/ ButtplugIoDeviceInfo/VirtualDeviceInfo variants -> ...DeviceDetectionInfo), matching the vocabulary already used elsewhere (announceDetectedDevice, acquireDetectedDevice, detectedDeviceAcquireQueue, deviceDetected event). - DeviceDetectionInfo.id -> detectionId, naming it for what it is: the id assigned at detection time, before canonical identity may be resolved. - enablementId -> canonicalId, naming it for what it is: the device's final, authoritative id. Pure identifier rename, no logic changes. The unrelated DeviceInfo type in slvCtrlProtocol.ts (wire-protocol handshake payload) is untouched. --- src/device/deviceManager.ts | 82 ++++++++----------- .../protocol/airotic/airoticDeviceProvider.ts | 10 +-- .../buttplugIoWebsocketDeviceProvider.ts | 14 ++-- .../estim2b/estim2bSerialDeviceProvider.ts | 6 +- .../slvCtrlPlusSerialDeviceProvider.ts | 6 +- .../protocol/virtual/virtualDeviceProvider.ts | 12 +-- .../protocol/zc95/zc95SerialDeviceProvider.ts | 6 +- src/device/provider/bleDeviceProvider.ts | 14 ++-- src/device/provider/deviceProvider.ts | 22 ++--- src/device/provider/serialDeviceProvider.ts | 12 +-- src/device/transport/bleObserver.ts | 8 +- src/device/transport/serialPortObserver.ts | 10 +-- tests/unit/device/deviceManager.spec.ts | 32 ++++---- .../provider/deviceProviderManager.spec.ts | 8 +- .../unit/device/transport/bleObserver.spec.ts | 2 +- .../transport/serialPortObserver.spec.ts | 10 +-- 16 files changed, 120 insertions(+), 134 deletions(-) diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 64bea6e0..d27d855b 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -7,9 +7,9 @@ import { logError } from '../util/error.js'; import { DeviceId } from './deviceId.js'; import SettingsManager from '../settings/settingsManager.js'; -export type DeviceInfo = { +export type DeviceDetectionInfo = { type: string; - id: DeviceId; + detectionId: DeviceId; }; export enum DeviceManagerEvent { @@ -28,7 +28,7 @@ type DeviceManagerEventMap = { [DeviceManagerEvent.deviceConnected]: [device: AnyDevice]; [DeviceManagerEvent.deviceDisconnected]: [device: AnyDevice]; [DeviceManagerEvent.deviceRefreshed]: [device: AnyDevice]; - [DeviceManagerEvent.deviceDetected]: [deviceInfo: DeviceInfo]; + [DeviceManagerEvent.deviceDetected]: [deviceInfo: DeviceDetectionInfo]; [DeviceManagerEvent.deviceNotification]: [device: AnyDevice, notification: DeviceNotification]; } @@ -50,12 +50,13 @@ export default class DeviceManager * final device id can only be determined post-handshake). Re-announced once their known * device gets (re-)enabled, see `onSettingsChanged()`. * - * `enablementId` is the id whose enablement gates the retry: it is the device's final, - * canonical id (which may differ from the preliminary `deviceInfo.id` for protocols that only - * learn their real id during a handshake), so a retry only happens once *that* device is - * enabled - not on every unrelated settings change. `deviceInfo` is what gets re-announced. + * `canonicalId` is the id whose enablement gates the retry: it is the device's final, + * canonical id (which may differ from the preliminary `deviceInfo.detectionId` for protocols + * that only learn their real id during a handshake), so a retry only happens once *that* + * device is enabled - not on every unrelated settings change. `deviceInfo` is what gets + * re-announced. */ - private readonly pendingDisabledDevices: Map = new Map(); + private readonly pendingDisabledDevices: Map = new Map(); public constructor( eventEmitter: EventEmitter, @@ -69,58 +70,49 @@ export default class DeviceManager this.settingsManager = settingsManager; } - /** - * This is the single, authoritative place deciding whether a known device is currently - * allowed to be connected. Any protocol-specific id assigned before a device's identity is - * fully resolved (e.g. during a handshake) may differ from its final, canonical device id - * (`Device.getDeviceId`), so this check is only truly reliable once called with that final - * id - which is exactly what `addDevice()` does below. Callers with only a preliminary id - * (e.g. providers deciding whether it's worth attempting a connection at all) may still use - * this as a best-effort optimization, but must not treat a resulting `true` as a guarantee. - */ public isDeviceEnabled(deviceId: DeviceId): boolean { return this.settingsManager.getSettings()?.getKnownDeviceById(deviceId)?.enabled ?? true; } - public announceDetectedDevice(deviceInfo: DeviceInfo): void + public announceDetectedDevice(deviceInfo: DeviceDetectionInfo): void { - if (this.detectedDeviceAcquireQueue.has(deviceInfo.id)) { + if (this.detectedDeviceAcquireQueue.has(deviceInfo.detectionId)) { return; } - if (this.connectedDevices.has(deviceInfo.id)) { - this.logger.debug(`Device with id '${deviceInfo.id}' is already connected, not announcing it as detected`); + if (this.connectedDevices.has(deviceInfo.detectionId)) { + this.logger.debug(`Device with id '${deviceInfo.detectionId}' is already connected, not announcing it as detected`); return; } - if (!this.isDeviceEnabled(deviceInfo.id)) { - this.logger.debug(`Device with id '${deviceInfo.id}' is disabled, not announcing it as detected`); - // At announcement time no connection has happened yet, so the preliminary id is the - // only id we have; it also doubles as the enablement id here. - this.registerPendingRetry(deviceInfo, deviceInfo.id); + if (!this.isDeviceEnabled(deviceInfo.detectionId)) { + this.logger.debug(`Device with id '${deviceInfo.detectionId}' is disabled, not announcing it as detected`); + // At announcement time no connection has happened yet, so the preliminary detection id + // is the only id we have; it also doubles as the canonical id here. + this.registerPendingRetry(deviceInfo, deviceInfo.detectionId); return; } - this.logger.info(`Detected new device with id ${deviceInfo.id}`); + this.logger.info(`Detected new device with id ${deviceInfo.detectionId}`); - this.detectedDeviceAcquireQueue.set(deviceInfo.id, []); + this.detectedDeviceAcquireQueue.set(deviceInfo.detectionId, []); const hadListeners = this.eventEmitter.emit(DeviceManagerEvent.deviceDetected, deviceInfo); if (!hadListeners) { // no subscribed providers, remove empty list from acquire queue for this device - this.logger.info(`No provider available for detected device with id '${deviceInfo.id}'`); - this.detectedDeviceAcquireQueue.delete(deviceInfo.id); + this.logger.info(`No provider available for detected device with id '${deviceInfo.detectionId}'`); + this.detectedDeviceAcquireQueue.delete(deviceInfo.detectionId); } } - public revokeDetectedDevice(deviceInfo: DeviceInfo): void + public revokeDetectedDevice(deviceInfo: DeviceDetectionInfo): void { // A device that has physically disappeared should no longer be retried once its known // device gets re-enabled, so drop any pending-retry entry alongside the acquire queue. - // The pending map is keyed by the preliminary detection id (deviceInfo.id). - this.pendingDisabledDevices.delete(deviceInfo.id); - this.clearDetectedDeviceAcquireQueue(deviceInfo.id, `Device with id '${deviceInfo.id}' has disappeared`); + // The pending map is keyed by the preliminary detection id (deviceInfo.detectionId). + this.pendingDisabledDevices.delete(deviceInfo.detectionId); + this.clearDetectedDeviceAcquireQueue(deviceInfo.detectionId, `Device with id '${deviceInfo.detectionId}' has disappeared`); } public async acquireDetectedDevice(deviceId: DeviceId): Promise @@ -171,7 +163,7 @@ export default class DeviceManager * `announceDetectedDevice()`), used to resolve that pipeline's bookkeeping: claiming it on * success, or releasing it and registering it for retry on rejection. */ - public addDevice(deviceInfo: DeviceInfo, device: AnyDevice): boolean + public addDevice(deviceInfo: DeviceDetectionInfo, device: AnyDevice): boolean { if (!this.isDeviceEnabled(device.getDeviceId)) { this.logger.info(`Not adding device '${device.getDeviceId}' since it is disabled`); @@ -180,7 +172,7 @@ export default class DeviceManager // The final, canonical id (device.getDeviceId) is the one that was found disabled and // must therefore gate the retry - not the preliminary detection id. this.registerPendingRetry(deviceInfo, device.getDeviceId); - this.releaseDetectedDevice(deviceInfo.id); + this.releaseDetectedDevice(deviceInfo.detectionId); return false; } @@ -195,26 +187,20 @@ export default class DeviceManager this.eventEmitter.emit(DeviceManagerEvent.deviceConnected, device); - this.claimDetectedDevice(deviceInfo.id); + this.claimDetectedDevice(deviceInfo.detectionId); return true; } /** - * Registers a device for retry once the known device identified by `enablementId` gets + * Registers a device for retry once the known device identified by `canonicalId` gets * (re-)enabled. Keyed by the preliminary detection id so `revokeDetectedDevice()` (which only * has that id) can still drop it when the device disappears. */ - private registerPendingRetry(deviceInfo: DeviceInfo, enablementId: DeviceId): void { - this.pendingDisabledDevices.set(deviceInfo.id, { deviceInfo, enablementId }); + private registerPendingRetry(deviceInfo: DeviceDetectionInfo, canonicalId: DeviceId): void { + this.pendingDisabledDevices.set(deviceInfo.detectionId, { deviceInfo, canonicalId }); } - /** - * Closes any currently connected device whose known device has since been disabled, and - * re-announces any previously rejected device whose known device has since been (re-)enabled - * - letting it run through the exact same detection pipeline as a brand new device. Should be - * called whenever settings change. - */ public async onSettingsChanged(): Promise { for (const device of this.connectedDevices.values()) { if (this.isDeviceEnabled(device.getDeviceId)) { @@ -230,8 +216,8 @@ export default class DeviceManager } } - for (const [detectionId, { deviceInfo, enablementId }] of this.pendingDisabledDevices) { - if (!this.isDeviceEnabled(enablementId)) { + for (const [detectionId, { deviceInfo, canonicalId }] of this.pendingDisabledDevices) { + if (!this.isDeviceEnabled(canonicalId)) { continue; } diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index c4171972..f2f48c89 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -4,7 +4,7 @@ import DeviceManager from '../../deviceManager.js'; import AiroticDevice from './airoticDevice.js'; import Logger from '../../../logging/Logger.js'; import { promiseWithTimeout } from '../../../util/async.js'; -import { BleDeviceInfo } from '../../transport/bleObserver.js'; +import { BleDeviceDetectionInfo } from '../../transport/bleObserver.js'; import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; import AiroticProtocol from './airoticProtocol.js'; import MessageResponseHandler from '../messageResponseHandler.js'; @@ -35,14 +35,14 @@ export default class AiroticDeviceProvider extends BleDeviceProvider { + protected override async connectBleDevice(deviceInfo: BleDeviceDetectionInfo): Promise { const transport = await promiseWithTimeout(BleUartDeviceTransport.create( deviceInfo.peripheral, AiroticDeviceProvider.UART_RX_CHAR_UUID, AiroticDeviceProvider.UART_TX_CHAR_UUID - ), 5000, `Timed out while creating BLE transport for device ${deviceInfo.id}`); + ), 5000, `Timed out while creating BLE transport for device ${deviceInfo.detectionId}`); - this.logger.debug(`Connected to device: ${deviceInfo.id}`); + this.logger.debug(`Connected to device: ${deviceInfo.detectionId}`); const protocol = new AiroticProtocol(); const messageResponseHandler = MessageResponseHandler.create(protocol, transport, this.logger, 2000); @@ -55,7 +55,7 @@ export default class AiroticDeviceProvider extends BleDeviceProvider { public static readonly providerName = 'buttplugIoWebsocket'; @@ -162,10 +162,10 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< }, ButtplugIoWebsocketDeviceProvider.SCAN_DURATION_MS); } - private toDeviceInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceInfo { + private toDeviceInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceDetectionInfo { const deviceId = ButtplugIoDeviceFactory.computeDeviceId(buttplugDevice, this.useDeviceNameAsId); - return { type: 'buttplugIo', id: deviceId, buttplugClientDevice: buttplugDevice }; + return { type: 'buttplugIo', detectionId: deviceId, buttplugClientDevice: buttplugDevice }; } /** @@ -179,11 +179,11 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.deviceManager.announceDetectedDevice(this.toDeviceInfo(buttplugDevice)); } - protected override supportsDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is ButtplugIoDeviceInfo { + protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is ButtplugIoDeviceDetectionInfo { return deviceInfo.type === 'buttplugIo'; } - protected override createDevice(deviceInfo: ButtplugIoDeviceInfo): Promise { + protected override createDevice(deviceInfo: ButtplugIoDeviceDetectionInfo): Promise { const device = this.buttplugIoDeviceFactory.create( deviceInfo.buttplugClientDevice, ButtplugIoWebsocketDeviceProvider.providerName, diff --git a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts index e16f64c6..b452b0b5 100644 --- a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts +++ b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts @@ -12,7 +12,7 @@ import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import DeviceManager from '../../deviceManager.js'; -import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; +import { SerialDeviceDetectionInfo } from '../../transport/serialPortObserver.js'; export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { @@ -36,7 +36,7 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider): Promise { + protected async connectSerialDevice(deviceInfo: SerialDeviceDetectionInfo, port: SerialPortStream): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: '\n' })); const syncPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, this.logger); const transport = this.transportFactory.create(syncPort, undefined, Buffer.from('\r')); @@ -55,7 +55,7 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { @@ -40,7 +40,7 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide this.deviceTransportFactory = deviceTransportFactory; } - protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPortStream): Promise + protected async connectSerialDevice(deviceInfo: SerialDeviceDetectionInfo, port: SerialPortStream): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: SlvCtrlProtocol.EOF })); const syncPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, this.logger); @@ -49,7 +49,7 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide await this.performHandshakeWithRetries(transport, 4); const device = await this.slvCtrlPlusDeviceFactory.create( - deviceInfo.id, + deviceInfo.detectionId, transport, SlvCtrlPlusSerialDeviceProvider.providerName ); diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 9f413a49..fbfdc50d 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -6,18 +6,18 @@ import KnownDevice from '../../../settings/knownDevice.js'; import SettingsManager from '../../../settings/settingsManager.js'; import SettingsEventType from '../../../settings/settingsEventType.js'; import type Settings from '../../../settings/settings.js'; -import { DeviceInfo } from '../../deviceManager.js'; +import { DeviceDetectionInfo } from '../../deviceManager.js'; import VirtualDeviceFactory from './virtualDeviceFactory.js'; import DeviceManager from '../../deviceManager.js'; import { asyncHandler } from '../../../util/async.js'; import { logError } from '../../../util/error.js'; -export type VirtualDeviceInfo = DeviceInfo & { +export type VirtualDeviceDetectionInfo = DeviceDetectionInfo & { type: 'virtual'; knownDevice: KnownDevice; }; -export default class VirtualDeviceProvider extends DeviceProvider> +export default class VirtualDeviceProvider extends DeviceProvider> { public static readonly providerName = 'virtual'; @@ -56,11 +56,11 @@ export default class VirtualDeviceProvider extends DeviceProvider | undefined> { + protected override createDevice(deviceInfo: VirtualDeviceDetectionInfo): Promise | undefined> { this.logger.info(`Virtual device detected: ${deviceInfo.knownDevice.name}`, deviceInfo.knownDevice); return this.deviceFactory.create(deviceInfo.knownDevice, VirtualDeviceProvider.providerName); @@ -86,7 +86,7 @@ export default class VirtualDeviceProvider extends DeviceProvider { @@ -37,7 +37,7 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider): Promise { + protected async connectSerialDevice(deviceInfo: SerialDeviceDetectionInfo, port: SerialPortStream): Promise { const serialLogger = this.logger.child({ name: Zc95Device.name }) const parser = port.pipe(new FrameParser({ stx: Zc95Protocol.STX, etx: Zc95Protocol.ETX })); @@ -61,7 +61,7 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider extends DeviceProvider +export default abstract class BleDeviceProvider extends DeviceProvider { protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger); } - protected override supportsDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo { + protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is BleDeviceDetectionInfo { return deviceInfo.type === 'ble'; } - protected override createDevice(deviceInfo: BleDeviceInfo): Promise { + protected override createDevice(deviceInfo: BleDeviceDetectionInfo): Promise { return this.connectBleDevice(deviceInfo); } - protected override async onConnectFailed(deviceInfo: BleDeviceInfo): Promise { + protected override async onConnectFailed(deviceInfo: BleDeviceDetectionInfo): Promise { await this.disconnectPeripheral(deviceInfo.peripheral); } @@ -42,5 +42,5 @@ export default abstract class BleDeviceProvider extends } } - protected abstract connectBleDevice(deviceInfo: BleDeviceInfo): Promise; + protected abstract connectBleDevice(deviceInfo: BleDeviceDetectionInfo): Promise; } diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index d35817a8..35f09838 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -1,15 +1,15 @@ import EventEmitter from 'events'; -import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager.js'; +import DeviceManager, { DeviceDetectionInfo, DeviceManagerEvent } from '../deviceManager.js'; import Logger from '../../logging/Logger.js'; import { asyncHandler } from '../../util/async.js'; import { logError } from '../../util/error.js'; import { AnyDevice, DeviceEvent } from '../device.js'; import { DeviceId } from '../deviceId.js'; -export type AnyDeviceProvider = DeviceProvider; +export type AnyDeviceProvider = DeviceProvider; export default abstract class DeviceProvider< - DI extends DeviceInfo, + DI extends DeviceDetectionInfo, D extends AnyDevice > { @@ -21,7 +21,7 @@ export default abstract class DeviceProvider< private readonly connectedDevices: Map = new Map(); - private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; + private readonly deviceDetectedListener: (deviceInfo: DeviceDetectionInfo) => void; private stopped: boolean = false; @@ -65,14 +65,14 @@ export default abstract class DeviceProvider< return this.connectedDevices.get(deviceId); } - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { - if (!this.supportsDeviceInfo(deviceInfo)) { + private async handleDeviceDetection(deviceInfo: DeviceDetectionInfo): Promise { + if (!this.canHandleDeviceDetectionInfo(deviceInfo)) { return; } - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); + this.logger.debug(`Requesting to acquire device: ${deviceInfo.detectionId}`); - const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); + const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.detectionId); if (!acquireResult.successful) { this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); @@ -84,7 +84,7 @@ export default abstract class DeviceProvider< try { device = await this.createDevice(deviceInfo); } catch (e: unknown) { - logError(this.logger, `Error while connecting to device '${deviceInfo.id}'`, e); + logError(this.logger, `Error while connecting to device '${deviceInfo.detectionId}'`, e); await this.abortDetection(deviceInfo); return; } @@ -117,10 +117,10 @@ export default abstract class DeviceProvider< */ private async abortDetection(deviceInfo: DI): Promise { await this.onConnectFailed(deviceInfo); - this.deviceManager.releaseDetectedDevice(deviceInfo.id); + this.deviceManager.releaseDetectedDevice(deviceInfo.detectionId); } - protected abstract supportsDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is DI; + protected abstract canHandleDeviceDetectionInfo(deviceDetectionInfo: DeviceDetectionInfo): deviceDetectionInfo is DI; protected abstract createDevice(deviceInfo: DI): Promise; diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index c2fd0669..5590fbae 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -7,14 +7,14 @@ import { SerialPortStream } from '@serialport/stream'; import SerialPortFactory from '../../factory/serialPortFactory.js'; import { AutoDetectTypes } from '@serialport/bindings-cpp'; import BaseError from 'modern-errors'; -import DeviceManager, { DeviceInfo } from '../deviceManager.js'; +import DeviceManager, { DeviceDetectionInfo } from '../deviceManager.js'; import { logError } from '../../util/error.js'; -import { SerialDeviceInfo } from '../transport/serialPortObserver.js'; +import { SerialDeviceDetectionInfo } from '../transport/serialPortObserver.js'; import { AnyPeripheralDevice } from '../peripheralDevice.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; -export default abstract class SerialDeviceProvider extends DeviceProvider +export default abstract class SerialDeviceProvider extends DeviceProvider { private readonly serialPortFactory: SerialPortFactory; @@ -29,11 +29,11 @@ export default abstract class SerialDeviceProvider { + protected override async createDevice(deviceInfo: SerialDeviceDetectionInfo): Promise { const portInfo = deviceInfo.portInfo; this.logger.info(`Connection attempt for serial device '${portInfo.path}' (s/n: ${portInfo.serialNumber})`); @@ -87,7 +87,7 @@ export default abstract class SerialDeviceProvider): Promise; + protected abstract connectSerialDevice(deviceInfo: SerialDeviceDetectionInfo, port: SerialPortStream): Promise; protected abstract getSerialDeviceProviderPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; } diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index 8374bb18..49d2b329 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -1,11 +1,11 @@ import noble, { Peripheral } from '@stoprocent/noble'; import Logger from '../../logging/Logger.js'; -import DeviceManager, { DeviceInfo } from '../deviceManager.js'; +import DeviceManager, { DeviceDetectionInfo } from '../deviceManager.js'; import { logError } from '../../util/error.js'; import { DeviceId } from '../deviceId.js'; import { asyncHandler } from '../../util/async.js'; -export type BleDeviceInfo = DeviceInfo & { +export type BleDeviceDetectionInfo = DeviceDetectionInfo & { type: 'ble'; peripheral: Peripheral; }; @@ -67,9 +67,9 @@ export default class BleObserver return; } - const deviceInfo: BleDeviceInfo = { + const deviceInfo: BleDeviceDetectionInfo = { type: 'ble', - id: DeviceId.create(peripheral.id), + detectionId: DeviceId.create(peripheral.id), peripheral, }; diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index a3d78f69..c697069f 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -1,12 +1,12 @@ import { SerialPort } from 'serialport'; import { PortInfo } from '@serialport/bindings-interface'; import Logger from '../../logging/Logger.js'; -import DeviceManager, { DeviceInfo } from '../deviceManager.js'; +import DeviceManager, { DeviceDetectionInfo } from '../deviceManager.js'; import { usb } from 'usb'; import { logError } from '../../util/error.js'; import { DeviceId } from '../deviceId.js'; -export type SerialDeviceInfo = DeviceInfo & { +export type SerialDeviceDetectionInfo = DeviceDetectionInfo & { type: 'serial'; portInfo: PortInfo; }; @@ -19,7 +19,7 @@ export default class SerialPortObserver public static readonly name = 'serial'; - private managedDevices: Map = new Map(); + private managedDevices: Map = new Map(); private onUsbEventRef?: () => void; @@ -84,9 +84,9 @@ export default class SerialPortObserver foundDevices.set(portInfo.serialNumber, null); if (!this.managedDevices.has(portInfo.serialNumber)) { - const deviceInfo: SerialDeviceInfo = { + const deviceInfo: SerialDeviceDetectionInfo = { type: 'serial', - id: DeviceId.create(portInfo.serialNumber), + detectionId: DeviceId.create(portInfo.serialNumber), portInfo }; diff --git a/tests/unit/device/deviceManager.spec.ts b/tests/unit/device/deviceManager.spec.ts index 596669a3..77901675 100644 --- a/tests/unit/device/deviceManager.spec.ts +++ b/tests/unit/device/deviceManager.spec.ts @@ -1,6 +1,6 @@ import {describe, it, expect, beforeEach} from "vitest"; import {mock,mockClear} from "vitest-mock-extended"; -import DeviceManager, { DeviceManagerEvent, DeviceInfo } from "../../../src/device/deviceManager.js"; +import DeviceManager, { DeviceManagerEvent, DeviceDetectionInfo } from "../../../src/device/deviceManager.js"; import {EventEmitter} from "events"; import Device from "../../../src/device/device.js"; import TestDevice from "./testDevice.js"; @@ -30,7 +30,7 @@ describe('deviceManager', () => { // New device connected expect(deviceManager.getConnectedDevices().length).toBe(0); - deviceManager.addDevice({ type: 'test', id: deviceId }, device); + deviceManager.addDevice({ type: 'test', detectionId: deviceId }, device); let actualDevices = deviceManager.getConnectedDevices(); @@ -56,7 +56,7 @@ describe('deviceManager', () => { const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedSettingsManager, mockedLogger); - deviceManager.addDevice({ type: 'test', id: deviceId }, device); + deviceManager.addDevice({ type: 'test', detectionId: deviceId }, device); // Connected device refreshed await device.refresh(); @@ -82,7 +82,7 @@ describe('deviceManager', () => { const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedSettingsManager, mockedLogger); - deviceManager.addDevice({ type: 'test', id: deviceId }, device); + deviceManager.addDevice({ type: 'test', detectionId: deviceId }, device); // Connected device closed await device.close(); @@ -123,7 +123,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-1'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -190,7 +190,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-2'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -233,7 +233,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-3'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -264,7 +264,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-4'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -313,7 +313,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-5'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -396,7 +396,7 @@ describe('deviceManager', () => { const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); - const added = manager.addDevice({ type: 'test', id: deviceId }, device); + const added = manager.addDevice({ type: 'test', detectionId: deviceId }, device); expect(added).toBe(false); expect(manager.getConnectedDevices()).toHaveLength(0); @@ -414,7 +414,7 @@ describe('deviceManager', () => { const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); - const added = manager.addDevice({ type: 'test', id: deviceId }, device); + const added = manager.addDevice({ type: 'test', detectionId: deviceId }, device); expect(added).toBe(true); expect(manager.getConnectedDevices()).toHaveLength(1); @@ -441,7 +441,7 @@ describe('deviceManager', () => { const manager = new DeviceManager(mock(), connectedDevices, settingsManager, mockedLogger); const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); - manager.addDevice({ type: 'test', id: deviceId }, device); + manager.addDevice({ type: 'test', detectionId: deviceId }, device); expect(manager.getConnectedDevices()).toHaveLength(1); const disabledSettings = new Settings(); @@ -465,7 +465,7 @@ describe('deviceManager', () => { const manager = new DeviceManager(mock(), connectedDevices, settingsManager, mockedLogger); const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); - manager.addDevice({ type: 'test', id: deviceId }, device); + manager.addDevice({ type: 'test', detectionId: deviceId }, device); await manager.onSettingsChanged(); @@ -474,7 +474,7 @@ describe('deviceManager', () => { it('re-announces a device rejected by announceDetectedDevice once its known device gets re-enabled', async () => { const deviceId = DeviceId.create('device-pending-1'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; const disabledSettings = new Settings(); disabledSettings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); @@ -504,7 +504,7 @@ describe('deviceManager', () => { // known after connecting, e.g. a serial number read during a handshake) is different. const detectionId = DeviceId.create('device-pending-2-detected'); const canonicalId = DeviceId.create('device-pending-2-canonical'); - const deviceInfo: DeviceInfo = { type: 'test', id: detectionId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId }; const settings = new Settings(); // Only the canonical device is a known, disabled device. @@ -538,7 +538,7 @@ describe('deviceManager', () => { it('does not re-announce a still-disabled pending device', async () => { const deviceId = DeviceId.create('device-pending-3'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; const settings = new Settings(); settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); diff --git a/tests/unit/device/provider/deviceProviderManager.spec.ts b/tests/unit/device/provider/deviceProviderManager.spec.ts index 46e0fcee..748b7a0f 100644 --- a/tests/unit/device/provider/deviceProviderManager.spec.ts +++ b/tests/unit/device/provider/deviceProviderManager.spec.ts @@ -4,14 +4,14 @@ import EventEmitter from 'events'; import DeviceProviderManager from '../../../../src/device/provider/deviceProviderManager.js'; import DeviceProviderFactory from '../../../../src/device/provider/deviceProviderFactory.js'; import DeviceProvider from '../../../../src/device/provider/deviceProvider.js'; -import DeviceManager, { DeviceInfo } from '../../../../src/device/deviceManager.js'; +import DeviceManager, { DeviceDetectionInfo } from '../../../../src/device/deviceManager.js'; import { AnyDevice } from '../../../../src/device/device.js'; import Logger from '../../../../src/logging/Logger.js'; import Settings from '../../../../src/settings/settings.js'; import DeviceSource from '../../../../src/settings/deviceSource.js'; import { JsonObject } from '../../../../src/types.js'; -class RecordingDeviceProvider extends DeviceProvider +class RecordingDeviceProvider extends DeviceProvider { public initCalls = 0; public stopCalls = 0; @@ -45,11 +45,11 @@ class RecordingDeviceProvider extends DeviceProvider } // This test double never actually detects devices; it only exercises the lifecycle. - protected supportsDeviceInfo(_deviceInfo: DeviceInfo): _deviceInfo is DeviceInfo { + protected canHandleDeviceDetectionInfo(_deviceInfo: DeviceDetectionInfo): _deviceInfo is DeviceDetectionInfo { return false; } - protected createDevice(_deviceInfo: DeviceInfo): Promise { + protected createDevice(_deviceInfo: DeviceDetectionInfo): Promise { return Promise.resolve(undefined); } } diff --git a/tests/unit/device/transport/bleObserver.spec.ts b/tests/unit/device/transport/bleObserver.spec.ts index cf45f5b7..2a9f338e 100644 --- a/tests/unit/device/transport/bleObserver.spec.ts +++ b/tests/unit/device/transport/bleObserver.spec.ts @@ -177,7 +177,7 @@ describe('BleObserver', () => { getNobleListener('discover')?.(peripheral); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('abc-123') }), + expect.objectContaining({ detectionId: DeviceId.create('abc-123') }), ); }); diff --git a/tests/unit/device/transport/serialPortObserver.spec.ts b/tests/unit/device/transport/serialPortObserver.spec.ts index c3e27676..3a3c3c01 100644 --- a/tests/unit/device/transport/serialPortObserver.spec.ts +++ b/tests/unit/device/transport/serialPortObserver.spec.ts @@ -98,7 +98,7 @@ describe('SerialPortObserver', () => { expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN001'), portInfo: port }), + expect.objectContaining({ detectionId: DeviceId.create('SN001'), portInfo: port }), ); }); @@ -111,7 +111,7 @@ describe('SerialPortObserver', () => { const expectedSn = 'serial-0403-6001-port1'; expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create(expectedSn) }), + expect.objectContaining({ detectionId: DeviceId.create(expectedSn) }), ); }); @@ -138,7 +138,7 @@ describe('SerialPortObserver', () => { expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledOnce(); expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN001') }), + expect.objectContaining({ detectionId: DeviceId.create('SN001') }), ); }); @@ -176,10 +176,10 @@ describe('SerialPortObserver', () => { expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledTimes(2); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN001') }), + expect.objectContaining({ detectionId: DeviceId.create('SN001') }), ); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN002') }), + expect.objectContaining({ detectionId: DeviceId.create('SN002') }), ); }); }); From 833c607ef9af4079639418a28d34c6e401ee47c4 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 09:18:07 +0200 Subject: [PATCH 18/35] Removed some more unnecessay comments --- src/device/bleDevice.ts | 5 ----- src/device/device.ts | 19 ++++--------------- src/device/peripheralDevice.ts | 5 ----- src/device/provider/deviceProviderManager.ts | 11 ++--------- .../provider/genericDeviceProviderFactory.ts | 4 +--- 5 files changed, 7 insertions(+), 37 deletions(-) diff --git a/src/device/bleDevice.ts b/src/device/bleDevice.ts index 5f7d6374..961b531b 100644 --- a/src/device/bleDevice.ts +++ b/src/device/bleDevice.ts @@ -10,11 +10,6 @@ import { logError } from '../util/error.js'; import Logger from '../logging/Logger.js'; import { asyncHandler, promiseWithTimeout } from '../util/async.js'; -/** - * Concrete-attribute-agnostic view of a BLE device, analogous to `AnyDevice` but retaining the - * BLE-specific surface (`getPeripheral`) so it stays distinguishable from other device families. - * See `AnyDevice` for why `setAttribute` has to be erased and widened. - */ export type AnyBleDevice = Omit & { setAttribute(attributeName: string, value: AttributeValue): Promise; }; diff --git a/src/device/device.ts b/src/device/device.ts index 3d255b68..03d1787c 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -49,6 +49,10 @@ export type DeviceEventMap< [DeviceEvent.deviceNotification]: [device: TDevice, notification: DeviceNotification]; } +export type AnyDevice = Omit & { + setAttribute(attributeName: string, value: AttributeValue): Promise; +}; + @Exclude() export default abstract class Device< TAttributes extends DeviceAttributes = DeviceAttributes, @@ -206,18 +210,3 @@ export default abstract class Device< return attr !== null && typeof attr === 'object' && 'name' in attr && Object.keys(this.attributes).includes(attr.name); } } - -/** - * A concrete-device-agnostic view of a `Device`, for the places that handle "some device" without - * caring about its attribute types (the device manager, repository, updater, automation runtime). - * - * A concrete `Device` is NOT assignable to `Device` (or even `Device`): its `setAttribute>` override narrows a parameter, - * which TypeScript rejects as a supertype (method parameter bivariance). Erasing that one method - * and re-adding a wide, string-keyed version restores assignability from every concrete device - * while still requiring the full remaining `Device` surface, so non-device types are still - * rejected. The concrete devices keep their strict `setAttribute` for their own call sites. - */ -export type AnyDevice = Omit & { - setAttribute(attributeName: string, value: AttributeValue): Promise; -}; diff --git a/src/device/peripheralDevice.ts b/src/device/peripheralDevice.ts index 72863690..51144a09 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -6,11 +6,6 @@ import { AttributeValue } from './attribute/deviceAttribute.js'; import EventEmitter from 'events'; import { DeviceId } from './deviceId.js'; -/** - * Concrete-attribute-agnostic view of a serial/peripheral device, analogous to `AnyDevice` but - * retaining the peripheral-specific surface (`getTransport`) so it stays distinguishable from - * other device families. See `AnyDevice` for why `setAttribute` has to be erased and widened. - */ export type AnyPeripheralDevice = Omit>>, 'setAttribute'> & { setAttribute(attributeName: string, value: AttributeValue): Promise; }; diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index f76a7e5b..bdab6773 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -29,16 +29,9 @@ export default class DeviceProviderManager this.logger = logger.child({ name: DeviceProviderManager.name }); } - /** - * (Re-)synchronizes the running device providers with the given settings: providers for - * device sources that were removed or disabled are stopped, providers for device sources - * that are new or were (re-)enabled are created and started. Already running providers for - * device sources that are still enabled are left untouched. Known devices being individually - * enabled/disabled is handled centrally by `DeviceManager`, not here. - */ public loadFromSettings(settings: Settings): Promise { - return this.enqueueOperation(() => this.doReload(settings)); + return this.enqueueOperation(() => this.doLoadFromSettings(settings)); } public stopProviders(): Promise { @@ -55,7 +48,7 @@ export default class DeviceProviderManager return result; } - private async doReload(settings: Settings): Promise + private async doLoadFromSettings(settings: Settings): Promise { const configuredDeviceSources = settings.getDeviceSources(); diff --git a/src/device/provider/genericDeviceProviderFactory.ts b/src/device/provider/genericDeviceProviderFactory.ts index 8ecaa71e..74f87b1c 100644 --- a/src/device/provider/genericDeviceProviderFactory.ts +++ b/src/device/provider/genericDeviceProviderFactory.ts @@ -3,9 +3,7 @@ import DeviceProviderFactory from './deviceProviderFactory.js'; type ConcreteCtor = new (...args: any[]) => T; -export default class GenericDeviceProviderFactory< - DP extends AnyDeviceProvider -> implements DeviceProviderFactory +export default class GenericDeviceProviderFactory implements DeviceProviderFactory { private readonly ctor: ConcreteCtor; private readonly args: ConstructorParameters>; From faf22f7466cbfb7315d42d4bff78f739054db092 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 13:07:06 +0200 Subject: [PATCH 19/35] Reference-count BLE/serial observers; buttplugIo devices close themselves on removal BLE/serial observers were previously started unconditionally at app boot regardless of whether any matching device source was even configured, wasting BLE radio/USB polling resources. They're now started/stopped by the individual providers that need them (BleDeviceProvider/SerialDeviceProvider), reference-counted so multiple providers sharing the same observer (e.g. zc95Serial + estim2bSerial + slvCtrlPlusSerial all sharing one SerialPortObserver) don't double-start or stop it out from under each other. Fixed a latent bug this surfaced along the way: AiroticDeviceProvider.init() overrode the base method without calling super.init(), which would have silently skipped starting the BLE observer entirely. app.ts's loadDeviceProviders()/shutdown() no longer touch the observers directly - that's now fully driven by DeviceProviderManager calling each provider's init()/stop(). Separately, ButtplugIoDevice now closes itself when the buttplug.io server reports it removed (while the overall connection stays up), listening directly to its own ButtplugClientDevice's 'deviceremoved' event - mirroring how BleDevice/PeripheralDevice detect their own physical disconnect. The provider's connection-lost handling still closes every connected device in bulk, since that's the one case an individual device could never notice on its own (the buttplug protocol doesn't emit per-device removal messages once the connection itself is gone). Also: ButtplugIoDevice.getRefreshInterval now returns undefined for actuator-only devices instead of unconditionally polling sensors that don't exist - sensor values can only be polled (no push/subscribe API exists in the installed buttplug client), so there's nothing to refresh on a device with no sensors. --- src/app.ts | 9 +-- .../protocol/airotic/airoticDeviceProvider.ts | 9 +-- .../protocol/buttplugIo/buttplugIoDevice.ts | 16 +++- ...rovider.ts => buttplugIoDeviceProvider.ts} | 65 ++++++++-------- ....ts => buttplugIoDeviceProviderFactory.ts} | 10 +-- .../estim2b/estim2bSerialDeviceProvider.ts | 5 +- .../slvCtrlPlusSerialDeviceProvider.ts | 5 +- .../protocol/zc95/zc95SerialDeviceProvider.ts | 5 +- src/device/provider/bleDeviceProvider.ts | 23 +++++- src/device/provider/serialDeviceProvider.ts | 22 +++++- src/device/transport/bleObserver.ts | 29 +++++++ src/device/transport/serialPortObserver.ts | 27 +++++++ src/serviceMap.ts | 4 +- src/serviceProvider/deviceServiceProvider.ts | 14 ++-- .../devices/buttplugIoDevice.spec.ts | 20 ++--- .../buttplugIo/buttplugIoDevice.spec.ts | 47 +++++++++++ .../unit/device/transport/bleObserver.spec.ts | 75 ++++++++++++++++++ .../transport/serialPortObserver.spec.ts | 77 ++++++++++++++++++- 18 files changed, 382 insertions(+), 80 deletions(-) rename src/device/protocol/buttplugIo/{buttplugIoWebsocketDeviceProvider.ts => buttplugIoDeviceProvider.ts} (79%) rename src/device/protocol/buttplugIo/{buttplugIoWebsocketDeviceProviderFactory.ts => buttplugIoDeviceProviderFactory.ts} (74%) diff --git a/src/app.ts b/src/app.ts index 36c0cc38..043d111c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -141,8 +141,6 @@ const configureWebsocket = (io: WebsocketServer, container: Container): void => { - const serialPortObserver = container.get('device.observer.serial'); - const bleObserver = container.get('device.observer.ble'); const logger = container.get('logger.default'); const settings = container.get('settings'); const deviceProviderManager = container.get('device.provider.loader'); @@ -150,9 +148,6 @@ const loadDeviceProviders = (container: Container): void => { deviceProviderManager .loadFromSettings(settings) .catch(e => logError(logger, `Loading device providers failed`, e)); - - serialPortObserver.start().catch(e => logError(logger, `Initializing serial port observer failed`, e)); - bleObserver.init().catch(e => logError(logger, `Initializing BLE observer failed`, e)); }; const buildCorsOptions = (allowedOrigins: string[]): CorsOptions => ({ @@ -265,8 +260,8 @@ export const createApp = (container: Container, options: AppOptions) logger.info('Shutting down...'); await container.get('automation.scriptRuntime').stop(); - await container.get('device.observer.serial').stop(); - await container.get('device.observer.ble').stop(); + // Stopping providers cascades into stopping the serial/BLE observers they depend on + // (reference-counted, see BleDeviceProvider/SerialDeviceProvider). await container.get('device.provider.loader').stopProviders(); container.get('health.metricsCollector').stop(); diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index f2f48c89..fa54cb63 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -4,7 +4,7 @@ import DeviceManager from '../../deviceManager.js'; import AiroticDevice from './airoticDevice.js'; import Logger from '../../../logging/Logger.js'; import { promiseWithTimeout } from '../../../util/async.js'; -import { BleDeviceDetectionInfo } from '../../transport/bleObserver.js'; +import BleObserver, { BleDeviceDetectionInfo } from '../../transport/bleObserver.js'; import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; import AiroticProtocol from './airoticProtocol.js'; import MessageResponseHandler from '../messageResponseHandler.js'; @@ -22,19 +22,16 @@ export default class AiroticDeviceProvider extends BleDeviceProvider { - this.logger.debug('Initialized AiroticDeviceProvider'); - } - protected override async connectBleDevice(deviceInfo: BleDeviceDetectionInfo): Promise { const transport = await promiseWithTimeout(BleUartDeviceTransport.create( deviceInfo.peripheral, diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index 177bfc81..955dd982 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -8,6 +8,7 @@ import IntDeviceAttribute from '../../attribute/intDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; import EventEmitter from 'events'; import { DeviceId } from '../../deviceId.js'; +import { asyncHandler } from '../../../util/async.js'; type ButtplugActuatorTypeKey = `${ActuatorType}-${number}`; type ButtplugSensorTypeKey = `${SensorType}-${number}`; @@ -28,6 +29,8 @@ export default class ButtplugIoDevice extends Device @Expose() private deviceModel: string; + private readonly deviceRemovedHandler: () => void; + public constructor( deviceId: DeviceId, deviceName: string, @@ -41,10 +44,19 @@ export default class ButtplugIoDevice extends Device super(deviceId, deviceName, provider, connectedSince, true, attributes, {}, eventEmitter); this.buttplugClientDevice = buttplugClientDevice; this.deviceModel = deviceModel; + + this.deviceRemovedHandler = asyncHandler(async () => { await this.close(); }, console.error); + this.buttplugClientDevice.on('deviceremoved', this.deviceRemovedHandler); } - public override get getRefreshInterval(): number { - return 100; + protected override async doClose(): Promise { + this.buttplugClientDevice.off('deviceremoved', this.deviceRemovedHandler); + } + + public override get getRefreshInterval(): number | undefined { + const sensorCount = this.buttplugClientDevice.messageAttributes.SensorReadCmd?.length ?? 0; + + return (sensorCount === 0) ? undefined : 100; } protected override async doRefresh(): Promise { diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceProvider.ts similarity index 79% rename from src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts rename to src/device/protocol/buttplugIo/buttplugIoDeviceProvider.ts index 5674ea7f..43948468 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceProvider.ts @@ -15,12 +15,13 @@ export type ButtplugIoDeviceDetectionInfo = DeviceDetectionInfo & { buttplugClientDevice: ButtplugClientDevice; }; -export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< +export default class ButtplugIoDeviceProvider extends DeviceProvider< ButtplugIoDeviceDetectionInfo, ButtplugIoDevice > { - public static readonly providerName = 'buttplugIoWebsocket'; + public static readonly providerName = 'buttplugIo'; + // How often to (re)attempt connecting to the Intiface/buttplug.io server while disconnected. private static readonly CONNECT_RETRY_INTERVAL_MS = 1_000; // How often a fresh scan cycle is kicked off while `autoScan` is enabled and we're connected. @@ -52,7 +53,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< useDeviceNameAsId: boolean, logger: Logger ) { - super(deviceManager, eventEmitter, logger.child({ name: ButtplugIoWebsocketDeviceProvider.name })); + super(deviceManager, eventEmitter, logger.child({ name: ButtplugIoDeviceProvider.name })); this.buttplugIoDeviceFactory = deviceFactory; this.websocketAddress = websocketAddress; this.autoScan = autoScan; @@ -67,21 +68,20 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< (e: unknown) => logError(this.logger, `Error in disconnect handler`, e) )); this.buttplugClient.on('deviceadded', this.announceButtplugIoDevice.bind(this)); - this.buttplugClient.on('deviceremoved', asyncHandler( - this.removeButtplugIoDevice.bind(this), - (e: unknown) => logError(this.logger, `Error in deviceremoved handler`, e) - )); + this.buttplugClient.on('deviceremoved', this.revokePendingButtplugIoDevice.bind(this)); } public override async init(): Promise { this.connectionIntervalRef ??= setImmediateInterval( () => void this.connectToServer(), - ButtplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS + ButtplugIoDeviceProvider.CONNECT_RETRY_INTERVAL_MS ); } public override async stop(): Promise { - // Marks the provider stopped (isStopped()) and closes/clears the registered devices. + // Marks the provider stopped (isStopped()) and closes/clears the registered devices - + // must run before we tear down the buttplug client listeners below (see the comment + // there for why). await super.stop(); clearInterval(this.connectionIntervalRef); @@ -118,18 +118,26 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.connectionIntervalRef = undefined; if (this.autoScan) { - this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, ButtplugIoWebsocketDeviceProvider.AUTO_SCAN_INTERVAL_MS); + this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, ButtplugIoDeviceProvider.AUTO_SCAN_INTERVAL_MS); } } catch (e: unknown) { logError(this.logger, `Could not connect to buttplug.io server (${url})`, hasProperty(e, 'message') ? e.message : 'unknown'); } } + /** + * The whole connection to the buttplug.io server was lost - close every device this provider + * currently has connected, since none of them are reachable anymore. This is a distinct + * scenario from a single device being reported as removed (see + * `revokePendingButtplugIoDevice()`): the buttplug protocol never emits per-device removal + * messages once the connection itself is already gone, so an already-connected + * `ButtplugIoDevice` would never notice on its own and relies entirely on this cleanup. + */ private async handleLostConnection(url: string): Promise { this.logger.info(`Lost connection to buttplug.io server (${url})`); - for (const device of this.getConnectedDevices()) { - await this.removeButtplugIoDevice(device.getButtplugClientDevice); + for (const device of [...this.getConnectedDevices()]) { + await device.close(); } clearInterval(this.autoScanningIntervalRef); @@ -159,7 +167,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.buttplugClient.stopScanning() .then(() => this.logger.info('Stop scanning for Buttplug.io devices')) .catch((e: unknown) => this.logger.error(`Could not stop scanning for buttplug.io devices`, e)); - }, ButtplugIoWebsocketDeviceProvider.SCAN_DURATION_MS); + }, ButtplugIoDeviceProvider.SCAN_DURATION_MS); } private toDeviceInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceDetectionInfo { @@ -179,6 +187,16 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.deviceManager.announceDetectedDevice(this.toDeviceInfo(buttplugDevice)); } + /** + * Drops any pending detection/retry bookkeeping for a device the buttplug.io server reported + * as removed. A no-op if the device is actually connected - nothing is pending for it there, + * since the live `ButtplugIoDevice` closes itself directly off this same server event (see + * its constructor). + */ + private revokePendingButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { + this.deviceManager.revokeDetectedDevice(this.toDeviceInfo(buttplugDevice)); + } + protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is ButtplugIoDeviceDetectionInfo { return deviceInfo.type === 'buttplugIo'; } @@ -186,29 +204,10 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< protected override createDevice(deviceInfo: ButtplugIoDeviceDetectionInfo): Promise { const device = this.buttplugIoDeviceFactory.create( deviceInfo.buttplugClientDevice, - ButtplugIoWebsocketDeviceProvider.providerName, + ButtplugIoDeviceProvider.providerName, this.useDeviceNameAsId ); return Promise.resolve(device); } - - private async removeButtplugIoDevice(buttplugDevice: ButtplugClientDevice): Promise { - const deviceId = ButtplugIoDeviceFactory.computeDeviceId(buttplugDevice, this.useDeviceNameAsId); - const device = this.getConnectedDevice(deviceId); - - if (undefined === device) { - // Not locally connected - it may still be sitting in the device manager as a - // detected-but-disabled device awaiting retry, so revoke it there to avoid leaking it. - this.deviceManager.revokeDetectedDevice(this.toDeviceInfo(buttplugDevice)); - return; - } - - try { - await device.close(); - this.logger.info(`Device removed: ${device.getDeviceId} (${buttplugDevice.name}@${buttplugDevice.index})`); - } catch (e: unknown) { - logError(this.logger, `Could not remove device '${device.getDeviceId}' (${buttplugDevice.name}@${buttplugDevice.index})`, e); - } - } } diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceProviderFactory.ts similarity index 74% rename from src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts rename to src/device/protocol/buttplugIo/buttplugIoDeviceProviderFactory.ts index 8f7064d6..c92a8d3d 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceProviderFactory.ts @@ -2,16 +2,16 @@ import EventEmitter from 'events'; import DeviceProviderFactory from '../../provider/deviceProviderFactory.js'; import Logger from '../../../logging/Logger.js'; import ButtplugIoDeviceFactory from './buttplugIoDeviceFactory.js'; -import ButtplugIoWebsocketDeviceProvider from './buttplugIoWebsocketDeviceProvider.js'; +import ButtplugIoDeviceProvider from './buttplugIoDeviceProvider.js'; import DeviceManager from '../../deviceManager.js'; -type ButtplugIoWebsocketConfig = { +type ButtplugIoDeviceProviderConfig = { address: string, autoScan: boolean, useDeviceNameAsId: boolean } -export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceProviderFactory +export default class ButtplugIoDeviceProviderFactory implements DeviceProviderFactory { private readonly deviceManager: DeviceManager; @@ -33,9 +33,9 @@ export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceP this.logger = logger; } - public create(config: ButtplugIoWebsocketConfig): ButtplugIoWebsocketDeviceProvider + public create(config: ButtplugIoDeviceProviderConfig): ButtplugIoDeviceProvider { - return new ButtplugIoWebsocketDeviceProvider( + return new ButtplugIoDeviceProvider( this.deviceManager, this.eventEmitter, this.deviceFactory, diff --git a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts index b452b0b5..5c90271a 100644 --- a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts +++ b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts @@ -12,7 +12,7 @@ import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import DeviceManager from '../../deviceManager.js'; -import { SerialDeviceDetectionInfo } from '../../transport/serialPortObserver.js'; +import SerialPortObserver, { SerialDeviceDetectionInfo } from '../../transport/serialPortObserver.js'; export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { @@ -25,12 +25,13 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { @@ -30,12 +30,13 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide public constructor( deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, + serialPortObserver: SerialPortObserver, eventEmitter: EventEmitter, deviceFactory: SlvCtrlPlusDeviceFactory, deviceTransportFactory: SerialDeviceTransportFactory, logger: Logger ) { - super(deviceManager, serialPortFactory, eventEmitter, logger.child({ name: SlvCtrlPlusSerialDeviceProvider.name })); + super(deviceManager, serialPortFactory, serialPortObserver, eventEmitter, logger.child({ name: SlvCtrlPlusSerialDeviceProvider.name })); this.slvCtrlPlusDeviceFactory = deviceFactory; this.deviceTransportFactory = deviceTransportFactory; } diff --git a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts index 5fdc939a..ff47ae2d 100644 --- a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts +++ b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts @@ -13,7 +13,7 @@ import MessageResponseHandler from '../messageResponseHandler.js'; import Zc95MessageFactory from './zc95MessageFactory.js'; import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; import DeviceManager from '../../deviceManager.js'; -import { SerialDeviceDetectionInfo } from '../../transport/serialPortObserver.js'; +import SerialPortObserver, { SerialDeviceDetectionInfo } from '../../transport/serialPortObserver.js'; export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { @@ -26,12 +26,13 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider extends DeviceProvider { - protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { + private readonly bleObserver: BleObserver; + + protected constructor(deviceManager: DeviceManager, bleObserver: BleObserver, eventEmitter: EventEmitter, logger: Logger) { super(deviceManager, eventEmitter, logger); + this.bleObserver = bleObserver; + } + + public override async init(): Promise { + await super.init(); + await this.bleObserver.init(); + } + + public override async stop(): Promise { + await super.stop(); + await this.bleObserver.stop(); } protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is BleDeviceDetectionInfo { diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 5590fbae..194c9be7 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -9,24 +9,44 @@ import { AutoDetectTypes } from '@serialport/bindings-cpp'; import BaseError from 'modern-errors'; import DeviceManager, { DeviceDetectionInfo } from '../deviceManager.js'; import { logError } from '../../util/error.js'; -import { SerialDeviceDetectionInfo } from '../transport/serialPortObserver.js'; +import SerialPortObserver, { SerialDeviceDetectionInfo } from '../transport/serialPortObserver.js'; import { AnyPeripheralDevice } from '../peripheralDevice.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; +/** + * Owns starting/stopping the shared `SerialPortObserver` alongside this provider's own lifecycle, + * so serial port scanning only ever runs while at least one serial-based device source is + * actually configured and enabled - see `SerialPortObserver` for how it stays safe to be + * started/stopped by more than one provider at once. + */ export default abstract class SerialDeviceProvider extends DeviceProvider { private readonly serialPortFactory: SerialPortFactory; + private readonly serialPortObserver: SerialPortObserver; + protected constructor( deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, + serialPortObserver: SerialPortObserver, eventEmitter: EventEmitter, logger: Logger ) { super(deviceManager, eventEmitter, logger); this.serialPortFactory = serialPortFactory; + this.serialPortObserver = serialPortObserver; + } + + public override async init(): Promise { + await super.init(); + await this.serialPortObserver.start(); + } + + public override async stop(): Promise { + await super.stop(); + await this.serialPortObserver.stop(); } protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is SerialDeviceDetectionInfo { diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index 49d2b329..661dcf59 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -21,6 +21,17 @@ export default class BleObserver private isScanning = false; + /** + * Multiple `BleDeviceProvider`s (one per BLE-based protocol, e.g. airotic) can be running at + * once and each depend on this same observer, since it's a DI singleton shared across all of + * them. `init()`/`stop()` are reference-counted so the underlying noble scan only actually + * starts once (on the first caller) and only actually stops once every caller that started it + * has also stopped it - two providers calling `init()` must never result in two overlapping + * noble listener registrations, and one provider stopping must not kill scanning for another + * still-active one. + */ + private activeUsers = 0; + public constructor( deviceManager: DeviceManager, logger: Logger @@ -31,6 +42,13 @@ export default class BleObserver public async init(): Promise { + this.activeUsers++; + + if (this.activeUsers > 1) { + this.logger.debug(`Already running, now used by ${this.activeUsers} provider(s)`); + return; + } + noble.on('discover', this.onDiscover.bind(this)); noble.on('stateChange', asyncHandler( @@ -49,6 +67,17 @@ export default class BleObserver public async stop(): Promise { + if (this.activeUsers === 0) { + return; + } + + this.activeUsers--; + + if (this.activeUsers > 0) { + this.logger.debug(`Still used by ${this.activeUsers} provider(s), not stopping`); + return; + } + noble.removeAllListeners(); if (this.isScanning) { diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index c697069f..7bdc1d5f 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -27,6 +27,15 @@ export default class SerialPortObserver private discoveryInFlight = false; + /** + * Multiple `SerialDeviceProvider`s (one per serial-based protocol, e.g. zc95, estim2b, + * slvCtrlPlus) can be running at once and each depend on this same observer, since it's a DI + * singleton shared across all of them. `start()`/`stop()` are reference-counted so USB + * enumeration/listening only actually starts once (on the first caller) and only actually + * stops once every caller that started it has also stopped it. + */ + private activeUsers = 0; + public constructor( deviceManager: DeviceManager, logger: Logger @@ -37,6 +46,13 @@ export default class SerialPortObserver public async start(): Promise { + this.activeUsers++; + + if (this.activeUsers > 1) { + this.logger.debug(`Already running, now used by ${this.activeUsers} provider(s)`); + return; + } + await this.discoverSerialDevices(); this.onUsbEventRef = (): void => { @@ -111,6 +127,17 @@ export default class SerialPortObserver } public async stop(): Promise { + if (this.activeUsers === 0) { + return; + } + + this.activeUsers--; + + if (this.activeUsers > 0) { + this.logger.debug(`Still used by ${this.activeUsers} provider(s), not stopping`); + return; + } + if (this.rescanTimer !== undefined) { clearTimeout(this.rescanTimer); this.rescanTimer = undefined; diff --git a/src/serviceMap.ts b/src/serviceMap.ts index 53f23393..66e183e2 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -48,7 +48,7 @@ import BleObserver from './device/transport/bleObserver.js'; import SlvCtrlPlusSerialDeviceProvider from './device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; import Zc95SerialDeviceProvider from './device/protocol/zc95/zc95SerialDeviceProvider.js'; import EStim2bSerialDeviceProvider from './device/protocol/estim2b/estim2bSerialDeviceProvider.js'; -import ButtplugIoWebsocketDeviceProvider from './device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; +import ButtplugIoDeviceProvider from './device/protocol/buttplugIo/buttplugIoDeviceProvider.js'; import AiroticDeviceProvider from './device/protocol/airotic/airoticDeviceProvider.js'; import AiroticDeviceFactory from './device/protocol/airotic/airoticDeviceFactory.js'; import KnownDeviceRegistry from './device/knownDeviceRegistry.js'; @@ -73,7 +73,7 @@ type ServiceMap = { 'device.provider.factory.slvCtrlPlusSerial': DeviceProviderFactory, 'device.provider.factory.zc95Serial': DeviceProviderFactory, 'device.provider.factory.estim2bSerial': DeviceProviderFactory, - 'device.provider.factory.buttplugIoWebsocket': DeviceProviderFactory, + 'device.provider.factory.buttplugIoWebsocket': DeviceProviderFactory, 'device.provider.factory.airotic': DeviceProviderFactory, 'device.serial.factory.buttplugIo': ButtplugIoDeviceFactory, 'device.virtual.provider': VirtualDeviceProvider, diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index 48fb6d69..a431c416 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -10,9 +10,9 @@ import SerialDeviceTransportFactory from '../device/transport/serialDeviceTransp import { AnyDevice } from '../device/device.js'; import DeviceProviderManager from '../device/provider/deviceProviderManager.js'; import SlvCtrlPlusSerialDeviceProvider from '../device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; -import ButtplugIoWebsocketDeviceProvider from '../device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; -import ButtplugIoWebsocketDeviceProviderFactory - from '../device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.js'; +import ButtplugIoDeviceProvider from '../device/protocol/buttplugIo/buttplugIoDeviceProvider.js'; +import ButtplugIoDeviceProviderFactory + from '../device/protocol/buttplugIo/buttplugIoDeviceProviderFactory.js'; import ButtplugIoDeviceFactory from '../device/protocol/buttplugIo/buttplugIoDeviceFactory.js'; import ServiceMap from '../serviceMap.js'; import VirtualDeviceProvider from '../device/protocol/virtual/virtualDeviceProvider.js'; @@ -56,6 +56,7 @@ export default class DeviceServiceProvider implements ServiceProvider new ButtplugIoWebsocketDeviceProviderFactory( + () => new ButtplugIoDeviceProviderFactory( container.get('device.manager'), container.get('factory.eventEmitter').create(), container.get('device.serial.factory.buttplugIo'), @@ -190,7 +191,7 @@ export default class DeviceServiceProvider implements ServiceProvider { const [payload] = await deviceConnected; const expectedAttributes = { - provider: ButtplugIoWebsocketDeviceProvider.providerName, + provider: ButtplugIoDeviceProvider.providerName, type: 'buttplugIo', attributes: { 'Vibrate-0': { @@ -132,15 +132,17 @@ describe('Buttplug.io device lifecycle', () => { // via WebSocket event simulator.receivedScalarCmds = []; - const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed); - + // MockVibe is actuator-only (no sensors), so it has no periodic refresh cycle and + // ButtplugIoDevice.setAttribute() doesn't emit deviceRefreshed on its own either - + // poll for the resulting scalar command instead of waiting for a WS event that will + // never come. See the equivalent airotic test for the same pattern (there polling the + // REST endpoint instead, since that's what it has to observe). wsClient.emit(WebSocketEvent.deviceUpdateReceived, { deviceId: payload.deviceId, data: { 'Vibrate-0': 5 } }); - const [payloadDeviceRefreshed] = await deviceRefreshed; + await vi.waitFor(() => { + expect(simulator.receivedScalarCmds).toHaveLength(1); + }, { timeout: 3000, interval: 50 }); - expect(payloadDeviceRefreshed).toMatchObject({ deviceId: payload.deviceId }); - - expect(simulator.receivedScalarCmds).toHaveLength(1); const wsCmd = simulator.receivedScalarCmds[0]; expect(wsCmd?.actuatorType).toBe('Vibrate'); expect(wsCmd?.index).toBe(0); diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts index bf73271c..b5fcc314 100644 --- a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts @@ -229,4 +229,51 @@ describe('ButtplugIoDevice', () => { // Assert expect(buttplugDeviceMock.sensorRead).not.toHaveBeenCalled(); }); + + it('it reports a refresh interval when the device has sensors', () => { + + // Arrange + const buttplugDeviceMock = mock(); + const device = createDevice(buttplugDeviceMock, {}); + + const sensorAttrDef = new SensorDeviceMessageAttributes({ Index: 0 }); + sensorAttrDef.SensorType = SensorType.Battery; + Object.defineProperty(buttplugDeviceMock, 'messageAttributes', { + get: () => new MessageAttributes({ SensorReadCmd: [sensorAttrDef] }), + configurable: true, + }); + + // Act & Assert + expect(device.getRefreshInterval).toBe(100); + }); + + it('it reports no refresh interval when the device has no sensors (actuator-only)', () => { + + // Arrange + const buttplugDeviceMock = mock(); + const device = createDevice(buttplugDeviceMock, {}); + + Object.defineProperty(buttplugDeviceMock, 'messageAttributes', { + get: () => new MessageAttributes({}), + configurable: true, + }); + + // Act & Assert + expect(device.getRefreshInterval).toBeUndefined(); + }); + + it('it reports no refresh interval when SensorReadCmd is an empty array', () => { + + // Arrange + const buttplugDeviceMock = mock(); + const device = createDevice(buttplugDeviceMock, {}); + + Object.defineProperty(buttplugDeviceMock, 'messageAttributes', { + get: () => new MessageAttributes({ SensorReadCmd: [] }), + configurable: true, + }); + + // Act & Assert + expect(device.getRefreshInterval).toBeUndefined(); + }); }); diff --git a/tests/unit/device/transport/bleObserver.spec.ts b/tests/unit/device/transport/bleObserver.spec.ts index 2a9f338e..19b9d212 100644 --- a/tests/unit/device/transport/bleObserver.spec.ts +++ b/tests/unit/device/transport/bleObserver.spec.ts @@ -11,6 +11,8 @@ const mockNoble = vi.hoisted(() => ({ waitForPoweredOnAsync: vi.fn(), startScanningAsync: vi.fn(), stopScanningAsync: vi.fn(), + removeAllListeners: vi.fn(), + stop: vi.fn(), })); vi.mock('@stoprocent/noble', () => ({ default: mockNoble })); @@ -190,4 +192,77 @@ describe('BleObserver', () => { expect(mockLogger.debug).toHaveBeenCalled(); }); }); + + describe('reference counting (multiple BleDeviceProviders sharing one observer)', () => { + it('does not touch noble at all when stop() is called without a matching init()', async () => { + const observer = createObserver(); + + await observer.stop(); + + expect(mockNoble.removeAllListeners).not.toHaveBeenCalled(); + expect(mockNoble.stop).not.toHaveBeenCalled(); + }); + + it('only wires up noble once when init() is called by two providers', async () => { + const observer = createObserver(); + + await observer.init(); + await observer.init(); + + expect(mockNoble.on).toHaveBeenCalledTimes(3); // discover + stateChange + scanStop, not doubled + expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledOnce(); + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + }); + + it('keeps scanning after one of two providers stops', async () => { + const observer = createObserver(); + await observer.init(); + await observer.init(); + + await observer.stop(); + + expect(mockNoble.removeAllListeners).not.toHaveBeenCalled(); + expect(mockNoble.stop).not.toHaveBeenCalled(); + }); + + it('stops scanning only once every provider that started it has also stopped it', async () => { + const observer = createObserver(); + await observer.init(); + await observer.init(); + + await observer.stop(); + await observer.stop(); + + expect(mockNoble.removeAllListeners).toHaveBeenCalledOnce(); + expect(mockNoble.stop).toHaveBeenCalledOnce(); + }); + + it('does not go negative or re-stop noble when stop() is called more times than init()', async () => { + const observer = createObserver(); + await observer.init(); + + await observer.stop(); + mockNoble.removeAllListeners.mockClear(); + mockNoble.stop.mockClear(); + + await observer.stop(); + + expect(mockNoble.removeAllListeners).not.toHaveBeenCalled(); + expect(mockNoble.stop).not.toHaveBeenCalled(); + }); + + it('starts scanning again after a full stop and a fresh init() (e.g. the last provider stopped, then a new one started)', async () => { + const observer = createObserver(); + await observer.init(); + await observer.stop(); + + mockNoble.on.mockClear(); + mockNoble.startScanningAsync.mockClear(); + + await observer.init(); + + expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/tests/unit/device/transport/serialPortObserver.spec.ts b/tests/unit/device/transport/serialPortObserver.spec.ts index 3a3c3c01..60ead1c0 100644 --- a/tests/unit/device/transport/serialPortObserver.spec.ts +++ b/tests/unit/device/transport/serialPortObserver.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { SerialPort } from 'serialport'; +import { usb } from 'usb'; import DeviceManager from '../../../../src/device/deviceManager.js'; import Logger from '../../../../src/logging/Logger.js'; import SerialPortObserver from '../../../../src/device/transport/serialPortObserver.js'; @@ -19,6 +20,10 @@ type PortInfoLike = { describe('SerialPortObserver', () => { let mockDeviceManager: ReturnType>; let mockLogger: ReturnType>; + // usb.addEventListener() is not mocked, so every observer created in a test must have its + // listeners torn down again afterwards - otherwise they pile up across the whole file's test + // run and eventually trip Node's MaxListenersExceededWarning. + let createdObservers: SerialPortObserver[]; function makePortInfo(overrides: Partial & { path: string }): PortInfoLike { return { @@ -33,7 +38,9 @@ describe('SerialPortObserver', () => { } function createObserver(): SerialPortObserver { - return new SerialPortObserver(mockDeviceManager, mockLogger); + const observer = new SerialPortObserver(mockDeviceManager, mockLogger); + createdObservers.push(observer); + return observer; } beforeEach(() => { @@ -42,9 +49,18 @@ describe('SerialPortObserver', () => { mockDeviceManager = mock(); mockLogger = mock(); mockLogger.child.mockReturnValue(mockLogger); + createdObservers = []; }); - afterEach(() => { + afterEach(async () => { + // Each observer's own reference count may need more than one stop() call to actually + // tear down its listeners (see the reference-counting tests below) - stop() is a safe + // no-op once fully stopped, so calling it repeatedly here is fine. + for (const observer of createdObservers) { + await observer.stop(); + await observer.stop(); + } + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -183,4 +199,61 @@ describe('SerialPortObserver', () => { ); }); }); + + describe('reference counting (multiple SerialDeviceProviders sharing one observer)', () => { + it('does not run a discovery pass when stop() is called without a matching start()', async () => { + const listSpy = vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const observer = createObserver(); + + await observer.stop(); + + expect(listSpy).not.toHaveBeenCalled(); + }); + + it('only runs one discovery pass when start() is called by two providers', async () => { + const listSpy = vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const observer = createObserver(); + + await observer.start(); + await observer.start(); + + expect(listSpy).toHaveBeenCalledOnce(); + }); + + it('keeps the USB listeners registered after one of two providers stops', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const removeListenerSpy = vi.spyOn(usb, 'removeEventListener'); + const observer = createObserver(); + await observer.start(); + await observer.start(); + + await observer.stop(); + + expect(removeListenerSpy).not.toHaveBeenCalled(); + }); + + it('removes the USB listeners only once every provider that started it has also stopped it', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const removeListenerSpy = vi.spyOn(usb, 'removeEventListener'); + const observer = createObserver(); + await observer.start(); + await observer.start(); + + await observer.stop(); + await observer.stop(); + + expect(removeListenerSpy).toHaveBeenCalledWith('connect', expect.any(Function)); + expect(removeListenerSpy).toHaveBeenCalledWith('disconnect', expect.any(Function)); + }); + + it('does not error when stop() is called more times than start()', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const observer = createObserver(); + await observer.start(); + + await observer.stop(); + + await expect(observer.stop()).resolves.not.toThrow(); + }); + }); }); From dd3a17b683d328c0e76bcfc757333630a3259b48 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 13:15:05 +0200 Subject: [PATCH 20/35] Revert renaming --- ...vider.ts => buttplugIoWebsocketDeviceProvider.ts} | 12 ++++++------ ...s => buttplugIoWebsocketDeviceProviderFactory.ts} | 8 ++++---- src/serviceMap.ts | 4 ++-- src/serviceProvider/deviceServiceProvider.ts | 10 +++++----- tests/integration/devices/buttplugIoDevice.spec.ts | 6 +++--- 5 files changed, 20 insertions(+), 20 deletions(-) rename src/device/protocol/buttplugIo/{buttplugIoDeviceProvider.ts => buttplugIoWebsocketDeviceProvider.ts} (95%) rename src/device/protocol/buttplugIo/{buttplugIoDeviceProviderFactory.ts => buttplugIoWebsocketDeviceProviderFactory.ts} (76%) diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts similarity index 95% rename from src/device/protocol/buttplugIo/buttplugIoDeviceProvider.ts rename to src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 43948468..7c5f7d2d 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -15,7 +15,7 @@ export type ButtplugIoDeviceDetectionInfo = DeviceDetectionInfo & { buttplugClientDevice: ButtplugClientDevice; }; -export default class ButtplugIoDeviceProvider extends DeviceProvider< +export default class buttplugIoWebsocketDeviceProvider extends DeviceProvider< ButtplugIoDeviceDetectionInfo, ButtplugIoDevice > { @@ -53,7 +53,7 @@ export default class ButtplugIoDeviceProvider extends DeviceProvider< useDeviceNameAsId: boolean, logger: Logger ) { - super(deviceManager, eventEmitter, logger.child({ name: ButtplugIoDeviceProvider.name })); + super(deviceManager, eventEmitter, logger.child({ name: buttplugIoWebsocketDeviceProvider.name })); this.buttplugIoDeviceFactory = deviceFactory; this.websocketAddress = websocketAddress; this.autoScan = autoScan; @@ -74,7 +74,7 @@ export default class ButtplugIoDeviceProvider extends DeviceProvider< public override async init(): Promise { this.connectionIntervalRef ??= setImmediateInterval( () => void this.connectToServer(), - ButtplugIoDeviceProvider.CONNECT_RETRY_INTERVAL_MS + buttplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS ); } @@ -118,7 +118,7 @@ export default class ButtplugIoDeviceProvider extends DeviceProvider< this.connectionIntervalRef = undefined; if (this.autoScan) { - this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, ButtplugIoDeviceProvider.AUTO_SCAN_INTERVAL_MS); + this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, buttplugIoWebsocketDeviceProvider.AUTO_SCAN_INTERVAL_MS); } } catch (e: unknown) { logError(this.logger, `Could not connect to buttplug.io server (${url})`, hasProperty(e, 'message') ? e.message : 'unknown'); @@ -167,7 +167,7 @@ export default class ButtplugIoDeviceProvider extends DeviceProvider< this.buttplugClient.stopScanning() .then(() => this.logger.info('Stop scanning for Buttplug.io devices')) .catch((e: unknown) => this.logger.error(`Could not stop scanning for buttplug.io devices`, e)); - }, ButtplugIoDeviceProvider.SCAN_DURATION_MS); + }, buttplugIoWebsocketDeviceProvider.SCAN_DURATION_MS); } private toDeviceInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceDetectionInfo { @@ -204,7 +204,7 @@ export default class ButtplugIoDeviceProvider extends DeviceProvider< protected override createDevice(deviceInfo: ButtplugIoDeviceDetectionInfo): Promise { const device = this.buttplugIoDeviceFactory.create( deviceInfo.buttplugClientDevice, - ButtplugIoDeviceProvider.providerName, + buttplugIoWebsocketDeviceProvider.providerName, this.useDeviceNameAsId ); diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceProviderFactory.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts similarity index 76% rename from src/device/protocol/buttplugIo/buttplugIoDeviceProviderFactory.ts rename to src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts index c92a8d3d..ddad16e1 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceProviderFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts @@ -2,7 +2,7 @@ import EventEmitter from 'events'; import DeviceProviderFactory from '../../provider/deviceProviderFactory.js'; import Logger from '../../../logging/Logger.js'; import ButtplugIoDeviceFactory from './buttplugIoDeviceFactory.js'; -import ButtplugIoDeviceProvider from './buttplugIoDeviceProvider.js'; +import buttplugIoWebsocketDeviceProvider from './buttplugIoWebsocketDeviceProvider.js'; import DeviceManager from '../../deviceManager.js'; type ButtplugIoDeviceProviderConfig = { @@ -11,7 +11,7 @@ type ButtplugIoDeviceProviderConfig = { useDeviceNameAsId: boolean } -export default class ButtplugIoDeviceProviderFactory implements DeviceProviderFactory +export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceProviderFactory { private readonly deviceManager: DeviceManager; @@ -33,9 +33,9 @@ export default class ButtplugIoDeviceProviderFactory implements DeviceProviderFa this.logger = logger; } - public create(config: ButtplugIoDeviceProviderConfig): ButtplugIoDeviceProvider + public create(config: ButtplugIoDeviceProviderConfig): buttplugIoWebsocketDeviceProvider { - return new ButtplugIoDeviceProvider( + return new buttplugIoWebsocketDeviceProvider( this.deviceManager, this.eventEmitter, this.deviceFactory, diff --git a/src/serviceMap.ts b/src/serviceMap.ts index 66e183e2..ff15bd81 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -48,7 +48,7 @@ import BleObserver from './device/transport/bleObserver.js'; import SlvCtrlPlusSerialDeviceProvider from './device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; import Zc95SerialDeviceProvider from './device/protocol/zc95/zc95SerialDeviceProvider.js'; import EStim2bSerialDeviceProvider from './device/protocol/estim2b/estim2bSerialDeviceProvider.js'; -import ButtplugIoDeviceProvider from './device/protocol/buttplugIo/buttplugIoDeviceProvider.js'; +import buttplugIoWebsocketDeviceProvider from './device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; import AiroticDeviceProvider from './device/protocol/airotic/airoticDeviceProvider.js'; import AiroticDeviceFactory from './device/protocol/airotic/airoticDeviceFactory.js'; import KnownDeviceRegistry from './device/knownDeviceRegistry.js'; @@ -73,7 +73,7 @@ type ServiceMap = { 'device.provider.factory.slvCtrlPlusSerial': DeviceProviderFactory, 'device.provider.factory.zc95Serial': DeviceProviderFactory, 'device.provider.factory.estim2bSerial': DeviceProviderFactory, - 'device.provider.factory.buttplugIoWebsocket': DeviceProviderFactory, + 'device.provider.factory.buttplugIoWebsocket': DeviceProviderFactory, 'device.provider.factory.airotic': DeviceProviderFactory, 'device.serial.factory.buttplugIo': ButtplugIoDeviceFactory, 'device.virtual.provider': VirtualDeviceProvider, diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index a431c416..5510ffdc 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -10,9 +10,9 @@ import SerialDeviceTransportFactory from '../device/transport/serialDeviceTransp import { AnyDevice } from '../device/device.js'; import DeviceProviderManager from '../device/provider/deviceProviderManager.js'; import SlvCtrlPlusSerialDeviceProvider from '../device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; -import ButtplugIoDeviceProvider from '../device/protocol/buttplugIo/buttplugIoDeviceProvider.js'; -import ButtplugIoDeviceProviderFactory - from '../device/protocol/buttplugIo/buttplugIoDeviceProviderFactory.js'; +import buttplugIoWebsocketDeviceProvider from '../device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; +import ButtplugIoWebsocketDeviceProviderFactory + from '../device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.js'; import ButtplugIoDeviceFactory from '../device/protocol/buttplugIo/buttplugIoDeviceFactory.js'; import ServiceMap from '../serviceMap.js'; import VirtualDeviceProvider from '../device/protocol/virtual/virtualDeviceProvider.js'; @@ -66,7 +66,7 @@ export default class DeviceServiceProvider implements ServiceProvider new ButtplugIoDeviceProviderFactory( + () => new ButtplugIoWebsocketDeviceProviderFactory( container.get('device.manager'), container.get('factory.eventEmitter').create(), container.get('device.serial.factory.buttplugIo'), @@ -191,7 +191,7 @@ export default class DeviceServiceProvider implements ServiceProvider { const [payload] = await deviceConnected; const expectedAttributes = { - provider: ButtplugIoDeviceProvider.providerName, + provider: buttplugIoWebsocketDeviceProvider.providerName, type: 'buttplugIo', attributes: { 'Vibrate-0': { From d472a408f299fb8b2e764647a3615724be793c3e Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 13:28:03 +0200 Subject: [PATCH 21/35] Mock the usb module in SerialPortObserver tests instead of double-calling stop() usb is a real, module-wide EventTarget - without mocking it, every addEventListener() call made by an observer under test was still registered against the real usb object when the next test ran, piling up across the whole file's run and eventually risking Node's MaxListenersExceededWarning. The afterEach hook was working around this by calling stop() twice on every created observer, on the assumption that no test's reference count would ever exceed 2 - a fragile magic number that would silently start leaking listeners again if a future test called start() a third time. Mock usb the same way bleObserver.spec.ts already mocks @stoprocent/noble: no real listeners are ever registered, so there's nothing to leak and nothing to work around. --- .../transport/serialPortObserver.spec.ts | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/tests/unit/device/transport/serialPortObserver.spec.ts b/tests/unit/device/transport/serialPortObserver.spec.ts index 60ead1c0..fb87f6a9 100644 --- a/tests/unit/device/transport/serialPortObserver.spec.ts +++ b/tests/unit/device/transport/serialPortObserver.spec.ts @@ -7,6 +7,16 @@ import Logger from '../../../../src/logging/Logger.js'; import SerialPortObserver from '../../../../src/device/transport/serialPortObserver.js'; import { DeviceId } from '../../../../src/device/deviceId.js'; +// usb is a real, module-wide EventTarget - without mocking it, addEventListener() calls made in +// one test would still be registered when the next test runs, eventually tripping Node's +// MaxListenersExceededWarning. +const mockUsb = vi.hoisted(() => ({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +})); + +vi.mock('usb', () => ({ usb: mockUsb })); + type PortInfoLike = { path: string; manufacturer: string | undefined; @@ -20,10 +30,6 @@ type PortInfoLike = { describe('SerialPortObserver', () => { let mockDeviceManager: ReturnType>; let mockLogger: ReturnType>; - // usb.addEventListener() is not mocked, so every observer created in a test must have its - // listeners torn down again afterwards - otherwise they pile up across the whole file's test - // run and eventually trip Node's MaxListenersExceededWarning. - let createdObservers: SerialPortObserver[]; function makePortInfo(overrides: Partial & { path: string }): PortInfoLike { return { @@ -38,29 +44,19 @@ describe('SerialPortObserver', () => { } function createObserver(): SerialPortObserver { - const observer = new SerialPortObserver(mockDeviceManager, mockLogger); - createdObservers.push(observer); - return observer; + return new SerialPortObserver(mockDeviceManager, mockLogger); } beforeEach(() => { vi.useFakeTimers(); + vi.resetAllMocks(); mockDeviceManager = mock(); mockLogger = mock(); mockLogger.child.mockReturnValue(mockLogger); - createdObservers = []; }); - afterEach(async () => { - // Each observer's own reference count may need more than one stop() call to actually - // tear down its listeners (see the reference-counting tests below) - stop() is a safe - // no-op once fully stopped, so calling it repeatedly here is fine. - for (const observer of createdObservers) { - await observer.stop(); - await observer.stop(); - } - + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); From e661512204c298d7bf46da68ffa40502ede62b4e Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 13:56:06 +0200 Subject: [PATCH 22/35] Stop recomputing buttplugIo device id in the factory, matching other protocols Every other protocol factory (airotic, zc95, estim2b, slvCtrlPlus) takes the already-computed deviceId as a plain parameter and only ButtplugIoDeviceFactory independently recomputed the same id from the raw ButtplugClientDevice via its own static computeDeviceId(). ButtplugIoDeviceFactory.create() now takes (deviceId, buttplugDevice, provider) like its siblings, and the id computation itself moved into buttplugIoWebsocketDeviceProvider's toDeviceInfo() - the one place it was actually called from - since the separate computeDeviceId() method was only ever a couple of lines wrapping that single call site. Also fixes the buttplugIoWebsocketDeviceProvider class name's casing (lowercase 'b' was a pre-existing typo against TS convention) and realigns providerName with what main already uses ('buttplugIoWebsocket'), rather than what this branch had reverted it to earlier. --- .../buttplugIo/buttplugIoDeviceFactory.ts | 16 +++---------- .../buttplugIoWebsocketDeviceProvider.ts | 23 +++++++++++-------- ...uttplugIoWebsocketDeviceProviderFactory.ts | 8 +++---- src/serviceMap.ts | 4 ++-- src/serviceProvider/deviceServiceProvider.ts | 4 ++-- .../devices/buttplugIoDevice.spec.ts | 6 ++--- 6 files changed, 28 insertions(+), 33 deletions(-) diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index d6f24e93..689df152 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -36,8 +36,8 @@ export default class ButtplugIoDeviceFactory this.logger = logger; } - public create(buttplugDevice: ButtplugClientDevice, provider: string, useDeviceNameAsId: boolean): ButtplugIoDevice { - const knownDevice = this.resolveKnownDevice(buttplugDevice, provider, useDeviceNameAsId); + public create(deviceId: DeviceId, buttplugDevice: ButtplugClientDevice, provider: string): ButtplugIoDevice { + const knownDevice = this.resolveKnownDevice(deviceId, buttplugDevice, provider); const deviceAttrs = ButtplugIoDeviceFactory.parseDeviceAttributes(buttplugDevice); @@ -115,17 +115,7 @@ export default class ButtplugIoDeviceFactory return attributes; } - public static computeDeviceId(buttplugDevice: ButtplugClientDevice, useDeviceNameAsId: boolean): DeviceId { - // Since we don't get a unique identifier for the Bluetooth device from Intiface, - // we need to use the index assigned to the device by Intiface. It's the best we have. - // or the name if using Intiface-engine without id persistence - const nameString = buttplugDevice.name.replace(/[^a-zA-Z0-9]/g, ''); - return DeviceId.create(useDeviceNameAsId ? `buttplugio-${nameString}` : `buttplugio-${buttplugDevice.index}`); - } - - private resolveKnownDevice(buttplugDevice: ButtplugClientDevice, provider: string, useDeviceNameAsId: boolean): KnownDevice { - const deviceId = ButtplugIoDeviceFactory.computeDeviceId(buttplugDevice, useDeviceNameAsId); - + private resolveKnownDevice(deviceId: DeviceId, buttplugDevice: ButtplugClientDevice, provider: string): KnownDevice { return this.knownDeviceRegistry.resolve( deviceId, buttplugDevice.name, diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 7c5f7d2d..927326fa 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -9,17 +9,18 @@ import SlvCtrlPlusButtplugWebsocketClientConnector from './slvCtrlPlusButtplugWe import DeviceManager, { DeviceDetectionInfo } from '../../deviceManager.js'; import { logError } from '../../../util/error.js'; import { hasProperty } from '../../../util/objects.js'; +import { DeviceId } from '../../deviceId.js'; export type ButtplugIoDeviceDetectionInfo = DeviceDetectionInfo & { type: 'buttplugIo'; buttplugClientDevice: ButtplugClientDevice; }; -export default class buttplugIoWebsocketDeviceProvider extends DeviceProvider< +export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< ButtplugIoDeviceDetectionInfo, ButtplugIoDevice > { - public static readonly providerName = 'buttplugIo'; + public static readonly providerName = 'buttplugIoWebsocket'; // How often to (re)attempt connecting to the Intiface/buttplug.io server while disconnected. private static readonly CONNECT_RETRY_INTERVAL_MS = 1_000; @@ -53,7 +54,7 @@ export default class buttplugIoWebsocketDeviceProvider extends DeviceProvider< useDeviceNameAsId: boolean, logger: Logger ) { - super(deviceManager, eventEmitter, logger.child({ name: buttplugIoWebsocketDeviceProvider.name })); + super(deviceManager, eventEmitter, logger.child({ name: ButtplugIoWebsocketDeviceProvider.name })); this.buttplugIoDeviceFactory = deviceFactory; this.websocketAddress = websocketAddress; this.autoScan = autoScan; @@ -74,7 +75,7 @@ export default class buttplugIoWebsocketDeviceProvider extends DeviceProvider< public override async init(): Promise { this.connectionIntervalRef ??= setImmediateInterval( () => void this.connectToServer(), - buttplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS + ButtplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS ); } @@ -118,7 +119,7 @@ export default class buttplugIoWebsocketDeviceProvider extends DeviceProvider< this.connectionIntervalRef = undefined; if (this.autoScan) { - this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, buttplugIoWebsocketDeviceProvider.AUTO_SCAN_INTERVAL_MS); + this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, ButtplugIoWebsocketDeviceProvider.AUTO_SCAN_INTERVAL_MS); } } catch (e: unknown) { logError(this.logger, `Could not connect to buttplug.io server (${url})`, hasProperty(e, 'message') ? e.message : 'unknown'); @@ -167,11 +168,15 @@ export default class buttplugIoWebsocketDeviceProvider extends DeviceProvider< this.buttplugClient.stopScanning() .then(() => this.logger.info('Stop scanning for Buttplug.io devices')) .catch((e: unknown) => this.logger.error(`Could not stop scanning for buttplug.io devices`, e)); - }, buttplugIoWebsocketDeviceProvider.SCAN_DURATION_MS); + }, ButtplugIoWebsocketDeviceProvider.SCAN_DURATION_MS); } private toDeviceInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceDetectionInfo { - const deviceId = ButtplugIoDeviceFactory.computeDeviceId(buttplugDevice, this.useDeviceNameAsId); + // Since we don't get a unique identifier for the Bluetooth device from Intiface, + // we need to use the index assigned to the device by Intiface. It's the best we have. + // or the name if using Intiface-engine without id persistence + const nameString = buttplugDevice.name.replace(/[^a-zA-Z0-9]/g, ''); + const deviceId = DeviceId.create(this.useDeviceNameAsId ? `buttplugio-${nameString}` : `buttplugio-${buttplugDevice.index}`); return { type: 'buttplugIo', detectionId: deviceId, buttplugClientDevice: buttplugDevice }; } @@ -203,9 +208,9 @@ export default class buttplugIoWebsocketDeviceProvider extends DeviceProvider< protected override createDevice(deviceInfo: ButtplugIoDeviceDetectionInfo): Promise { const device = this.buttplugIoDeviceFactory.create( + deviceInfo.detectionId, deviceInfo.buttplugClientDevice, - buttplugIoWebsocketDeviceProvider.providerName, - this.useDeviceNameAsId + ButtplugIoWebsocketDeviceProvider.providerName ); return Promise.resolve(device); diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts index ddad16e1..c947211e 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts @@ -2,7 +2,7 @@ import EventEmitter from 'events'; import DeviceProviderFactory from '../../provider/deviceProviderFactory.js'; import Logger from '../../../logging/Logger.js'; import ButtplugIoDeviceFactory from './buttplugIoDeviceFactory.js'; -import buttplugIoWebsocketDeviceProvider from './buttplugIoWebsocketDeviceProvider.js'; +import ButtplugIoWebsocketDeviceProvider from './buttplugIoWebsocketDeviceProvider.js'; import DeviceManager from '../../deviceManager.js'; type ButtplugIoDeviceProviderConfig = { @@ -11,7 +11,7 @@ type ButtplugIoDeviceProviderConfig = { useDeviceNameAsId: boolean } -export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceProviderFactory +export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceProviderFactory { private readonly deviceManager: DeviceManager; @@ -33,9 +33,9 @@ export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceP this.logger = logger; } - public create(config: ButtplugIoDeviceProviderConfig): buttplugIoWebsocketDeviceProvider + public create(config: ButtplugIoDeviceProviderConfig): ButtplugIoWebsocketDeviceProvider { - return new buttplugIoWebsocketDeviceProvider( + return new ButtplugIoWebsocketDeviceProvider( this.deviceManager, this.eventEmitter, this.deviceFactory, diff --git a/src/serviceMap.ts b/src/serviceMap.ts index ff15bd81..53f23393 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -48,7 +48,7 @@ import BleObserver from './device/transport/bleObserver.js'; import SlvCtrlPlusSerialDeviceProvider from './device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; import Zc95SerialDeviceProvider from './device/protocol/zc95/zc95SerialDeviceProvider.js'; import EStim2bSerialDeviceProvider from './device/protocol/estim2b/estim2bSerialDeviceProvider.js'; -import buttplugIoWebsocketDeviceProvider from './device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; +import ButtplugIoWebsocketDeviceProvider from './device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; import AiroticDeviceProvider from './device/protocol/airotic/airoticDeviceProvider.js'; import AiroticDeviceFactory from './device/protocol/airotic/airoticDeviceFactory.js'; import KnownDeviceRegistry from './device/knownDeviceRegistry.js'; @@ -73,7 +73,7 @@ type ServiceMap = { 'device.provider.factory.slvCtrlPlusSerial': DeviceProviderFactory, 'device.provider.factory.zc95Serial': DeviceProviderFactory, 'device.provider.factory.estim2bSerial': DeviceProviderFactory, - 'device.provider.factory.buttplugIoWebsocket': DeviceProviderFactory, + 'device.provider.factory.buttplugIoWebsocket': DeviceProviderFactory, 'device.provider.factory.airotic': DeviceProviderFactory, 'device.serial.factory.buttplugIo': ButtplugIoDeviceFactory, 'device.virtual.provider': VirtualDeviceProvider, diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index 5510ffdc..acd23496 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -10,7 +10,7 @@ import SerialDeviceTransportFactory from '../device/transport/serialDeviceTransp import { AnyDevice } from '../device/device.js'; import DeviceProviderManager from '../device/provider/deviceProviderManager.js'; import SlvCtrlPlusSerialDeviceProvider from '../device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; -import buttplugIoWebsocketDeviceProvider from '../device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; +import ButtplugIoWebsocketDeviceProvider from '../device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; import ButtplugIoWebsocketDeviceProviderFactory from '../device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.js'; import ButtplugIoDeviceFactory from '../device/protocol/buttplugIo/buttplugIoDeviceFactory.js'; @@ -191,7 +191,7 @@ export default class DeviceServiceProvider implements ServiceProvider { const [payload] = await deviceConnected; const expectedAttributes = { - provider: buttplugIoWebsocketDeviceProvider.providerName, + provider: ButtplugIoWebsocketDeviceProvider.providerName, type: 'buttplugIo', attributes: { 'Vibrate-0': { From 338aca25417a868ee0e68401368a04294a299991 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 14:20:53 +0200 Subject: [PATCH 23/35] Don't abort virtual device reconciliation when one device fails to close A rejected device.close() previously bubbled straight out of the loop, skipping the close of any remaining removed devices and also skipping discovery of newly configured devices for that reconciliation pass entirely. Catch and log each close failure individually so one misbehaving device can't block cleanup/discovery for the rest. --- src/device/protocol/virtual/virtualDeviceProvider.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index fbfdc50d..702426bb 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -79,7 +79,11 @@ export default class VirtualDeviceProvider extends DeviceProvider Date: Sun, 19 Jul 2026 14:21:49 +0200 Subject: [PATCH 24/35] Rename buttplugIo naming leftovers for clarity toDeviceInfo() -> createDeviceDetectionInfo() to match the type it actually builds (ButtplugIoDeviceDetectionInfo), and ButtplugIoDeviceProviderConfig -> ButtplugIoWebsocketDeviceProviderConfig to match the provider class it configures. --- .../buttplugIo/buttplugIoWebsocketDeviceProvider.ts | 6 +++--- .../buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 927326fa..cfc38b41 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -171,7 +171,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< }, ButtplugIoWebsocketDeviceProvider.SCAN_DURATION_MS); } - private toDeviceInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceDetectionInfo { + private createDeviceDetectionInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceDetectionInfo { // Since we don't get a unique identifier for the Bluetooth device from Intiface, // we need to use the index assigned to the device by Intiface. It's the best we have. // or the name if using Intiface-engine without id persistence @@ -189,7 +189,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< private announceButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { this.logger.info(`Device detected: ${buttplugDevice.name}`, buttplugDevice); - this.deviceManager.announceDetectedDevice(this.toDeviceInfo(buttplugDevice)); + this.deviceManager.announceDetectedDevice(this.createDeviceDetectionInfo(buttplugDevice)); } /** @@ -199,7 +199,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< * its constructor). */ private revokePendingButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { - this.deviceManager.revokeDetectedDevice(this.toDeviceInfo(buttplugDevice)); + this.deviceManager.revokeDetectedDevice(this.createDeviceDetectionInfo(buttplugDevice)); } protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is ButtplugIoDeviceDetectionInfo { diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts index c947211e..00ac3d00 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts @@ -5,7 +5,7 @@ import ButtplugIoDeviceFactory from './buttplugIoDeviceFactory.js'; import ButtplugIoWebsocketDeviceProvider from './buttplugIoWebsocketDeviceProvider.js'; import DeviceManager from '../../deviceManager.js'; -type ButtplugIoDeviceProviderConfig = { +type ButtplugIoWebsocketDeviceProviderConfig = { address: string, autoScan: boolean, useDeviceNameAsId: boolean @@ -33,7 +33,7 @@ export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceP this.logger = logger; } - public create(config: ButtplugIoDeviceProviderConfig): ButtplugIoWebsocketDeviceProvider + public create(config: ButtplugIoWebsocketDeviceProviderConfig): ButtplugIoWebsocketDeviceProvider { return new ButtplugIoWebsocketDeviceProvider( this.deviceManager, From 7ea3b0d3c76d1a70b9a70ef52e13066f14725f88 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 15:50:02 +0200 Subject: [PATCH 25/35] Don't leave a provider permanently zombied when device.close() fails during stop() DeviceProviderManager deliberately keeps a provider recorded whose stop() rejected, on the assumption it may still be partially running. But DeviceProvider.stop() marks itself stopped and unsubscribes from device detection before awaiting each device's close() - both irreversible and necessary so in-flight detections can't survive stop(). If any device's close() then rejected, that exception propagated out of stop() itself, leaving the provider internally dead (stopped, unsubscribed) yet retained by the manager as if it might recover, permanently blocking a later re-enable of that source from ever creating a replacement. Catch and log each device's close() failure individually instead, matching the same pattern already used in VirtualDeviceProvider's reconciliation loop and DeviceManager.onSettingsChanged(). Device.close() already emits deviceDisconnected and transitions state in a finally block regardless of whether the underlying doClose() throws, so nothing is left dangling by catching here - stop() can now always complete, so DeviceProviderManager can always safely remove the provider and create a fresh one on the next reload. Also includes a few smaller cleanups: dropped an unused JsonObject import/ eslint-disable in VirtualDeviceProviderFactory, removed a stale docstring comment from BleDeviceProvider, and renamed DeviceProvider's DI type param to DDI. --- .../virtual/virtualDeviceProviderFactory.ts | 4 +-- src/device/provider/bleDeviceProvider.ts | 6 ---- src/device/provider/deviceProvider.ts | 28 +++++++++---------- 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts index 7ab737d5..a92a2e55 100644 --- a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts +++ b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts @@ -5,7 +5,6 @@ import SettingsManager from '../../../settings/settingsManager.js'; import VirtualDeviceFactory from './virtualDeviceFactory.js'; import DeviceManager from '../../deviceManager.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; -import { JsonObject } from '../../../types.js'; export default class VirtualDeviceProviderFactory implements DeviceProviderFactory { @@ -33,8 +32,7 @@ export default class VirtualDeviceProviderFactory implements DeviceProviderFacto this.logger = logger; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public create(config: JsonObject): VirtualDeviceProvider { + public create(): VirtualDeviceProvider { return new VirtualDeviceProvider( this.deviceManager, this.eventEmitterFactory.create(), diff --git a/src/device/provider/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts index 40597ecf..686849f8 100644 --- a/src/device/provider/bleDeviceProvider.ts +++ b/src/device/provider/bleDeviceProvider.ts @@ -8,12 +8,6 @@ import { logError } from '../../util/error.js'; import BleObserver, { BleDeviceDetectionInfo } from '../transport/bleObserver.js'; import { AnyBleDevice } from '../bleDevice.js'; -/** - * Owns starting/stopping the shared `BleObserver` alongside this provider's own lifecycle, so - * BLE scanning only ever runs while at least one BLE-based device source is actually configured - * and enabled - see `BleObserver` for how it stays safe to be started/stopped by more than one - * provider at once. - */ export default abstract class BleDeviceProvider extends DeviceProvider { private readonly bleObserver: BleObserver; diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index 35f09838..bce9f5d8 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -8,10 +8,7 @@ import { DeviceId } from '../deviceId.js'; export type AnyDeviceProvider = DeviceProvider; -export default abstract class DeviceProvider< - DI extends DeviceDetectionInfo, - D extends AnyDevice -> +export default abstract class DeviceProvider { protected readonly deviceManager: DeviceManager; @@ -47,8 +44,16 @@ export default abstract class DeviceProvider< this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + // A rejected close() must not abort the loop or leave stop() itself rejected: DeviceProviderManager + // keeps a provider whose stop() throws around (assuming it may still be partially running), which + // would make this instance permanently unusable - already stopped and detached above, yet never + // replaced since the manager thinks a re-enable of this source doesn't need a fresh provider. for (const device of this.connectedDevices.values()) { - await device.close(); + try { + await device.close(); + } catch (e: unknown) { + logError(this.logger, `Failed to close device '${device.getDeviceId}' while stopping provider`, e); + } } this.connectedDevices.clear(); } @@ -110,22 +115,17 @@ export default abstract class DeviceProvider< this.logger.info(`Connected devices: ${this.connectedDevices.size}`); } - /** - * Cleans up after a failed/aborted connection attempt. Transport-level cleanup runs *before* - * the acquire claim is released, so the next provider in the queue cannot begin a new attempt - * against a transport this provider is still tearing down. - */ - private async abortDetection(deviceInfo: DI): Promise { + private async abortDetection(deviceInfo: DDI): Promise { await this.onConnectFailed(deviceInfo); this.deviceManager.releaseDetectedDevice(deviceInfo.detectionId); } - protected abstract canHandleDeviceDetectionInfo(deviceDetectionInfo: DeviceDetectionInfo): deviceDetectionInfo is DI; + protected abstract canHandleDeviceDetectionInfo(deviceDetectionInfo: DeviceDetectionInfo): deviceDetectionInfo is DDI; - protected abstract createDevice(deviceInfo: DI): Promise; + protected abstract createDevice(deviceInfo: DDI): Promise; // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected async onConnectFailed(deviceInfo: DI): Promise { + protected async onConnectFailed(deviceInfo: DDI): Promise { return Promise.resolve(); } } From 440a05821e57e6e7b97fc1bcfe9814bf24057317 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 16:17:41 +0200 Subject: [PATCH 26/35] Replace DeviceProviderManager's hand-rolled operation queue with SequentialTaskQueue The manual Promise chain (this.operationQueue.then(operation, operation), re-assigned with a .catch(() => undefined) to keep the chain from wedging on a rejected operation) was reimplementing what SequentialTaskQueue already does out of the box: run pushed tasks strictly one at a time in FIFO order, and move on to the next queued task regardless of whether the previous one resolved or rejected. This is the same package already used for the same kind of serialization in synchronousSerialPort.ts and bufferedDeviceUpdater.ts. enqueueOperation() stays as a thin wrapper rather than calling push() at each call site directly, since it earns its keep on two fronts: SequentialTaskQueue.push() returns a CancellablePromiseLike (only has .then(), not .catch()/.finally()), which app.ts relies on via .catch() after loadFromSettings() - awaiting it inside an async method is what normalizes it back into a real Promise without an any/type-assertion. Its operation: () => Promise parameter type is also a tighter contract than push()'s own untyped Function parameter, which accepts any callable regardless of arity or return type. --- src/device/provider/deviceProviderManager.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index bdab6773..9b4cceba 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -1,3 +1,4 @@ +import { SequentialTaskQueue } from 'sequential-task-queue'; import Settings from '../../settings/settings.js'; import DeviceProviderFactory from './deviceProviderFactory.js'; import Logger from '../../logging/Logger.js'; @@ -19,7 +20,7 @@ export default class DeviceProviderManager * map, otherwise a later call could observe a half-finished earlier one and reach the wrong * conclusion about whether a provider is already running. */ - private operationQueue: Promise = Promise.resolve(); + private readonly operationQueue: SequentialTaskQueue = new SequentialTaskQueue(); public constructor( factories: Map>, @@ -38,14 +39,8 @@ export default class DeviceProviderManager return this.enqueueOperation(() => this.doStopProviders()); } - private enqueueOperation(operation: () => Promise): Promise { - const result = this.operationQueue.then(operation, operation); - - // Swallow rejections in the chain itself (each caller still gets the real - // rejection via `result`), so a failed operation doesn't permanently wedge the queue. - this.operationQueue = result.catch(() => undefined); - - return result; + private async enqueueOperation(operation: () => Promise): Promise { + await this.operationQueue.push(operation); } private async doLoadFromSettings(settings: Settings): Promise From 249e4a0912fa1b10f14dd3402fd067996c7d08b6 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 16:40:39 +0200 Subject: [PATCH 27/35] Extract shared reference-counting logic from BleObserver/SerialPortObserver into SharedObserver Both observers are DI singletons shared across multiple DeviceProviders of the same transport, and both had near-identical activeUsers counting logic (plus matching doc comments) reimplementing the same 'only start on the first caller, only stop once every caller has also stopped' contract independently. Moved that bookkeeping into a new abstract SharedObserver base class: acquire()/release() own the counting and 'already running'/'still used' logging, and delegate the actual transport-specific work to abstract onFirstStart()/onLastStop() hooks. BleObserver and SerialPortObserver keep their existing public method names (init()/stop() and start()/stop() respectively) as thin wrappers around acquire()/release(), so nothing calling them (BleDeviceProvider, SerialDeviceProvider) needs to change. Also removed SerialPortObserver's 'public static readonly name = "serial"', which shadowed the class's built-in static name property for no reason beyond its own constructor/test - it wasn't used as a DI key, discriminator, or anywhere else. Its logger now shows 'SerialPortObserver' like every other class in the codebase that relies on the built-in name for its logger label, instead of being the one exception. --- src/device/provider/serialDeviceProvider.ts | 6 --- src/device/transport/bleObserver.ts | 44 ++++++------------- src/device/transport/serialPortObserver.ts | 41 +++++------------- src/device/transport/sharedObserver.ts | 48 +++++++++++++++++++++ 4 files changed, 70 insertions(+), 69 deletions(-) create mode 100644 src/device/transport/sharedObserver.ts diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 194c9be7..3bc1f35f 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -14,12 +14,6 @@ import { AnyPeripheralDevice } from '../peripheralDevice.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; -/** - * Owns starting/stopping the shared `SerialPortObserver` alongside this provider's own lifecycle, - * so serial port scanning only ever runs while at least one serial-based device source is - * actually configured and enabled - see `SerialPortObserver` for how it stays safe to be - * started/stopped by more than one provider at once. - */ export default abstract class SerialDeviceProvider extends DeviceProvider { private readonly serialPortFactory: SerialPortFactory; diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index 661dcf59..e8c07f4f 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -4,51 +4,42 @@ import DeviceManager, { DeviceDetectionInfo } from '../deviceManager.js'; import { logError } from '../../util/error.js'; import { DeviceId } from '../deviceId.js'; import { asyncHandler } from '../../util/async.js'; +import SharedObserver from './sharedObserver.js'; export type BleDeviceDetectionInfo = DeviceDetectionInfo & { type: 'ble'; peripheral: Peripheral; }; -export default class BleObserver +export default class BleObserver extends SharedObserver { private static readonly MIN_RSSI = -70; private static readonly UART_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; private readonly deviceManager: DeviceManager; - private readonly logger: Logger; - private isScanning = false; - /** - * Multiple `BleDeviceProvider`s (one per BLE-based protocol, e.g. airotic) can be running at - * once and each depend on this same observer, since it's a DI singleton shared across all of - * them. `init()`/`stop()` are reference-counted so the underlying noble scan only actually - * starts once (on the first caller) and only actually stops once every caller that started it - * has also stopped it - two providers calling `init()` must never result in two overlapping - * noble listener registrations, and one provider stopping must not kill scanning for another - * still-active one. - */ - private activeUsers = 0; - public constructor( deviceManager: DeviceManager, logger: Logger ) { + super(logger.child({ name: BleObserver.name })); this.deviceManager = deviceManager; - this.logger = logger.child({ name: BleObserver.name }); } public async init(): Promise { - this.activeUsers++; + await this.acquire(); + } - if (this.activeUsers > 1) { - this.logger.debug(`Already running, now used by ${this.activeUsers} provider(s)`); - return; - } + public async stop(): Promise + { + await this.release(); + } + protected async onFirstStart(): Promise + { noble.on('discover', this.onDiscover.bind(this)); noble.on('stateChange', asyncHandler( @@ -65,19 +56,8 @@ export default class BleObserver await this.observe(); } - public async stop(): Promise + protected async onLastStop(): Promise { - if (this.activeUsers === 0) { - return; - } - - this.activeUsers--; - - if (this.activeUsers > 0) { - this.logger.debug(`Still used by ${this.activeUsers} provider(s), not stopping`); - return; - } - noble.removeAllListeners(); if (this.isScanning) { diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index 7bdc1d5f..b665e09b 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -5,20 +5,17 @@ import DeviceManager, { DeviceDetectionInfo } from '../deviceManager.js'; import { usb } from 'usb'; import { logError } from '../../util/error.js'; import { DeviceId } from '../deviceId.js'; +import SharedObserver from './sharedObserver.js'; export type SerialDeviceDetectionInfo = DeviceDetectionInfo & { type: 'serial'; portInfo: PortInfo; }; -export default class SerialPortObserver +export default class SerialPortObserver extends SharedObserver { - protected readonly logger: Logger; - protected readonly deviceManager: DeviceManager; - public static readonly name = 'serial'; - private managedDevices: Map = new Map(); private onUsbEventRef?: () => void; @@ -27,32 +24,21 @@ export default class SerialPortObserver private discoveryInFlight = false; - /** - * Multiple `SerialDeviceProvider`s (one per serial-based protocol, e.g. zc95, estim2b, - * slvCtrlPlus) can be running at once and each depend on this same observer, since it's a DI - * singleton shared across all of them. `start()`/`stop()` are reference-counted so USB - * enumeration/listening only actually starts once (on the first caller) and only actually - * stops once every caller that started it has also stopped it. - */ - private activeUsers = 0; - public constructor( deviceManager: DeviceManager, logger: Logger ) { + super(logger.child({ name: SerialPortObserver.name })); this.deviceManager = deviceManager; - this.logger = logger.child({ name: SerialPortObserver.name }); } public async start(): Promise { - this.activeUsers++; - - if (this.activeUsers > 1) { - this.logger.debug(`Already running, now used by ${this.activeUsers} provider(s)`); - return; - } + await this.acquire(); + } + protected async onFirstStart(): Promise + { await this.discoverSerialDevices(); this.onUsbEventRef = (): void => { @@ -127,17 +113,10 @@ export default class SerialPortObserver } public async stop(): Promise { - if (this.activeUsers === 0) { - return; - } - - this.activeUsers--; - - if (this.activeUsers > 0) { - this.logger.debug(`Still used by ${this.activeUsers} provider(s), not stopping`); - return; - } + await this.release(); + } + protected async onLastStop(): Promise { if (this.rescanTimer !== undefined) { clearTimeout(this.rescanTimer); this.rescanTimer = undefined; diff --git a/src/device/transport/sharedObserver.ts b/src/device/transport/sharedObserver.ts new file mode 100644 index 00000000..007713eb --- /dev/null +++ b/src/device/transport/sharedObserver.ts @@ -0,0 +1,48 @@ +import Logger from '../../logging/Logger.js'; + +export default abstract class SharedObserver +{ + protected readonly logger: Logger; + + private activeUsers = 0; + + protected constructor(logger: Logger) { + this.logger = logger; + } + + protected async acquire(): Promise { + this.activeUsers++; + + if (this.activeUsers > 1) { + this.logger.debug(`Already running, now used by ${this.activeUsers} provider(s)`); + return; + } + + await this.onFirstStart(); + } + + protected async release(): Promise { + if (this.activeUsers === 0) { + return; + } + + this.activeUsers--; + + if (this.activeUsers > 0) { + this.logger.debug(`Still used by ${this.activeUsers} provider(s), not stopping`); + return; + } + + await this.onLastStop(); + } + + /** + * Runs once, when the first caller acquires this observer. + */ + protected abstract onFirstStart(): Promise; + + /** + * Runs once, when the last remaining caller releases this observer. + */ + protected abstract onLastStop(): Promise; +} From d68a8bde0d8fdbf8566e475c5ba93ea4385c3df6 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 18:30:46 +0200 Subject: [PATCH 28/35] Hydrate schema defaults and validate via AJV in PlainToClassSerializer.transform() KnownDevice/DeviceSource previously defended against a missing 'enabled' by falling back to '?? true' in their getters, working around class-transformer bypassing the constructor on plain-JSON deserialization. That fallback only ever protected consumers of these two specific classes, and needed a matching '_enabled'/toClassOnly + getter/toPlainOnly split just to keep the serialized payload looking like a plain boolean. PlainToClassSerializer.transform() now takes an optional TypeBox schema alongside the target class: when given, it validates plain against it via the shared Ajv instance (throwing a SchemaValidationError carrying Ajv's own error objects on failure), then hydrates any schema-declared defaults (e.g. enabled: true) into plain via Value.Default() before deserializing. Both SettingsManager.load() and PutSettingsController now just pass SettingsSchema into transform() instead of running their own separate JsonSchemaValidator validate-then-transform dance, and PutSettingsController's structured 400 response now comes straight from the thrown error's validationErrors. This let KnownDevice/DeviceSource drop the '?? true' workaround entirely - 'enabled' is now a plain field like every other property on these classes, since anything that deserializes them through Settings is guaranteed to already have it hydrated. One TypeBox quirk had to be worked around: Value.Default() only recurses into an *existing* Type.Record entry to apply that entry's own nested defaults if the entry's own schema also carries a 'default' - otherwise it leaves already-present-but-incomplete entries untouched. Added 'default: {}' to the knownDevices/deviceSources per-entry object schemas to opt into that recursion (verified empirically with a throwaway script before landing it). Removed the now-fully-unused 'settings.schema.validator' DI registration (JsonSchemaValidatorFactory/JsonSchemaValidator themselves stay - still used independently by GenericVirtualDeviceFactory for per-device-type config validation). --- .../settings/putSettingsController.ts | 23 +++--- src/schemaValidation/schemaValidationError.ts | 18 +++++ src/serialization/plainToClassSerializer.ts | 22 +++++- src/serviceMap.ts | 4 +- .../controllerServiceProvider.ts | 1 - .../serializationServiceProvider.ts | 2 +- .../settingsServiceProvider.ts | 8 -- src/settings/deviceSource.ts | 11 +-- src/settings/knownDevice.ts | 11 +-- src/settings/settings.ts | 6 +- src/settings/settingsManager.ts | 24 +++--- tests/unit/settings/deviceSource.spec.ts | 10 --- tests/unit/settings/knownDevice.spec.ts | 12 --- tests/unit/settings/settings.spec.ts | 74 +++++++++++++++++++ 14 files changed, 144 insertions(+), 82 deletions(-) create mode 100644 src/schemaValidation/schemaValidationError.ts create mode 100644 tests/unit/settings/settings.spec.ts diff --git a/src/controller/settings/putSettingsController.ts b/src/controller/settings/putSettingsController.ts index aa745f9b..50a857ba 100644 --- a/src/controller/settings/putSettingsController.ts +++ b/src/controller/settings/putSettingsController.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import ControllerInterface from '../controllerInterface.js'; import SettingsManager from '../../settings/settingsManager.js'; import Settings, { SettingsSchema } from '../../settings/settings.js'; -import JsonSchemaValidator from '../../schemaValidation/JsonSchemaValidator.js'; +import SchemaValidationError from '../../schemaValidation/schemaValidationError.js'; import PlainToClassSerializer from '../../serialization/plainToClassSerializer.js'; import ClassToPlainSerializer from '../../serialization/classToPlainSerializer.js'; import { JsonObject } from '../../types.js'; @@ -17,35 +17,34 @@ export default class PutSettingsController implements ControllerInterface private classToPlainSerializer: ClassToPlainSerializer; - private settingsSchemaValidator: JsonSchemaValidator; - public constructor( settingsManager: SettingsManager, classToPlainSerializer: ClassToPlainSerializer, - plainToClassSerializer: PlainToClassSerializer, - settingsSchemaValidator: JsonSchemaValidator + plainToClassSerializer: PlainToClassSerializer ) { this.settingsManager = settingsManager; - this.settingsSchemaValidator = settingsSchemaValidator; this.plainToClassSerializer = plainToClassSerializer; this.classToPlainSerializer = classToPlainSerializer; } public execute(req: PutSettingsRequest, res: Response): void { - const valid = this.settingsSchemaValidator.validate(req.body); + let settings: Settings; + + try { + settings = this.plainToClassSerializer.transform(Settings, req.body, SettingsSchema); + } catch (e: unknown) { + if (!(e instanceof SchemaValidationError)) { + throw e; + } - if (!valid) { - const validationErrors = this.settingsSchemaValidator.getValidationErrors(); res.status(400).json({ message: `Settings are not in a valid format`, - errors: [...validationErrors] + errors: e.validationErrors }); return; } - const settings = this.plainToClassSerializer.transform(Settings, req.body); - this.settingsManager.replace(settings); res.send(JSON.stringify(this.classToPlainSerializer.transform( diff --git a/src/schemaValidation/schemaValidationError.ts b/src/schemaValidation/schemaValidationError.ts new file mode 100644 index 00000000..5a0275bb --- /dev/null +++ b/src/schemaValidation/schemaValidationError.ts @@ -0,0 +1,18 @@ +import { ErrorObject } from 'ajv'; + +/** + * Thrown by PlainToClassSerializer.transform() when a schema is passed and the plain value fails + * validation against it. Callers that don't need the details can just let it propagate/log its + * message; callers that do (e.g. to return a structured 400 response) can catch it and read + * `validationErrors` directly, same shape as `Ajv.errors`. + */ +export default class SchemaValidationError extends Error +{ + public readonly validationErrors: ErrorObject[]; + + public constructor(message: string, validationErrors: ErrorObject[]) { + super(message); + this.name = 'SchemaValidationError'; + this.validationErrors = validationErrors; + } +} diff --git a/src/serialization/plainToClassSerializer.ts b/src/serialization/plainToClassSerializer.ts index 56d7a5b9..36db8d1f 100644 --- a/src/serialization/plainToClassSerializer.ts +++ b/src/serialization/plainToClassSerializer.ts @@ -1,16 +1,34 @@ import { ClassConstructor, ClassTransformOptions, plainToInstance } from 'class-transformer'; +import { TSchema } from '@sinclair/typebox'; +import { Value } from '@sinclair/typebox/value'; +import { Ajv } from 'ajv'; +import SchemaValidationError from '../schemaValidation/schemaValidationError.js'; export default class PlainToClassSerializer { + private readonly ajv: Ajv; + private readonly options?: ClassTransformOptions; - public constructor(options?: ClassTransformOptions) + public constructor(ajv: Ajv, options?: ClassTransformOptions) { + this.ajv = ajv; this.options = options; } - public transform(cls: ClassConstructor, plain: V): T + public transform(cls: ClassConstructor, plain: V, schema?: TSchema): T { + if (undefined !== schema) { + if (!this.ajv.validate(schema, plain)) { + throw new SchemaValidationError( + this.ajv.errorsText(this.ajv.errors), + this.ajv.errors ?? [] + ); + } + + Value.Default(schema, plain); + } + return plainToInstance(cls, plain, this.options); } } diff --git a/src/serviceMap.ts b/src/serviceMap.ts index 53f23393..b209c38b 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -11,7 +11,7 @@ import DeviceNameGenerator from './device/deviceNameGenerator.js'; import DeviceUpdaterInterface from './device/updater/deviceUpdaterInterface.js'; import UuidFactory from './factory/uuidFactory.js'; import DateFactory from './factory/dateFactory.js'; -import Settings, { SettingsSchema } from './settings/settings.js'; +import Settings from './settings/settings.js'; import SettingsManager from './settings/settingsManager.js'; import ScriptRuntime from './automation/scriptRuntime.js'; import ConnectedDeviceRepository from './repository/connectedDeviceRepository.js'; @@ -36,7 +36,6 @@ import VirtualDeviceProviderFactory from './device/protocol/virtual/virtualDevic import GetSettingsController from './controller/settings/getSettingsController.js'; import PutSettingsController from './controller/settings/putSettingsController.js'; import JsonSchemaValidatorFactory from './schemaValidation/JsonSchemaValidatorFactory.js'; -import JsonSchemaValidator from './schemaValidation/JsonSchemaValidator.js'; import VersionController from './controller/versionController.js'; import SerialPortObserver from './device/transport/serialPortObserver.js'; import Zc95DeviceFactory from './device/protocol/zc95/zc95DeviceFactory.js'; @@ -97,7 +96,6 @@ type ServiceMap = { /* settingsServiceProvider */ 'settings': Settings, 'settings.manager': SettingsManager, - 'settings.schema.validator': JsonSchemaValidator, /* automationServiceProvider */ 'automation.scriptRuntime': ScriptRuntime, diff --git a/src/serviceProvider/controllerServiceProvider.ts b/src/serviceProvider/controllerServiceProvider.ts index 092aa3a4..e4370708 100644 --- a/src/serviceProvider/controllerServiceProvider.ts +++ b/src/serviceProvider/controllerServiceProvider.ts @@ -106,7 +106,6 @@ export default class ControllerServiceProvider implements ServiceProvider): void { container.set('serializer.plainToClass', () => { - return new PlainToClassSerializer({ excludeExtraneousValues: true }); + return new PlainToClassSerializer(container.get('ajv'), { excludeExtraneousValues: true }); }); container.set('serializer.classToPlain', () => { diff --git a/src/serviceProvider/settingsServiceProvider.ts b/src/serviceProvider/settingsServiceProvider.ts index 836e97c1..1ddb3b03 100644 --- a/src/serviceProvider/settingsServiceProvider.ts +++ b/src/serviceProvider/settingsServiceProvider.ts @@ -3,7 +3,6 @@ import SettingsManager from '../settings/settingsManager.js'; import os from 'os'; import fs from 'fs'; import ServiceMap from '../serviceMap.js'; -import { SettingsSchema } from '../settings/settings.js'; export default class SettingsServiceProvider implements ServiceProvider { @@ -14,12 +13,6 @@ export default class SettingsServiceProvider implements ServiceProvider): void { - container.set('settings.schema.validator', () => { - const jsonSchemaValidatorFactory = container.get('factory.validator.schema.json'); - - return jsonSchemaValidatorFactory.create(SettingsSchema); - }); - container.set('settings.manager', () => { const dataPath = this.dataPath ?? `${os.homedir()}/.slvctrlplus`; @@ -33,7 +26,6 @@ export default class SettingsServiceProvider implements ServiceProvider; - public constructor( settingsFilePath: string, plainToClassSerializer: PlainToClassSerializer, classToPlainSerializer: ClassToPlainSerializer, - settingsSchemaValidator: JsonSchemaValidator, eventEmitter: EventEmitter, logger: Logger ) { this.settingsFilePath = settingsFilePath; this.plainToClassSerializer = plainToClassSerializer; this.classToPlainSerializer = classToPlainSerializer; - this.settingsSchemaValidator = settingsSchemaValidator; this.eventEmitter = eventEmitter; this.logger = logger; } @@ -61,15 +56,18 @@ export default class SettingsManager } else { const plainJsonSettings: JsonObject = JSON.parse(fs.readFileSync(this.settingsFilePath, 'utf8')); - if (!this.settingsSchemaValidator.validate(plainJsonSettings)) { - const validationErrors = this.settingsSchemaValidator.getValidationErrorsAsText(); - const invalidFormatMsg = `Settings are not in a valid format: ${validationErrors}`; + try { + this.settings = this.plainToClassSerializer.transform(Settings, plainJsonSettings, SettingsSchema); + } catch (e: unknown) { + if (!(e instanceof SchemaValidationError)) { + throw e; + } + + const invalidFormatMsg = `Settings are not in a valid format: ${e.message}`; this.logger.error(invalidFormatMsg); - throw new Error(invalidFormatMsg); + throw new Error(invalidFormatMsg, { cause: e }); } - this.settings = this.plainToClassSerializer.transform(Settings, plainJsonSettings); - this.logger.info(`Settings loaded from file: ${this.settingsFilePath}`); } diff --git a/tests/unit/settings/deviceSource.spec.ts b/tests/unit/settings/deviceSource.spec.ts index d38df26d..6a35b6d0 100644 --- a/tests/unit/settings/deviceSource.spec.ts +++ b/tests/unit/settings/deviceSource.spec.ts @@ -15,16 +15,6 @@ describe('DeviceSource', () => { expect(deviceSource.enabled).toBe(false); }); - it('is enabled by default when deserialized from plain JSON without an "enabled" property', () => { - const deviceSource = plainToInstance(DeviceSource, { - id: 'test-id', - type: 'virtual', - config: {}, - }); - - expect(deviceSource.enabled).toBe(true); - }); - it('is disabled when deserialized from plain JSON with "enabled": false', () => { const deviceSource = plainToInstance(DeviceSource, { id: 'test-id', diff --git a/tests/unit/settings/knownDevice.spec.ts b/tests/unit/settings/knownDevice.spec.ts index 2bb3ee2d..7ddaafc8 100644 --- a/tests/unit/settings/knownDevice.spec.ts +++ b/tests/unit/settings/knownDevice.spec.ts @@ -16,18 +16,6 @@ describe('KnownDevice', () => { expect(knownDevice.enabled).toBe(false); }); - it('is enabled by default when deserialized from plain JSON without an "enabled" property', () => { - const knownDevice = plainToInstance(KnownDevice, { - id: DeviceId.create('test'), - name: 'Test Device', - type: 'randomGenerator', - source: 'virtual', - config: {}, - }); - - expect(knownDevice.enabled).toBe(true); - }); - it('is disabled when deserialized from plain JSON with "enabled": false', () => { const knownDevice = plainToInstance(KnownDevice, { id: DeviceId.create('test'), diff --git a/tests/unit/settings/settings.spec.ts b/tests/unit/settings/settings.spec.ts new file mode 100644 index 00000000..6d4fce0d --- /dev/null +++ b/tests/unit/settings/settings.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { plainToInstance } from 'class-transformer'; +import { Value } from '@sinclair/typebox/value'; +import Settings, { SettingsSchema } from '../../../src/settings/settings.js'; +import { DeviceId } from '../../../src/device/deviceId.js'; + +describe('Settings', () => { + // KnownDevice/DeviceSource no longer default a missing 'enabled' themselves - that + // responsibility moved to SettingsManager.load()/PutSettingsController hydrating the schema's + // defaults into the plain JSON before it gets deserialized. This is the same hydration step, + // exercised directly against the schema/class pairing to guard the underlying invariant: + // legacy/hand-edited settings.json entries missing 'enabled' must still come out enabled. + it('defaults a known device missing "enabled" to enabled once hydrated against the schema', () => { + const plain = { + knownDevices: { + [DeviceId.create('test')]: { + id: DeviceId.create('test'), + name: 'Test Device', + type: 'randomGenerator', + source: 'virtual', + config: {}, + }, + }, + deviceSources: {}, + }; + + Value.Default(SettingsSchema, plain); + + const settings = plainToInstance(Settings, plain); + + expect(settings.getKnownDeviceById(DeviceId.create('test'))?.enabled).toBe(true); + }); + + it('defaults a device source missing "enabled" to enabled once hydrated against the schema', () => { + const plain = { + knownDevices: {}, + deviceSources: { + 'source-1': { + id: 'source-1', + type: 'virtual', + config: {}, + }, + }, + }; + + Value.Default(SettingsSchema, plain); + + const settings = plainToInstance(Settings, plain); + + expect(settings.getDeviceSources().get('source-1')?.enabled).toBe(true); + }); + + it('leaves an explicit "enabled": false untouched when hydrated', () => { + const plain = { + knownDevices: { + [DeviceId.create('test')]: { + id: DeviceId.create('test'), + name: 'Test Device', + type: 'randomGenerator', + source: 'virtual', + config: {}, + enabled: false, + }, + }, + deviceSources: {}, + }; + + Value.Default(SettingsSchema, plain); + + const settings = plainToInstance(Settings, plain); + + expect(settings.getKnownDeviceById(DeviceId.create('test'))?.enabled).toBe(false); + }); +}); From 448318b7acef42007cc36665649dce5e85ba8355 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 18:30:58 +0200 Subject: [PATCH 29/35] Drop redundant void prefix on a promise that already has a .catch() @typescript-eslint/no-floating-promises already treats a promise chain ending in .catch() as handled - confirmed empirically (no new lint warning without it). The void operator here wasn't doing anything the .catch() wasn't already covering. --- src/device/deviceManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index d27d855b..86f8507a 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -167,7 +167,7 @@ export default class DeviceManager { if (!this.isDeviceEnabled(device.getDeviceId)) { this.logger.info(`Not adding device '${device.getDeviceId}' since it is disabled`); - void device.close().catch((e: unknown) => logError(this.logger, `Failed to close disabled device '${device.getDeviceId}'`, e)); + device.close().catch((e: unknown) => logError(this.logger, `Failed to close disabled device '${device.getDeviceId}'`, e)); // The final, canonical id (device.getDeviceId) is the one that was found disabled and // must therefore gate the retry - not the preliminary detection id. From 1a9005cbcd9aec494cb55f53ae2813ea3943d82b Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 19 Jul 2026 19:06:45 +0200 Subject: [PATCH 30/35] Remove some more comments --- src/app.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index 043d111c..b246450a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -260,8 +260,6 @@ export const createApp = (container: Container, options: AppOptions) logger.info('Shutting down...'); await container.get('automation.scriptRuntime').stop(); - // Stopping providers cascades into stopping the serial/BLE observers they depend on - // (reference-counted, see BleDeviceProvider/SerialDeviceProvider). await container.get('device.provider.loader').stopProviders(); container.get('health.metricsCollector').stop(); From e2bfc6ba7c9547f8b0723ebe43e5116edef77a7f Mon Sep 17 00:00:00 2001 From: HRS Date: Tue, 21 Jul 2026 00:25:14 +0200 Subject: [PATCH 31/35] Fix remaining CodeRabbit findings, parallelize provider start/stop, unify observer/provider naming Fixes for findings from PR review comments that were embedded in full review bodies (outside-diff/nitpick sections) rather than separate discussion threads, so an earlier per-thread pass missed them: - DeviceManager.onSettingsChanged() is invoked fire-and-forget from a settings-changed listener, so rapid successive settings changes could overlap and race on connectedDevices/pendingDisabledDevices across the await device.close() boundary. Serialized via the same SequentialTaskQueue pattern already used by DeviceProviderManager. - BleObserver.init() (now start()) awaited noble.waitForPoweredOnAsync() directly, which - while bounded to noble's own 10s default rather than truly indefinite - still meant a BLE adapter that's slow/never powers on delays this provider's start() by that long, and raced against stop() resolving mid-wait. Restructured to run the power-on wait as a background task instead of blocking start()/acquire() on it (see below). - ButtplugIoWebsocketDeviceProvider's delayed scan-stop callback didn't account for the connection having dropped or the provider having stopped during the scan window, since buttplugClient.isScanning isn't reset by a lost/closed connection - only by an explicit start/stopScanning call or a 'scanningfinished' message. Added connected()/isStopped() checks. - DeviceProvider.abortDetection() didn't guarantee releaseDetectedDevice() runs if onConnectFailed()/device.close() themselves threw, permanently leaking the acquire-queue claim for that device. Wrapped both in finally. (This was previously marked resolved by the review bot, but checking the referenced commit showed the finally-wrapping was never actually added.) BleObserver's power-on wait is now a genuinely backgrounded task (started fire-and-forget from onFirstStart(), not awaited) so a BLE-based device source with no adapter available can't stall DeviceProviderManager's whole reload pipeline behind it. It still uses noble's own recommended waitForPoweredOnAsync() (matching noble's documented usage), but re-issues it in 10-minute chunks rather than one huge timeout, since noble has no "wait forever" option and Node's setTimeout silently clamps any delay above ~24.8 days down to 1ms. stop() cancels the wait via a self-controlled Promise.race signal - though since noble exposes no cancellation API, the abandoned noble call's own internal timer keeps running regardless for up to that chunk size, which is also why it's bounded to a human-scale duration rather than something much larger. Separately, followed up on the earlier SharedObserver reference-counting fix: concurrent acquire() calls are now actually reachable, since DeviceProviderManager.doLoadFromSettings() parallelizes starting/stopping providers for different device sources via Promise.allSettled() instead of one at a time - so one slow/hung provider no longer delays every other, unrelated device source during a settings reload. Each task only ever touches its own id in the providers map, so nothing new to race there. Finally, two naming cleanups for consistency: - SharedObserver.start()/stop() are now concrete public methods on the base class itself (renamed from the protected acquire()/release()), since every subclass's public entry point was already a pure one-line passthrough to them with no added behavior - removes that redundant wrapper entirely from both BleObserver and SerialPortObserver, and BleObserver's own init()/stop() become the inherited start()/stop() directly. - DeviceProvider.init() renamed to start() across the whole provider hierarchy to match this same start()/stop() convention. Added tests/unit/helper/async.ts's waitTicks() for tests that need to let an already-pending promise chain progress a specific number of microtask turns - preferred over a real setTimeout()-based wait since it's fully deterministic and works the same regardless of whether the calling test uses vi.useFakeTimers() (serialPortObserver.spec.ts does). --- .../buttplugIoWebsocketDeviceProvider.ts | 6 +- .../protocol/virtual/virtualDeviceProvider.ts | 2 +- src/device/provider/bleDeviceProvider.ts | 6 +- src/device/provider/deviceProvider.ts | 18 ++- src/device/provider/deviceProviderManager.ts | 55 +++---- src/device/provider/serialDeviceProvider.ts | 4 +- src/device/transport/bleObserver.ts | 83 +++++++---- src/device/transport/serialPortObserver.ts | 9 -- src/device/transport/sharedObserver.ts | 20 ++- .../provider/deviceProviderManager.spec.ts | 57 +++++-- .../unit/device/transport/bleObserver.spec.ts | 139 +++++++++++------- .../transport/serialPortObserver.spec.ts | 27 ++++ tests/unit/helper/async.ts | 5 + 13 files changed, 280 insertions(+), 151 deletions(-) create mode 100644 tests/unit/helper/async.ts diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index cfc38b41..64aad7b7 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -72,7 +72,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.buttplugClient.on('deviceremoved', this.revokePendingButtplugIoDevice.bind(this)); } - public override async init(): Promise { + public override async start(): Promise { this.connectionIntervalRef ??= setImmediateInterval( () => void this.connectToServer(), ButtplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS @@ -148,7 +148,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< return; } - await this.init(); + await this.start(); } private discoverButtplugIoDevices(): void { @@ -161,7 +161,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< .catch((e: unknown) => this.logger.error(`Could not start scanning for buttplug.io devices`, e)); setTimeout(() => { - if (undefined === this.buttplugClient || !this.buttplugClient.isScanning) { + if (this.isStopped() || !this.buttplugClient.connected || !this.buttplugClient.isScanning) { return; } diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 702426bb..82be6c7b 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -44,7 +44,7 @@ export default class VirtualDeviceProvider extends DeviceProvider { + public override async start(): Promise { this.settingsManager.on(SettingsEventType.changed, this.settingsChangedListener); await this.discoverVirtualDevices(); diff --git a/src/device/provider/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts index 686849f8..9e211d82 100644 --- a/src/device/provider/bleDeviceProvider.ts +++ b/src/device/provider/bleDeviceProvider.ts @@ -17,9 +17,9 @@ export default abstract class BleDeviceProvider extends this.bleObserver = bleObserver; } - public override async init(): Promise { - await super.init(); - await this.bleObserver.init(); + public override async start(): Promise { + await super.start(); + await this.bleObserver.start(); } public override async stop(): Promise { diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index bce9f5d8..469ac3ad 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -35,7 +35,7 @@ export default abstract class DeviceProvider { + public async start(): Promise { return Promise.resolve(); } @@ -95,10 +95,13 @@ export default abstract class DeviceProvider { - await this.onConnectFailed(deviceInfo); - this.deviceManager.releaseDetectedDevice(deviceInfo.detectionId); + try { + await this.onConnectFailed(deviceInfo); + } finally { + this.deviceManager.releaseDetectedDevice(deviceInfo.detectionId); + } } protected abstract canHandleDeviceDetectionInfo(deviceDetectionInfo: DeviceDetectionInfo): deviceDetectionInfo is DDI; diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index 9b4cceba..fc353031 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -49,14 +49,18 @@ export default class DeviceProviderManager this.logger.debug(`Found ${configuredDeviceSources.size} configured device source(s)`); - for (const [id, provider] of this.providers) { + // Snapshotted upfront (rather than iterated live) so each provider's stop()/start() can run + // concurrently below without one slow provider delaying every other, unrelated device source + const providersToStop = [...this.providers.entries()].filter(([id]) => { const deviceSource = configuredDeviceSources.get(id); - if (undefined !== deviceSource && deviceSource.enabled) { - continue; - } + return undefined === deviceSource || !deviceSource.enabled; + }); + await Promise.allSettled(providersToStop.map(async ([id, provider]) => { + const deviceSource = configuredDeviceSources.get(id); const reason = undefined === deviceSource ? 'removed from config' : 'disabled'; + this.logger.info(`Stopping device source '${id}' (${reason})`); try { @@ -68,25 +72,25 @@ export default class DeviceProviderManager } catch (error: unknown) { logError(this.logger, `Failed to stop device provider for device source '${id}'`, error); } - } + })); - for (const [id, deviceSource] of configuredDeviceSources) { - if (!deviceSource.enabled || this.providers.has(id)) { - continue; - } + const sourcesToStart = [...configuredDeviceSources.entries()].filter(([id, deviceSource]) => { + return deviceSource.enabled && !this.providers.has(id); + }); + await Promise.allSettled(sourcesToStart.map(async ([id, deviceSource]) => { const factory = this.factories.get(deviceSource.type); if (undefined === factory) { this.logger.warn(`Device source with id ${id} and type ${deviceSource.type} is not supported`); - continue; + return; } const provider = factory.create(deviceSource.config); try { - await provider.init(); - // Only record the provider once it initialized successfully, so a failed start + await provider.start(); + // Only record the provider once it started successfully, so a failed start // doesn't leave a stuck entry that blocks all future retries for this source. this.providers.set(id, provider); } catch (error: unknown) { @@ -98,22 +102,23 @@ export default class DeviceProviderManager logError(this.logger, `Failed to clean up half-started device provider for device source '${id}'`, cleanupError); } } - } + })); } private async doStopProviders(): Promise { - const errors: unknown[] = []; - - for (const [id, provider] of this.providers) { - try { - await provider.stop(); - // Remove only providers that actually stopped; a failed stop stays recorded so it - // isn't mistaken for a free slot on a later reload. - this.providers.delete(id); - } catch (error: unknown) { - errors.push(error); - this.logger.error('Failed to stop device provider', error); - } + const results = await Promise.allSettled([...this.providers.entries()].map(async ([id, provider]) => { + await provider.stop(); + // Remove only providers that actually stopped; a failed stop stays recorded so it + // isn't mistaken for a free slot on a later reload. + this.providers.delete(id); + })); + + const errors = results + .filter((result): result is PromiseRejectedResult => 'rejected' === result.status) + .map((result) => result.reason); + + for (const error of errors) { + this.logger.error('Failed to stop device provider', error); } if (errors.length > 0) { diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index 3bc1f35f..fd118926 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -33,8 +33,8 @@ export default abstract class SerialDeviceProvider { - await super.init(); + public override async start(): Promise { + await super.start(); await this.serialPortObserver.start(); } diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index e8c07f4f..12a78bf0 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -3,7 +3,6 @@ import Logger from '../../logging/Logger.js'; import DeviceManager, { DeviceDetectionInfo } from '../deviceManager.js'; import { logError } from '../../util/error.js'; import { DeviceId } from '../deviceId.js'; -import { asyncHandler } from '../../util/async.js'; import SharedObserver from './sharedObserver.js'; export type BleDeviceDetectionInfo = DeviceDetectionInfo & { @@ -16,10 +15,16 @@ export default class BleObserver extends SharedObserver private static readonly MIN_RSSI = -70; private static readonly UART_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; + private static readonly POWER_ON_WAIT_CHUNK_MS = 10 * 60 * 1000; // 10 minutes + private readonly deviceManager: DeviceManager; private isScanning = false; + // Resolves the in-flight wait-for-power-on loop immediately when stop() is called, instead + // of waiting out the rest of the current POWER_ON_WAIT_CHUNK_MS chunk. + private cancelPowerOnWait?: () => void; + public constructor( deviceManager: DeviceManager, logger: Logger @@ -28,36 +33,20 @@ export default class BleObserver extends SharedObserver this.deviceManager = deviceManager; } - public async init(): Promise - { - await this.acquire(); - } - - public async stop(): Promise - { - await this.release(); - } - protected async onFirstStart(): Promise { noble.on('discover', this.onDiscover.bind(this)); - - noble.on('stateChange', asyncHandler( - async (state) => { - if (state === 'poweredOn') { - await this.observe(); - } - }, - (err: unknown) => logError(this.logger, 'Error in stateChange handler', err) - )); - noble.on('scanStop', () => { this.logger.info('Noble scanning stopped'); }); - await this.observe(); + // Waiting for the adapter to power on can take an arbitrarily long time (or never happen + // at all, e.g. no BLE hardware present), so it must not block start()/stop() + void this.startScanningOncePoweredOn(); } protected async onLastStop(): Promise { + this.cancelPowerOnWait?.(); + noble.removeAllListeners(); if (this.isScanning) { @@ -85,14 +74,13 @@ export default class BleObserver extends SharedObserver this.deviceManager.announceDetectedDevice(deviceInfo); } - private async observe(): Promise { - if (this.isScanning) { - return; - } - + private async startScanningOncePoweredOn(): Promise { try { - // Wait for Adapter poweredOn state - await noble.waitForPoweredOnAsync(); + const stopped = await this.waitForPoweredOnUnlessStopped(); + + if (stopped) { + return; + } this.isScanning = true; await noble.startScanningAsync([BleObserver.UART_SERVICE_UUID], true); @@ -101,7 +89,42 @@ export default class BleObserver extends SharedObserver } catch (error: unknown) { logError(this.logger, 'BLE device discovery error', error); this.isScanning = false; - await noble.stopScanningAsync(); + + try { + await noble.stopScanningAsync(); + } catch (stopError: unknown) { + logError(this.logger, 'Could not stop BLE scanning after a discovery error', stopError); + } } } + + private async waitForPoweredOnUnlessStopped(): Promise { + let stopped = false; + + const stopRequested = new Promise((resolve) => { + this.cancelPowerOnWait = (): void => { + stopped = true; + resolve(); + }; + }); + + while (!stopped) { + const outcome = await Promise.race([ + noble.waitForPoweredOnAsync(BleObserver.POWER_ON_WAIT_CHUNK_MS) + .then(() => 'poweredOn' as const) + .catch(() => 'timeout' as const), + stopRequested.then(() => 'stopped' as const), + ]); + + if ('timeout' !== outcome) { + break; + } + + this.logger.debug('Still waiting for the BLE adapter to power on...'); + } + + this.cancelPowerOnWait = undefined; + + return stopped; + } } diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index b665e09b..336d3591 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -32,11 +32,6 @@ export default class SerialPortObserver extends SharedObserver this.deviceManager = deviceManager; } - public async start(): Promise - { - await this.acquire(); - } - protected async onFirstStart(): Promise { await this.discoverSerialDevices(); @@ -112,10 +107,6 @@ export default class SerialPortObserver extends SharedObserver } } - public async stop(): Promise { - await this.release(); - } - protected async onLastStop(): Promise { if (this.rescanTimer !== undefined) { clearTimeout(this.rescanTimer); diff --git a/src/device/transport/sharedObserver.ts b/src/device/transport/sharedObserver.ts index 007713eb..586a3788 100644 --- a/src/device/transport/sharedObserver.ts +++ b/src/device/transport/sharedObserver.ts @@ -6,22 +6,36 @@ export default abstract class SharedObserver private activeUsers = 0; + // Used to let all providers who want to acquire this observer wait for the first start() to finish, without having to re-run onFirstStart() for each of them. + private startupPromise: Promise | undefined; + protected constructor(logger: Logger) { this.logger = logger; } - protected async acquire(): Promise { + public async start(): Promise { this.activeUsers++; if (this.activeUsers > 1) { this.logger.debug(`Already running, now used by ${this.activeUsers} provider(s)`); + + if (this.startupPromise !== undefined) { + await this.startupPromise; + } + return; } - await this.onFirstStart(); + this.startupPromise = this.onFirstStart(); + + try { + await this.startupPromise; + } finally { + this.startupPromise = undefined; + } } - protected async release(): Promise { + public async stop(): Promise { if (this.activeUsers === 0) { return; } diff --git a/tests/unit/device/provider/deviceProviderManager.spec.ts b/tests/unit/device/provider/deviceProviderManager.spec.ts index 748b7a0f..8f95e77f 100644 --- a/tests/unit/device/provider/deviceProviderManager.spec.ts +++ b/tests/unit/device/provider/deviceProviderManager.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import EventEmitter from 'events'; import DeviceProviderManager from '../../../../src/device/provider/deviceProviderManager.js'; @@ -13,29 +13,29 @@ import { JsonObject } from '../../../../src/types.js'; class RecordingDeviceProvider extends DeviceProvider { - public initCalls = 0; + public startCalls = 0; public stopCalls = 0; public stopResolved = false; - // Allows tests to control when init()/stop() resolve, to simulate slow-running operations. - private initGate: Promise = Promise.resolve(); + // Allows tests to control when start()/stop() resolve, to simulate slow-running operations. + private startGate: Promise = Promise.resolve(); private stopGate: Promise = Promise.resolve(); public constructor() { super(mock(), new EventEmitter(), mock()); } - public setInitGate(gate: Promise): void { - this.initGate = gate; + public setStartGate(gate: Promise): void { + this.startGate = gate; } public setStopGate(gate: Promise): void { this.stopGate = gate; } - public override async init(): Promise { - this.initCalls++; - await this.initGate; + public override async start(): Promise { + this.startCalls++; + await this.startGate; } public override async stop(): Promise { @@ -89,7 +89,7 @@ describe('DeviceProviderManager', () => { await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); - expect(provider.initCalls).toBe(1); + expect(provider.startCalls).toBe(1); expect(provider.stopCalls).toBe(0); }); @@ -99,7 +99,7 @@ describe('DeviceProviderManager', () => { await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); - expect(provider.initCalls).toBe(0); + expect(provider.startCalls).toBe(0); }); it('stops a running provider once its device source is disabled', async () => { @@ -107,7 +107,7 @@ describe('DeviceProviderManager', () => { const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); - expect(provider.initCalls).toBe(1); + expect(provider.startCalls).toBe(1); await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); expect(provider.stopCalls).toBe(1); @@ -118,7 +118,7 @@ describe('DeviceProviderManager', () => { const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); - expect(provider.initCalls).toBe(1); + expect(provider.startCalls).toBe(1); await manager.loadFromSettings(makeSettings([])); expect(provider.stopCalls).toBe(1); @@ -139,7 +139,7 @@ describe('DeviceProviderManager', () => { const manager = new DeviceProviderManager(factories, makeLogger()); await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); - expect(providerA.initCalls).toBe(1); + expect(providerA.startCalls).toBe(1); // Make the disabling reload()'s stop() call slow, so it's still in-flight when the very // next reload() (re-enabling the same source) is triggered without awaiting the first. @@ -158,7 +158,32 @@ describe('DeviceProviderManager', () => { expect(providerA.stopCalls).toBe(1); // Without serialization, the re-enable reload() would have (incorrectly) assumed // providerA was still valid and never created providerB. - expect(providerB.initCalls).toBe(1); + expect(providerB.startCalls).toBe(1); + }); + + it('initializes multiple newly-enabled device sources concurrently, not one at a time', async () => { + const slowProvider = new RecordingDeviceProvider(); + const fastProvider = new RecordingDeviceProvider(); + + let releaseSlowStart: () => void = () => undefined; + slowProvider.setStartGate(new Promise((resolve) => { releaseSlowStart = resolve; })); + + const manager = new DeviceProviderManager(makeFactoryMap({ slow: slowProvider, fast: fastProvider }), makeLogger()); + + // 'source-slow' is listed first - with a sequential loop, the still-pending slow provider + // would block 'source-fast' from even starting its own start() call. + const reloadPromise = manager.loadFromSettings(makeSettings([ + { id: 'source-slow', type: 'slow', enabled: true }, + { id: 'source-fast', type: 'fast', enabled: true }, + ])); + + await vi.waitFor(() => { + expect(slowProvider.startCalls).toBe(1); + expect(fastProvider.startCalls).toBe(1); + }); + + releaseSlowStart(); + await reloadPromise; }); it('stopProviders stops all running providers and clears internal state', async () => { @@ -178,6 +203,6 @@ describe('DeviceProviderManager', () => { // After stopProviders(), a subsequent reload() on the SAME manager with the same enabled // source must create a fresh provider - proving stopProviders() cleared its internal state. await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); - expect(provider2.initCalls).toBe(1); + expect(provider2.startCalls).toBe(1); }); }); diff --git a/tests/unit/device/transport/bleObserver.spec.ts b/tests/unit/device/transport/bleObserver.spec.ts index 19b9d212..a36ee42b 100644 --- a/tests/unit/device/transport/bleObserver.spec.ts +++ b/tests/unit/device/transport/bleObserver.spec.ts @@ -57,73 +57,84 @@ describe('BleObserver', () => { it('registers a discover listener on noble', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); }); - it('registers a stateChange listener on noble', async () => { + it('registers a scanStop listener on noble', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); - expect(mockNoble.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); + expect(mockNoble.on).toHaveBeenCalledWith('scanStop', expect.any(Function)); }); - it('registers a scanStop listener on noble', async () => { + it('does not block start() while waiting for the BLE adapter to power on', async () => { + let resolvePowerOn: () => void = () => undefined; + mockNoble.waitForPoweredOnAsync.mockImplementation(() => new Promise((resolve) => { + resolvePowerOn = resolve; + })); + const observer = createObserver(); - await observer.init(); + await expect(observer.start()).resolves.toBeUndefined(); - expect(mockNoble.on).toHaveBeenCalledWith('scanStop', expect.any(Function)); + // start() already resolved, even though the power-on wait is still pending. + expect(mockNoble.startScanningAsync).not.toHaveBeenCalled(); + + resolvePowerOn(); + await vi.waitFor(() => { + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + }); }); - it('calls waitForPoweredOnAsync and startScanningAsync with the UART UUID', async () => { + it('calls startScanningAsync with the UART UUID once the adapter reports poweredOn', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); - expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledOnce(); - expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + }); expect(mockNoble.startScanningAsync).toHaveBeenCalledWith( ['6e400001b5a3f393e0a9e50e24dcca9e'], true, ); }); - it('does not call startScanningAsync a second time when stateChange poweredOn fires', async () => { - const observer = createObserver(); - await observer.init(); - - getNobleListener('stateChange')?.('poweredOn'); + it('retries waitForPoweredOnAsync in 10-minute chunks after a timeout until the adapter powers on', async () => { + mockNoble.waitForPoweredOnAsync + .mockRejectedValueOnce(new Error('Timeout waiting for Noble to be powered on')) + .mockResolvedValueOnce(undefined); - // observe() returns early because isScanning is already true - expect(mockNoble.startScanningAsync).toHaveBeenCalledTimes(1); - }); - - it('does not call observe when stateChange fires with a non-poweredOn state', async () => { const observer = createObserver(); - await observer.init(); - mockNoble.waitForPoweredOnAsync.mockClear(); - getNobleListener('stateChange')?.('poweredOff'); + await observer.start(); + + await vi.waitFor(() => { + expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledTimes(2); + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + }); - expect(mockNoble.waitForPoweredOnAsync).not.toHaveBeenCalled(); + expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledWith(10 * 60 * 1000); }); - it('calls stopScanningAsync and allows retry when waitForPoweredOnAsync rejects', async () => { - mockNoble.waitForPoweredOnAsync.mockRejectedValue(new Error('BLE unavailable')); + it('calls stopScanningAsync and logs an error when startScanningAsync rejects', async () => { + mockNoble.startScanningAsync.mockRejectedValue(new Error('BLE unavailable')); const observer = createObserver(); - await expect(observer.init()).resolves.not.toThrow(); + await expect(observer.start()).resolves.not.toThrow(); - expect(mockNoble.stopScanningAsync).toHaveBeenCalledOnce(); - expect(mockLogger.error).toHaveBeenCalled(); + await vi.waitFor(() => { + expect(mockNoble.stopScanningAsync).toHaveBeenCalledOnce(); + expect(mockLogger.error).toHaveBeenCalled(); + }); }); it('logs info when scanStop event fires', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); getNobleListener('scanStop')?.(); @@ -141,7 +152,7 @@ describe('BleObserver', () => { it('ignores a peripheral whose RSSI is below the minimum threshold', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); getNobleListener('discover')?.(createPeripheral(-80, 'weak-device')); @@ -150,7 +161,7 @@ describe('BleObserver', () => { it('announces a peripheral whose RSSI is exactly at the minimum threshold (-70)', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); const peripheral = createPeripheral(-70, 'at-threshold'); getNobleListener('discover')?.(peripheral); @@ -163,7 +174,7 @@ describe('BleObserver', () => { it('announces a peripheral whose RSSI is above the minimum threshold', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); const peripheral = createPeripheral(-50, 'strong-device'); getNobleListener('discover')?.(peripheral); @@ -173,7 +184,7 @@ describe('BleObserver', () => { it('uses the peripheral id to build the DeviceId passed to announceDetectedDevice', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); const peripheral = createPeripheral(-60, 'abc-123'); getNobleListener('discover')?.(peripheral); @@ -185,7 +196,7 @@ describe('BleObserver', () => { it('logs a debug message when ignoring a weak-signal peripheral', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); getNobleListener('discover')?.(createPeripheral(-80, 'noisy-device')); @@ -194,7 +205,7 @@ describe('BleObserver', () => { }); describe('reference counting (multiple BleDeviceProviders sharing one observer)', () => { - it('does not touch noble at all when stop() is called without a matching init()', async () => { + it('does not touch noble at all when stop() is called without a matching start()', async () => { const observer = createObserver(); await observer.stop(); @@ -203,21 +214,41 @@ describe('BleObserver', () => { expect(mockNoble.stop).not.toHaveBeenCalled(); }); - it('only wires up noble once when init() is called by two providers', async () => { + it('only wires up noble once when start() is called by two providers', async () => { const observer = createObserver(); - await observer.init(); - await observer.init(); + await observer.start(); + await observer.start(); - expect(mockNoble.on).toHaveBeenCalledTimes(3); // discover + stateChange + scanStop, not doubled - expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledOnce(); - expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + expect(mockNoble.on).toHaveBeenCalledTimes(2); // discover + scanStop, not doubled + await vi.waitFor(() => { + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + }); + }); + + it('stop() cancels a pending power-on wait without ever starting to scan', async () => { + mockNoble.waitForPoweredOnAsync.mockImplementation(() => new Promise(() => { + // Never resolves on its own - only stop() should be able to end this wait. + })); + + const observer = createObserver(); + await observer.start(); + + await vi.waitFor(() => { + expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledOnce(); + }); + + await observer.stop(); + + expect(mockNoble.startScanningAsync).not.toHaveBeenCalled(); + expect(mockNoble.removeAllListeners).toHaveBeenCalledOnce(); + expect(mockNoble.stop).toHaveBeenCalledOnce(); }); it('keeps scanning after one of two providers stops', async () => { const observer = createObserver(); - await observer.init(); - await observer.init(); + await observer.start(); + await observer.start(); await observer.stop(); @@ -227,8 +258,8 @@ describe('BleObserver', () => { it('stops scanning only once every provider that started it has also stopped it', async () => { const observer = createObserver(); - await observer.init(); - await observer.init(); + await observer.start(); + await observer.start(); await observer.stop(); await observer.stop(); @@ -237,9 +268,9 @@ describe('BleObserver', () => { expect(mockNoble.stop).toHaveBeenCalledOnce(); }); - it('does not go negative or re-stop noble when stop() is called more times than init()', async () => { + it('does not go negative or re-stop noble when stop() is called more times than start()', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); await observer.stop(); mockNoble.removeAllListeners.mockClear(); @@ -251,18 +282,20 @@ describe('BleObserver', () => { expect(mockNoble.stop).not.toHaveBeenCalled(); }); - it('starts scanning again after a full stop and a fresh init() (e.g. the last provider stopped, then a new one started)', async () => { + it('starts scanning again after a full stop and a fresh start() (e.g. the last provider stopped, then a new one started)', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); await observer.stop(); mockNoble.on.mockClear(); mockNoble.startScanningAsync.mockClear(); - await observer.init(); + await observer.start(); expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); - expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + }); }); }); }); diff --git a/tests/unit/device/transport/serialPortObserver.spec.ts b/tests/unit/device/transport/serialPortObserver.spec.ts index fb87f6a9..934ba529 100644 --- a/tests/unit/device/transport/serialPortObserver.spec.ts +++ b/tests/unit/device/transport/serialPortObserver.spec.ts @@ -6,6 +6,7 @@ import DeviceManager from '../../../../src/device/deviceManager.js'; import Logger from '../../../../src/logging/Logger.js'; import SerialPortObserver from '../../../../src/device/transport/serialPortObserver.js'; import { DeviceId } from '../../../../src/device/deviceId.js'; +import { waitTicks } from '../../helper/async.js'; // usb is a real, module-wide EventTarget - without mocking it, addEventListener() calls made in // one test would still be registered when the next test runs, eventually tripping Node's @@ -216,6 +217,32 @@ describe('SerialPortObserver', () => { expect(listSpy).toHaveBeenCalledOnce(); }); + it('a concurrent start() call waits for the in-flight discovery to finish instead of returning early', async () => { + let resolveList: (ports: []) => void = () => undefined; + vi.spyOn(SerialPort, 'list').mockImplementation(() => new Promise((resolve) => { + resolveList = resolve; + })); + + const observer = createObserver(); + + let secondStartResolved = false; + const firstStart = observer.start(); + const secondStart = observer.start().then(() => { secondStartResolved = true; }); + + // Exactly 1 microtask tick: enough for start()'s own promise to settle (were the + // buggy early-return path taken) and notify our `.then()` below, but no more + await waitTicks(1); + + // Without waiting for the in-flight discovery, the second start() would have already + // resolved here, before the port list has even actually been fetched. + expect(secondStartResolved).toBe(false); + + resolveList([]); + await Promise.all([firstStart, secondStart]); + + expect(secondStartResolved).toBe(true); + }); + it('keeps the USB listeners registered after one of two providers stops', async () => { vi.spyOn(SerialPort, 'list').mockResolvedValue([]); const removeListenerSpy = vi.spyOn(usb, 'removeEventListener'); diff --git a/tests/unit/helper/async.ts b/tests/unit/helper/async.ts new file mode 100644 index 00000000..d69f8bcc --- /dev/null +++ b/tests/unit/helper/async.ts @@ -0,0 +1,5 @@ +export async function waitTicks(ticks: number): Promise { + for (let i = 0; i < ticks; i++) { + await Promise.resolve(); // a tick + } +} From 1a6466c64a9bec3b04a61e95f143b500aec21741 Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 22 Jul 2026 22:07:07 +0200 Subject: [PATCH 32/35] Fix settings-change races, provider restart, and DI/scope cleanup - app.ts: reload device sources before applying device enable/disable changes on a settings change (was unordered, could re-announce a device before its provider reloaded); split the reactive settings- change handling out of configureWebsocket() into its own configureSettingsChangeHandling(), since it has nothing to do with WebSockets beyond the broadcast itself. - DeviceProvider: start()/stop() are now template methods calling subclass hooks doStart()/doStop(), matching SharedObserver's onFirstStart()/onLastStop() pattern. The deviceManager.on() subscription moved out of the constructor into start() (previously reactive before the provider was ever started). Providers now properly support a stop() -> start() restart cycle instead of throwing, collapsed into a single `running` field instead of two. - ButtplugIoWebsocketDeviceProvider: split reconnect() out of doStart() so a lost-connection reconnect no longer re-registers the buttplug client's listeners. - DeviceProviderManager: restart a provider when its device source's type/config changes instead of leaving it running with stale config. - SharedObserver: a failed onFirstStart() no longer permanently increments the refcount; reworked so a caller only counts as active once startup actually succeeds, removing the need for manual rollback on failure. - BleObserver: distinguish real adapter errors from a power-on timeout instead of swallowing both as "timeout"; fixed a start/stop race around the scanning flag. - PlainToClassSerializer: don't mutate the caller's input object when hydrating schema defaults. - Removed dead code (BleDevice.getPeripheral, PeripheralDevice.getTransport), routed ButtplugIoDevice's device-removed handler through its logger instead of console.error, extracted a shared WithUntypedAttributes helper instead of duplicating the same Omit/intersection three times, renamed the stale device.provider.loader DI key to device.provider.manager, and unified deviceDetectionInfo/deviceInfo parameter naming across DeviceProvider and its subclasses. Tests: added coverage for SharedObserver's startup-failure/idempotency behaviour, DeviceProvider's restart cycle and listener bookkeeping, and DeviceProviderManager's config-change restart; fixed a flaky integration test that could observe a stale sensor reading. --- src/app.ts | 27 ++-- src/device/bleDevice.ts | 7 +- src/device/device.ts | 4 +- src/device/deviceManager.ts | 77 +++++------ src/device/peripheralDevice.ts | 7 +- .../protocol/airotic/airoticDeviceProvider.ts | 12 +- .../protocol/buttplugIo/buttplugIoDevice.ts | 11 +- .../buttplugIo/buttplugIoDeviceFactory.ts | 1 + .../buttplugIoWebsocketDeviceProvider.ts | 73 ++++------ .../estim2b/estim2bSerialDeviceProvider.ts | 8 +- .../slvCtrlPlusSerialDeviceProvider.ts | 8 +- .../protocol/virtual/virtualDeviceProvider.ts | 16 +-- .../protocol/zc95/zc95SerialDeviceProvider.ts | 8 +- src/device/provider/bleDeviceProvider.ts | 20 ++- src/device/provider/deviceProvider.ts | 80 +++++++---- src/device/provider/deviceProviderManager.ts | 58 ++++---- src/device/provider/serialDeviceProvider.ts | 18 ++- src/device/transport/bleObserver.ts | 28 +++- src/device/transport/sharedObserver.ts | 29 ++-- src/serialization/plainToClassSerializer.ts | 18 +-- src/serviceMap.ts | 2 +- src/serviceProvider/deviceServiceProvider.ts | 2 +- .../devices/buttplugIoDevice.spec.ts | 9 +- .../buttplugIo/buttplugIoDevice.spec.ts | 2 + .../device/provider/deviceProvider.spec.ts | 128 ++++++++++++++++++ .../provider/deviceProviderManager.spec.ts | 41 +++++- .../device/transport/sharedObserver.spec.ts | 59 ++++++++ tests/unit/settings/settings.spec.ts | 7 +- 28 files changed, 508 insertions(+), 252 deletions(-) create mode 100644 tests/unit/device/provider/deviceProvider.spec.ts create mode 100644 tests/unit/device/transport/sharedObserver.spec.ts diff --git a/src/app.ts b/src/app.ts index b246450a..41b83506 100644 --- a/src/app.ts +++ b/src/app.ts @@ -126,28 +126,31 @@ const configureWebsocket = (io: WebsocketServer, container: Container { io.emit(SettingsEventType.changed, serializer.transform(settings)); - - deviceManager - .onSettingsChanged() - .catch(e => logError(logger, 'Failed to apply device enabled/disabled changes', e)); - - container.get('device.provider.loader') - .loadFromSettings(settings) - .catch(e => logError(logger, 'Failed to reload device sources after settings change', e)); }); // Automation events scriptRuntime.on(AutomationEventType.consoleLog, (data: string) => io.emit(AutomationEventType.consoleLog, data)); }; -const loadDeviceProviders = (container: Container): void => { +const startDeviceProviders = (container: Container): void => { const logger = container.get('logger.default'); + const settingsManager = container.get('settings.manager'); const settings = container.get('settings'); - const deviceProviderManager = container.get('device.provider.loader'); + const deviceProviderManager = container.get('device.provider.manager'); + const deviceManager = container.get('device.manager'); deviceProviderManager .loadFromSettings(settings) .catch(e => logError(logger, `Loading device providers failed`, e)); + + settingsManager.on(SettingsEventType.changed, (changedSettings: Settings) => { + // Reload device sources first so re-enabled devices are only re-announced once their provider runs again + deviceProviderManager + .loadFromSettings(changedSettings) + .catch(e => logError(logger, 'Failed to reload device sources after settings change', e)) + .then(() => deviceManager.onSettingsChanged()) + .catch(e => logError(logger, 'Failed to apply device enabled/disabled changes', e)); + }); }; const buildCorsOptions = (allowedOrigins: string[]): CorsOptions => ({ @@ -206,7 +209,7 @@ export const createApp = (container: Container, options: AppOptions) configureRoutes(app, container); configureWebsocket(websocketServer, container); - loadDeviceProviders(container); + startDeviceProviders(container); let serveResult: ServeResult | undefined; let canBeShutDown = false; @@ -260,7 +263,7 @@ export const createApp = (container: Container, options: AppOptions) logger.info('Shutting down...'); await container.get('automation.scriptRuntime').stop(); - await container.get('device.provider.loader').stopProviders(); + await container.get('device.provider.manager').stopProviders(); container.get('health.metricsCollector').stop(); await websocketServer.close(); diff --git a/src/device/bleDevice.ts b/src/device/bleDevice.ts index 961b531b..4ffb9a1b 100644 --- a/src/device/bleDevice.ts +++ b/src/device/bleDevice.ts @@ -1,8 +1,7 @@ import { Peripheral } from '@stoprocent/noble'; import BaseError from 'modern-errors'; -import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications } from './device.js'; +import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications, WithUntypedAttributes } from './device.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; -import { AttributeValue } from './attribute/deviceAttribute.js'; import { Expose } from 'class-transformer'; import { EventEmitter } from 'events'; import { DeviceId } from './deviceId.js'; @@ -10,9 +9,7 @@ import { logError } from '../util/error.js'; import Logger from '../logging/Logger.js'; import { asyncHandler, promiseWithTimeout } from '../util/async.js'; -export type AnyBleDevice = Omit & { - setAttribute(attributeName: string, value: AttributeValue): Promise; -}; +export type AnyBleDevice = WithUntypedAttributes; export default abstract class BleDevice< TAttributes extends DeviceAttributes = DeviceAttributes, diff --git a/src/device/device.ts b/src/device/device.ts index 03d1787c..8df695dd 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -49,10 +49,12 @@ export type DeviceEventMap< [DeviceEvent.deviceNotification]: [device: TDevice, notification: DeviceNotification]; } -export type AnyDevice = Omit & { +export type WithUntypedAttributes> = Omit & { setAttribute(attributeName: string, value: AttributeValue): Promise; }; +export type AnyDevice = WithUntypedAttributes; + @Exclude() export default abstract class Device< TAttributes extends DeviceAttributes = DeviceAttributes, diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 86f8507a..d88047a0 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -1,5 +1,6 @@ import { AnyDevice, DeviceEvent, DeviceNotification } from './device.js'; import EventEmitter from 'events'; +import { SequentialTaskQueue } from 'sequential-task-queue'; import DeviceState from './deviceState.js'; import { setIntervalAsync } from '../util/async.js'; import Logger from '../logging/Logger.js'; @@ -45,18 +46,15 @@ export default class DeviceManager private readonly settingsManager: SettingsManager; /** - * Devices that were announced as detected while belonging to a disabled known device (or - * whose registration was rejected by `addDevice()` after connecting, for protocols where the - * final device id can only be determined post-handshake). Re-announced once their known - * device gets (re-)enabled, see `onSettingsChanged()`. - * - * `canonicalId` is the id whose enablement gates the retry: it is the device's final, - * canonical id (which may differ from the preliminary `deviceInfo.detectionId` for protocols - * that only learn their real id during a handshake), so a retry only happens once *that* - * device is enabled - not on every unrelated settings change. `deviceInfo` is what gets - * re-announced. + * Devices whose retry is pending because their known device is disabled; re-announced once + * it gets (re-)enabled, see `onSettingsChanged()`. `canonicalId` is the device's final id + * whose enablement gates the retry (protocols may only learn it during a handshake, so it + * can differ from the map key, the preliminary `deviceInfo.detectionId`). */ - private readonly pendingDisabledDevices: Map = new Map(); + private readonly pendingRetries: Map }> = new Map(); + + // Serializes onSettingsChanged() runs so rapid settings changes don't interleave + private readonly settingsChangeQueue: SequentialTaskQueue = new SequentialTaskQueue(); public constructor( eventEmitter: EventEmitter, @@ -87,8 +85,7 @@ export default class DeviceManager if (!this.isDeviceEnabled(deviceInfo.detectionId)) { this.logger.debug(`Device with id '${deviceInfo.detectionId}' is disabled, not announcing it as detected`); - // At announcement time no connection has happened yet, so the preliminary detection id - // is the only id we have; it also doubles as the canonical id here. + // No connection happened yet, so the detection id doubles as the canonical id here this.registerPendingRetry(deviceInfo, deviceInfo.detectionId); return; } @@ -108,10 +105,8 @@ export default class DeviceManager public revokeDetectedDevice(deviceInfo: DeviceDetectionInfo): void { - // A device that has physically disappeared should no longer be retried once its known - // device gets re-enabled, so drop any pending-retry entry alongside the acquire queue. - // The pending map is keyed by the preliminary detection id (deviceInfo.detectionId). - this.pendingDisabledDevices.delete(deviceInfo.detectionId); + // A device that physically disappeared should no longer be retried on re-enable + this.pendingRetries.delete(deviceInfo.detectionId); this.clearDetectedDeviceAcquireQueue(deviceInfo.detectionId, `Device with id '${deviceInfo.detectionId}' has disappeared`); } @@ -155,23 +150,19 @@ export default class DeviceManager } /** - * Registers a fully connected device, unless the known device it belongs to (identified by - * its final `getDeviceId`) has been disabled - in that case, the device is closed right away - * and never registered. Returns whether the device was actually added. - * - * `deviceInfo` is the original info this device was detected with (as passed to - * `announceDetectedDevice()`), used to resolve that pipeline's bookkeeping: claiming it on - * success, or releasing it and registering it for retry on rejection. + * Registers a fully connected device, unless its known device (identified by the final + * `getDeviceId`) is disabled - then the device is closed and registered for retry instead. + * Returns whether the device was added. */ public addDevice(deviceInfo: DeviceDetectionInfo, device: AnyDevice): boolean { if (!this.isDeviceEnabled(device.getDeviceId)) { this.logger.info(`Not adding device '${device.getDeviceId}' since it is disabled`); - device.close().catch((e: unknown) => logError(this.logger, `Failed to close disabled device '${device.getDeviceId}'`, e)); - // The final, canonical id (device.getDeviceId) is the one that was found disabled and - // must therefore gate the retry - not the preliminary detection id. - this.registerPendingRetry(deviceInfo, device.getDeviceId); + const closingDevice = device.close() + .catch((e: unknown) => logError(this.logger, `Failed to close disabled device '${device.getDeviceId}'`, e)); + + this.registerPendingRetry(deviceInfo, device.getDeviceId, closingDevice); this.releaseDetectedDevice(deviceInfo.detectionId); return false; @@ -192,16 +183,20 @@ export default class DeviceManager return true; } - /** - * Registers a device for retry once the known device identified by `canonicalId` gets - * (re-)enabled. Keyed by the preliminary detection id so `revokeDetectedDevice()` (which only - * has that id) can still drop it when the device disappears. - */ - private registerPendingRetry(deviceInfo: DeviceDetectionInfo, canonicalId: DeviceId): void { - this.pendingDisabledDevices.set(deviceInfo.detectionId, { deviceInfo, canonicalId }); + // Keyed by detection id so revokeDetectedDevice() (which only has that id) can drop it + private registerPendingRetry( + deviceInfo: DeviceDetectionInfo, + canonicalId: DeviceId, + closingDevice?: Promise + ): void { + this.pendingRetries.set(deviceInfo.detectionId, { deviceInfo, canonicalId, closingDevice }); } public async onSettingsChanged(): Promise { + await this.settingsChangeQueue.push(() => this.applySettingsChange()); + } + + private async applySettingsChange(): Promise { for (const device of this.connectedDevices.values()) { if (this.isDeviceEnabled(device.getDeviceId)) { continue; @@ -216,12 +211,18 @@ export default class DeviceManager } } - for (const [detectionId, { deviceInfo, canonicalId }] of this.pendingDisabledDevices) { + for (const [detectionId, { deviceInfo, canonicalId, closingDevice }] of this.pendingRetries) { if (!this.isDeviceEnabled(canonicalId)) { continue; } - this.pendingDisabledDevices.delete(detectionId); + this.pendingRetries.delete(detectionId); + + // Make sure a device rejected by addDevice() has finished closing before re-announcing + if (undefined !== closingDevice) { + await closingDevice; + } + this.announceDetectedDevice(deviceInfo); } } @@ -278,7 +279,7 @@ export default class DeviceManager this.clearDetectedDeviceAcquireQueue(deviceId, 'Device manager reset'); } - this.pendingDisabledDevices.clear(); + this.pendingRetries.clear(); if (undefined !== closeError) { throw closeError; diff --git a/src/device/peripheralDevice.ts b/src/device/peripheralDevice.ts index 51144a09..28d1d063 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -1,14 +1,11 @@ -import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications } from './device.js'; +import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications, WithUntypedAttributes } from './device.js'; import BidirectionalDeviceTransport from './transport/deviceBidirectionalTransport.js'; import DeviceProtocol, { MessageWithResponse } from './protocol/deviceProtocol.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; -import { AttributeValue } from './attribute/deviceAttribute.js'; import EventEmitter from 'events'; import { DeviceId } from './deviceId.js'; -export type AnyPeripheralDevice = Omit>>, 'setAttribute'> & { - setAttribute(attributeName: string, value: AttributeValue): Promise; -}; +export type AnyPeripheralDevice = WithUntypedAttributes>>>; export default abstract class PeripheralDevice< TProtocol extends DeviceProtocol>, diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index fa54cb63..ede27b87 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -32,14 +32,14 @@ export default class AiroticDeviceProvider extends BleDeviceProvider { + protected override async connectBleDevice(deviceDetectionInfo: BleDeviceDetectionInfo): Promise { const transport = await promiseWithTimeout(BleUartDeviceTransport.create( - deviceInfo.peripheral, + deviceDetectionInfo.peripheral, AiroticDeviceProvider.UART_RX_CHAR_UUID, AiroticDeviceProvider.UART_TX_CHAR_UUID - ), 5000, `Timed out while creating BLE transport for device ${deviceInfo.detectionId}`); + ), 5000, `Timed out while creating BLE transport for device ${deviceDetectionInfo.detectionId}`); - this.logger.debug(`Connected to device: ${deviceInfo.detectionId}`); + this.logger.debug(`Connected to device: ${deviceDetectionInfo.detectionId}`); const protocol = new AiroticProtocol(); const messageResponseHandler = MessageResponseHandler.create(protocol, transport, this.logger, 2000); @@ -52,8 +52,8 @@ export default class AiroticDeviceProvider extends BleDeviceProvider connectedSince: Date, buttplugClientDevice: ButtplugClientDevice, attributes: ButtplugIoDeviceAttributes, - eventEmitter: EventEmitter + eventEmitter: EventEmitter, + logger: Logger ) { super(deviceId, deviceName, provider, connectedSince, true, attributes, {}, eventEmitter); this.buttplugClientDevice = buttplugClientDevice; this.deviceModel = deviceModel; - this.deviceRemovedHandler = asyncHandler(async () => { await this.close(); }, console.error); + const deviceLogger = logger.child({ name: ButtplugIoDevice.name }); + this.deviceRemovedHandler = asyncHandler( + async () => { await this.close(); }, + (e: unknown) => logError(deviceLogger, `Failed to close removed device '${deviceId}'`, e) + ); this.buttplugClientDevice.on('deviceremoved', this.deviceRemovedHandler); } diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index 689df152..d51c3ffb 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -50,6 +50,7 @@ export default class ButtplugIoDeviceFactory buttplugDevice, deviceAttrs, this.eventEmitterFactory.create(), + this.logger, ); if (null === device) { diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 64aad7b7..9289ad63 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -22,15 +22,13 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< > { public static readonly providerName = 'buttplugIoWebsocket'; - // How often to (re)attempt connecting to the Intiface/buttplug.io server while disconnected. + // How often to (re)attempt connecting to the Intiface/buttplug.io server while disconnected private static readonly CONNECT_RETRY_INTERVAL_MS = 1_000; - // How often a fresh scan cycle is kicked off while `autoScan` is enabled and we're connected. + // How often a fresh scan cycle is kicked off while `autoScan` is enabled and connected private static readonly AUTO_SCAN_INTERVAL_MS = 60_000; - // How long a scan window stays open before we stop it again. For the desktop websocket setup - // the server keeps scanning until told to stop, so we bound each scan ourselves rather than - // relying on a server-sent 'scanningfinished' (which that setup does not reliably emit). + // How long a scan window stays open; the server scans until told to stop, so we bound it ourselves private static readonly SCAN_DURATION_MS = 30_000; private buttplugConnector: ButtplugNodeWebsocketClientConnector; @@ -64,35 +62,30 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< this.buttplugConnector = new SlvCtrlPlusButtplugWebsocketClientConnector(url); this.buttplugClient = new ButtplugClient('SlvCtrlPlus'); + } + + protected override async doStart(): Promise { + const url = `ws://${this.websocketAddress}/buttplug`; + this.buttplugClient.on('disconnect', asyncHandler( this.handleLostConnection.bind(this, url), (e: unknown) => logError(this.logger, `Error in disconnect handler`, e) )); this.buttplugClient.on('deviceadded', this.announceButtplugIoDevice.bind(this)); this.buttplugClient.on('deviceremoved', this.revokePendingButtplugIoDevice.bind(this)); - } - public override async start(): Promise { - this.connectionIntervalRef ??= setImmediateInterval( - () => void this.connectToServer(), - ButtplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS - ); + this.connectClient(); } - public override async stop(): Promise { - // Marks the provider stopped (isStopped()) and closes/clears the registered devices - - // must run before we tear down the buttplug client listeners below (see the comment - // there for why). - await super.stop(); - + protected override async doStop(): Promise { clearInterval(this.connectionIntervalRef); this.connectionIntervalRef = undefined; clearInterval(this.autoScanningIntervalRef); this.autoScanningIntervalRef = undefined; - // Drop the buttplug client's own listeners before disconnecting so the resulting - // 'disconnect' event can't run handleLostConnection() and bring the provider back up. + // Dropped before disconnecting so the resulting 'disconnect' event can't run + // handleLostConnection() and bring the provider back up this.buttplugClient.removeAllListeners(); if (this.buttplugClient.connected) { @@ -104,6 +97,13 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< } } + private connectClient(): void { + this.connectionIntervalRef ??= setImmediateInterval( + () => void this.connectToServer(), + ButtplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS + ); + } + private async connectToServer(): Promise { if (this.isStopped() || this.buttplugClient.connected) { return; @@ -126,14 +126,8 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< } } - /** - * The whole connection to the buttplug.io server was lost - close every device this provider - * currently has connected, since none of them are reachable anymore. This is a distinct - * scenario from a single device being reported as removed (see - * `revokePendingButtplugIoDevice()`): the buttplug protocol never emits per-device removal - * messages once the connection itself is already gone, so an already-connected - * `ButtplugIoDevice` would never notice on its own and relies entirely on this cleanup. - */ + // The server connection is gone, so all connected devices are unreachable and must be closed + // here - the protocol emits no per-device removal messages once the connection itself is lost private async handleLostConnection(url: string): Promise { this.logger.info(`Lost connection to buttplug.io server (${url})`); @@ -148,7 +142,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< return; } - await this.start(); + this.connectClient(); } private discoverButtplugIoDevices(): void { @@ -181,35 +175,26 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< return { type: 'buttplugIo', detectionId: deviceId, buttplugClientDevice: buttplugDevice }; } - /** - * Announces a device reported by the Buttplug.io server to the device manager, which runs - * the enabled/disabled check centrally and takes care of retrying once a currently disabled - * device gets re-enabled - see `createDevice()` for the actual construction step. - */ private announceButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { this.logger.info(`Device detected: ${buttplugDevice.name}`, buttplugDevice); this.deviceManager.announceDetectedDevice(this.createDeviceDetectionInfo(buttplugDevice)); } - /** - * Drops any pending detection/retry bookkeeping for a device the buttplug.io server reported - * as removed. A no-op if the device is actually connected - nothing is pending for it there, - * since the live `ButtplugIoDevice` closes itself directly off this same server event (see - * its constructor). - */ + // Drops pending detection/retry bookkeeping for a removed device; a connected + // ButtplugIoDevice closes itself off this same server event instead private revokePendingButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { this.deviceManager.revokeDetectedDevice(this.createDeviceDetectionInfo(buttplugDevice)); } - protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is ButtplugIoDeviceDetectionInfo { - return deviceInfo.type === 'buttplugIo'; + protected override canHandleDeviceDetectionInfo(deviceDetectionInfo: DeviceDetectionInfo): deviceDetectionInfo is ButtplugIoDeviceDetectionInfo { + return deviceDetectionInfo.type === 'buttplugIo'; } - protected override createDevice(deviceInfo: ButtplugIoDeviceDetectionInfo): Promise { + protected override createDevice(deviceDetectionInfo: ButtplugIoDeviceDetectionInfo): Promise { const device = this.buttplugIoDeviceFactory.create( - deviceInfo.detectionId, - deviceInfo.buttplugClientDevice, + deviceDetectionInfo.detectionId, + deviceDetectionInfo.buttplugClientDevice, ButtplugIoWebsocketDeviceProvider.providerName ); diff --git a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts index 5c90271a..156028ad 100644 --- a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts +++ b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts @@ -37,9 +37,9 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider): Promise { + protected async connectSerialDevice(deviceDetectionInfo: SerialDeviceDetectionInfo, port: SerialPortStream): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: '\n' })); - const syncPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, this.logger); + const syncPort = new SynchronousSerialPort(deviceDetectionInfo.portInfo, parser, port, this.logger); const transport = this.transportFactory.create(syncPort, undefined, Buffer.from('\r')); const estim2bProtocol = new EStim2bProtocol(); @@ -53,10 +53,10 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider): Promise + protected async connectSerialDevice(deviceDetectionInfo: SerialDeviceDetectionInfo, port: SerialPortStream): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: SlvCtrlProtocol.EOF })); - const syncPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, this.logger); + const syncPort = new SynchronousSerialPort(deviceDetectionInfo.portInfo, parser, port, this.logger); const transport = this.deviceTransportFactory.create(syncPort, undefined, Buffer.from(SlvCtrlProtocol.EOF)); await this.performHandshakeWithRetries(transport, 4); const device = await this.slvCtrlPlusDeviceFactory.create( - deviceInfo.detectionId, + deviceDetectionInfo.detectionId, transport, SlvCtrlPlusSerialDeviceProvider.providerName ); - this.logger.info(`Module detected: ${device.getDeviceModel} (${deviceInfo.portInfo.serialNumber})`); + this.logger.info(`Module detected: ${device.getDeviceModel} (${deviceDetectionInfo.portInfo.serialNumber})`); return device; } diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index 82be6c7b..5a4f07ac 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -44,26 +44,24 @@ export default class VirtualDeviceProvider extends DeviceProvider { + protected override async doStart(): Promise { this.settingsManager.on(SettingsEventType.changed, this.settingsChangedListener); await this.discoverVirtualDevices(); } - public override async stop(): Promise { + protected override async doStop(): Promise { this.settingsManager.off(SettingsEventType.changed, this.settingsChangedListener); - - await super.stop(); } - protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is VirtualDeviceDetectionInfo { - return deviceInfo.type === 'virtual'; + protected override canHandleDeviceDetectionInfo(deviceDetectionInfo: DeviceDetectionInfo): deviceDetectionInfo is VirtualDeviceDetectionInfo { + return deviceDetectionInfo.type === 'virtual'; } - protected override createDevice(deviceInfo: VirtualDeviceDetectionInfo): Promise | undefined> { - this.logger.info(`Virtual device detected: ${deviceInfo.knownDevice.name}`, deviceInfo.knownDevice); + protected override createDevice(deviceDetectionInfo: VirtualDeviceDetectionInfo): Promise | undefined> { + this.logger.info(`Virtual device detected: ${deviceDetectionInfo.knownDevice.name}`, deviceDetectionInfo.knownDevice); - return this.deviceFactory.create(deviceInfo.knownDevice, VirtualDeviceProvider.providerName); + return this.deviceFactory.create(deviceDetectionInfo.knownDevice, VirtualDeviceProvider.providerName); } private async discoverVirtualDevices(): Promise { diff --git a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts index ff47ae2d..52174b91 100644 --- a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts +++ b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts @@ -38,11 +38,11 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider): Promise { + protected async connectSerialDevice(deviceDetectionInfo: SerialDeviceDetectionInfo, port: SerialPortStream): Promise { const serialLogger = this.logger.child({ name: Zc95Device.name }) const parser = port.pipe(new FrameParser({ stx: Zc95Protocol.STX, etx: Zc95Protocol.ETX })); - const serialPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, serialLogger); + const serialPort = new SynchronousSerialPort(deviceDetectionInfo.portInfo, parser, port, serialLogger); const transport = this.transportFactory.create( serialPort, Buffer.from([Zc95Protocol.STX]), Buffer.from([Zc95Protocol.ETX]) ); @@ -59,10 +59,10 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider extends this.bleObserver = bleObserver; } - public override async start(): Promise { - await super.start(); + protected override async doStart(): Promise { await this.bleObserver.start(); } - public override async stop(): Promise { - await super.stop(); + protected override async doStop(): Promise { await this.bleObserver.stop(); } - protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is BleDeviceDetectionInfo { - return deviceInfo.type === 'ble'; + protected override canHandleDeviceDetectionInfo(deviceDetectionInfo: DeviceDetectionInfo): deviceDetectionInfo is BleDeviceDetectionInfo { + return deviceDetectionInfo.type === 'ble'; } - protected override createDevice(deviceInfo: BleDeviceDetectionInfo): Promise { - return this.connectBleDevice(deviceInfo); + protected override createDevice(deviceDetectionInfo: BleDeviceDetectionInfo): Promise { + return this.connectBleDevice(deviceDetectionInfo); } - protected override async onConnectFailed(deviceInfo: BleDeviceDetectionInfo): Promise { - await this.disconnectPeripheral(deviceInfo.peripheral); + protected override async onConnectFailed(deviceDetectionInfo: BleDeviceDetectionInfo): Promise { + await this.disconnectPeripheral(deviceDetectionInfo.peripheral); } private async disconnectPeripheral(peripheral: Peripheral): Promise { @@ -55,5 +53,5 @@ export default abstract class BleDeviceProvider extends } } - protected abstract connectBleDevice(deviceInfo: BleDeviceDetectionInfo): Promise; + protected abstract connectBleDevice(deviceDetectionInfo: BleDeviceDetectionInfo): Promise; } diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index 469ac3ad..e6f7a3ca 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -18,9 +18,9 @@ export default abstract class DeviceProvider = new Map(); - private readonly deviceDetectedListener: (deviceInfo: DeviceDetectionInfo) => void; + private readonly deviceDetectedListener: (deviceDetectionInfo: DeviceDetectionInfo) => void; - private stopped: boolean = false; + private running: boolean = false; protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { this.deviceManager = deviceManager; @@ -31,23 +31,31 @@ export default abstract class DeviceProvider logError(this.logger, 'Error in device detection handler', err) ); - - this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); } public async start(): Promise { - return Promise.resolve(); + // Providers support a full stop() -> start() restart cycle, not just the initial start + if (!this.running) { + this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + this.running = true; + } + + await this.doStart(); } public async stop(): Promise { - this.stopped = true; + // Flipped before doStop() so isStopped() reports the new state immediately, while the + // previous value is kept around to decide whether the subscription needs to be dropped + const wasRunning = this.running; + this.running = false; + + await this.doStop(); - this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + if (wasRunning) { + this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + } - // A rejected close() must not abort the loop or leave stop() itself rejected: DeviceProviderManager - // keeps a provider whose stop() throws around (assuming it may still be partially running), which - // would make this instance permanently unusable - already stopped and detached above, yet never - // replaced since the manager thinks a re-enable of this source doesn't need a fresh provider. + // A rejected close() must neither abort the loop nor leave stop() itself rejected for (const device of this.connectedDevices.values()) { try { await device.close(); @@ -58,8 +66,23 @@ export default abstract class DeviceProvider { + // no-op default + } + + /** + * Runs before the base tears down its own subscription and closes connected devices. + */ + protected async doStop(): Promise { + // no-op default + } + protected isStopped(): boolean { - return this.stopped; + return !this.running; } protected getConnectedDevices(): IterableIterator { @@ -70,14 +93,14 @@ export default abstract class DeviceProvider { - if (!this.canHandleDeviceDetectionInfo(deviceInfo)) { + private async handleDeviceDetection(deviceDetectionInfo: DeviceDetectionInfo): Promise { + if (!this.canHandleDeviceDetectionInfo(deviceDetectionInfo)) { return; } - this.logger.debug(`Requesting to acquire device: ${deviceInfo.detectionId}`); + this.logger.debug(`Requesting to acquire device: ${deviceDetectionInfo.detectionId}`); - const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.detectionId); + const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceDetectionInfo.detectionId); if (!acquireResult.successful) { this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); @@ -87,29 +110,28 @@ export default abstract class DeviceProvider this.connectedDevices.delete(d.getDeviceId)); - if (!this.deviceManager.addDevice(deviceInfo, device)) { - // The device has not been added by the device manager. - // For example, it may be a disabled device. + // The device manager may reject the device, e.g. because it is disabled + if (!this.deviceManager.addDevice(deviceDetectionInfo, device)) { return; } @@ -118,20 +140,20 @@ export default abstract class DeviceProvider { + private async abortDetection(deviceDetectionInfo: DDI): Promise { try { - await this.onConnectFailed(deviceInfo); + await this.onConnectFailed(deviceDetectionInfo); } finally { - this.deviceManager.releaseDetectedDevice(deviceInfo.detectionId); + this.deviceManager.releaseDetectedDevice(deviceDetectionInfo.detectionId); } } protected abstract canHandleDeviceDetectionInfo(deviceDetectionInfo: DeviceDetectionInfo): deviceDetectionInfo is DDI; - protected abstract createDevice(deviceInfo: DDI): Promise; + protected abstract createDevice(deviceDetectionInfo: DDI): Promise; // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected async onConnectFailed(deviceInfo: DDI): Promise { + protected async onConnectFailed(deviceDetectionInfo: DDI): Promise { return Promise.resolve(); } } diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index fc353031..9700a9aa 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -1,25 +1,26 @@ import { SequentialTaskQueue } from 'sequential-task-queue'; import Settings from '../../settings/settings.js'; +import DeviceSource from '../../settings/deviceSource.js'; import DeviceProviderFactory from './deviceProviderFactory.js'; import Logger from '../../logging/Logger.js'; import { AnyDeviceProvider } from './deviceProvider.js'; import { logError } from '../../util/error.js'; +type RunningProvider = { + provider: AnyDeviceProvider; + sourceFingerprint: string; +}; + export default class DeviceProviderManager { private factories: Map>; private readonly logger: Logger; - private readonly providers: Map = new Map(); + private readonly providers: Map = new Map(); - /** - * `reload()` and `stopProviders()` mutate the shared `providers` map asynchronously. - * Since settings can change in rapid succession (e.g. a device source being disabled and - * immediately re-enabled), overlapping calls need to be serialized to avoid racing on that - * map, otherwise a later call could observe a half-finished earlier one and reach the wrong - * conclusion about whether a provider is already running. - */ + // Settings can change in rapid succession, so overlapping loadFromSettings()/stopProviders() + // calls are serialized to avoid racing on the shared providers map private readonly operationQueue: SequentialTaskQueue = new SequentialTaskQueue(); public constructor( @@ -49,25 +50,27 @@ export default class DeviceProviderManager this.logger.debug(`Found ${configuredDeviceSources.size} configured device source(s)`); - // Snapshotted upfront (rather than iterated live) so each provider's stop()/start() can run - // concurrently below without one slow provider delaying every other, unrelated device source - const providersToStop = [...this.providers.entries()].filter(([id]) => { + // Snapshotted upfront so each provider's stop()/start() can run concurrently below + const providersToStop = [...this.providers.entries()].filter(([id, runningProvider]) => { const deviceSource = configuredDeviceSources.get(id); - return undefined === deviceSource || !deviceSource.enabled; + return undefined === deviceSource + || !deviceSource.enabled + || runningProvider.sourceFingerprint !== DeviceProviderManager.fingerprintOf(deviceSource); }); - await Promise.allSettled(providersToStop.map(async ([id, provider]) => { + await Promise.allSettled(providersToStop.map(async ([id, runningProvider]) => { const deviceSource = configuredDeviceSources.get(id); - const reason = undefined === deviceSource ? 'removed from config' : 'disabled'; + const reason = undefined === deviceSource + ? 'removed from config' + : (!deviceSource.enabled ? 'disabled' : 'configuration changed'); this.logger.info(`Stopping device source '${id}' (${reason})`); try { - await provider.stop(); - // Only forget the provider once it actually stopped. A provider that failed to - // stop may still be running, so keeping it recorded prevents a duplicate from - // being started for the same source on a later reload. + await runningProvider.provider.stop(); + // A provider that failed to stop may still be running, so it stays recorded + // to prevent starting a duplicate for the same source on a later reload this.providers.delete(id); } catch (error: unknown) { logError(this.logger, `Failed to stop device provider for device source '${id}'`, error); @@ -90,9 +93,11 @@ export default class DeviceProviderManager try { await provider.start(); - // Only record the provider once it started successfully, so a failed start - // doesn't leave a stuck entry that blocks all future retries for this source. - this.providers.set(id, provider); + // Only recorded once started successfully, so a failed start doesn't block retries + this.providers.set(id, { + provider, + sourceFingerprint: DeviceProviderManager.fingerprintOf(deviceSource), + }); } catch (error: unknown) { logError(this.logger, `Failed to start device provider for device source '${id}'`, error); @@ -105,11 +110,14 @@ export default class DeviceProviderManager })); } + private static fingerprintOf(deviceSource: DeviceSource): string { + return JSON.stringify({ type: deviceSource.type, config: deviceSource.config }); + } + private async doStopProviders(): Promise { - const results = await Promise.allSettled([...this.providers.entries()].map(async ([id, provider]) => { - await provider.stop(); - // Remove only providers that actually stopped; a failed stop stays recorded so it - // isn't mistaken for a free slot on a later reload. + const results = await Promise.allSettled([...this.providers.entries()].map(async ([id, runningProvider]) => { + await runningProvider.provider.stop(); + // A provider that failed to stop stays recorded so it isn't mistaken for a free slot this.providers.delete(id); })); diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index fd118926..f6954b7d 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -33,22 +33,20 @@ export default abstract class SerialDeviceProvider { - await super.start(); + protected override async doStart(): Promise { await this.serialPortObserver.start(); } - public override async stop(): Promise { - await super.stop(); + protected override async doStop(): Promise { await this.serialPortObserver.stop(); } - protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is SerialDeviceDetectionInfo { - return deviceInfo.type === 'serial'; + protected override canHandleDeviceDetectionInfo(deviceDetectionInfo: DeviceDetectionInfo): deviceDetectionInfo is SerialDeviceDetectionInfo { + return deviceDetectionInfo.type === 'serial'; } - protected override async createDevice(deviceInfo: SerialDeviceDetectionInfo): Promise { - const portInfo = deviceInfo.portInfo; + protected override async createDevice(deviceDetectionInfo: SerialDeviceDetectionInfo): Promise { + const portInfo = deviceDetectionInfo.portInfo; this.logger.info(`Connection attempt for serial device '${portInfo.path}' (s/n: ${portInfo.serialNumber})`); @@ -68,7 +66,7 @@ export default abstract class SerialDeviceProvider): Promise; + protected abstract connectSerialDevice(deviceDetectionInfo: SerialDeviceDetectionInfo, port: SerialPortStream): Promise; protected abstract getSerialDeviceProviderPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; } diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index 12a78bf0..e4cce4f3 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -21,6 +21,8 @@ export default class BleObserver extends SharedObserver private isScanning = false; + private stopRequested = false; + // Resolves the in-flight wait-for-power-on loop immediately when stop() is called, instead // of waiting out the rest of the current POWER_ON_WAIT_CHUNK_MS chunk. private cancelPowerOnWait?: () => void; @@ -35,6 +37,8 @@ export default class BleObserver extends SharedObserver protected async onFirstStart(): Promise { + this.stopRequested = false; + noble.on('discover', this.onDiscover.bind(this)); noble.on('scanStop', () => { this.logger.info('Noble scanning stopped'); }); @@ -45,6 +49,7 @@ export default class BleObserver extends SharedObserver protected async onLastStop(): Promise { + this.stopRequested = true; this.cancelPowerOnWait?.(); noble.removeAllListeners(); @@ -82,8 +87,16 @@ export default class BleObserver extends SharedObserver return; } - this.isScanning = true; await noble.startScanningAsync([BleObserver.UART_SERVICE_UUID], true); + this.isScanning = true; + + // stop() may have run while the scan was still starting up; it then saw isScanning + // as false and skipped stopping, so it is on us to stop the scan again + if (this.stopRequested) { + await noble.stopScanningAsync(); + this.isScanning = false; + return; + } this.logger.info('Looking for BLE UART devices'); } catch (error: unknown) { @@ -101,7 +114,7 @@ export default class BleObserver extends SharedObserver private async waitForPoweredOnUnlessStopped(): Promise { let stopped = false; - const stopRequested = new Promise((resolve) => { + const stopSignal = new Promise((resolve) => { this.cancelPowerOnWait = (): void => { stopped = true; resolve(); @@ -111,16 +124,17 @@ export default class BleObserver extends SharedObserver while (!stopped) { const outcome = await Promise.race([ noble.waitForPoweredOnAsync(BleObserver.POWER_ON_WAIT_CHUNK_MS) - .then(() => 'poweredOn' as const) - .catch(() => 'timeout' as const), - stopRequested.then(() => 'stopped' as const), + .then(() => ({ type: 'poweredOn' as const })) + .catch((error: unknown) => ({ type: 'notPoweredOn' as const, error })), + stopSignal.then(() => ({ type: 'stopped' as const })), ]); - if ('timeout' !== outcome) { + if ('notPoweredOn' !== outcome.type) { break; } - this.logger.debug('Still waiting for the BLE adapter to power on...'); + const reason = outcome.error instanceof Error ? outcome.error.message : String(outcome.error); + this.logger.warn(`BLE adapter not powered on yet (${reason}), still waiting...`); } this.cancelPowerOnWait = undefined; diff --git a/src/device/transport/sharedObserver.ts b/src/device/transport/sharedObserver.ts index 586a3788..5705241f 100644 --- a/src/device/transport/sharedObserver.ts +++ b/src/device/transport/sharedObserver.ts @@ -6,7 +6,7 @@ export default abstract class SharedObserver private activeUsers = 0; - // Used to let all providers who want to acquire this observer wait for the first start() to finish, without having to re-run onFirstStart() for each of them. + // Lets concurrent start() callers await an in-flight onFirstStart() instead of re-running it private startupPromise: Promise | undefined; protected constructor(logger: Logger) { @@ -14,24 +14,27 @@ export default abstract class SharedObserver } public async start(): Promise { - this.activeUsers++; - - if (this.activeUsers > 1) { - this.logger.debug(`Already running, now used by ${this.activeUsers} provider(s)`); + // Not already running and nobody else currently starting it either - I'm the first + if (this.activeUsers === 0 && this.startupPromise === undefined) { + this.startupPromise = this.onFirstStart(); + } - if (this.startupPromise !== undefined) { + if (this.startupPromise !== undefined) { + try { + // Only count as an active user once startup actually succeeded - a rejection + // here propagates out of start() without incrementing, for every concurrent + // caller awaiting the same promise, so a failed startup doesn't leave anyone + // thinking the observer is running await this.startupPromise; + } finally { + this.startupPromise = undefined; } - - return; } - this.startupPromise = this.onFirstStart(); + this.activeUsers++; - try { - await this.startupPromise; - } finally { - this.startupPromise = undefined; + if (this.activeUsers > 1) { + this.logger.debug(`Already running, now used by ${this.activeUsers} provider(s)`); } } diff --git a/src/serialization/plainToClassSerializer.ts b/src/serialization/plainToClassSerializer.ts index 36db8d1f..2189934e 100644 --- a/src/serialization/plainToClassSerializer.ts +++ b/src/serialization/plainToClassSerializer.ts @@ -18,17 +18,17 @@ export default class PlainToClassSerializer public transform(cls: ClassConstructor, plain: V, schema?: TSchema): T { - if (undefined !== schema) { - if (!this.ajv.validate(schema, plain)) { - throw new SchemaValidationError( - this.ajv.errorsText(this.ajv.errors), - this.ajv.errors ?? [] - ); - } + if (undefined === schema) { + return plainToInstance(cls, plain, this.options); + } - Value.Default(schema, plain); + if (!this.ajv.validate(schema, plain)) { + throw new SchemaValidationError( + this.ajv.errorsText(this.ajv.errors), + this.ajv.errors ?? [] + ); } - return plainToInstance(cls, plain, this.options); + return plainToInstance(cls, Value.Default(schema, Value.Clone(plain)), this.options); } } diff --git a/src/serviceMap.ts b/src/serviceMap.ts index b209c38b..1e426dc6 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -104,7 +104,7 @@ type ServiceMap = { 'repository.connectedDevices': ConnectedDeviceRepository, 'repository.automationScript': AutomationScriptRepository, - 'device.provider.loader': DeviceProviderManager, + 'device.provider.manager': DeviceProviderManager, 'socket.deviceUpdateHandler': DeviceUpdateHandler, /* controllerServiceProvider */ diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index acd23496..0bc98513 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -183,7 +183,7 @@ export default class DeviceServiceProvider implements ServiceProvider { + container.set('device.provider.manager', (): DeviceProviderManager => { return new DeviceProviderManager( new Map>([ [ diff --git a/tests/integration/devices/buttplugIoDevice.spec.ts b/tests/integration/devices/buttplugIoDevice.spec.ts index f9a26526..87efd7ad 100644 --- a/tests/integration/devices/buttplugIoDevice.spec.ts +++ b/tests/integration/devices/buttplugIoDevice.spec.ts @@ -162,7 +162,14 @@ describe('Buttplug.io device lifecycle', () => { const nextReading = 77; - const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed); + // A refresh may already be in flight with the old reading, so wait specifically + // for the event carrying the new one instead of just the next deviceRefreshed + const deviceRefreshed = waitForNextWsEvent( + wsEmitSpy, + WebSocketEvent.deviceRefreshed, + 5000, + ([device]) => device.attributes['Pressure-0']?.value === nextReading, + ); simulator.setSensorReading(deviceIndex, 0, nextReading); const [payloadDeviceRefreshed] = await deviceRefreshed; diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts index b5fcc314..5f66ec59 100644 --- a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts @@ -12,6 +12,7 @@ import {describe, it, expect} from "vitest"; import {mock} from "vitest-mock-extended"; import {EventEmitter} from 'events'; import {DeviceId} from '../../../../../src/device/deviceId.js'; +import Logger from '../../../../../src/logging/Logger.js'; describe('ButtplugIoDevice', () => { @@ -26,6 +27,7 @@ describe('ButtplugIoDevice', () => { buttplugDeviceMock, attrs, new EventEmitter(), + mock(), ); } diff --git a/tests/unit/device/provider/deviceProvider.spec.ts b/tests/unit/device/provider/deviceProvider.spec.ts new file mode 100644 index 00000000..66dd89c4 --- /dev/null +++ b/tests/unit/device/provider/deviceProvider.spec.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import EventEmitter from 'events'; +import DeviceProvider from '../../../../src/device/provider/deviceProvider.js'; +import DeviceManager, { DeviceDetectionInfo } from '../../../../src/device/deviceManager.js'; +import { AnyDevice } from '../../../../src/device/device.js'; +import Logger from '../../../../src/logging/Logger.js'; +import SettingsManager from '../../../../src/settings/settingsManager.js'; +import { DeviceId } from '../../../../src/device/deviceId.js'; +import TestDevice from '../testDevice.js'; + +class TestProvider extends DeviceProvider +{ + public doStartCalls = 0; + public doStopCalls = 0; + + public constructor(deviceManager: DeviceManager = mock()) { + super(deviceManager, new EventEmitter(), mock()); + } + + protected canHandleDeviceDetectionInfo(_deviceDetectionInfo: DeviceDetectionInfo): _deviceDetectionInfo is DeviceDetectionInfo { + return false; + } + + protected createDevice(_deviceDetectionInfo: DeviceDetectionInfo): Promise { + return Promise.resolve(undefined); + } + + protected override async doStart(): Promise { + this.doStartCalls++; + } + + protected override async doStop(): Promise { + this.doStopCalls++; + } +} + +// Actually handles detection, so restart tests can prove detected devices are added again - +// not just that start()/stop()/doStart()/doStop() bookkeeping runs. +class DetectingTestProvider extends DeviceProvider +{ + public constructor(deviceManager: DeviceManager) { + super(deviceManager, new EventEmitter(), mock()); + } + + protected canHandleDeviceDetectionInfo(deviceDetectionInfo: DeviceDetectionInfo): deviceDetectionInfo is DeviceDetectionInfo { + return deviceDetectionInfo.type === 'test'; + } + + protected createDevice(deviceDetectionInfo: DeviceDetectionInfo): Promise { + return Promise.resolve(new TestDevice(deviceDetectionInfo.detectionId, 'Foo', new Date(), false, new EventEmitter())); + } +} + +describe('DeviceProvider', () => { + it('subscribes to deviceDetected exactly once across repeated start() calls while running', async () => { + const deviceManager = mock(); + const provider = new TestProvider(deviceManager); + + await provider.start(); + await provider.start(); + + expect(deviceManager.on).toHaveBeenCalledTimes(1); + // doStart() itself still runs on every call - subclasses re-entering start() while + // running (e.g. a reconnect loop) are responsible for making their own logic idempotent + expect(provider.doStartCalls).toBe(2); + }); + + it('unsubscribes from deviceDetected exactly once when stopped', async () => { + const deviceManager = mock(); + const provider = new TestProvider(deviceManager); + + await provider.start(); + await provider.stop(); + + expect(deviceManager.off).toHaveBeenCalledTimes(1); + }); + + describe('restart', () => { + it('can be started again after being stopped', async () => { + const deviceManager = mock(); + const provider = new TestProvider(deviceManager); + + await provider.start(); + await provider.stop(); + + await expect(provider.start()).resolves.toBeUndefined(); + }); + + it('re-subscribes to deviceDetected on restart', async () => { + const deviceManager = mock(); + const provider = new TestProvider(deviceManager); + + await provider.start(); + await provider.stop(); + await provider.start(); + + expect(deviceManager.on).toHaveBeenCalledTimes(2); + expect(deviceManager.off).toHaveBeenCalledTimes(1); + }); + + it('resumes detecting and adding devices after a stop() -> start() cycle', async () => { + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(undefined); // every device enabled by default + + const logger = mock(); + logger.child.mockReturnValue(logger); + + const deviceManager = new DeviceManager(new EventEmitter(), new Map(), settingsManager, logger); + const provider = new DetectingTestProvider(deviceManager); + + await provider.start(); + deviceManager.announceDetectedDevice({ type: 'test', detectionId: DeviceId.create('device-a') }); + await vi.waitFor(() => expect(deviceManager.getConnectedDevices()).toHaveLength(1)); + + await provider.stop(); + // stop() closes connected devices, which removes them from the device manager too + expect(deviceManager.getConnectedDevices()).toHaveLength(0); + + await provider.start(); + deviceManager.announceDetectedDevice({ type: 'test', detectionId: DeviceId.create('device-b') }); + + // If `stopped` were not reset by start(), handleDeviceDetection() would abort this + // detection immediately and the device would never be added. + await vi.waitFor(() => expect(deviceManager.getConnectedDevices()).toHaveLength(1)); + }); + }); +}); diff --git a/tests/unit/device/provider/deviceProviderManager.spec.ts b/tests/unit/device/provider/deviceProviderManager.spec.ts index 8f95e77f..90fe82d9 100644 --- a/tests/unit/device/provider/deviceProviderManager.spec.ts +++ b/tests/unit/device/provider/deviceProviderManager.spec.ts @@ -33,23 +33,23 @@ class RecordingDeviceProvider extends DeviceProvider { + protected override async doStart(): Promise { this.startCalls++; await this.startGate; } - public override async stop(): Promise { + protected override async doStop(): Promise { this.stopCalls++; await this.stopGate; this.stopResolved = true; } // This test double never actually detects devices; it only exercises the lifecycle. - protected canHandleDeviceDetectionInfo(_deviceInfo: DeviceDetectionInfo): _deviceInfo is DeviceDetectionInfo { + protected canHandleDeviceDetectionInfo(_deviceDetectionInfo: DeviceDetectionInfo): _deviceDetectionInfo is DeviceDetectionInfo { return false; } - protected createDevice(_deviceInfo: DeviceDetectionInfo): Promise { + protected createDevice(_deviceDetectionInfo: DeviceDetectionInfo): Promise { return Promise.resolve(undefined); } } @@ -60,11 +60,11 @@ function makeLogger(): Logger { return logger; } -function makeSettings(sources: { id: string, type: string, enabled?: boolean }[]): Settings { +function makeSettings(sources: { id: string, type: string, enabled?: boolean, config?: JsonObject }[]): Settings { const settings = new Settings(); for (const source of sources) { - settings.addDeviceSource(new DeviceSource(source.id, source.type, {}, source.enabled ?? true)); + settings.addDeviceSource(new DeviceSource(source.id, source.type, source.config ?? {}, source.enabled ?? true)); } return settings; @@ -124,6 +124,35 @@ describe('DeviceProviderManager', () => { expect(provider.stopCalls).toBe(1); }); + it('restarts a provider when its device source configuration changes', async () => { + const providerA = new RecordingDeviceProvider(); + const providerB = new RecordingDeviceProvider(); + let creationCount = 0; + const factories = new Map>([ + ['virtual', { create: (): RecordingDeviceProvider => (creationCount++ === 0 ? providerA : providerB) }], + ]); + const manager = new DeviceProviderManager(factories, makeLogger()); + + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', config: { url: 'a' } }])); + expect(providerA.startCalls).toBe(1); + + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', config: { url: 'b' } }])); + + expect(providerA.stopCalls).toBe(1); + expect(providerB.startCalls).toBe(1); + }); + + it('keeps a provider running when its device source configuration is unchanged', async () => { + const provider = new RecordingDeviceProvider(); + const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); + + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', config: { url: 'a' } }])); + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', config: { url: 'a' } }])); + + expect(provider.startCalls).toBe(1); + expect(provider.stopCalls).toBe(0); + }); + it('serializes overlapping reload() calls so a disable immediately followed by a re-enable ends up running', async () => { // Use two distinct provider instances so we can tell which one ends up "running" and // reproduce the manager's internal bookkeeping the same way distinct factory.create() diff --git a/tests/unit/device/transport/sharedObserver.spec.ts b/tests/unit/device/transport/sharedObserver.spec.ts new file mode 100644 index 00000000..50f54ea2 --- /dev/null +++ b/tests/unit/device/transport/sharedObserver.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import SharedObserver from '../../../../src/device/transport/sharedObserver.js'; +import Logger from '../../../../src/logging/Logger.js'; + +class TestObserver extends SharedObserver +{ + public firstStarts = 0; + public lastStops = 0; + public failNextStart = false; + + public constructor() { + super(mock()); + } + + protected async onFirstStart(): Promise { + this.firstStarts++; + + if (this.failNextStart) { + this.failNextStart = false; + throw new Error('startup failed'); + } + } + + protected async onLastStop(): Promise { + this.lastStops++; + } +} + +describe('SharedObserver', () => { + it('does not count a failed startup as an active user', async () => { + const observer = new TestObserver(); + observer.failNextStart = true; + + await expect(observer.start()).rejects.toThrow('startup failed'); + + // The next start() must actually run onFirstStart() again instead of + // assuming the observer is already running + await observer.start(); + expect(observer.firstStarts).toBe(2); + + await observer.stop(); + expect(observer.lastStops).toBe(1); + }); + + it('stops only after the last user released it', async () => { + const observer = new TestObserver(); + + await observer.start(); + await observer.start(); + expect(observer.firstStarts).toBe(1); + + await observer.stop(); + expect(observer.lastStops).toBe(0); + + await observer.stop(); + expect(observer.lastStops).toBe(1); + }); +}); diff --git a/tests/unit/settings/settings.spec.ts b/tests/unit/settings/settings.spec.ts index 6d4fce0d..512702ef 100644 --- a/tests/unit/settings/settings.spec.ts +++ b/tests/unit/settings/settings.spec.ts @@ -5,11 +5,8 @@ import Settings, { SettingsSchema } from '../../../src/settings/settings.js'; import { DeviceId } from '../../../src/device/deviceId.js'; describe('Settings', () => { - // KnownDevice/DeviceSource no longer default a missing 'enabled' themselves - that - // responsibility moved to SettingsManager.load()/PutSettingsController hydrating the schema's - // defaults into the plain JSON before it gets deserialized. This is the same hydration step, - // exercised directly against the schema/class pairing to guard the underlying invariant: - // legacy/hand-edited settings.json entries missing 'enabled' must still come out enabled. + // Guards the schema-default hydration invariant: legacy/hand-edited settings.json entries + // missing 'enabled' must still come out enabled after Value.Default + deserialization it('defaults a known device missing "enabled" to enabled once hydrated against the schema', () => { const plain = { knownDevices: { From 72736a5b62ab5e5a9156980ccc799b9c3ec09538 Mon Sep 17 00:00:00 2001 From: HRS Date: Wed, 22 Jul 2026 22:27:52 +0200 Subject: [PATCH 33/35] fix: guard provider shutdown/stop errors so graceful shutdown completes - shutdown() no longer aborts remaining cleanup steps if stopProviders() throws - DeviceProvider.stop() no longer skips listener detachment and device cleanup if doStop() throws Addresses CodeRabbit findings on PR #92 --- src/app.ts | 8 +++++++- src/device/provider/deviceProvider.ts | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index 41b83506..811b1633 100644 --- a/src/app.ts +++ b/src/app.ts @@ -263,7 +263,13 @@ export const createApp = (container: Container, options: AppOptions) logger.info('Shutting down...'); await container.get('automation.scriptRuntime').stop(); - await container.get('device.provider.manager').stopProviders(); + + try { + await container.get('device.provider.manager').stopProviders(); + } catch (e: unknown) { + logError(logger, 'Failed to stop device providers during shutdown', e); + } + container.get('health.metricsCollector').stop(); await websocketServer.close(); diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index e6f7a3ca..0cb0f9f6 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -49,7 +49,11 @@ export default abstract class DeviceProvider Date: Thu, 23 Jul 2026 09:56:59 +0200 Subject: [PATCH 34/35] fix: close race between automation log file creation and read POST /automation/run resolved before the log file's write stream finished opening, so an immediate GET /automation/log could hit read-last-lines' fs.exists() check before the file existed on disk, causing a 500. load() now awaits the stream's open event before resolving. getLog() also treats a missing log file as an empty log instead of an error, covering the case of no script having run yet. --- src/automation/scriptRuntime.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 8f065c82..417c9b6d 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -299,10 +299,17 @@ export default class ScriptRuntime this.dispatchRef = await this.vmContext.global.get('__dispatchEvent'); this.lifecycleRef = await this.vmContext.global.get('__dispatchLifecycle'); - this.logWriter = fs.createWriteStream(`${this.logPath}/automation.log`); - this.logWriter.on('error', (err) => { + const writer = fs.createWriteStream(`${this.logPath}/automation.log`); + writer.on('error', (err) => { this.logger.error(`Automation log write error: ${err.message}`); }); + + await new Promise((resolve, reject) => { + writer.once('open', () => resolve()); + writer.once('error', (err) => reject(err)); + }); + + this.logWriter = writer; this.runningSince = new Date(); const lifecycleRef = this.lifecycleRef; @@ -441,7 +448,17 @@ export default class ScriptRuntime public async getLog(maxLines: number): Promise { - return readLastLines.read(`${this.logPath}/automation.log`, maxLines); + try { + return await readLastLines.read(`${this.logPath}/automation.log`, maxLines); + } catch (e: unknown) { + // No script has run yet (or its log file was not created) - treat as empty log + // rather than an error condition. + if (e instanceof Error && e.message.includes('file does not exist')) { + return ''; + } + + throw e; + } } public isRunning(): boolean From 4dffc967a836d5a591e919285113b7c02e944f09 Mon Sep 17 00:00:00 2001 From: HRS Date: Thu, 23 Jul 2026 17:56:34 +0200 Subject: [PATCH 35/35] fix: open automation log before isolate creation to avoid leak on writer failure --- src/automation/scriptRuntime.ts | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 417c9b6d..9caa0773 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -223,6 +223,25 @@ export default class ScriptRuntime public async load(scriptCode: string): Promise { + const writer = fs.createWriteStream(`${this.logPath}/automation.log`); + writer.on('error', (err) => { + this.logger.error(`Automation log write error: ${err.message}`); + }); + + // Open the log file before creating the isolate: if this fails, nothing + // needs to be torn down yet. + try { + await new Promise((resolve, reject) => { + writer.once('open', () => resolve()); + writer.once('error', (err) => reject(err)); + }); + } catch (e) { + writer.destroy(); + throw e; + } + + this.logWriter = writer; + this.isolate = new ivm.Isolate({ memoryLimit: 128 }); this.vmContext = await this.isolate.createContext(); @@ -299,17 +318,6 @@ export default class ScriptRuntime this.dispatchRef = await this.vmContext.global.get('__dispatchEvent'); this.lifecycleRef = await this.vmContext.global.get('__dispatchLifecycle'); - const writer = fs.createWriteStream(`${this.logPath}/automation.log`); - writer.on('error', (err) => { - this.logger.error(`Automation log write error: ${err.message}`); - }); - - await new Promise((resolve, reject) => { - writer.once('open', () => resolve()); - writer.once('error', (err) => reject(err)); - }); - - this.logWriter = writer; this.runningSince = new Date(); const lifecycleRef = this.lifecycleRef;