From 2d05929680aae2b6c63e466333876f5c2b1b2e7f Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 10:23:44 +0200 Subject: [PATCH 01/17] Extract KnownDeviceResolver, finish settings/identity WIP cleanup Centralizes the previously duplicated createKnownDevice() logic (found in SlvCtrlPlusDeviceFactory, SlvCtrlPlusSerialDeviceProvider, AiroticDeviceProvider and ButtplugIoWebsocketDeviceProvider) into a single shared KnownDeviceResolver. Keeps identity resolution in the Factory for protocols where the device type is only known after a successful handshake (SlvCtrlPlus), and in the Provider for protocols where identity is known upfront from external metadata (Buttplug, Airotic). Also reverts the abstract DeviceProvider base class back to not requiring a Settings dependency - injecting KnownDeviceResolver explicitly where needed is more robust than threading a base constructor param through every provider subclass. Fixes the build, which was broken by an in-progress, uncommitted refactor. --- src/device/knownDeviceResolver.ts | 43 +++++++++++++++++++ .../protocol/airotic/airoticDeviceProvider.ts | 27 +++--------- .../buttplugIo/buttplugIoDeviceFactory.ts | 39 ++--------------- .../buttplugIoWebsocketDeviceProvider.ts | 27 +++++++++++- ...uttplugIoWebsocketDeviceProviderFactory.ts | 6 +++ .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 40 +++-------------- src/serviceMap.ts | 2 + src/serviceProvider/deviceServiceProvider.ts | 14 ++++-- src/settings/knownDevice.ts | 8 ++-- 9 files changed, 107 insertions(+), 99 deletions(-) create mode 100644 src/device/knownDeviceResolver.ts diff --git a/src/device/knownDeviceResolver.ts b/src/device/knownDeviceResolver.ts new file mode 100644 index 00000000..0d766181 --- /dev/null +++ b/src/device/knownDeviceResolver.ts @@ -0,0 +1,43 @@ +import Settings from '../settings/settings.js'; +import KnownDevice from '../settings/knownDevice.js'; +import DeviceNameGenerator from './deviceNameGenerator.js'; +import Logger from '../logging/Logger.js'; +import { DeviceId } from './deviceId.js'; + +/** + * Resolves the persisted `KnownDevice` identity for a newly detected raw device (serial port, + * BLE peripheral, buttplug.io device, ...), creating and persisting a new entry if none exists yet. + * + * Centralizes logic that used to be duplicated across several device providers/factories. + */ +export default class KnownDeviceResolver +{ + private readonly settings: Settings; + + private readonly nameGenerator: DeviceNameGenerator; + + private readonly logger: Logger; + + public constructor(settings: Settings, nameGenerator: DeviceNameGenerator, logger: Logger) { + this.settings = settings; + this.nameGenerator = nameGenerator; + this.logger = logger.child({ name: KnownDeviceResolver.name }); + } + + public resolveOrCreate(deviceId: DeviceId, type: string, provider: string, name?: string): KnownDevice { + const knownDevice = this.settings.getKnownDeviceById(deviceId); + + if (undefined !== knownDevice) { + // Return already existing device if already known (previously detected serial number) + this.logger.debug(`Device is already known: ${knownDevice.id}`); + return knownDevice; + } + + // Create a new device and persist it if not yet known + const newKnownDevice = new KnownDevice(deviceId, name ?? this.nameGenerator.generateName(), type, provider); + + this.settings.addKnownDevice(newKnownDevice); + + return newKnownDevice; + } +} diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index 12f8730f..4506fcd9 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -10,13 +10,11 @@ import AiroticProtocol from './airtonicProtocol.js'; import MessageResponseHandler from '../messageResponseHandler.js'; import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; -import Settings from '../../../settings/settings.js'; -import KnownDevice from '../../../settings/knownDevice.js'; -import { DeviceId } from '../../deviceId.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import FloatDeviceAttribute from '../../attribute/floatDeviceAttribute.js'; import BleDeviceProvider from '../../provider/bleDeviceProvider.js'; import { hsvByteToRgb } from '../../../util/color.js'; +import KnownDeviceResolver from '../../knownDeviceResolver.js'; export default class AiroticDeviceProvider extends BleDeviceProvider { @@ -25,12 +23,12 @@ export default class AiroticDeviceProvider extends BleDeviceProvider { @@ -56,8 +54,10 @@ export default class AiroticDeviceProvider extends BleDeviceProvider { const deviceInfo = await this.getDeviceInfo(transport); const protocol = deviceInfo.protocol; - const knownDevice = this.createKnownDevice(deviceId, deviceInfo.deviceType, provider); + const knownDevice = this.knownDeviceResolver.resolveOrCreate(deviceId, deviceInfo.deviceType, provider); const deviceAttributes = await this.getAttributes(transport, protocol); - const device = new GenericSlvCtrlPlusDevice( + return new GenericSlvCtrlPlusDevice( deviceInfo.fwVersion, knownDevice.id, knownDevice.name, @@ -59,10 +53,6 @@ export default class SlvCtrlPlusDeviceFactory this.eventEmitterFactory.create(), this.logger, ); - - this.settings.addKnownDevice(knownDevice); - - return device; } private async getDeviceInfo(transport: DeviceBidirectionalTransport): Promise @@ -125,22 +115,4 @@ export default class SlvCtrlPlusDeviceFactory return new SlvCtrlProtocolV1(); } - - private createKnownDevice(deviceId: DeviceId, deviceType: string, provider: string): KnownDevice { - const knownDevice = this.settings.getKnownDeviceById(deviceId) - - if (undefined !== knownDevice) { - // Return already existing device if already known (previously detected serial number) - this.logger.debug(`Device is already known: ${knownDevice.id}`); - return knownDevice; - } - - // Create a new device and return if not yet known (new serial number) - return new KnownDevice( - deviceId, - this.nameGenerator.generateName(), - deviceType, - provider - ); - } } diff --git a/src/serviceMap.ts b/src/serviceMap.ts index e27d557c..73e9f5e8 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -50,6 +50,7 @@ import Zc95SerialDeviceProvider from './device/protocol/zc95/zc95SerialDevicePro import EStim2bSerialDeviceProvider from './device/protocol/estim2b/estim2bSerialDeviceProvider.js'; import ButtplugIoWebsocketDeviceProvider from './device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; import AiroticDeviceProvider from './device/protocol/airotic/airoticDeviceProvider.js'; +import KnownDeviceResolver from './device/knownDeviceResolver.js'; type ServiceMap = { @@ -76,6 +77,7 @@ type ServiceMap = { 'device.virtual.provider': VirtualDeviceProvider, 'device.virtual.factory': VirtualDeviceFactory, 'device.uniqueNameGenerator': DeviceNameGenerator, + 'device.knownDeviceResolver': KnownDeviceResolver, 'device.updater': DeviceUpdaterInterface, 'device.observer.serial': SerialPortObserver, 'device.observer.ble': BleObserver, diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index ffe303ac..86dd71d7 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -40,6 +40,7 @@ import BleObserver from '../device/transport/bleObserver.js'; import AiroticDeviceProvider from '../device/protocol/airotic/airoticDeviceProvider.js'; import DeviceProviderFactory from '../device/provider/deviceProviderFactory.js'; import { DeviceId } from '../device/deviceId.js'; +import KnownDeviceResolver from '../device/knownDeviceResolver.js'; export default class DeviceServiceProvider implements ServiceProvider { public register(container: Pimple): void { @@ -66,6 +67,7 @@ export default class DeviceServiceProvider implements ServiceProvider new ButtplugIoWebsocketDeviceProviderFactory( container.get('device.manager'), container.get('factory.eventEmitter').create(), + container.get('device.knownDeviceResolver'), container.get('device.serial.factory.buttplugIo'), container.get('logger.default'), ) @@ -90,18 +92,22 @@ export default class DeviceServiceProvider implements ServiceProvider new KnownDeviceResolver( + container.get('settings'), + container.get('device.uniqueNameGenerator'), + container.get('logger.default'), + )); + container.set('device.serial.factory.slvCtrlPlus', () => new SlvCtrlPlusDeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), - container.get('settings'), - container.get('device.uniqueNameGenerator'), + container.get('device.knownDeviceResolver'), container.get('logger.default'), )); container.set('device.serial.factory.buttplugIo', () => new ButtplugIoDeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), - container.get('settings'), container.get('logger.default'), )); @@ -229,7 +235,7 @@ export default class DeviceServiceProvider implements ServiceProvider Date: Sun, 5 Jul 2026 10:34:00 +0200 Subject: [PATCH 02/17] Merge BleObserver into BleDeviceProvider, extract AiroticDeviceFactory BleDeviceProvider now owns BLE discovery (noble scanning/hotplug) directly instead of relying on a separate BleObserver + DeviceManager's announce/acquire/release/claim arbitration queue. Since there's only ever one BLE radio to scan with, a shared broker to arbitrate between independently scanning observers/providers is unnecessary complexity - the provider is both the sole observer and the sole consumer of what it observes. AiroticDeviceProvider's handshake/construction logic is extracted into a new AiroticDeviceFactory implementing the new BleProtocolFactory interface. BleDeviceProvider tries every registered factory (in registration order) for each newly discovered peripheral until one connects, or none do - replacing the old event-driven mutex queue with a plain loop. Factory registration is settings-driven: which DeviceSource entries are present in settings.json (by protocol name, e.g. 'airotic') determines which factories get registered into the shared provider, preserving today's per-protocol enable/disable behavior without requiring a settings migration. bleObserver.spec.ts is replaced by bleDeviceProvider.spec.ts covering the merged discovery + factory-trial + dedupe behavior. --- src/app.ts | 6 +- ...iceProvider.ts => airoticDeviceFactory.ts} | 51 ++-- src/device/provider/bleDeviceProvider.ts | 171 ++++++++---- src/device/provider/bleProtocolFactory.ts | 29 ++ src/device/transport/bleObserver.ts | 98 ------- src/serviceMap.ts | 8 +- src/serviceProvider/deviceServiceProvider.ts | 38 +-- .../integration/devices/airoticDevice.spec.ts | 6 +- .../device/provider/bleDeviceProvider.spec.ts | 263 ++++++++++++++++++ .../unit/device/transport/bleObserver.spec.ts | 193 ------------- 10 files changed, 464 insertions(+), 399 deletions(-) rename src/device/protocol/airotic/{airoticDeviceProvider.ts => airoticDeviceFactory.ts} (74%) create mode 100644 src/device/provider/bleProtocolFactory.ts delete mode 100644 src/device/transport/bleObserver.ts create mode 100644 tests/unit/device/provider/bleDeviceProvider.spec.ts delete mode 100644 tests/unit/device/transport/bleObserver.spec.ts diff --git a/src/app.ts b/src/app.ts index cd4499b4..e3c64a81 100644 --- a/src/app.ts +++ b/src/app.ts @@ -134,7 +134,7 @@ const configureWebsocket = (io: WebsocketServer, container: Container): void => { const serialPortObserver = container.get('device.observer.serial'); - const bleObserver = container.get('device.observer.ble'); + const bleDeviceProvider = container.get('device.provider.ble'); const logger = container.get('logger.default'); const settings = container.get('settings'); const deviceProviderManager = container.get('device.provider.loader'); @@ -146,7 +146,7 @@ const loadDeviceProviders = (container: Container): void => { .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)); + bleDeviceProvider.init().catch(e => logError(logger, `Initializing BLE device provider failed`, e)); }; const buildCorsOptions = (allowedOrigins: string[]): CorsOptions => ({ @@ -260,7 +260,7 @@ export const createApp = (container: Container, options: AppOptions) await container.get('automation.scriptRuntime').stop(); await container.get('device.observer.serial').stop(); - await container.get('device.observer.ble').stop(); + await container.get('device.provider.ble').stop(); 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/airoticDeviceFactory.ts similarity index 74% rename from src/device/protocol/airotic/airoticDeviceProvider.ts rename to src/device/protocol/airotic/airoticDeviceFactory.ts index 4506fcd9..053de318 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceFactory.ts @@ -1,10 +1,9 @@ import EventEmitter from 'events'; +import { Peripheral } from '@stoprocent/noble'; import BaseError from 'modern-errors'; -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 BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; import AiroticProtocol from './airtonicProtocol.js'; import MessageResponseHandler from '../messageResponseHandler.js'; @@ -12,37 +11,37 @@ import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import FloatDeviceAttribute from '../../attribute/floatDeviceAttribute.js'; -import BleDeviceProvider from '../../provider/bleDeviceProvider.js'; +import BleProtocolFactory from '../../provider/bleProtocolFactory.js'; import { hsvByteToRgb } from '../../../util/color.js'; import KnownDeviceResolver from '../../knownDeviceResolver.js'; +import { DeviceId } from '../../deviceId.js'; -export default class AiroticDeviceProvider extends BleDeviceProvider +export default class AiroticDeviceFactory implements BleProtocolFactory { - public static readonly providerName = 'airotic'; + public static readonly protocolName = 'airotic'; + + public readonly protocolName = AiroticDeviceFactory.protocolName; private static readonly UART_RX_CHAR_UUID = '6e400002b5a3f393e0a9e50e24dcca9e'; private static readonly UART_TX_CHAR_UUID = '6e400003b5a3f393e0a9e50e24dcca9e'; private readonly knownDeviceResolver: KnownDeviceResolver; - public constructor(deviceManager: DeviceManager, knownDeviceResolver: KnownDeviceResolver, eventEmitter: EventEmitter, logger: Logger) { - super(deviceManager, eventEmitter, logger.child({ name: AiroticDeviceProvider.name })); + private readonly logger: Logger; + public constructor(knownDeviceResolver: KnownDeviceResolver, logger: Logger) { this.knownDeviceResolver = knownDeviceResolver; + this.logger = logger.child({ name: AiroticDeviceFactory.name }); } - public override async init(): Promise { - this.logger.debug('Initialized AiroticDeviceProvider'); - } - - protected override async connectBleDevice(deviceInfo: BleDeviceInfo): Promise { + public async tryConnect(deviceId: DeviceId, peripheral: Peripheral): 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}`); + peripheral, + AiroticDeviceFactory.UART_RX_CHAR_UUID, + AiroticDeviceFactory.UART_TX_CHAR_UUID + ), 5000, `Timed out while creating BLE transport for device ${deviceId}`); - this.logger.debug(`Connected to device: ${deviceInfo.id}`); + this.logger.debug(`Connected to device: ${deviceId}`); const protocol = new AiroticProtocol(); const messageResponseHandler = MessageResponseHandler.create(protocol, transport, this.logger, 2000); @@ -55,19 +54,19 @@ export default class AiroticDeviceProvider extends BleDeviceProvider, - TAttributes extends DeviceAttributes = InferBleDeviceAttributes, - TNotifications extends DeviceNotifications = InferDeviceNotifications, - TConfig extends AnyDeviceConfig = InferBleDeviceConfig -> extends DeviceProvider +import BleDevice from '../bleDevice.js'; +import { DeviceId } from '../deviceId.js'; +import BleProtocolFactory from './bleProtocolFactory.js'; + +/** + * Owns BLE discovery (via noble) and, for every newly discovered peripheral, tries every + * registered protocol factory in registration order until one of them successfully connects. + * + * Absorbs what used to be a separate `BleObserver` transport class - since there's only ever + * one BLE radio to scan with, there's no need for a shared broker to arbitrate between + * independently-scanning observers/providers; this class is both the sole observer and the + * sole consumer of what it observes. + */ +export default class BleDeviceProvider extends DeviceProvider { - private connectedDevices: Set = new Set(); - - 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 static readonly MIN_RSSI = -70; + private static readonly UART_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; + + private readonly factories: BleProtocolFactory[] = []; + + private readonly connectedDevices: Set> = new Set(); + + private readonly inFlightDeviceIds: Set = new Set(); + + private isScanning = false; + + public constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { + super(deviceManager, eventEmitter, logger.child({ name: BleDeviceProvider.name })); } - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { - if (!this.isBleDeviceInfo(deviceInfo)) { - return; + public registerFactory(factory: BleProtocolFactory): this { + this.factories.push(factory); + + return this; + } + + public override async init(): Promise { + noble.on('discover', asyncHandler( + this.onDiscover.bind(this), + (err: unknown) => logError(this.logger, 'Error in discover handler', err) + )); + + 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(); + } + + public override async stop(): Promise { + noble.removeAllListeners(); + + if (this.isScanning) { + await noble.stopScanningAsync(); + this.isScanning = false; } - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); + noble.stop(); - const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); + for (const device of this.connectedDevices) { + await device.close(); + } + this.connectedDevices.clear(); + } - if (!acquireResult.successful) { - this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); + private async observe(): Promise { + if (this.isScanning) { return; } try { - const device = await this.connectBleDevice(deviceInfo); + // Wait for Adapter poweredOn state + await noble.waitForPoweredOnAsync(); - if (undefined === device) { - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - return; - } + this.isScanning = true; + await noble.startScanningAsync([BleDeviceProvider.UART_SERVICE_UUID], true); - this.connectedDevices.add(device); - 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); - await this.disconnectPeripheral(deviceInfo.peripheral); + this.logger.info('Looking for BLE UART devices'); + } catch (error: unknown) { + logError(this.logger, 'BLE device discovery error', error); + this.isScanning = false; + await noble.stopScanningAsync(); } } - public override async stop(): Promise { - for (const device of this.connectedDevices) { - await device.close(); + private async onDiscover(peripheral: Peripheral): Promise { + if (peripheral.rssi < BleDeviceProvider.MIN_RSSI) { + // Ignore devices with very weak signal, as they are likely not in range or just noise + this.logger.debug(`Ignoring device ${peripheral.id} with weak signal (RSSI: ${peripheral.rssi})`); + return; + } + + const deviceId = DeviceId.create(peripheral.id); + + if (this.inFlightDeviceIds.has(deviceId) || null !== this.deviceManager.getConnectedDevice(deviceId)) { + // Already being attempted or already connected - ignore repeated advertisements + return; } - this.connectedDevices.clear(); - } - private isBleDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo { - return deviceInfo.type === 'ble'; + this.inFlightDeviceIds.add(deviceId); + + try { + for (const factory of this.factories) { + let device: BleDevice | undefined; + + try { + device = await factory.tryConnect(deviceId, peripheral); + } catch (e: unknown) { + logError(this.logger, `Error while connecting to BLE device via '${factory.protocolName}'`, e); + continue; + } + + if (undefined === device) { + continue; + } + + this.connectedDevices.add(device); + this.deviceManager.addDevice(device); + + return; + } + + // No registered factory recognized this peripheral's protocol + await this.disconnectPeripheral(peripheral); + } finally { + this.inFlightDeviceIds.delete(deviceId); + } } private async disconnectPeripheral(peripheral: Peripheral): Promise { @@ -89,6 +156,4 @@ export default abstract class BleDeviceProvider< peripheral.cancelConnect(); } } - - protected abstract connectBleDevice(deviceInfo: BleDeviceInfo): Promise; } diff --git a/src/device/provider/bleProtocolFactory.ts b/src/device/provider/bleProtocolFactory.ts new file mode 100644 index 00000000..4026ce55 --- /dev/null +++ b/src/device/provider/bleProtocolFactory.ts @@ -0,0 +1,29 @@ +import { Peripheral } from '@stoprocent/noble'; +import BleDevice, { InferBleDeviceAttributes, InferBleDeviceConfig } from '../bleDevice.js'; +import { DeviceAttributes, DeviceNotifications, InferDeviceNotifications } from '../device.js'; +import { AnyDeviceConfig } from '../deviceConfig.js'; +import { DeviceId } from '../deviceId.js'; + +/** + * Implemented by protocol-specific factories that know how to probe a freshly discovered BLE + * peripheral and, if it speaks their protocol, construct the resulting Device. + * + * `BleDeviceProvider` tries every registered factory (in registration order) against a newly + * discovered peripheral until one of them returns a Device, or none of them do. + */ +export default interface BleProtocolFactory< + D extends BleDevice, + TAttributes extends DeviceAttributes = InferBleDeviceAttributes, + TNotifications extends DeviceNotifications = InferDeviceNotifications, + TConfig extends AnyDeviceConfig = InferBleDeviceConfig +> +{ + readonly protocolName: string; + + /** + * Attempt to connect to and identify the given peripheral as this factory's protocol. + * Resolve with `undefined` (rather than throwing) when the peripheral simply doesn't speak + * this protocol, so the provider can move on to the next registered factory. + */ + tryConnect(deviceId: DeviceId, peripheral: Peripheral): Promise; +} diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts deleted file mode 100644 index 8374bb18..00000000 --- a/src/device/transport/bleObserver.ts +++ /dev/null @@ -1,98 +0,0 @@ -import noble, { Peripheral } from '@stoprocent/noble'; -import Logger from '../../logging/Logger.js'; -import DeviceManager, { DeviceInfo } from '../deviceManager.js'; -import { logError } from '../../util/error.js'; -import { DeviceId } from '../deviceId.js'; -import { asyncHandler } from '../../util/async.js'; - -export type BleDeviceInfo = DeviceInfo & { - type: 'ble'; - peripheral: Peripheral; -}; - -export default class BleObserver -{ - private static readonly MIN_RSSI = -70; - private static readonly UART_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; - - private readonly deviceManager: DeviceManager; - - private readonly logger: Logger; - - private isScanning = false; - - public constructor( - deviceManager: DeviceManager, - logger: Logger - ) { - this.deviceManager = deviceManager; - this.logger = logger.child({ name: BleObserver.name }); - } - - public async init(): 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(); - } - - public async stop(): Promise - { - noble.removeAllListeners(); - - if (this.isScanning) { - await noble.stopScanningAsync(); - this.isScanning = false; - } - - noble.stop(); - } - - private onDiscover(peripheral: Peripheral): void - { - if (peripheral.rssi < BleObserver.MIN_RSSI) { - // Ignore devices with very weak signal, as they are likely not in range or just noise - this.logger.debug(`Ignoring device ${peripheral.id} with weak signal (RSSI: ${peripheral.rssi})`); - return; - } - - const deviceInfo: BleDeviceInfo = { - type: 'ble', - id: DeviceId.create(peripheral.id), - peripheral, - }; - - this.deviceManager.announceDetectedDevice(deviceInfo); - } - - private async observe(): Promise { - if (this.isScanning) { - return; - } - - try { - // Wait for Adapter poweredOn state - await noble.waitForPoweredOnAsync(); - - this.isScanning = true; - await noble.startScanningAsync([BleObserver.UART_SERVICE_UUID], true); - - this.logger.info('Looking for BLE UART devices'); - } catch (error: unknown) { - logError(this.logger, 'BLE device discovery error', error); - this.isScanning = false; - await noble.stopScanningAsync(); - } - } -} diff --git a/src/serviceMap.ts b/src/serviceMap.ts index 73e9f5e8..bdfe0bb5 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -44,12 +44,12 @@ import VirtualDeviceFactory from './device/protocol/virtual/virtualDeviceFactory import SerialPortFactory from './factory/serialPortFactory.js'; import Estim2bDeviceFactory from './device/protocol/estim2b/estim2bDeviceFactory.js'; import EventEmitterFactory from './factory/eventEmitterFactory.js'; -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 AiroticDeviceProvider from './device/protocol/airotic/airoticDeviceProvider.js'; +import AiroticDeviceFactory from './device/protocol/airotic/airoticDeviceFactory.js'; +import BleDeviceProvider from './device/provider/bleDeviceProvider.js'; import KnownDeviceResolver from './device/knownDeviceResolver.js'; @@ -72,7 +72,7 @@ type ServiceMap = { 'device.provider.factory.zc95Serial': DeviceProviderFactory, 'device.provider.factory.estim2bSerial': DeviceProviderFactory, 'device.provider.factory.buttplugIoWebsocket': DeviceProviderFactory, - 'device.provider.factory.airotic': DeviceProviderFactory, + 'device.factory.airotic': AiroticDeviceFactory, 'device.serial.factory.buttplugIo': ButtplugIoDeviceFactory, 'device.virtual.provider': VirtualDeviceProvider, 'device.virtual.factory': VirtualDeviceFactory, @@ -80,7 +80,7 @@ type ServiceMap = { 'device.knownDeviceResolver': KnownDeviceResolver, 'device.updater': DeviceUpdaterInterface, 'device.observer.serial': SerialPortObserver, - 'device.observer.ble': BleObserver, + 'device.provider.ble': BleDeviceProvider, /* factoryServiceProvider */ 'factory.uuid': UuidFactory, diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index 86dd71d7..85d7faf4 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -36,8 +36,8 @@ import GenericVirtualDeviceLogicFactory from '../device/protocol/virtual/generic import GenericDeviceProviderFactory from '../device/provider/genericDeviceProviderFactory.js'; import EStim2bSerialDeviceProvider from '../device/protocol/estim2b/estim2bSerialDeviceProvider.js'; import Estim2bDeviceFactory from '../device/protocol/estim2b/estim2bDeviceFactory.js'; -import BleObserver from '../device/transport/bleObserver.js'; -import AiroticDeviceProvider from '../device/protocol/airotic/airoticDeviceProvider.js'; +import BleDeviceProvider from '../device/provider/bleDeviceProvider.js'; +import AiroticDeviceFactory from '../device/protocol/airotic/airoticDeviceFactory.js'; import DeviceProviderFactory from '../device/provider/deviceProviderFactory.js'; import { DeviceId } from '../device/deviceId.js'; import KnownDeviceResolver from '../device/knownDeviceResolver.js'; @@ -198,10 +198,6 @@ export default class DeviceServiceProvider implements ServiceProvider { - return new GenericDeviceProviderFactory( - AiroticDeviceProvider, - container.get('device.manager'), - container.get('device.knownDeviceResolver'), - container.get('factory.eventEmitter').create(), - container.get('logger.default'), - ); - }); + container.set('device.factory.airotic', () => new AiroticDeviceFactory( + container.get('device.knownDeviceResolver'), + container.get('logger.default'), + )); container.set('device.observer.serial', () => { return new SerialPortObserver( @@ -248,11 +239,22 @@ export default class DeviceServiceProvider implements ServiceProvider { - return new BleObserver( + container.set('device.provider.ble', () => { + const provider = new BleDeviceProvider( container.get('device.manager'), - container.get('logger.default') + container.get('factory.eventEmitter').create(), + container.get('logger.default'), ); + + const bleFactoriesByProtocolName = new Map void>([ + [AiroticDeviceFactory.protocolName, (): void => { provider.registerFactory(container.get('device.factory.airotic')); }], + ]); + + for (const [, deviceSource] of container.get('settings').getDeviceSources()) { + bleFactoriesByProtocolName.get(deviceSource.type)?.(); + } + + return provider; }); } } diff --git a/tests/integration/devices/airoticDevice.spec.ts b/tests/integration/devices/airoticDevice.spec.ts index 120cda11..2585cc22 100644 --- a/tests/integration/devices/airoticDevice.spec.ts +++ b/tests/integration/devices/airoticDevice.spec.ts @@ -1,7 +1,7 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import request from 'supertest'; import { io as ioClient } from 'socket.io-client'; -import AiroticDeviceProvider from '../../../src/device/protocol/airotic/airoticDeviceProvider.js'; +import AiroticDeviceFactory from '../../../src/device/protocol/airotic/airoticDeviceFactory.js'; import WebSocketEvent from '../../../src/device/webSocketEvent.js'; import { AiroticDeviceSimulator, mockNoble } from '../helpers/airoticDeviceSimulator.js'; import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; @@ -25,7 +25,7 @@ const bleSettings = { deviceSources: { [BLE_SOURCE_ID]: { id: BLE_SOURCE_ID, - type: AiroticDeviceProvider.providerName, + type: AiroticDeviceFactory.protocolName, config: {}, }, }, @@ -66,7 +66,7 @@ describe('Airotic BLE device provider', () => { const [payload] = await deviceConnected; const expectedDeviceObject = { - provider: AiroticDeviceProvider.providerName, + provider: AiroticDeviceFactory.protocolName, type: 'airotic', attributes: { restColor: { diff --git a/tests/unit/device/provider/bleDeviceProvider.spec.ts b/tests/unit/device/provider/bleDeviceProvider.spec.ts new file mode 100644 index 00000000..a364f56c --- /dev/null +++ b/tests/unit/device/provider/bleDeviceProvider.spec.ts @@ -0,0 +1,263 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import EventEmitter from 'events'; +import { Peripheral } from '@stoprocent/noble'; +import DeviceManager from '../../../../src/device/deviceManager.js'; +import Logger from '../../../../src/logging/Logger.js'; +import BleDeviceProvider from '../../../../src/device/provider/bleDeviceProvider.js'; +import BleProtocolFactory from '../../../../src/device/provider/bleProtocolFactory.js'; +import BleDevice from '../../../../src/device/bleDevice.js'; +import { DeviceId } from '../../../../src/device/deviceId.js'; + +const mockNoble = vi.hoisted(() => ({ + on: vi.fn(), + removeAllListeners: vi.fn(), + waitForPoweredOnAsync: vi.fn(), + startScanningAsync: vi.fn(), + stopScanningAsync: vi.fn(), + stop: vi.fn(), +})); + +vi.mock('@stoprocent/noble', () => ({ default: mockNoble })); + +describe('BleDeviceProvider', () => { + let mockDeviceManager: ReturnType>; + let mockLogger: ReturnType>; + + function createProvider(): BleDeviceProvider { + return new BleDeviceProvider(mockDeviceManager, new EventEmitter(), mockLogger); + } + + function getNobleListener(event: string) { + return mockNoble.on.mock.calls.find(([e]) => e === event)?.[1]; + } + + function createPeripheral(rssi: number, id: string): ReturnType> { + const peripheral = mock(); + Object.defineProperty(peripheral, 'rssi', { get: () => rssi, configurable: true }); + Object.defineProperty(peripheral, 'id', { get: () => id, configurable: true }); + peripheral.state = 'disconnected'; + return peripheral; + } + + function createFactory(protocolName: string): ReturnType>> { + const factory = mock>(); + Object.defineProperty(factory, 'protocolName', { get: () => protocolName, configurable: true }); + return factory; + } + + beforeEach(() => { + vi.resetAllMocks(); + + mockDeviceManager = mock(); + mockDeviceManager.getConnectedDevice.mockReturnValue(null); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + + mockNoble.waitForPoweredOnAsync.mockResolvedValue(undefined); + mockNoble.startScanningAsync.mockResolvedValue(undefined); + mockNoble.stopScanningAsync.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('creates a child logger with the provider class name', () => { + createProvider(); + + expect(mockLogger.child).toHaveBeenCalledWith({ name: BleDeviceProvider.name }); + }); + }); + + describe('init', () => { + it('registers discover, stateChange and scanStop listeners on noble', async () => { + const provider = createProvider(); + + await provider.init(); + + expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); + expect(mockNoble.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); + expect(mockNoble.on).toHaveBeenCalledWith('scanStop', expect.any(Function)); + }); + + it('calls waitForPoweredOnAsync and startScanningAsync with the UART UUID', async () => { + const provider = createProvider(); + + await provider.init(); + + expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledOnce(); + expect(mockNoble.startScanningAsync).toHaveBeenCalledWith( + ['6e400001b5a3f393e0a9e50e24dcca9e'], + true, + ); + }); + + it('does not scan again when stateChange poweredOn fires while already scanning', async () => { + const provider = createProvider(); + await provider.init(); + + getNobleListener('stateChange')?.('poweredOn'); + await vi.waitFor(() => expect(mockNoble.startScanningAsync).toHaveBeenCalledTimes(1)); + }); + + it('logs info when scanStop fires', async () => { + const provider = createProvider(); + await provider.init(); + + getNobleListener('scanStop')?.(); + + expect(mockLogger.info).toHaveBeenCalledWith('Noble scanning stopped'); + }); + + it('handles waitForPoweredOnAsync rejection gracefully', async () => { + mockNoble.waitForPoweredOnAsync.mockRejectedValue(new Error('BLE unavailable')); + const provider = createProvider(); + + await expect(provider.init()).resolves.not.toThrow(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); + + describe('onDiscover (via discover event)', () => { + it('ignores a peripheral whose RSSI is below the minimum threshold', async () => { + const provider = createProvider(); + const factory = createFactory('test'); + provider.registerFactory(factory); + await provider.init(); + + await getNobleListener('discover')?.(createPeripheral(-80, 'weak-device')); + + expect(factory.tryConnect).not.toHaveBeenCalled(); + }); + + it('tries registered factories in registration order until one connects', async () => { + const provider = createProvider(); + const failingFactory = createFactory('failing'); + failingFactory.tryConnect.mockResolvedValue(undefined); + const successfulDevice = mock>(); + const successfulFactory = createFactory('successful'); + successfulFactory.tryConnect.mockResolvedValue(successfulDevice); + const untriedFactory = createFactory('untried'); + + provider.registerFactory(failingFactory).registerFactory(successfulFactory).registerFactory(untriedFactory); + await provider.init(); + + const peripheral = createPeripheral(-50, 'device-1'); + getNobleListener('discover')?.(peripheral); + + await vi.waitFor(() => expect(mockDeviceManager.addDevice).toHaveBeenCalledWith(successfulDevice)); + + expect(failingFactory.tryConnect).toHaveBeenCalledWith(DeviceId.create('device-1'), peripheral); + expect(successfulFactory.tryConnect).toHaveBeenCalledWith(DeviceId.create('device-1'), peripheral); + expect(untriedFactory.tryConnect).not.toHaveBeenCalled(); + }); + + it('disconnects the peripheral when no registered factory recognizes it', async () => { + const provider = createProvider(); + const factory = createFactory('test'); + factory.tryConnect.mockResolvedValue(undefined); + provider.registerFactory(factory); + await provider.init(); + + const peripheral = createPeripheral(-50, 'device-1'); + peripheral.state = 'connected'; + + getNobleListener('discover')?.(peripheral); + + await vi.waitFor(() => expect(peripheral.disconnectAsync).toHaveBeenCalledOnce()); + expect(mockDeviceManager.addDevice).not.toHaveBeenCalled(); + }); + + it('cancels an in-progress connection attempt when no factory recognizes the peripheral', async () => { + const provider = createProvider(); + const factory = createFactory('test'); + factory.tryConnect.mockResolvedValue(undefined); + provider.registerFactory(factory); + await provider.init(); + + const peripheral = createPeripheral(-50, 'device-1'); + peripheral.state = 'connecting'; + + getNobleListener('discover')?.(peripheral); + + await vi.waitFor(() => expect(peripheral.cancelConnect).toHaveBeenCalledOnce()); + }); + + it('logs and disconnects when a factory throws', async () => { + const provider = createProvider(); + const factory = createFactory('broken'); + factory.tryConnect.mockRejectedValue(new Error('handshake exploded')); + provider.registerFactory(factory); + await provider.init(); + + const peripheral = createPeripheral(-50, 'device-1'); + peripheral.state = 'connected'; + + getNobleListener('discover')?.(peripheral); + + await vi.waitFor(() => expect(peripheral.disconnectAsync).toHaveBeenCalledOnce()); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('does not re-attempt a peripheral that is already connected', async () => { + mockDeviceManager.getConnectedDevice.mockReturnValue(mock()); + const provider = createProvider(); + const factory = createFactory('test'); + provider.registerFactory(factory); + await provider.init(); + + await getNobleListener('discover')?.(createPeripheral(-50, 'already-connected')); + + expect(factory.tryConnect).not.toHaveBeenCalled(); + }); + + it('does not start a second attempt while one is already in flight for the same peripheral', async () => { + const provider = createProvider(); + const factory = createFactory('slow'); + let resolveTryConnect: (device: undefined) => void = () => {}; + factory.tryConnect.mockReturnValue(new Promise((resolve) => { resolveTryConnect = resolve; })); + provider.registerFactory(factory); + await provider.init(); + + const peripheral = createPeripheral(-50, 'device-1'); + const firstAttempt = getNobleListener('discover')?.(peripheral); + await getNobleListener('discover')?.(peripheral); + + expect(factory.tryConnect).toHaveBeenCalledOnce(); + + resolveTryConnect(undefined); + await firstAttempt; + }); + }); + + describe('stop', () => { + it('removes noble listeners and stops scanning', async () => { + const provider = createProvider(); + await provider.init(); + + await provider.stop(); + + expect(mockNoble.removeAllListeners).toHaveBeenCalledOnce(); + expect(mockNoble.stopScanningAsync).toHaveBeenCalledOnce(); + expect(mockNoble.stop).toHaveBeenCalledOnce(); + }); + + it('closes all connected devices', async () => { + const provider = createProvider(); + const device = mock>(); + const factory = createFactory('test'); + factory.tryConnect.mockResolvedValue(device); + provider.registerFactory(factory); + await provider.init(); + + getNobleListener('discover')?.(createPeripheral(-50, 'device-1')); + await vi.waitFor(() => expect(mockDeviceManager.addDevice).toHaveBeenCalledWith(device)); + + await provider.stop(); + + expect(device.close).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/tests/unit/device/transport/bleObserver.spec.ts b/tests/unit/device/transport/bleObserver.spec.ts deleted file mode 100644 index cf45f5b7..00000000 --- a/tests/unit/device/transport/bleObserver.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import { Peripheral } from '@stoprocent/noble'; -import DeviceManager from '../../../../src/device/deviceManager.js'; -import Logger from '../../../../src/logging/Logger.js'; -import BleObserver from '../../../../src/device/transport/bleObserver.js'; -import { DeviceId } from '../../../../src/device/deviceId.js'; - -const mockNoble = vi.hoisted(() => ({ - on: vi.fn(), - waitForPoweredOnAsync: vi.fn(), - startScanningAsync: vi.fn(), - stopScanningAsync: vi.fn(), -})); - -vi.mock('@stoprocent/noble', () => ({ default: mockNoble })); - -describe('BleObserver', () => { - let mockDeviceManager: ReturnType>; - let mockLogger: ReturnType>; - - function createObserver(): BleObserver { - return new BleObserver(mockDeviceManager, mockLogger); - } - - function getNobleListener(event: string) { - return mockNoble.on.mock.calls.find(([e]) => e === event)?.[1]; - } - - beforeEach(() => { - vi.resetAllMocks(); - - mockDeviceManager = mock(); - mockLogger = mock(); - mockLogger.child.mockReturnValue(mockLogger); - - mockNoble.waitForPoweredOnAsync.mockResolvedValue(undefined); - mockNoble.startScanningAsync.mockResolvedValue(undefined); - mockNoble.stopScanningAsync.mockResolvedValue(undefined); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('creates a child logger with the observer class name', () => { - createObserver(); - - expect(mockLogger.child).toHaveBeenCalledWith({ name: BleObserver.name }); - }); - }); - - describe('init', () => { - it('registers a discover listener on noble', async () => { - const observer = createObserver(); - - await observer.init(); - - expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); - }); - - it('registers a stateChange listener on noble', async () => { - const observer = createObserver(); - - await observer.init(); - - expect(mockNoble.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); - }); - - it('registers a scanStop listener on noble', async () => { - const observer = createObserver(); - - await observer.init(); - - expect(mockNoble.on).toHaveBeenCalledWith('scanStop', expect.any(Function)); - }); - - it('calls waitForPoweredOnAsync and startScanningAsync with the UART UUID', async () => { - const observer = createObserver(); - - await observer.init(); - - expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledOnce(); - 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'); - - // 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'); - - expect(mockNoble.waitForPoweredOnAsync).not.toHaveBeenCalled(); - }); - - it('calls stopScanningAsync and allows retry when waitForPoweredOnAsync rejects', async () => { - mockNoble.waitForPoweredOnAsync.mockRejectedValue(new Error('BLE unavailable')); - const observer = createObserver(); - - await expect(observer.init()).resolves.not.toThrow(); - - expect(mockNoble.stopScanningAsync).toHaveBeenCalledOnce(); - expect(mockLogger.error).toHaveBeenCalled(); - }); - - it('logs info when scanStop event fires', async () => { - const observer = createObserver(); - await observer.init(); - - getNobleListener('scanStop')?.(); - - expect(mockLogger.info).toHaveBeenCalledWith('Noble scanning stopped'); - }); - }); - - describe('onDiscover (via discover event)', () => { - function createPeripheral(rssi: number, id: string): ReturnType> { - const peripheral = mock(); - Object.defineProperty(peripheral, 'rssi', { get: () => rssi, configurable: true }); - Object.defineProperty(peripheral, 'id', { get: () => id, configurable: true }); - return peripheral; - } - - it('ignores a peripheral whose RSSI is below the minimum threshold', async () => { - const observer = createObserver(); - await observer.init(); - - getNobleListener('discover')?.(createPeripheral(-80, 'weak-device')); - - expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); - }); - - it('announces a peripheral whose RSSI is exactly at the minimum threshold (-70)', async () => { - const observer = createObserver(); - await observer.init(); - const peripheral = createPeripheral(-70, 'at-threshold'); - - getNobleListener('discover')?.(peripheral); - - expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); - expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ type: 'ble', peripheral }), - ); - }); - - it('announces a peripheral whose RSSI is above the minimum threshold', async () => { - const observer = createObserver(); - await observer.init(); - const peripheral = createPeripheral(-50, 'strong-device'); - - getNobleListener('discover')?.(peripheral); - - expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); - }); - - it('uses the peripheral id to build the DeviceId passed to announceDetectedDevice', async () => { - const observer = createObserver(); - await observer.init(); - const peripheral = createPeripheral(-60, 'abc-123'); - - getNobleListener('discover')?.(peripheral); - - expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('abc-123') }), - ); - }); - - it('logs a debug message when ignoring a weak-signal peripheral', async () => { - const observer = createObserver(); - await observer.init(); - - getNobleListener('discover')?.(createPeripheral(-80, 'noisy-device')); - - expect(mockLogger.debug).toHaveBeenCalled(); - }); - }); -}); From 6a5cb87e1fa8a068720e2bee8210a8f90181dbf5 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 10:59:08 +0200 Subject: [PATCH 03/17] Merge SerialPortObserver into SerialDeviceProvider SerialDeviceProvider now owns serial port discovery (USB list + hotplug events) directly, instead of relying on a separate SerialPortObserver + DeviceManager's announce/acquire/release/claim arbitration queue. SlvCtrlPlusSerialDeviceProvider, Zc95SerialDeviceProvider and EStim2bSerialDeviceProvider are removed - their port-open/handshake logic moves into SlvCtrlPlusDeviceFactory, Zc95DeviceFactory and EStim2bDeviceFactory respectively, each implementing the new SerialProtocolFactory interface (getPortOpenOptions, optional preparePort, tryConnect). For each newly discovered port, SerialDeviceProvider tries every registered factory in registration order - reopening the port fresh with each factory's own port settings (baud rate etc.) between attempts - until one connects or none do. This replaces the old event-driven mutex queue with a plain loop, while preserving the existing 'try each newly-seen port exactly once, retry if it disappears and reappears' semantics from the old observer. Factory registration is settings-driven, same as the BLE merge: which DeviceSource entries are present in settings.json (by protocol name, e.g. 'slvCtrlPlusSerial') determines which factories get registered, preserving today's per-protocol enable/disable behavior without a settings migration. serialPortObserver.spec.ts is replaced by serialDeviceProvider.spec.ts. Integration tests updated to reference the merged provider service key and the relocated protocolName constants. --- src/app.ts | 6 +- .../protocol/estim2b/estim2bDeviceFactory.ts | 48 ++- .../estim2b/estim2bSerialDeviceProvider.ts | 71 ----- .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 84 ++++- .../slvCtrlPlusSerialDeviceProvider.ts | 110 ------- src/device/protocol/zc95/zc95DeviceFactory.ts | 79 ++++- .../protocol/zc95/zc95SerialDeviceProvider.ts | 104 ------- src/device/provider/serialDeviceProvider.ts | 229 +++++++++----- src/device/provider/serialProtocolFactory.ts | 49 +++ src/device/transport/serialPortObserver.ts | 125 -------- src/serviceMap.ts | 10 +- src/serviceProvider/deviceServiceProvider.ts | 76 ++--- src/settings/settingsManager.ts | 4 +- .../integration/devices/estim2bDevice.spec.ts | 16 +- .../devices/slvCtrlSerialDevice.spec.ts | 16 +- tests/integration/devices/zc95Device.spec.ts | 18 +- .../helpers/mockSerialPortFactory.ts | 2 +- .../provider/serialDeviceProvider.spec.ts | 288 ++++++++++++++++++ .../transport/serialPortObserver.spec.ts | 186 ----------- 19 files changed, 758 insertions(+), 763 deletions(-) delete mode 100644 src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts delete mode 100644 src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts delete mode 100644 src/device/protocol/zc95/zc95SerialDeviceProvider.ts create mode 100644 src/device/provider/serialProtocolFactory.ts delete mode 100644 src/device/transport/serialPortObserver.ts create mode 100644 tests/unit/device/provider/serialDeviceProvider.spec.ts delete mode 100644 tests/unit/device/transport/serialPortObserver.spec.ts diff --git a/src/app.ts b/src/app.ts index e3c64a81..511991c2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -133,7 +133,7 @@ const configureWebsocket = (io: WebsocketServer, container: Container): void => { - const serialPortObserver = container.get('device.observer.serial'); + const serialDeviceProvider = container.get('device.provider.serial'); const bleDeviceProvider = container.get('device.provider.ble'); const logger = container.get('logger.default'); const settings = container.get('settings'); @@ -145,7 +145,7 @@ const loadDeviceProviders = (container: Container): void => { .startProviders() .catch(e => logError(logger, `Loading device providers failed`, e)); - serialPortObserver.start().catch(e => logError(logger, `Initializing serial port observer failed`, e)); + serialDeviceProvider.start().catch(e => logError(logger, `Initializing serial device provider failed`, e)); bleDeviceProvider.init().catch(e => logError(logger, `Initializing BLE device provider failed`, e)); }; @@ -259,7 +259,7 @@ 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.provider.serial').stop(); await container.get('device.provider.ble').stop(); await container.get('device.provider.loader').stopProviders(); container.get('health.metricsCollector').stop(); diff --git a/src/device/protocol/estim2b/estim2bDeviceFactory.ts b/src/device/protocol/estim2b/estim2bDeviceFactory.ts index 012fc906..d46e213e 100644 --- a/src/device/protocol/estim2b/estim2bDeviceFactory.ts +++ b/src/device/protocol/estim2b/estim2bDeviceFactory.ts @@ -1,3 +1,6 @@ +import { ReadlineParser } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface } from '@serialport/bindings-interface'; import Settings from '../../../settings/settings.js'; import DeviceNameGenerator from '../../deviceNameGenerator.js'; import DateFactory from '../../../factory/dateFactory.js'; @@ -14,15 +17,25 @@ import ListDeviceAttribute from '../../attribute/listDeviceAttribute.js'; import DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; import { DeviceId } from '../../deviceId.js'; +import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; +import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; +import { getErrorFromDecodeResult } from '../deviceProtocol.js'; +import SerialProtocolFactory, { SerialDeviceInfo, SerialDeviceProviderPortOpenOptions } from '../../provider/serialProtocolFactory.js'; -export default class Estim2bDeviceFactory +export default class Estim2bDeviceFactory implements SerialProtocolFactory { + public static readonly protocolName = 'estim2bSerial'; + + public readonly protocolName = Estim2bDeviceFactory.protocolName; + private readonly dateFactory: DateFactory; private readonly settings: Settings; private readonly nameGenerator: DeviceNameGenerator; + private readonly transportFactory: SerialDeviceTransportFactory; + private readonly logger: Logger; private readonly eventEmitterFactory: EventEmitterFactory; @@ -32,6 +45,7 @@ export default class Estim2bDeviceFactory eventEmitterFactory: EventEmitterFactory, settings: Settings, nameGenerator: DeviceNameGenerator, + transportFactory: SerialDeviceTransportFactory, logger: Logger ) { this.dateFactory = dateFactory; @@ -39,9 +53,41 @@ export default class Estim2bDeviceFactory this.settings = settings; this.nameGenerator = nameGenerator; + this.transportFactory = transportFactory; this.logger = logger; } + public getPortOpenOptions(): SerialDeviceProviderPortOpenOptions { + return { baudRate: 9600 }; + } + + public async tryConnect(deviceInfo: SerialDeviceInfo, 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')); + const estim2bProtocol = new EStim2bProtocol(); + + const encodedMessage = estim2bProtocol.encode(estim2bProtocol.createGetStatusCommand()); + const response = await transport.sendAndAwaitReceive(encodedMessage); + const decodedResponse = estim2bProtocol.decode(response); + + if ('error' in decodedResponse) { + throw getErrorFromDecodeResult(decodedResponse.error, response); + } + + const status = decodedResponse.message; + + this.logger.info(`Module detected: E-Stim Systems 2B ${status.firmwareVersion} (${deviceInfo.portInfo.serialNumber})`); + + return this.create( + deviceInfo.id, + estim2bProtocol, + transport, + status, + Estim2bDeviceFactory.protocolName + ); + } + public async create( deviceId: DeviceId, protocol: EStim2bProtocol, diff --git a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts deleted file mode 100644 index e16f64c6..00000000 --- a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ReadlineParser } from 'serialport'; -import { SerialPortStream } from '@serialport/stream'; -import { BindingInterface } from '@serialport/bindings-interface'; -import EventEmitter from 'events'; -import Logger from '../../../logging/Logger.js'; -import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; -import EStim2bProtocol from './estim2bProtocol.js'; -import EStim2bDeviceFactory from './estim2bDeviceFactory.js'; -import SerialPortFactory from '../../../factory/serialPortFactory.js'; -import Estim2bDevice from './estim2bDevice.js'; -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'; - -export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider -{ - public static readonly providerName = 'estim2bSerial'; - - private readonly transportFactory: SerialDeviceTransportFactory; - - private readonly deviceFactory: EStim2bDeviceFactory; - - public constructor( - deviceManager: DeviceManager, - serialPortFactory: SerialPortFactory, - transportFactory: SerialDeviceTransportFactory, - eventEmitter: EventEmitter, - deviceFactory: EStim2bDeviceFactory, - logger: Logger - ) { - super(deviceManager, serialPortFactory, eventEmitter, logger.child({ name: EStim2bSerialDeviceProvider.name })); - - this.transportFactory = transportFactory; - this.deviceFactory = deviceFactory; - } - - protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, 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')); - const estim2bProtocol = new EStim2bProtocol(); - - const encodedMessage = estim2bProtocol.encode(estim2bProtocol.createGetStatusCommand()); - const response = await transport.sendAndAwaitReceive(encodedMessage); - const decodedResponse = estim2bProtocol.decode(response); - - if ('error' in decodedResponse) { - throw getErrorFromDecodeResult(decodedResponse.error, response); - } - - const status = decodedResponse.message; - - this.logger.info(`Module detected: E-Stim Systems 2B ${status.firmwareVersion} (${deviceInfo.portInfo.serialNumber})`); - - const device = await this.deviceFactory.create( - deviceInfo.id, - estim2bProtocol, - transport, - status, - EStim2bSerialDeviceProvider.providerName - ); - - return device; - } - - protected getSerialDeviceProviderPortOpenOptions(): SerialDeviceProviderPortOpenOptions { - return { baudRate: 9600 }; - } -} diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts index e7b7a8f4..ca22e886 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts @@ -1,3 +1,7 @@ +import { ReadlineParser, ReadyParser } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; +import BaseError from 'modern-errors'; import GenericSlvCtrlPlusDevice from './genericSlvCtrlPlusDevice.js'; import DateFactory from '../../../factory/dateFactory.js'; import DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; @@ -10,29 +14,107 @@ import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; import { SlvCtrlPlusDeviceAttributes } from './slvCtrlPlusDevice.js'; import { DeviceId } from '../../deviceId.js'; import KnownDeviceResolver from '../../knownDeviceResolver.js'; +import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; +import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; +import SerialProtocolFactory, { SerialDeviceInfo, SerialDeviceProviderPortOpenOptions } from '../../provider/serialProtocolFactory.js'; -export default class SlvCtrlPlusDeviceFactory +export default class SlvCtrlPlusDeviceFactory implements SerialProtocolFactory { + public static readonly protocolName = 'slvCtrlPlusSerial'; + + public readonly protocolName = SlvCtrlPlusDeviceFactory.protocolName; + + private static readonly moduleReadyByte = 0x07; + + private static readonly arduinoVendorId = '2341'; + private readonly dateFactory: DateFactory; protected readonly eventEmitterFactory: EventEmitterFactory; private readonly knownDeviceResolver: KnownDeviceResolver; + private readonly deviceTransportFactory: SerialDeviceTransportFactory; + private readonly logger: Logger; public constructor( dateFactory: DateFactory, eventEmitterFactory: EventEmitterFactory, knownDeviceResolver: KnownDeviceResolver, + deviceTransportFactory: SerialDeviceTransportFactory, logger: Logger ) { this.dateFactory = dateFactory; this.eventEmitterFactory = eventEmitterFactory; this.knownDeviceResolver = knownDeviceResolver; + this.deviceTransportFactory = deviceTransportFactory; this.logger = logger.child({ name: SlvCtrlPlusDeviceFactory.name }); } + public getPortOpenOptions(): SerialDeviceProviderPortOpenOptions { + return { baudRate: 9600 }; + } + + public preparePort(port: SerialPortStream, portInfo: PortInfo): Promise { + return new Promise((resolve, reject) => { + if (portInfo.vendorId !== SlvCtrlPlusDeviceFactory.arduinoVendorId) { + // It's NOT an Arduino + resolve(); + return; + } + + const readyParser = port.pipe(new ReadyParser({ + delimiter: [SlvCtrlPlusDeviceFactory.moduleReadyByte] + })); + + // Let's timeout if we don't receive the ready bytes for a few seconds + const timeout = setTimeout(() => { + port.unpipe(readyParser); + readyParser.destroy(); + reject(new Error(`Timed out while waiting for ready bytes`)); + }, 3000); + + readyParser.once('ready', () => { + clearTimeout(timeout); + port.unpipe(readyParser); + readyParser.destroy(); + resolve(); + }); + }); + } + + public async tryConnect(deviceInfo: SerialDeviceInfo, port: SerialPortStream): Promise { + const parser = port.pipe(new ReadlineParser({ delimiter: SlvCtrlProtocol.EOF })); + const syncPort = new SynchronousSerialPort(deviceInfo.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.create(deviceInfo.id, transport, SlvCtrlPlusDeviceFactory.protocolName); + + this.logger.info(`Module detected: ${device.getDeviceModel} (${deviceInfo.portInfo.serialNumber})`); + + return device; + } + + private async performHandshakeWithRetries(transport: DeviceBidirectionalTransport, maxAttempts: number): Promise { + let lastError; + + for (let i = 1; i <= maxAttempts; i++) { + try { + await transport.sendAndAwaitReceive(Buffer.from(`clear`), 250); + return; + } catch(e: unknown) { + const error = BaseError.normalize(e); + this.logger.info(`Retrying because handshake attempt ${i} failed: ${error.message}`); + if (i === maxAttempts) lastError = e; + } + } + + throw lastError; + } + public async create(deviceId: DeviceId, transport: DeviceBidirectionalTransport, provider: string): Promise { const deviceInfo = await this.getDeviceInfo(transport); const protocol = deviceInfo.protocol; diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts deleted file mode 100644 index af1f1445..00000000 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { ReadlineParser, ReadyParser } from 'serialport'; -import { SerialPortStream } from '@serialport/stream'; -import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; -import SlvCtrlPlusDeviceFactory from './slvCtrlPlusDeviceFactory.js'; -import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; -import EventEmitter from 'events'; -import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; -import Logger from '../../../logging/Logger.js'; -import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; -import SerialPortFactory from '../../../factory/serialPortFactory.js'; -import BaseError from 'modern-errors'; -import SlvCtrlProtocol from './slvCtrlProtocol.js'; -import DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; -import DeviceManager from '../../deviceManager.js'; -import GenericSlvCtrlPlusDevice from './genericSlvCtrlPlusDevice.js'; -import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; - -export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvider -{ - public static readonly providerName = 'slvCtrlPlusSerial'; - - private static readonly moduleReadyByte = 0x07; - - private static readonly arduinoVendorId = '2341'; - - private readonly slvCtrlPlusDeviceFactory: SlvCtrlPlusDeviceFactory; - - private readonly deviceTransportFactory: SerialDeviceTransportFactory; - - public constructor( - deviceManager: DeviceManager, - serialPortFactory: SerialPortFactory, - eventEmitter: EventEmitter, - deviceFactory: SlvCtrlPlusDeviceFactory, - deviceTransportFactory: SerialDeviceTransportFactory, - logger: Logger - ) { - super(deviceManager, serialPortFactory, eventEmitter, logger.child({ name: SlvCtrlPlusSerialDeviceProvider.name })); - this.slvCtrlPlusDeviceFactory = deviceFactory; - this.deviceTransportFactory = deviceTransportFactory; - } - - protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPortStream): Promise - { - const parser = port.pipe(new ReadlineParser({ delimiter: SlvCtrlProtocol.EOF })); - const syncPort = new SynchronousSerialPort(deviceInfo.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.id, - transport, - SlvCtrlPlusSerialDeviceProvider.providerName - ); - - this.logger.info(`Module detected: ${device.getDeviceModel} (${deviceInfo.portInfo.serialNumber})`); - - return device; - } - - private async performHandshakeWithRetries(transport: DeviceBidirectionalTransport, maxAttempts: number): Promise { - let lastError; - - for (let i = 1; i <= maxAttempts; i++) { - try { - await transport.sendAndAwaitReceive(Buffer.from(`clear`), 250); - return; - } catch(e: unknown) { - const error = BaseError.normalize(e); - this.logger.info(`Retrying because handshake attempt ${i} failed: ${error.message}`); - if (i === maxAttempts) lastError = e; - } - } - - throw lastError; - } - - protected getSerialDeviceProviderPortOpenOptions(): SerialDeviceProviderPortOpenOptions { - return { baudRate: 9600 }; - } - - protected override preparePort(port: SerialPortStream, portInfo: PortInfo): Promise { - return new Promise((resolve, reject) => { - if (portInfo.vendorId !== SlvCtrlPlusSerialDeviceProvider.arduinoVendorId) { - // It's NOT an Arduino - resolve(); - return; - } - - const readyParser = port.pipe(new ReadyParser({ - delimiter: [SlvCtrlPlusSerialDeviceProvider.moduleReadyByte] - })); - - // Let's timeout if we don't receive the ready bytes for a few seconds - const timeout = setTimeout(() => { - port.unpipe(readyParser); - readyParser.destroy(); - reject(new Error(`Timed out while waiting for ready bytes`)); - }, 3000); - - readyParser.once('ready', () => { - clearTimeout(timeout); - port.unpipe(readyParser); - readyParser.destroy(); - resolve(); - }); - }); - } -} diff --git a/src/device/protocol/zc95/zc95DeviceFactory.ts b/src/device/protocol/zc95/zc95DeviceFactory.ts index 3b02f57e..1ccc4f53 100644 --- a/src/device/protocol/zc95/zc95DeviceFactory.ts +++ b/src/device/protocol/zc95/zc95DeviceFactory.ts @@ -1,3 +1,5 @@ +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface } from '@serialport/bindings-interface'; import Settings from '../../../settings/settings.js'; import DeviceNameGenerator from '../../deviceNameGenerator.js'; import DateFactory from '../../../factory/dateFactory.js'; @@ -14,9 +16,17 @@ import MessageResponseHandler from '../messageResponseHandler.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; import { logError } from '../../../util/error.js'; import { DeviceId } from '../../deviceId.js'; +import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; +import { FrameParser } from '../../../serial/frameParser.js'; +import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; +import SerialProtocolFactory, { SerialDeviceInfo, SerialDeviceProviderPortOpenOptions } from '../../provider/serialProtocolFactory.js'; -export default class Zc95DeviceFactory +export default class Zc95DeviceFactory implements SerialProtocolFactory { + public static readonly protocolName = 'zc95Serial'; + + public readonly protocolName = Zc95DeviceFactory.protocolName; + private readonly dateFactory: DateFactory; private readonly eventEmitterFactory: EventEmitterFactory; @@ -25,6 +35,8 @@ export default class Zc95DeviceFactory private readonly nameGenerator: DeviceNameGenerator; + private readonly transportFactory: SerialDeviceTransportFactory; + private readonly logger: Logger; public constructor( @@ -32,15 +44,80 @@ export default class Zc95DeviceFactory eventEmitterFactory: EventEmitterFactory, settings: Settings, nameGenerator: DeviceNameGenerator, + transportFactory: SerialDeviceTransportFactory, logger: Logger ) { this.dateFactory = dateFactory; this.eventEmitterFactory = eventEmitterFactory; this.settings = settings; this.nameGenerator = nameGenerator; + this.transportFactory = transportFactory; this.logger = logger; } + public getPortOpenOptions(): SerialDeviceProviderPortOpenOptions { + return { baudRate: 115200 }; + } + + public async tryConnect(deviceInfo: SerialDeviceInfo, 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 transport = this.transportFactory.create( + serialPort, Buffer.from([Zc95Protocol.STX]), Buffer.from([Zc95Protocol.ETX]) + ); + const protocol = new Zc95Protocol(); + const messageFactory = new Zc95MessageFactory(); + + const messageResponseHandler = MessageResponseHandler.create( + protocol, + transport, + this.logger, + ); + + this.logger.debug(`Reset device connection`); + await this.reset(port, false); + const versionDetails = await messageResponseHandler.send(messageFactory.createGetVersionDetails()); + + this.logger.info(`Module detected: ZC95 ${versionDetails.ZC95} (${deviceInfo.portInfo.serialNumber})`); + + return this.create( + deviceInfo.id, + versionDetails, + protocol, + transport, + messageFactory, + messageResponseHandler, + Zc95DeviceFactory.protocolName + ); + } + + private async reset(port: SerialPortStream, close: boolean = false): Promise { + return new Promise((resolve, reject) => { + port.write(Buffer.from([Zc95Protocol.EOT]), (writeErr: Error | null | undefined) => { + if (null != writeErr) { + reject(writeErr); + return; + } + + this.logger.trace('> EOT'); + + if (close) { + port.close((closeErr: Error | null) => { + if (null != closeErr) { + reject(closeErr); + return; + } + setTimeout(resolve, 250); + }); + } else { + setTimeout(resolve, 250); + } + }); + }); + } + public async create( deviceId: DeviceId, versionDetails: VersionMsgResponse, diff --git a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts deleted file mode 100644 index 71703ee3..00000000 --- a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { SerialPortStream } from '@serialport/stream'; -import { BindingInterface } from '@serialport/bindings-interface'; -import EventEmitter from 'events'; -import Logger from '../../../logging/Logger.js'; -import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; -import Zc95DeviceFactory from './zc95DeviceFactory.js'; -import Zc95Device from './zc95Device.js'; -import SerialPortFactory from '../../../factory/serialPortFactory.js'; -import { FrameParser } from '../../../serial/frameParser.js'; -import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; -import Zc95Protocol from './zc95Protocol.js'; -import MessageResponseHandler from '../messageResponseHandler.js'; -import Zc95MessageFactory from './zc95MessageFactory.js'; -import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; -import DeviceManager from '../../deviceManager.js'; -import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; - -export default class Zc95SerialDeviceProvider extends SerialDeviceProvider -{ - public static readonly providerName = 'zc95Serial'; - - private readonly transportFactory: SerialDeviceTransportFactory; - - private readonly deviceFactory: Zc95DeviceFactory; - - public constructor( - deviceManager: DeviceManager, - serialPortFactory: SerialPortFactory, - transportFactory: SerialDeviceTransportFactory, - eventEmitter: EventEmitter, - deviceFactory: Zc95DeviceFactory, - logger: Logger - ) { - super(deviceManager, serialPortFactory, eventEmitter, logger.child({ name: Zc95SerialDeviceProvider.name })); - - this.transportFactory = transportFactory; - this.deviceFactory = deviceFactory; - } - - protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, 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 transport = this.transportFactory.create( - serialPort, Buffer.from([Zc95Protocol.STX]), Buffer.from([Zc95Protocol.ETX]) - ); - const protocol = new Zc95Protocol(); - const messageFactory = new Zc95MessageFactory(); - - const messageResponseHandler = MessageResponseHandler.create( - protocol, - transport, - this.logger, - ); - - this.logger.debug(`Reset device connection`); - await this.reset(port, false); - const versionDetails = await messageResponseHandler.send(messageFactory.createGetVersionDetails()); - - this.logger.info(`Module detected: ZC95 ${versionDetails.ZC95} (${deviceInfo.portInfo.serialNumber})`); - - const device = await this.deviceFactory.create( - deviceInfo.id, - versionDetails, - protocol, - transport, - messageFactory, - messageResponseHandler, - Zc95SerialDeviceProvider.providerName - ); - - return device; - } - - protected getSerialDeviceProviderPortOpenOptions(): SerialDeviceProviderPortOpenOptions { - return { baudRate: 115200 }; - } - - private async reset(port: SerialPortStream, close: boolean = false): Promise { - return new Promise((resolve, reject) => { - port.write(Buffer.from([Zc95Protocol.EOT]), (writeErr: Error | null | undefined) => { - if (null != writeErr) { - reject(writeErr); - return; - } - - this.logger.trace('> EOT'); - - if (close) { - port.close((closeErr: Error | null) => { - if (null != closeErr) { - reject(closeErr); - return; - } - setTimeout(resolve, 250); - }); - } else { - setTimeout(resolve, 250); - } - }); - }); - } -} diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index c3d0769e..f25bab21 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -2,93 +2,195 @@ import DeviceProvider from './deviceProvider.js'; import EventEmitter from 'events'; import Logger from '../../logging/Logger.js'; import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; -import { SerialPortOpenOptions } from 'serialport'; +import { SerialPort } from 'serialport'; 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 from '../deviceManager.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 { AnyDeviceConfig } from '../deviceConfig.js'; - -export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; - -export default abstract class SerialDeviceProvider< - D extends PeripheralDevice, - TAttributes extends DeviceAttributes = InferPeripheralDeviceAttributes, - TConfig extends AnyDeviceConfig = InferPeripheralDeviceConfig -> extends DeviceProvider +import { usb } from 'usb'; +import PeripheralDevice from '../peripheralDevice.js'; +import { DeviceId } from '../deviceId.js'; +import SerialProtocolFactory, { SerialDeviceInfo } from './serialProtocolFactory.js'; + +/** + * Owns serial port discovery (USB list + hotplug events) and, for every newly discovered port, + * tries every registered protocol factory in registration order until one of them successfully + * connects. + * + * Absorbs what used to be a separate `SerialPortObserver` transport class. Since multiple + * protocols compete for the same physical serial ports, connection attempts for a given port are + * tried strictly one factory at a time (in registration order), reopening the port fresh with + * each factory's own port settings (e.g. baud rate) between attempts. + */ +export default class SerialDeviceProvider extends DeviceProvider { private readonly serialPortFactory: SerialPortFactory; - private connectedDevices: Map = new Map(); + private readonly factories: SerialProtocolFactory[] = []; - private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; + private readonly connectedDevices: Map> = new Map(); - protected constructor(deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, eventEmitter: EventEmitter, logger: Logger) { - super(deviceManager, eventEmitter, logger); + private readonly inFlightDeviceIds: Set = new Set(); + + private readonly managedPortIds: Set = new Set(); + + private onUsbEventRef?: () => void; + + private rescanTimer?: NodeJS.Timeout; + + private discoveryInFlight = false; + + public constructor(deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, eventEmitter: EventEmitter, logger: Logger) { + super(deviceManager, eventEmitter, logger.child({ name: SerialDeviceProvider.name })); this.serialPortFactory = serialPortFactory; + } - this.deviceDetectedListener = asyncHandler( - this.handleDeviceDetection.bind(this), - (err: unknown) => logError(this.logger, 'Error in device detection handler', err) - ); + public registerFactory(factory: SerialProtocolFactory): this { + this.factories.push(factory); - this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + return this; } - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { - if (!this.isSerialDeviceInfo(deviceInfo)) { - return; + public async start(): Promise { + await this.discoverSerialDevices(); + + this.onUsbEventRef = (): void => { + this.logger.debug('USB event detected, scanning for serial devices in 1s...'); + + if (this.rescanTimer !== undefined) { + clearTimeout(this.rescanTimer); + } + + this.rescanTimer = setTimeout(() => { + if (this.discoveryInFlight) { + return; + } + this.discoveryInFlight = true; + this.discoverSerialDevices() + .catch(e => logError(this.logger, 'Error while scanning for new serial devices', e)) + .finally(() => { + this.discoveryInFlight = false; + }); + }, 1000); + }; + + usb.addEventListener('connect', this.onUsbEventRef); + usb.addEventListener('disconnect', this.onUsbEventRef); + } + + public async discoverSerialDevices(): Promise { + const foundPortIds: Set = new Set(); + const connectAttempts: Promise[] = []; + + try { + const ports = await SerialPort.list(); + + for (const portInfo of ports) { + if (undefined === portInfo.vendorId || undefined === portInfo.productId) { + continue; + } + + // If the serial number is not defined, create a "unique" one based on vendorId and productId + if (undefined === portInfo.serialNumber) { + portInfo.serialNumber = `serial-${portInfo.vendorId}-${portInfo.productId}-${portInfo.locationId}`; + } + + foundPortIds.add(portInfo.serialNumber); + + if (!this.managedPortIds.has(portInfo.serialNumber)) { + this.managedPortIds.add(portInfo.serialNumber); + this.logger.debug(`Managed devices: ${this.managedPortIds.size}`); + + const deviceInfo: SerialDeviceInfo = { id: DeviceId.create(portInfo.serialNumber), portInfo }; + + connectAttempts.push( + this.attemptConnect(deviceInfo) + .catch((err: unknown) => logError(this.logger, `Error while connecting to serial device '${portInfo.path}'`, err)) + ); + } + } + + // Forget devices that are no longer present, so they can be tried again if they reappear + for (const portId of this.managedPortIds) { + if (!foundPortIds.has(portId)) { + this.managedPortIds.delete(portId); + this.logger.info(`Managed devices: ${this.managedPortIds.size}`); + } + } + } catch (err) { + logError(this.logger, 'Could not list serial ports', err); } - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); + await Promise.all(connectAttempts); + } - const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); + public override async stop(): Promise { + if (this.rescanTimer !== undefined) { + clearTimeout(this.rescanTimer); + this.rescanTimer = undefined; + } + + if (this.onUsbEventRef !== undefined) { + usb.removeEventListener('connect', this.onUsbEventRef); + usb.removeEventListener('disconnect', this.onUsbEventRef); + this.onUsbEventRef = undefined; + } + } - if (false === acquireResult.successful) { - this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); + private async attemptConnect(deviceInfo: SerialDeviceInfo): Promise { + if (this.inFlightDeviceIds.has(deviceInfo.id) || null !== this.deviceManager.getConnectedDevice(deviceInfo.id)) { return; } + this.inFlightDeviceIds.add(deviceInfo.id); + try { - const device = await this.connectToDevice(deviceInfo); + for (const factory of this.factories) { + let device: PeripheralDevice | undefined; + + try { + device = await this.connectWithFactory(factory, deviceInfo); + } catch (e: unknown) { + logError(this.logger, `Error while connecting to serial device '${deviceInfo.portInfo.path}' via '${factory.protocolName}'`, e); + continue; + } + + if (undefined === device) { + continue; + } + + this.connectedDevices.set(device.getDeviceId, device); + this.deviceManager.addDevice(device); + + this.logger.debug(`Assigned device id: ${device.getDeviceId} (${deviceInfo.portInfo.path})`); + this.logger.info(`Connected devices: ${this.connectedDevices.size}`); - if (undefined === device) { - this.deviceManager.releaseDetectedDevice(deviceInfo.id); return; } - this.deviceManager.addDevice(device); - this.deviceManager.claimDetectedDevice(deviceInfo.id); - } catch (e: unknown) { - logError(this.logger, `Error while connecting to device`, e); - this.deviceManager.releaseDetectedDevice(deviceInfo.id); + this.logger.info(`Could not identify serial device '${deviceInfo.portInfo.path}': no matching protocol found`); + } finally { + this.inFlightDeviceIds.delete(deviceInfo.id); } } - private isSerialDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is SerialDeviceInfo - { - return deviceInfo.type === 'serial'; - } - - private async connectToDevice(deviceInfo: SerialDeviceInfo): Promise { + private async connectWithFactory( + factory: SerialProtocolFactory, + deviceInfo: SerialDeviceInfo + ): Promise | undefined> { const portInfo = deviceInfo.portInfo; - this.logger.info(`Connection attempt for serial device '${portInfo.path}' (s/n: ${portInfo.serialNumber})`); + this.logger.info(`Connection attempt for serial device '${portInfo.path}' via '${factory.protocolName}' (s/n: ${portInfo.serialNumber})`); const port = this.serialPortFactory.create({ path: portInfo.path, autoOpen: false, - ...this.getSerialDeviceProviderPortOpenOptions(portInfo) + ...factory.getPortOpenOptions(portInfo) }); - let device: D | undefined; + let device: PeripheralDevice | undefined; let attemptFailureReason = 'unknown'; try { @@ -96,10 +198,10 @@ export default abstract class SerialDeviceProvider< port.open(err => err ? reject(err) : resolve()); }); - await this.preparePort(port, portInfo); + await (factory.preparePort ?? SerialDeviceProvider.noopPreparePort)(port, portInfo); - device = await this.connectSerialDevice(deviceInfo, port); - } catch(e: unknown) { + device = await factory.tryConnect(deviceInfo, port); + } catch (e: unknown) { if (undefined !== device) { try { await device.close(); @@ -117,19 +219,16 @@ export default abstract class SerialDeviceProvider< port.close(err => err ? reject(err) : resolve()); }); } - this.logger.info(`Could not connect to serial device '${portInfo.path}': ${attemptFailureReason}`); + this.logger.info(`Could not connect to serial device '${portInfo.path}' via '${factory.protocolName}': ${attemptFailureReason}`); } else { - this.logger.info(`Successfully connected to serial device '${portInfo.path}'`); + this.logger.info(`Successfully connected to serial device '${portInfo.path}' via '${factory.protocolName}'`); - this.connectedDevices.set(device.getDeviceId, device); - - this.logger.debug(`Assigned device id: ${device.getDeviceId} (${portInfo.path})`); - this.logger.info(`Connected devices: ${this.connectedDevices.size}`); + const connectedDevice = device; port.on('close', () => { - this.connectedDevices.delete(device.getDeviceId); + this.connectedDevices.delete(connectedDevice.getDeviceId); - this.logger.info(`Lost serial device: ${device.getDeviceId}`); + this.logger.info(`Lost serial device: ${connectedDevice.getDeviceId}`); this.logger.info(`Connected devices: ${this.connectedDevices.size}`); }); } @@ -138,15 +237,7 @@ export default abstract class SerialDeviceProvider< } // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected preparePort(port: SerialPortStream, portInfo: PortInfo): Promise { + private static noopPreparePort(port: SerialPortStream, portInfo: PortInfo): Promise { return Promise.resolve(); } - - public override async stop(): Promise { - this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); - } - - protected abstract connectSerialDevice(deviceInfo: DeviceInfo, port: SerialPortStream): Promise; - - protected abstract getSerialDeviceProviderPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; } diff --git a/src/device/provider/serialProtocolFactory.ts b/src/device/provider/serialProtocolFactory.ts new file mode 100644 index 00000000..1dc04179 --- /dev/null +++ b/src/device/provider/serialProtocolFactory.ts @@ -0,0 +1,49 @@ +import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; +import { SerialPortStream } from '@serialport/stream'; +import { SerialPortOpenOptions } from 'serialport'; +import { AutoDetectTypes } from '@serialport/bindings-cpp'; +import PeripheralDevice, { InferPeripheralDeviceAttributes, InferPeripheralDeviceConfig } from '../peripheralDevice.js'; +import { DeviceAttributes } from '../device.js'; +import { AnyDeviceConfig } from '../deviceConfig.js'; +import { DeviceId } from '../deviceId.js'; + +export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; + +export type SerialDeviceInfo = { + id: DeviceId; + portInfo: PortInfo; +}; + +/** + * Implemented by protocol-specific factories that know how to probe a freshly discovered serial + * port and, if it speaks their protocol, construct the resulting Device. + * + * `SerialDeviceProvider` tries every registered factory (in registration order) against a newly + * discovered port until one of them successfully connects, or none of them do. Since each + * protocol may require different port settings (e.g. baud rate), the port is (re-)opened fresh + * with `getPortOpenOptions()` before every attempt. + */ +export default interface SerialProtocolFactory< + D extends PeripheralDevice, + TAttributes extends DeviceAttributes = InferPeripheralDeviceAttributes, + TConfig extends AnyDeviceConfig = InferPeripheralDeviceConfig +> +{ + readonly protocolName: string; + + getPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; + + /** + * Attempt to connect to and identify the device at the other end of an already-open port as + * this factory's protocol. Resolve with `undefined` (rather than throwing) when the device + * simply doesn't speak this protocol, so the provider can move on to the next registered + * factory. + */ + tryConnect(deviceInfo: SerialDeviceInfo, port: SerialPortStream): Promise; + + /** + * Optional hook to prepare a freshly opened port before `tryConnect()` is called (e.g. + * waiting for a module-ready signal). Defaults to a no-op when omitted. + */ + preparePort?(port: SerialPortStream, portInfo: PortInfo): Promise; +} diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts deleted file mode 100644 index a3d78f69..00000000 --- a/src/device/transport/serialPortObserver.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { SerialPort } from 'serialport'; -import { PortInfo } from '@serialport/bindings-interface'; -import Logger from '../../logging/Logger.js'; -import DeviceManager, { DeviceInfo } from '../deviceManager.js'; -import { usb } from 'usb'; -import { logError } from '../../util/error.js'; -import { DeviceId } from '../deviceId.js'; - -export type SerialDeviceInfo = DeviceInfo & { - type: 'serial'; - portInfo: PortInfo; -}; - -export default class SerialPortObserver -{ - protected readonly logger: Logger; - - protected readonly deviceManager: DeviceManager; - - public static readonly name = 'serial'; - - private managedDevices: Map = new Map(); - - private onUsbEventRef?: () => void; - - private rescanTimer?: NodeJS.Timeout; - - private discoveryInFlight = false; - - public constructor( - deviceManager: DeviceManager, - logger: Logger - ) { - this.deviceManager = deviceManager; - this.logger = logger.child({ name: SerialPortObserver.name }); - } - - public async start(): Promise - { - await this.discoverSerialDevices(); - - this.onUsbEventRef = (): void => { - this.logger.debug('USB event detected, scanning for serial devices in 1s...'); - - if (this.rescanTimer !== undefined) { - clearTimeout(this.rescanTimer); - } - - this.rescanTimer = setTimeout(() => { - if (this.discoveryInFlight) { - return; - } - this.discoveryInFlight = true; - this.discoverSerialDevices() - .catch(e => logError(this.logger, 'Error while scanning for new serial devices', e)) - .finally(() => { - this.discoveryInFlight = false; - }); - }, 1000); - }; - - usb.addEventListener('connect', this.onUsbEventRef); - usb.addEventListener('disconnect', this.onUsbEventRef); - } - - public async discoverSerialDevices(): Promise - { - const foundDevices: Map = new Map(); - - try { - const ports = await SerialPort.list(); - - // Iterate through all serial ports and add them to the managed devices and try to connect - for (const portInfo of ports) { - if (undefined === portInfo.vendorId || undefined === portInfo.productId) { - continue; - } - - // If the serial number is not defined, create a "unique" one based on vendorId and productId - if (undefined === portInfo.serialNumber) { - portInfo.serialNumber = `serial-${portInfo.vendorId}-${portInfo.productId}-${portInfo.locationId}`; - } - - foundDevices.set(portInfo.serialNumber, null); - - if (!this.managedDevices.has(portInfo.serialNumber)) { - const deviceInfo: SerialDeviceInfo = { - type: 'serial', - id: DeviceId.create(portInfo.serialNumber), - portInfo - }; - - this.managedDevices.set(portInfo.serialNumber, deviceInfo); - this.logger.debug(`Managed devices: ${this.managedDevices.size}`); - - this.deviceManager.announceDetectedDevice(deviceInfo); - } - } - - // Remove devices that are no longer present - for (const [key, deviceInfo] of this.managedDevices) { - if (!foundDevices.has(key)) { - this.deviceManager.revokeDetectedDevice(deviceInfo); - this.managedDevices.delete(key); - this.logger.info(`Managed devices: ${this.managedDevices.size}`); - } - } - } catch (err) { - logError(this.logger, 'Could not list serial ports', err); - } - } - - public async stop(): Promise { - if (this.rescanTimer !== undefined) { - clearTimeout(this.rescanTimer); - this.rescanTimer = undefined; - } - - if (this.onUsbEventRef !== undefined) { - usb.removeEventListener('connect', this.onUsbEventRef); - usb.removeEventListener('disconnect', this.onUsbEventRef); - this.onUsbEventRef = undefined; - } - } -} diff --git a/src/serviceMap.ts b/src/serviceMap.ts index bdfe0bb5..2129c450 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -38,18 +38,15 @@ import PutSettingsController from './controller/settings/putSettingsController.j 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'; import VirtualDeviceFactory from './device/protocol/virtual/virtualDeviceFactory.js'; import SerialPortFactory from './factory/serialPortFactory.js'; import Estim2bDeviceFactory from './device/protocol/estim2b/estim2bDeviceFactory.js'; import EventEmitterFactory from './factory/eventEmitterFactory.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 AiroticDeviceFactory from './device/protocol/airotic/airoticDeviceFactory.js'; import BleDeviceProvider from './device/provider/bleDeviceProvider.js'; +import SerialDeviceProvider from './device/provider/serialDeviceProvider.js'; import KnownDeviceResolver from './device/knownDeviceResolver.js'; @@ -68,9 +65,6 @@ type ServiceMap = { 'device.serial.factory.slvCtrlPlus': SlvCtrlPlusDeviceFactory, 'device.factory.zc95': Zc95DeviceFactory, 'device.factory.estim2b': Estim2bDeviceFactory, - 'device.provider.factory.slvCtrlPlusSerial': DeviceProviderFactory, - 'device.provider.factory.zc95Serial': DeviceProviderFactory, - 'device.provider.factory.estim2bSerial': DeviceProviderFactory, 'device.provider.factory.buttplugIoWebsocket': DeviceProviderFactory, 'device.factory.airotic': AiroticDeviceFactory, 'device.serial.factory.buttplugIo': ButtplugIoDeviceFactory, @@ -79,7 +73,7 @@ type ServiceMap = { 'device.uniqueNameGenerator': DeviceNameGenerator, 'device.knownDeviceResolver': KnownDeviceResolver, 'device.updater': DeviceUpdaterInterface, - 'device.observer.serial': SerialPortObserver, + 'device.provider.serial': SerialDeviceProvider, 'device.provider.ble': BleDeviceProvider, /* factoryServiceProvider */ diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index 85d7faf4..bc2773fe 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -9,7 +9,6 @@ import GenericDeviceUpdater from '../device/genericDeviceUpdater.js'; import SerialDeviceTransportFactory from '../device/transport/serialDeviceTransportFactory.js'; import Device 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'; @@ -22,8 +21,6 @@ import DisplayVirtualDeviceLogic from '../device/protocol/virtual/display/displa import RandomGeneratorVirtualDeviceLogic from '../device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.js'; import TtsVirtualDeviceLogic from '../device/protocol/virtual/audio/ttsVirtualDeviceLogic.js'; -import Zc95SerialDeviceProvider from '../device/protocol/zc95/zc95SerialDeviceProvider.js'; -import SerialPortObserver from '../device/transport/serialPortObserver.js'; import Zc95DeviceFactory from '../device/protocol/zc95/zc95DeviceFactory.js'; import PiperVirtualDeviceLogic from '../device/protocol/virtual/audio/piperVirtualDeviceLogic.js'; import { piperVirtualDeviceConfigSchema } from '../device/protocol/virtual/audio/piperVirtualDeviceConfig.js'; @@ -33,11 +30,10 @@ import { } from '../device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceConfig.js'; import { ttsVirtualDeviceConfigSchema } from '../device/protocol/virtual/audio/ttsVirtualDeviceConfig.js'; import GenericVirtualDeviceLogicFactory from '../device/protocol/virtual/genericVirtualDeviceLogicFactory.js'; -import GenericDeviceProviderFactory from '../device/provider/genericDeviceProviderFactory.js'; -import EStim2bSerialDeviceProvider from '../device/protocol/estim2b/estim2bSerialDeviceProvider.js'; import Estim2bDeviceFactory from '../device/protocol/estim2b/estim2bDeviceFactory.js'; import BleDeviceProvider from '../device/provider/bleDeviceProvider.js'; import AiroticDeviceFactory from '../device/protocol/airotic/airoticDeviceFactory.js'; +import SerialDeviceProvider from '../device/provider/serialDeviceProvider.js'; import DeviceProviderFactory from '../device/provider/deviceProviderFactory.js'; import { DeviceId } from '../device/deviceId.js'; import KnownDeviceResolver from '../device/knownDeviceResolver.js'; @@ -49,19 +45,6 @@ export default class DeviceServiceProvider implements ServiceProvider new SerialDeviceTransportFactory() ); - container.set( - 'device.provider.factory.slvCtrlPlusSerial', - () => new GenericDeviceProviderFactory( - SlvCtrlPlusSerialDeviceProvider, - container.get('device.manager'), - container.get('factory.serialPort'), - container.get('factory.eventEmitter').create(), - container.get('device.serial.factory.slvCtrlPlus'), - container.get('device.serial.transport.factory'), - container.get('logger.default'), - ) - ); - container.set( 'device.provider.factory.buttplugIoWebsocket', () => new ButtplugIoWebsocketDeviceProviderFactory( @@ -102,6 +85,7 @@ export default class DeviceServiceProvider implements ServiceProvider new AiroticDeviceFactory( + container.get('device.knownDeviceResolver'), container.get('logger.default'), )); @@ -178,10 +169,6 @@ export default class DeviceServiceProvider implements ServiceProvider { return new DeviceProviderManager( new Map>([ - [ - SlvCtrlPlusSerialDeviceProvider.providerName, - container.get('device.provider.factory.slvCtrlPlusSerial'), - ], [ ButtplugIoWebsocketDeviceProvider.providerName, container.get('device.provider.factory.buttplugIoWebsocket'), @@ -190,53 +177,30 @@ export default class DeviceServiceProvider implements ServiceProvider { - return new GenericDeviceProviderFactory( - Zc95SerialDeviceProvider, + container.set('device.provider.serial', () => { + const provider = new SerialDeviceProvider( container.get('device.manager'), container.get('factory.serialPort'), - container.get('device.serial.transport.factory'), container.get('factory.eventEmitter').create(), - container.get('device.factory.zc95'), container.get('logger.default'), ); - }); - container.set('device.provider.factory.estim2bSerial', () => { - return new GenericDeviceProviderFactory( - EStim2bSerialDeviceProvider, - container.get('device.manager'), - container.get('factory.serialPort'), - container.get('device.serial.transport.factory'), - container.get('factory.eventEmitter').create(), - container.get('device.factory.estim2b'), - container.get('logger.default'), - ); - }); + const serialFactoriesByProtocolName = new Map void>([ + [SlvCtrlPlusDeviceFactory.protocolName, (): void => { provider.registerFactory(container.get('device.serial.factory.slvCtrlPlus')); }], + [Zc95DeviceFactory.protocolName, (): void => { provider.registerFactory(container.get('device.factory.zc95')); }], + [Estim2bDeviceFactory.protocolName, (): void => { provider.registerFactory(container.get('device.factory.estim2b')); }], + ]); - container.set('device.factory.airotic', () => new AiroticDeviceFactory( - container.get('device.knownDeviceResolver'), - container.get('logger.default'), - )); + for (const [, deviceSource] of container.get('settings').getDeviceSources()) { + serialFactoriesByProtocolName.get(deviceSource.type)?.(); + } - container.set('device.observer.serial', () => { - return new SerialPortObserver( - container.get('device.manager'), - container.get('logger.default') - ); + return provider; }); container.set('device.provider.ble', () => { diff --git a/src/settings/settingsManager.ts b/src/settings/settingsManager.ts index ee05964e..6b0bb16e 100644 --- a/src/settings/settingsManager.ts +++ b/src/settings/settingsManager.ts @@ -5,7 +5,7 @@ import Settings from './settings.js'; import type { SettingsSchema } from './settings.js'; import onChange from 'on-change'; import DeviceSource from './deviceSource.js'; -import SlvCtrlPlusSerialDeviceProvider from '../device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; +import SlvCtrlPlusDeviceFactory from '../device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.js'; import Logger from '../logging/Logger.js'; import JsonSchemaValidator from '../schemaValidation/JsonSchemaValidator.js'; import EventEmitter from 'events'; @@ -117,7 +117,7 @@ export default class SettingsManager settings.addDeviceSource(new DeviceSource( 'b6a0f45e-c3d0-4dca-ab81-7daac0764291', - SlvCtrlPlusSerialDeviceProvider.providerName, + SlvCtrlPlusDeviceFactory.protocolName, {} )); diff --git a/tests/integration/devices/estim2bDevice.spec.ts b/tests/integration/devices/estim2bDevice.spec.ts index 866bfb1c..dbb751b6 100644 --- a/tests/integration/devices/estim2bDevice.spec.ts +++ b/tests/integration/devices/estim2bDevice.spec.ts @@ -1,7 +1,7 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import request from 'supertest'; import { io as ioClient } from 'socket.io-client'; -import EStim2bSerialDeviceProvider from '../../../src/device/protocol/estim2b/estim2bSerialDeviceProvider.js'; +import Estim2bDeviceFactory from '../../../src/device/protocol/estim2b/estim2bDeviceFactory.js'; import WebSocketEvent from '../../../src/device/webSocketEvent.js'; import { Estim2bDeviceSimulator } from '../helpers/estim2bDeviceSimulator.js'; import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; @@ -17,7 +17,7 @@ const serialSettings = { deviceSources: { [SERIAL_SOURCE_ID]: { id: SERIAL_SOURCE_ID, - type: EStim2bSerialDeviceProvider.providerName, + type: Estim2bDeviceFactory.protocolName, config: {}, }, }, @@ -45,7 +45,7 @@ describe('E-Stim Systems 2B serial device provider', () => { beforeEach(async () => { await app.container.get('device.manager').reset(); app.mockSerialPortFactory.reset(); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); wsEmitSpy.mockClear(); }); @@ -55,12 +55,12 @@ describe('E-Stim Systems 2B serial device provider', () => { const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const expectedDeviceObject = { - provider: EStim2bSerialDeviceProvider.providerName, + provider: Estim2bDeviceFactory.protocolName, type: 'estim2b', attributes: { mode: { @@ -136,7 +136,7 @@ describe('E-Stim Systems 2B serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -165,7 +165,7 @@ describe('E-Stim Systems 2B serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -203,7 +203,7 @@ describe('E-Stim Systems 2B serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; diff --git a/tests/integration/devices/slvCtrlSerialDevice.spec.ts b/tests/integration/devices/slvCtrlSerialDevice.spec.ts index e0330590..6bd4e610 100644 --- a/tests/integration/devices/slvCtrlSerialDevice.spec.ts +++ b/tests/integration/devices/slvCtrlSerialDevice.spec.ts @@ -1,7 +1,7 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import request from 'supertest'; import { io as ioClient } from 'socket.io-client'; -import SlvCtrlPlusSerialDeviceProvider from '../../../src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; +import SlvCtrlPlusDeviceFactory from '../../../src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.js'; import WebSocketEvent from '../../../src/device/webSocketEvent.js'; import { SlvCtrlPlusDeviceSimulator } from '../helpers/slvCtrlPlusDeviceSimulator.js'; import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; @@ -18,7 +18,7 @@ const serialSettings = { deviceSources: { [SERIAL_SOURCE_ID]: { id: SERIAL_SOURCE_ID, - type: SlvCtrlPlusSerialDeviceProvider.providerName, + type: SlvCtrlPlusDeviceFactory.protocolName, config: {}, }, }, @@ -50,7 +50,7 @@ describe('SlvCtrl serial device provider', () => { // timer alive and floods the event loop with I/O errors after the binding is torn down. await app.container.get('device.manager').reset(); app.mockSerialPortFactory.reset(); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); wsEmitSpy.mockClear(); }); @@ -64,12 +64,12 @@ describe('SlvCtrl serial device provider', () => { const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const expectedDeviceObject = { - provider: SlvCtrlPlusSerialDeviceProvider.providerName, + provider: SlvCtrlPlusDeviceFactory.protocolName, type: 'slvCtrlPlus', attributes: { connected: { @@ -188,7 +188,7 @@ describe('SlvCtrl serial device provider', () => { app.mockSerialPortFactory.attachDevice(portPath, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -225,7 +225,7 @@ describe('SlvCtrl serial device provider', () => { app.mockSerialPortFactory.attachDevice(portPath, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -268,7 +268,7 @@ describe('SlvCtrl serial device provider', () => { app.mockSerialPortFactory.attachDevice(portPath, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; diff --git a/tests/integration/devices/zc95Device.spec.ts b/tests/integration/devices/zc95Device.spec.ts index 95934dd8..2d2e8f21 100644 --- a/tests/integration/devices/zc95Device.spec.ts +++ b/tests/integration/devices/zc95Device.spec.ts @@ -1,7 +1,7 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import request from 'supertest'; import { io as ioClient } from 'socket.io-client'; -import Zc95SerialDeviceProvider from '../../../src/device/protocol/zc95/zc95SerialDeviceProvider.js'; +import Zc95DeviceFactory from '../../../src/device/protocol/zc95/zc95DeviceFactory.js'; import WebSocketEvent from '../../../src/device/webSocketEvent.js'; import { Zc95DeviceSimulator } from '../helpers/zc95DeviceSimulator.js'; import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; @@ -17,7 +17,7 @@ const serialSettings = { deviceSources: { [SERIAL_SOURCE_ID]: { id: SERIAL_SOURCE_ID, - type: Zc95SerialDeviceProvider.providerName, + type: Zc95DeviceFactory.protocolName, config: {}, }, }, @@ -45,7 +45,7 @@ describe('Zc95 serial device provider', () => { beforeEach(async () => { await app.container.get('device.manager').reset(); app.mockSerialPortFactory.reset(); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); wsEmitSpy.mockClear(); }); @@ -55,12 +55,12 @@ describe('Zc95 serial device provider', () => { const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const expectedDeviceObject = { - provider: Zc95SerialDeviceProvider.providerName, + provider: Zc95DeviceFactory.protocolName, type: 'zc95', attributes: { activePattern: { @@ -98,7 +98,7 @@ describe('Zc95 serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -161,7 +161,7 @@ describe('Zc95 serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -246,7 +246,7 @@ describe('Zc95 serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -306,7 +306,7 @@ describe('Zc95 serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.observer.serial').discoverSerialDevices(); + await app.container.get('device.provider.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; diff --git a/tests/integration/helpers/mockSerialPortFactory.ts b/tests/integration/helpers/mockSerialPortFactory.ts index 1175bff2..22f91cca 100644 --- a/tests/integration/helpers/mockSerialPortFactory.ts +++ b/tests/integration/helpers/mockSerialPortFactory.ts @@ -19,7 +19,7 @@ export default class MockSerialPortFactory extends SerialPortFactory { private readonly simulators: Map = new Map(); // Incremented on every attachDevice call so each mock port gets a unique serialNumber. - // Without this the SerialPortObserver synthesizes the same "serial-1234-5678-undefined" + // Without this the SerialDeviceProvider synthesizes the same "serial-1234-5678-undefined" // for every mock port (vendorId + productId + missing locationId), causing all devices // across all test iterations to share a single UUID via createKnownDevice. A unique // serial per connection means each device gets its own UUID and stale-device async-close diff --git a/tests/unit/device/provider/serialDeviceProvider.spec.ts b/tests/unit/device/provider/serialDeviceProvider.spec.ts new file mode 100644 index 00000000..24ed4365 --- /dev/null +++ b/tests/unit/device/provider/serialDeviceProvider.spec.ts @@ -0,0 +1,288 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import EventEmitter from 'events'; +import { SerialPort } from 'serialport'; +import DeviceManager from '../../../../src/device/deviceManager.js'; +import Logger from '../../../../src/logging/Logger.js'; +import SerialDeviceProvider from '../../../../src/device/provider/serialDeviceProvider.js'; +import SerialProtocolFactory from '../../../../src/device/provider/serialProtocolFactory.js'; +import SerialPortFactory from '../../../../src/factory/serialPortFactory.js'; +import PeripheralDevice from '../../../../src/device/peripheralDevice.js'; +import { DeviceId } from '../../../../src/device/deviceId.js'; + +type PortInfoLike = { + path: string; + manufacturer: string | undefined; + serialNumber: string | undefined; + pnpId: string | undefined; + locationId: string | undefined; + productId: string | undefined; + vendorId: string | undefined; +}; + +class FakeSerialPort extends EventEmitter { + public isOpen = false; + + private readonly openError?: Error; + + public constructor(openError?: Error) { + super(); + this.openError = openError; + } + + public open(cb: (err?: Error | null) => void): void { + if (undefined !== this.openError) { + cb(this.openError); + return; + } + this.isOpen = true; + cb(null); + } + + public close(cb: (err: Error | null) => void): void { + this.isOpen = false; + this.emit('close'); + cb(null); + } + + public pipe(dest: T): T { + return dest; + } + + public unpipe(): void { + // no-op + } +} + +describe('SerialDeviceProvider', () => { + let mockDeviceManager: ReturnType>; + let mockLogger: ReturnType>; + let mockSerialPortFactory: ReturnType>; + let createdPorts: FakeSerialPort[]; + + function makePortInfo(overrides: Partial & { path: string }): PortInfoLike { + return { + manufacturer: undefined, + serialNumber: undefined, + pnpId: undefined, + locationId: undefined, + productId: undefined, + vendorId: undefined, + ...overrides, + }; + } + + function createProvider(): SerialDeviceProvider { + return new SerialDeviceProvider(mockDeviceManager, mockSerialPortFactory, new EventEmitter(), mockLogger); + } + + function createFactory(protocolName: string, openError?: Error): ReturnType>> { + const factory = mock>(); + Object.defineProperty(factory, 'protocolName', { get: () => protocolName, configurable: true }); + factory.getPortOpenOptions.mockReturnValue({ baudRate: 9600 }); + factory.preparePort = undefined; + void openError; + return factory; + } + + beforeEach(() => { + vi.resetAllMocks(); + + mockDeviceManager = mock(); + mockDeviceManager.getConnectedDevice.mockReturnValue(null); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + + createdPorts = []; + mockSerialPortFactory = mock(); + mockSerialPortFactory.create.mockImplementation(() => { + const port = new FakeSerialPort(); + createdPorts.push(port); + return port as any; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('creates a child logger with the provider class name', () => { + createProvider(); + + expect(mockLogger.child).toHaveBeenCalledWith({ name: SerialDeviceProvider.name }); + }); + }); + + describe('discoverSerialDevices', () => { + it('does not attempt any port when none are found', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const provider = createProvider(); + const factory = createFactory('test'); + provider.registerFactory(factory); + + await provider.discoverSerialDevices(); + + expect(factory.tryConnect).not.toHaveBeenCalled(); + }); + + it('skips a port with no vendorId', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([ + makePortInfo({ path: '/dev/tty1', serialNumber: 'SN001', vendorId: undefined, productId: '6001' }), + ]); + const provider = createProvider(); + const factory = createFactory('test'); + provider.registerFactory(factory); + + await provider.discoverSerialDevices(); + + expect(factory.tryConnect).not.toHaveBeenCalled(); + }); + + it('skips a port with no productId', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([ + makePortInfo({ path: '/dev/tty1', serialNumber: 'SN001', vendorId: '0403', productId: undefined }), + ]); + const provider = createProvider(); + const factory = createFactory('test'); + provider.registerFactory(factory); + + await provider.discoverSerialDevices(); + + expect(factory.tryConnect).not.toHaveBeenCalled(); + }); + + it('handles a SerialPort.list error gracefully without throwing', async () => { + vi.spyOn(SerialPort, 'list').mockRejectedValue(new Error('USB stack error')); + const provider = createProvider(); + + await expect(provider.discoverSerialDevices()).resolves.not.toThrow(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('tries registered factories in registration order until one connects', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); + + const provider = createProvider(); + const failingFactory = createFactory('failing'); + failingFactory.tryConnect.mockResolvedValue(undefined); + const successfulDevice = mock>(); + const successfulFactory = createFactory('successful'); + successfulFactory.tryConnect.mockResolvedValue(successfulDevice); + const untriedFactory = createFactory('untried'); + + provider.registerFactory(failingFactory).registerFactory(successfulFactory).registerFactory(untriedFactory); + + await provider.discoverSerialDevices(); + + expect(failingFactory.tryConnect).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create('SN001') }), + expect.anything(), + ); + expect(successfulFactory.tryConnect).toHaveBeenCalled(); + expect(untriedFactory.tryConnect).not.toHaveBeenCalled(); + expect(mockDeviceManager.addDevice).toHaveBeenCalledWith(successfulDevice); + }); + + it('continues to the next factory when one throws', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); + + const provider = createProvider(); + const brokenFactory = createFactory('broken'); + brokenFactory.tryConnect.mockRejectedValue(new Error('handshake exploded')); + const successfulDevice = mock>(); + const successfulFactory = createFactory('successful'); + successfulFactory.tryConnect.mockResolvedValue(successfulDevice); + + provider.registerFactory(brokenFactory).registerFactory(successfulFactory); + + await provider.discoverSerialDevices(); + + expect(mockDeviceManager.addDevice).toHaveBeenCalledWith(successfulDevice); + }); + + it('closes the port again when no factory recognizes the device', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); + + const provider = createProvider(); + const factory = createFactory('test'); + factory.tryConnect.mockResolvedValue(undefined); + provider.registerFactory(factory); + + await provider.discoverSerialDevices(); + + expect(mockDeviceManager.addDevice).not.toHaveBeenCalled(); + }); + + it('generates a synthetic serial number when serialNumber is undefined', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: undefined, vendorId: '0403', productId: '6001', locationId: 'port1' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); + const provider = createProvider(); + const factory = createFactory('test'); + provider.registerFactory(factory); + + await provider.discoverSerialDevices(); + + expect(factory.tryConnect).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create('serial-0403-6001-port1') }), + expect.anything(), + ); + }); + + it('does not re-attempt a port that is still present on a later discovery run', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); + const provider = createProvider(); + const factory = createFactory('test'); + factory.tryConnect.mockResolvedValue(undefined); + provider.registerFactory(factory); + + await provider.discoverSerialDevices(); + await provider.discoverSerialDevices(); + + expect(factory.tryConnect).toHaveBeenCalledOnce(); + }); + + it('attempts a port again once it disappears and reappears', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list') + .mockResolvedValueOnce([port]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([port]); + const provider = createProvider(); + const factory = createFactory('test'); + factory.tryConnect.mockResolvedValue(undefined); + provider.registerFactory(factory); + + await provider.discoverSerialDevices(); + await provider.discoverSerialDevices(); + await provider.discoverSerialDevices(); + + expect(factory.tryConnect).toHaveBeenCalledTimes(2); + }); + + it('does not re-attempt a port that is already connected', async () => { + mockDeviceManager.getConnectedDevice.mockReturnValue(mock()); + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); + const provider = createProvider(); + const factory = createFactory('test'); + provider.registerFactory(factory); + + await provider.discoverSerialDevices(); + + expect(factory.tryConnect).not.toHaveBeenCalled(); + }); + }); + + describe('stop', () => { + it('does not throw when called without a prior start()', async () => { + const provider = createProvider(); + + await expect(provider.stop()).resolves.not.toThrow(); + }); + }); +}); diff --git a/tests/unit/device/transport/serialPortObserver.spec.ts b/tests/unit/device/transport/serialPortObserver.spec.ts deleted file mode 100644 index c3e27676..00000000 --- a/tests/unit/device/transport/serialPortObserver.spec.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import { SerialPort } from 'serialport'; -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'; - -type PortInfoLike = { - path: string; - manufacturer: string | undefined; - serialNumber: string | undefined; - pnpId: string | undefined; - locationId: string | undefined; - productId: string | undefined; - vendorId: string | undefined; -}; - -describe('SerialPortObserver', () => { - let mockDeviceManager: ReturnType>; - let mockLogger: ReturnType>; - - function makePortInfo(overrides: Partial & { path: string }): PortInfoLike { - return { - manufacturer: undefined, - serialNumber: undefined, - pnpId: undefined, - locationId: undefined, - productId: undefined, - vendorId: undefined, - ...overrides, - }; - } - - function createObserver(): SerialPortObserver { - return new SerialPortObserver(mockDeviceManager, mockLogger); - } - - beforeEach(() => { - vi.useFakeTimers(); - - mockDeviceManager = mock(); - mockLogger = mock(); - mockLogger.child.mockReturnValue(mockLogger); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('creates a child logger with the observer class name', () => { - createObserver(); - - expect(mockLogger.child).toHaveBeenCalledWith({ name: SerialPortObserver.name }); - }); - }); - - describe('init', () => { - it('does not announce any device when no serial ports are found', async () => { - vi.spyOn(SerialPort, 'list').mockResolvedValue([]); - const observer = createObserver(); - - await observer.start(); - - expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); - }); - - it('skips a port that has no vendorId', async () => { - vi.spyOn(SerialPort, 'list').mockResolvedValue([ - makePortInfo({ path: '/dev/tty1', serialNumber: 'SN001', vendorId: undefined, productId: '6001' }), - ]); - const observer = createObserver(); - - await observer.start(); - - expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); - }); - - it('skips a port that has no productId', async () => { - vi.spyOn(SerialPort, 'list').mockResolvedValue([ - makePortInfo({ path: '/dev/tty1', serialNumber: 'SN001', vendorId: '0403', productId: undefined }), - ]); - const observer = createObserver(); - - await observer.start(); - - expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); - }); - - it('announces a newly discovered port to the device manager', async () => { - const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); - vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); - const observer = createObserver(); - - await observer.start(); - - expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); - expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN001'), portInfo: port }), - ); - }); - - it('generates a synthetic serial number when serialNumber is undefined', async () => { - const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: undefined, vendorId: '0403', productId: '6001', locationId: 'port1' }); - vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); - const observer = createObserver(); - - await observer.start(); - - const expectedSn = 'serial-0403-6001-port1'; - expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create(expectedSn) }), - ); - }); - - it('does not re-announce a device that is already managed on the next discovery run', async () => { - const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); - vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); - const observer = createObserver(); - - await observer.start(); - await vi.advanceTimersByTimeAsync(3000); - - expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); - }); - - it('revokes a device that disappears from the port list', async () => { - const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); - vi.spyOn(SerialPort, 'list') - .mockResolvedValueOnce([port]) - .mockResolvedValueOnce([]); - const observer = createObserver(); - - await observer.start(); - await observer.discoverSerialDevices(); // manually trigger a discovery run - - expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledOnce(); - expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN001') }), - ); - }); - - it('announces a previously revoked device again when it reappears', async () => { - const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); - vi.spyOn(SerialPort, 'list') - .mockResolvedValueOnce([port]) // first run: present - .mockResolvedValueOnce([]) // second run: gone – revoked - .mockResolvedValueOnce([port]); // third run: back - const observer = createObserver(); - - - await observer.start(); - await observer.discoverSerialDevices(); // manually trigger a discovery run - await observer.discoverSerialDevices(); // manually trigger a discovery run - - expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledTimes(2); - }); - - it('handles a SerialPort.list error gracefully without throwing', async () => { - vi.spyOn(SerialPort, 'list').mockRejectedValue(new Error('USB stack error')); - const observer = createObserver(); - - await expect(observer.start()).resolves.not.toThrow(); - expect(mockLogger.error).toHaveBeenCalled(); - }); - - it('handles multiple valid ports in a single discovery run', async () => { - const port1 = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); - const port2 = makePortInfo({ path: '/dev/ttyUSB1', serialNumber: 'SN002', vendorId: '0403', productId: '6015' }); - vi.spyOn(SerialPort, 'list').mockResolvedValue([port1, port2]); - const observer = createObserver(); - - await observer.start(); - - expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledTimes(2); - expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN001') }), - ); - expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN002') }), - ); - }); - }); -}); From 33b470e70ccacfe1330de475915179df6e948c9f Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 11:02:15 +0200 Subject: [PATCH 04/17] Strip dead detection/acquire machinery from DeviceManager Now that SerialDeviceProvider and BleDeviceProvider both own their discovery and factory-trial loop directly, DeviceManager's deviceDetected event and its announce/acquire/release/claim/revoke arbitration queue have no remaining consumers - that machinery existed only to arbitrate between multiple independently-subscribed providers racing over a shared detected device, which no longer happens now that there's exactly one provider per transport. DeviceManager goes back to being just the connected-device registry plus refresh scheduling and connected/disconnected/refreshed/notification events. Also removes two now-dead leftovers exposed by this cleanup: - GenericDeviceProviderFactory, which had no remaining callers now that SlvCtrlPlus/Zc95/EStim2b/Airotic no longer go through DeviceProviderFactory/DeviceProviderManager (only Buttplug and Virtual do, via their own hand-written factories). - deviceProviderEvent.ts, an empty, unreferenced file. --- src/device/deviceManager.ts | 109 +---------- src/device/provider/deviceProviderEvent.ts | 0 .../provider/genericDeviceProviderFactory.ts | 21 -- tests/unit/device/deviceManager.spec.ts | 180 +----------------- 4 files changed, 10 insertions(+), 300 deletions(-) delete mode 100644 src/device/provider/deviceProviderEvent.ts delete mode 100644 src/device/provider/genericDeviceProviderFactory.ts diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 44082578..4791ffa1 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -5,41 +5,36 @@ 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'; - -export type DeviceInfo = { - type: string; - id: DeviceId; -}; export enum DeviceManagerEvent { deviceConnected = 'deviceConnected', deviceDisconnected = 'deviceDisconnected', deviceRefreshed = 'deviceRefreshed', - deviceDetected = 'deviceDetected', deviceNotification = 'deviceNotification', } -type AcquireResult = - | { successful: true } - | { successful: false, reason: string }; - type DeviceManagerEventMap = { [DeviceManagerEvent.deviceConnected]: [device: Device]; [DeviceManagerEvent.deviceDisconnected]: [device: Device]; [DeviceManagerEvent.deviceRefreshed]: [device: Device]; - [DeviceManagerEvent.deviceDetected]: [deviceInfo: DeviceInfo]; [DeviceManagerEvent.deviceNotification]: [device: Device, notification: DeviceNotification]; } +/** + * Registry of currently connected devices, plus the refresh scheduling and lifecycle events + * (connected/disconnected/refreshed/notification) consumed by sockets and automation scripts. + * + * Detection/arbitration between competing device providers is no longer this class's concern - + * each transport-level DeviceProvider (SerialDeviceProvider, BleDeviceProvider, ...) now owns its + * own discovery and decides for itself, in-process, which registered protocol factory (if any) + * gets to claim a newly discovered device. + */ export default class DeviceManager { private readonly eventEmitter: EventEmitter; private readonly logger: Logger; - private readonly detectedDeviceAcquireQueue: Map void }[]> = new Map(); - private readonly connectedDevices: Map>; public constructor(eventEmitter: EventEmitter, connectedDevices: Map, logger: Logger) { @@ -48,74 +43,6 @@ export default class DeviceManager this.connectedDevices = connectedDevices; } - public announceDetectedDevice(deviceInfo: DeviceInfo): void - { - if (this.detectedDeviceAcquireQueue.has(deviceInfo.id)) { - return; - } - - if (this.connectedDevices.has(deviceInfo.id)) { - this.logger.debug(`Device with id '${deviceInfo.id}' is already connected, not announcing it as detected`); - return; - } - - this.logger.info(`Detected new device with id ${deviceInfo.id}`); - - this.detectedDeviceAcquireQueue.set(deviceInfo.id, []); - - 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); - } - } - - public revokeDetectedDevice(deviceInfo: DeviceInfo): void - { - this.clearDetectedDeviceAcquireQueue(deviceInfo.id, `Device with id '${deviceInfo.id}' has disappeared`); - } - - public async acquireDetectedDevice(deviceId: DeviceId): Promise - { - return new Promise((resolve) => { - const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId); - - if (undefined === deviceQueue) { - resolve({ successful: false, reason: `Device with id '${deviceId}' is not available for claiming` }); - return; - } - - // Always add to queue first - deviceQueue.push({ resolve }); - - // If we're first in line, resolve immediately - if (deviceQueue.length === 1) { - resolve({ successful: true }); - } - }); - } - - public releaseDetectedDevice(deviceId: DeviceId): void - { - const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId); - - if (undefined === deviceQueue) { - return; - } - - // Release current claimant and hand off the claim to the next waiter - deviceQueue.shift(); - - if (deviceQueue.length === 0) { - this.detectedDeviceAcquireQueue.delete(deviceId); - return; - } - - deviceQueue[0]?.resolve({ successful: true }); - } - public addDevice( device: Device ): void @@ -131,11 +58,6 @@ export default class DeviceManager this.eventEmitter.emit(DeviceManagerEvent.deviceConnected, device); } - public claimDetectedDevice(deviceId: DeviceId): void - { - this.clearDetectedDeviceAcquireQueue(deviceId, `Device with id '${deviceId}' has been claimed by another provider`); - } - public getConnectedDevices(): Device[] { return Array.from(this.connectedDevices.values()); @@ -179,24 +101,11 @@ export default class DeviceManager } } - for (const [deviceId] of this.detectedDeviceAcquireQueue) { - this.clearDetectedDeviceAcquireQueue(deviceId, 'Device manager reset'); - } - if (undefined !== closeError) { throw closeError; } } - private clearDetectedDeviceAcquireQueue(deviceId: string, reason: string): void - { - for (const entry of this.detectedDeviceAcquireQueue.get(deviceId) ?? []) { - entry.resolve({ successful: false, reason }); - } - - this.detectedDeviceAcquireQueue.delete(deviceId); - } - private initDeviceRefresher(device: Device): void { this.logger.info(`Initializing refresher for device '${device.getDeviceName}' (id: ${device.getDeviceId})`); const deviceRefreshIntervalMs = device.getRefreshInterval; diff --git a/src/device/provider/deviceProviderEvent.ts b/src/device/provider/deviceProviderEvent.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/device/provider/genericDeviceProviderFactory.ts b/src/device/provider/genericDeviceProviderFactory.ts deleted file mode 100644 index abcfb9f6..00000000 --- a/src/device/provider/genericDeviceProviderFactory.ts +++ /dev/null @@ -1,21 +0,0 @@ -import DeviceProvider from './deviceProvider.js'; -import DeviceProviderFactory from './deviceProviderFactory.js'; - -type ConcreteCtor = new (...args: any[]) => T; - -export default class GenericDeviceProviderFactory< - DP extends DeviceProvider -> implements DeviceProviderFactory -{ - private readonly ctor: ConcreteCtor; - private readonly args: ConstructorParameters>; - - public constructor(ctor: ConcreteCtor, ...args: ConstructorParameters>) { - this.ctor = ctor; - this.args = args; - } - - public create(): DP { - return new this.ctor(...this.args); - } -} diff --git a/tests/unit/device/deviceManager.spec.ts b/tests/unit/device/deviceManager.spec.ts index 3e4c0e22..b35e68d6 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 } from "../../../src/device/deviceManager.js"; import {EventEmitter} from "events"; import Device from "../../../src/device/device.js"; import TestDevice from "./testDevice.js"; @@ -109,182 +109,4 @@ describe('deviceManager', () => { expect(manager.getConnectedDevice('unknown-uuid')).toBeNull(); }); }); - - describe('announceDetectedDevice', () => { - let mockedLogger: ReturnType>; - let mockedEventEmitter: ReturnType>; - const deviceId = DeviceId.create('device-1'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; - - beforeEach(() => { - mockedLogger = mock(); - mockedLogger.child.mockReturnValue(mockedLogger); - mockedEventEmitter = mock(); - }); - - it('emits deviceDetected event for a newly seen device', () => { - mockedEventEmitter.emit.mockReturnValue(true); - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); - - manager.announceDetectedDevice(deviceInfo); - - expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); - }); - - it('does not re-announce a device already in the acquire queue', () => { - mockedEventEmitter.emit.mockReturnValue(true); - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); - - manager.announceDetectedDevice(deviceInfo); - manager.announceDetectedDevice(deviceInfo); - - expect(mockedEventEmitter.emit).toHaveBeenCalledOnce(); - expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); - }); - - it('does not emit event when device is already connected', () => { - const connectedDevices = new Map([[deviceId, mock()]]); - const manager = new DeviceManager(mockedEventEmitter, connectedDevices, mockedLogger); - - manager.announceDetectedDevice(deviceInfo); - - expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); - }); - - it('removes device from queue when no listeners respond to deviceDetected', async () => { - mockedEventEmitter.emit.mockReturnValue(false); - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); - - manager.announceDetectedDevice(deviceInfo); - - const result = await manager.acquireDetectedDevice(deviceId); - expect(result.successful).toBe(false); - }); - }); - - describe('acquireDetectedDevice', () => { - let mockedLogger: ReturnType>; - let mockedEventEmitter: ReturnType>; - const deviceId = DeviceId.create('device-2'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; - - beforeEach(() => { - mockedLogger = mock(); - mockedLogger.child.mockReturnValue(mockedLogger); - mockedEventEmitter = mock(); - mockedEventEmitter.emit.mockReturnValue(true); - }); - - it('returns failure when device is not in the detect queue', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); - - const result = await manager.acquireDetectedDevice(deviceId); - - expect(result.successful).toBe(false); - }); - - it('resolves immediately with success for the first caller', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); - manager.announceDetectedDevice(deviceInfo); - - const result = await manager.acquireDetectedDevice(deviceId); - - expect(result).toStrictEqual({ successful: true }); - }); - - it('queues the second caller until the first releases', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); - manager.announceDetectedDevice(deviceInfo); - - await manager.acquireDetectedDevice(deviceId); - const secondCallerPromise = manager.acquireDetectedDevice(deviceId); - manager.releaseDetectedDevice(deviceId); - - const result = await secondCallerPromise; - expect(result).toStrictEqual({ successful: true }); - }); - }); - - describe('releaseDetectedDevice', () => { - let mockedLogger: ReturnType>; - let mockedEventEmitter: ReturnType>; - const deviceId = DeviceId.create('device-3'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; - - beforeEach(() => { - mockedLogger = mock(); - mockedLogger.child.mockReturnValue(mockedLogger); - mockedEventEmitter = mock(); - mockedEventEmitter.emit.mockReturnValue(true); - }); - - it('is a no-op when device is not in the acquire queue', () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), 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); - manager.announceDetectedDevice(deviceInfo); - await manager.acquireDetectedDevice(deviceId); - - manager.releaseDetectedDevice(deviceId); - - const result = await manager.acquireDetectedDevice(deviceId); - expect(result.successful).toBe(false); - }); - }); - - describe('revokeDetectedDevice', () => { - let mockedLogger: ReturnType>; - let mockedEventEmitter: ReturnType>; - const deviceId = DeviceId.create('device-4'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; - - beforeEach(() => { - mockedLogger = mock(); - mockedLogger.child.mockReturnValue(mockedLogger); - mockedEventEmitter = mock(); - mockedEventEmitter.emit.mockReturnValue(true); - }); - - it('resolves a pending second caller with failure', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); - manager.announceDetectedDevice(deviceInfo); - await manager.acquireDetectedDevice(deviceId); // first caller holds - const pendingPromise = manager.acquireDetectedDevice(deviceId); // second waits - - manager.revokeDetectedDevice(deviceInfo); - - const result = await pendingPromise; - expect(result.successful).toBe(false); - }); - }); - - describe('claimDetectedDevice', () => { - let mockedLogger: ReturnType>; - let mockedEventEmitter: ReturnType>; - const deviceId = DeviceId.create('device-5'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; - - beforeEach(() => { - mockedLogger = mock(); - mockedLogger.child.mockReturnValue(mockedLogger); - mockedEventEmitter = mock(); - mockedEventEmitter.emit.mockReturnValue(true); - }); - - it('resolves a pending caller with failure', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); - manager.announceDetectedDevice(deviceInfo); - await manager.acquireDetectedDevice(deviceId); // first caller holds - const pendingPromise = manager.acquireDetectedDevice(deviceId); // second waits - - manager.claimDetectedDevice(deviceId); - - const result = await pendingPromise; - expect(result.successful).toBe(false); - }); - }); }); From 5e22d8772010c3aca7f5642d37600b9e34a7da7c Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 11:05:14 +0200 Subject: [PATCH 05/17] Fix stale class name reference in comment --- src/device/transport/bleDeviceTransport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/device/transport/bleDeviceTransport.ts b/src/device/transport/bleDeviceTransport.ts index 042c8923..506779ef 100644 --- a/src/device/transport/bleDeviceTransport.ts +++ b/src/device/transport/bleDeviceTransport.ts @@ -60,7 +60,7 @@ export default class BleUartDeviceTransport implements DeviceBidirectionalTransp } const { characteristics } = await this.peripheral.discoverSomeServicesAndCharacteristicsAsync( - [/* AiroticDeviceProvider.UART_SERVICE_UUID */], + [/* AiroticDeviceFactory.UART_SERVICE_UUID */], [this.uartRxCharacteristicUuid, this.uartTxCharacteristicUuid], ); From 607fedc43e414e235ba29f046e510ad647f38f5b Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 11:07:32 +0200 Subject: [PATCH 06/17] Fix tests/tsconfig.json typecheck errors in new unit tests Object.defineProperty instead of direct assignment for Peripheral.state (readonly in @stoprocent/noble's types), and explicit mock() type params where TS otherwise infers unknown. --- .../unit/device/provider/bleDeviceProvider.spec.ts | 14 +++++++++----- .../device/provider/serialDeviceProvider.spec.ts | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/unit/device/provider/bleDeviceProvider.spec.ts b/tests/unit/device/provider/bleDeviceProvider.spec.ts index a364f56c..629ca230 100644 --- a/tests/unit/device/provider/bleDeviceProvider.spec.ts +++ b/tests/unit/device/provider/bleDeviceProvider.spec.ts @@ -36,10 +36,14 @@ describe('BleDeviceProvider', () => { const peripheral = mock(); Object.defineProperty(peripheral, 'rssi', { get: () => rssi, configurable: true }); Object.defineProperty(peripheral, 'id', { get: () => id, configurable: true }); - peripheral.state = 'disconnected'; + setPeripheralState(peripheral, 'disconnected'); return peripheral; } + function setPeripheralState(peripheral: Peripheral, state: Peripheral['state']): void { + Object.defineProperty(peripheral, 'state', { value: state, configurable: true }); + } + function createFactory(protocolName: string): ReturnType>> { const factory = mock>(); Object.defineProperty(factory, 'protocolName', { get: () => protocolName, configurable: true }); @@ -162,7 +166,7 @@ describe('BleDeviceProvider', () => { await provider.init(); const peripheral = createPeripheral(-50, 'device-1'); - peripheral.state = 'connected'; + setPeripheralState(peripheral, 'connected'); getNobleListener('discover')?.(peripheral); @@ -178,7 +182,7 @@ describe('BleDeviceProvider', () => { await provider.init(); const peripheral = createPeripheral(-50, 'device-1'); - peripheral.state = 'connecting'; + setPeripheralState(peripheral, 'connecting'); getNobleListener('discover')?.(peripheral); @@ -193,7 +197,7 @@ describe('BleDeviceProvider', () => { await provider.init(); const peripheral = createPeripheral(-50, 'device-1'); - peripheral.state = 'connected'; + setPeripheralState(peripheral, 'connected'); getNobleListener('discover')?.(peripheral); @@ -202,7 +206,7 @@ describe('BleDeviceProvider', () => { }); it('does not re-attempt a peripheral that is already connected', async () => { - mockDeviceManager.getConnectedDevice.mockReturnValue(mock()); + mockDeviceManager.getConnectedDevice.mockReturnValue(mock>()); const provider = createProvider(); const factory = createFactory('test'); provider.registerFactory(factory); diff --git a/tests/unit/device/provider/serialDeviceProvider.spec.ts b/tests/unit/device/provider/serialDeviceProvider.spec.ts index 24ed4365..e5dfb750 100644 --- a/tests/unit/device/provider/serialDeviceProvider.spec.ts +++ b/tests/unit/device/provider/serialDeviceProvider.spec.ts @@ -265,7 +265,7 @@ describe('SerialDeviceProvider', () => { }); it('does not re-attempt a port that is already connected', async () => { - mockDeviceManager.getConnectedDevice.mockReturnValue(mock()); + mockDeviceManager.getConnectedDevice.mockReturnValue(mock>()); const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); const provider = createProvider(); From d9b7d8a81d66cb1f13f294ce4157f3f7056820ed Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 12:07:28 +0200 Subject: [PATCH 07/17] Align SerialDeviceProvider.start() with DeviceProvider.init() SerialDeviceProvider had its own start() method instead of overriding the DeviceProvider base class's init(), unlike BleDeviceProvider which correctly overrides init(). Renaming to align naming and properly fulfil the base class's public lifecycle contract. --- src/app.ts | 2 +- src/device/provider/serialDeviceProvider.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index 511991c2..12c99974 100644 --- a/src/app.ts +++ b/src/app.ts @@ -145,7 +145,7 @@ const loadDeviceProviders = (container: Container): void => { .startProviders() .catch(e => logError(logger, `Loading device providers failed`, e)); - serialDeviceProvider.start().catch(e => logError(logger, `Initializing serial device provider failed`, e)); + serialDeviceProvider.init().catch(e => logError(logger, `Initializing serial device provider failed`, e)); bleDeviceProvider.init().catch(e => logError(logger, `Initializing BLE device provider failed`, e)); }; diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index f25bab21..eb971362 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -53,7 +53,7 @@ export default class SerialDeviceProvider extends DeviceProvider return this; } - public async start(): Promise { + public override async init(): Promise { await this.discoverSerialDevices(); this.onUsbEventRef = (): void => { From 62118046738c693c7cad1f6eefd0da54413ab635 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 12:16:17 +0200 Subject: [PATCH 08/17] Only persist a new KnownDevice after the device is successfully built resolveOrCreate() previously called settings.addKnownDevice() immediately when creating a new KnownDevice, before the caller had actually finished constructing the Device. If device construction failed afterwards (e.g. attribute fetch throws after identity resolution succeeds), a phantom KnownDevice entry was already persisted to settings.json even though no Device was ever actually connected. This was a regression introduced while extracting KnownDeviceResolver - the original, duplicated per-factory code always persisted at the very end, after the Device was fully constructed. Fixed by having resolveOrCreate() take a buildDevice callback: for a new identity, the KnownDevice is only persisted after buildDevice resolves successfully. This makes the correct ordering structural rather than relying on every caller remembering to persist at the right point. Updates SlvCtrlPlusDeviceFactory, AiroticDeviceFactory and ButtplugIoWebsocketDeviceProvider to pass their device-construction logic as the callback. Adds knownDeviceResolver.spec.ts covering the persist-on-success guarantee for both new and already-known devices. --- src/device/knownDeviceResolver.ts | 32 ++++-- .../protocol/airotic/airoticDeviceFactory.ts | 51 ++++----- .../buttplugIoWebsocketDeviceProvider.ts | 15 +-- .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 33 +++--- tests/unit/device/knownDeviceResolver.spec.ts | 106 ++++++++++++++++++ 5 files changed, 183 insertions(+), 54 deletions(-) create mode 100644 tests/unit/device/knownDeviceResolver.spec.ts diff --git a/src/device/knownDeviceResolver.ts b/src/device/knownDeviceResolver.ts index 0d766181..903c4929 100644 --- a/src/device/knownDeviceResolver.ts +++ b/src/device/knownDeviceResolver.ts @@ -24,20 +24,34 @@ export default class KnownDeviceResolver this.logger = logger.child({ name: KnownDeviceResolver.name }); } - public resolveOrCreate(deviceId: DeviceId, type: string, provider: string, name?: string): KnownDevice { - const knownDevice = this.settings.getKnownDeviceById(deviceId); - - if (undefined !== knownDevice) { - // Return already existing device if already known (previously detected serial number) - this.logger.debug(`Device is already known: ${knownDevice.id}`); - return knownDevice; + /** + * Resolves the KnownDevice identity for `deviceId` and hands it to `buildDevice` to actually + * construct the Device. A newly created (not previously known) KnownDevice is only persisted + * once `buildDevice` has *successfully* returned - if it throws, nothing is written to + * settings, so a failed connection attempt can never leak a phantom known device. + */ + public async resolveOrCreate( + deviceId: DeviceId, + type: string, + provider: string, + buildDevice: (knownDevice: KnownDevice) => Promise | D, + name?: string, + ): Promise { + const existingKnownDevice = this.settings.getKnownDeviceById(deviceId); + + if (undefined !== existingKnownDevice) { + // Already known (previously detected serial number) - nothing to persist here + this.logger.debug(`Device is already known: ${existingKnownDevice.id}`); + return buildDevice(existingKnownDevice); } - // Create a new device and persist it if not yet known const newKnownDevice = new KnownDevice(deviceId, name ?? this.nameGenerator.generateName(), type, provider); + const device = await buildDevice(newKnownDevice); + + // Only persist once the device has actually been built successfully this.settings.addKnownDevice(newKnownDevice); - return newKnownDevice; + return device; } } diff --git a/src/device/protocol/airotic/airoticDeviceFactory.ts b/src/device/protocol/airotic/airoticDeviceFactory.ts index 053de318..09d2d40e 100644 --- a/src/device/protocol/airotic/airoticDeviceFactory.ts +++ b/src/device/protocol/airotic/airoticDeviceFactory.ts @@ -53,35 +53,36 @@ export default class AiroticDeviceFactory implements BleProtocolFactory { + const advertisedColors = this.parseAdvertisedColors(peripheral.advertisement.manufacturerData); + + return new AiroticDevice( + knownDevice.id, + knownDevice.name, + AiroticDeviceFactory.protocolName, + peripheral, + transport, + messageResponseHandler, + new Date(), + true, + { + restColor: StrDeviceAttribute.create('restColor', 'Rest Color', DeviceAttributeModifier.readWrite, advertisedColors?.restColor), + breathInColor: StrDeviceAttribute.create('breathInColor', 'Breath In Color', DeviceAttributeModifier.readWrite, advertisedColors?.breathInColor), + resetColors: BoolDeviceAttribute.create('resetColors', 'Reset Colors', DeviceAttributeModifier.writeOnly), + reboot: BoolDeviceAttribute.create('reboot', 'Reboot bottle', DeviceAttributeModifier.writeOnly), + breathsPerMin: FloatDeviceAttribute.create('breathsPerMin', 'Breaths/min', DeviceAttributeModifier.readOnly, 'breaths/min'), + bpmTrend: StrDeviceAttribute.create('bpmTrend', 'BPM Trend', DeviceAttributeModifier.readOnly), + }, + {}, + new EventEmitter(), + this.logger, + ); }, - {}, - new EventEmitter(), - this.logger, + peripheral.advertisement.localName ?? `Airotic ${deviceId}`, ); } diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 72bdb2a7..3b2f5df8 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -11,7 +11,6 @@ import { logError } from '../../../util/error.js'; import { hasProperty } from '../../../util/objects.js'; import { DeviceId } from '../../deviceId.js'; import KnownDeviceResolver from '../../knownDeviceResolver.js'; -import KnownDevice from '../../../settings/knownDevice.js'; export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { public static readonly providerName = 'buttplugIoWebsocket'; @@ -58,7 +57,10 @@ 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', asyncHandler( + this.addButtplugIoDevice.bind(this), + (e: unknown) => logError(this.logger, `Error in deviceadded handler`, e) + )); this.buttplugClient.on('deviceremoved', asyncHandler( this.removeButtplugIoDevice.bind(this), (e: unknown) => logError(this.logger, `Error in deviceremoved handler`, e) @@ -126,13 +128,11 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { }, 30000); } - private addButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { + private async addButtplugIoDevice(buttplugDevice: ButtplugClientDevice): Promise { this.logger.info(`Device detected: ${buttplugDevice.name}`, buttplugDevice); try { - const knownDevice = this.resolveKnownDevice(buttplugDevice, ButtplugIoWebsocketDeviceProvider.providerName, this.useDeviceNameAsId); - - const device = this.buttplugIoDeviceFactory.create(buttplugDevice, knownDevice); + const device = await this.resolveAndCreateDevice(buttplugDevice, ButtplugIoWebsocketDeviceProvider.providerName, this.useDeviceNameAsId); this.connectedDevices.set(buttplugDevice.index, device); @@ -167,7 +167,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { this.logger.info(`Connected devices: ${this.connectedDevices.size}`); } - private resolveKnownDevice(buttplugDevice: ButtplugClientDevice, provider: string, useDeviceNameAsId: boolean): KnownDevice { + private resolveAndCreateDevice(buttplugDevice: ButtplugClientDevice, provider: string, useDeviceNameAsId: boolean): Promise { // 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 @@ -178,6 +178,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { deviceId, buttplugDevice.name, provider, + (knownDevice) => this.buttplugIoDeviceFactory.create(buttplugDevice, knownDevice), buttplugDevice.displayName ?? buttplugDevice.name, ); } diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts index ca22e886..9087f54a 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts @@ -118,22 +118,29 @@ export default class SlvCtrlPlusDeviceFactory implements SerialProtocolFactory { const deviceInfo = await this.getDeviceInfo(transport); const protocol = deviceInfo.protocol; - const knownDevice = this.knownDeviceResolver.resolveOrCreate(deviceId, deviceInfo.deviceType, provider); - const deviceAttributes = await this.getAttributes(transport, protocol); - return new GenericSlvCtrlPlusDevice( - deviceInfo.fwVersion, - knownDevice.id, - knownDevice.name, + return this.knownDeviceResolver.resolveOrCreate( + deviceId, deviceInfo.deviceType, provider, - this.dateFactory.now(), - protocol, - transport, - deviceInfo.protocolVersion, - deviceAttributes, - this.eventEmitterFactory.create(), - this.logger, + async (knownDevice) => { + const deviceAttributes = await this.getAttributes(transport, protocol); + + return new GenericSlvCtrlPlusDevice( + deviceInfo.fwVersion, + knownDevice.id, + knownDevice.name, + deviceInfo.deviceType, + provider, + this.dateFactory.now(), + protocol, + transport, + deviceInfo.protocolVersion, + deviceAttributes, + this.eventEmitterFactory.create(), + this.logger, + ); + }, ); } diff --git a/tests/unit/device/knownDeviceResolver.spec.ts b/tests/unit/device/knownDeviceResolver.spec.ts new file mode 100644 index 00000000..ffc53ec9 --- /dev/null +++ b/tests/unit/device/knownDeviceResolver.spec.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import KnownDeviceResolver from '../../../src/device/knownDeviceResolver.js'; +import Settings from '../../../src/settings/settings.js'; +import DeviceNameGenerator from '../../../src/device/deviceNameGenerator.js'; +import Logger from '../../../src/logging/Logger.js'; +import KnownDevice from '../../../src/settings/knownDevice.js'; +import { DeviceId } from '../../../src/device/deviceId.js'; + +describe('KnownDeviceResolver', () => { + let mockSettings: ReturnType>; + let mockNameGenerator: ReturnType>; + let mockLogger: ReturnType>; + let resolver: KnownDeviceResolver; + + beforeEach(() => { + mockSettings = mock(); + mockNameGenerator = mock(); + mockNameGenerator.generateName.mockReturnValue('Generated Name'); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + + resolver = new KnownDeviceResolver(mockSettings, mockNameGenerator, mockLogger); + }); + + it('does not persist a new KnownDevice when buildDevice throws', async () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const buildDevice = vi.fn().mockRejectedValue(new Error('handshake failed')); + + await expect( + resolver.resolveOrCreate(DeviceId.create('device-1'), 'testType', 'testProvider', buildDevice) + ).rejects.toThrow('handshake failed'); + + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('persists a new KnownDevice only after buildDevice succeeds', async () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const deviceId = DeviceId.create('device-1'); + + const buildDevice = vi.fn().mockImplementation((knownDevice: KnownDevice) => { + // addKnownDevice must not have been called yet at the point buildDevice runs + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + return `device-for-${knownDevice.id}`; + }); + + const result = await resolver.resolveOrCreate(deviceId, 'testType', 'testProvider', buildDevice); + + expect(result).toBe(`device-for-${deviceId}`); + expect(mockSettings.addKnownDevice).toHaveBeenCalledOnce(); + expect(mockSettings.addKnownDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: deviceId, type: 'testType', source: 'testProvider' }), + ); + }); + + it('uses the provided name over the generated one for a new device', async () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + await resolver.resolveOrCreate( + DeviceId.create('device-1'), 'testType', 'testProvider', () => 'built', 'Explicit Name' + ); + + expect(mockSettings.addKnownDevice).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Explicit Name' }), + ); + expect(mockNameGenerator.generateName).not.toHaveBeenCalled(); + }); + + it('falls back to a generated name when none is provided', async () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + await resolver.resolveOrCreate(DeviceId.create('device-1'), 'testType', 'testProvider', () => 'built'); + + expect(mockSettings.addKnownDevice).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Generated Name' }), + ); + }); + + it('reuses an already known device without persisting it again', async () => { + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Existing Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + const buildDevice = vi.fn().mockReturnValue('built-device'); + + const result = await resolver.resolveOrCreate(DeviceId.create('device-1'), 'testType', 'testProvider', buildDevice); + + expect(result).toBe('built-device'); + expect(buildDevice).toHaveBeenCalledWith(existingKnownDevice); + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('propagates a buildDevice failure for an already known device without touching settings', async () => { + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Existing Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + const buildDevice = vi.fn().mockRejectedValue(new Error('boom')); + + await expect( + resolver.resolveOrCreate(DeviceId.create('device-1'), 'testType', 'testProvider', buildDevice) + ).rejects.toThrow('boom'); + + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); +}); From 7ca6df18e0c2a1bf14f79d07da2b19feeb907d42 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 12:22:29 +0200 Subject: [PATCH 09/17] Revert "Only persist a new KnownDevice after the device is successfully built" This reverts commit 62118046738c693c7cad1f6eefd0da54413ab635. --- src/device/knownDeviceResolver.ts | 32 ++---- .../protocol/airotic/airoticDeviceFactory.ts | 51 +++++---- .../buttplugIoWebsocketDeviceProvider.ts | 15 ++- .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 33 +++--- tests/unit/device/knownDeviceResolver.spec.ts | 106 ------------------ 5 files changed, 54 insertions(+), 183 deletions(-) delete mode 100644 tests/unit/device/knownDeviceResolver.spec.ts diff --git a/src/device/knownDeviceResolver.ts b/src/device/knownDeviceResolver.ts index 903c4929..0d766181 100644 --- a/src/device/knownDeviceResolver.ts +++ b/src/device/knownDeviceResolver.ts @@ -24,34 +24,20 @@ export default class KnownDeviceResolver this.logger = logger.child({ name: KnownDeviceResolver.name }); } - /** - * Resolves the KnownDevice identity for `deviceId` and hands it to `buildDevice` to actually - * construct the Device. A newly created (not previously known) KnownDevice is only persisted - * once `buildDevice` has *successfully* returned - if it throws, nothing is written to - * settings, so a failed connection attempt can never leak a phantom known device. - */ - public async resolveOrCreate( - deviceId: DeviceId, - type: string, - provider: string, - buildDevice: (knownDevice: KnownDevice) => Promise | D, - name?: string, - ): Promise { - const existingKnownDevice = this.settings.getKnownDeviceById(deviceId); - - if (undefined !== existingKnownDevice) { - // Already known (previously detected serial number) - nothing to persist here - this.logger.debug(`Device is already known: ${existingKnownDevice.id}`); - return buildDevice(existingKnownDevice); + public resolveOrCreate(deviceId: DeviceId, type: string, provider: string, name?: string): KnownDevice { + const knownDevice = this.settings.getKnownDeviceById(deviceId); + + if (undefined !== knownDevice) { + // Return already existing device if already known (previously detected serial number) + this.logger.debug(`Device is already known: ${knownDevice.id}`); + return knownDevice; } + // Create a new device and persist it if not yet known const newKnownDevice = new KnownDevice(deviceId, name ?? this.nameGenerator.generateName(), type, provider); - const device = await buildDevice(newKnownDevice); - - // Only persist once the device has actually been built successfully this.settings.addKnownDevice(newKnownDevice); - return device; + return newKnownDevice; } } diff --git a/src/device/protocol/airotic/airoticDeviceFactory.ts b/src/device/protocol/airotic/airoticDeviceFactory.ts index 09d2d40e..053de318 100644 --- a/src/device/protocol/airotic/airoticDeviceFactory.ts +++ b/src/device/protocol/airotic/airoticDeviceFactory.ts @@ -53,37 +53,36 @@ export default class AiroticDeviceFactory implements BleProtocolFactory { - const advertisedColors = this.parseAdvertisedColors(peripheral.advertisement.manufacturerData); - - return new AiroticDevice( - knownDevice.id, - knownDevice.name, - AiroticDeviceFactory.protocolName, - peripheral, - transport, - messageResponseHandler, - new Date(), - true, - { - restColor: StrDeviceAttribute.create('restColor', 'Rest Color', DeviceAttributeModifier.readWrite, advertisedColors?.restColor), - breathInColor: StrDeviceAttribute.create('breathInColor', 'Breath In Color', DeviceAttributeModifier.readWrite, advertisedColors?.breathInColor), - resetColors: BoolDeviceAttribute.create('resetColors', 'Reset Colors', DeviceAttributeModifier.writeOnly), - reboot: BoolDeviceAttribute.create('reboot', 'Reboot bottle', DeviceAttributeModifier.writeOnly), - breathsPerMin: FloatDeviceAttribute.create('breathsPerMin', 'Breaths/min', DeviceAttributeModifier.readOnly, 'breaths/min'), - bpmTrend: StrDeviceAttribute.create('bpmTrend', 'BPM Trend', DeviceAttributeModifier.readOnly), - }, - {}, - new EventEmitter(), - this.logger, - ); - }, peripheral.advertisement.localName ?? `Airotic ${deviceId}`, ); + + const advertisedColors = this.parseAdvertisedColors(peripheral.advertisement.manufacturerData); + + return new AiroticDevice( + knownDevice.id, + knownDevice.name, + AiroticDeviceFactory.protocolName, + peripheral, + transport, + messageResponseHandler, + new Date(), + true, + { + restColor: StrDeviceAttribute.create('restColor', 'Rest Color', DeviceAttributeModifier.readWrite, advertisedColors?.restColor), + breathInColor: StrDeviceAttribute.create('breathInColor', 'Breath In Color', DeviceAttributeModifier.readWrite, advertisedColors?.breathInColor), + resetColors: BoolDeviceAttribute.create('resetColors', 'Reset Colors', DeviceAttributeModifier.writeOnly), + reboot: BoolDeviceAttribute.create('reboot', 'Reboot bottle', DeviceAttributeModifier.writeOnly), + breathsPerMin: FloatDeviceAttribute.create('breathsPerMin', 'Breaths/min', DeviceAttributeModifier.readOnly, 'breaths/min'), + bpmTrend: StrDeviceAttribute.create('bpmTrend', 'BPM Trend', DeviceAttributeModifier.readOnly), + }, + {}, + new EventEmitter(), + this.logger, + ); } /** diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 3b2f5df8..72bdb2a7 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -11,6 +11,7 @@ import { logError } from '../../../util/error.js'; import { hasProperty } from '../../../util/objects.js'; import { DeviceId } from '../../deviceId.js'; import KnownDeviceResolver from '../../knownDeviceResolver.js'; +import KnownDevice from '../../../settings/knownDevice.js'; export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { public static readonly providerName = 'buttplugIoWebsocket'; @@ -57,10 +58,7 @@ 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', asyncHandler( - this.addButtplugIoDevice.bind(this), - (e: unknown) => logError(this.logger, `Error in deviceadded handler`, e) - )); + this.buttplugClient.on('deviceadded', this.addButtplugIoDevice.bind(this)); this.buttplugClient.on('deviceremoved', asyncHandler( this.removeButtplugIoDevice.bind(this), (e: unknown) => logError(this.logger, `Error in deviceremoved handler`, e) @@ -128,11 +126,13 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { }, 30000); } - private async addButtplugIoDevice(buttplugDevice: ButtplugClientDevice): Promise { + private addButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { this.logger.info(`Device detected: ${buttplugDevice.name}`, buttplugDevice); try { - const device = await this.resolveAndCreateDevice(buttplugDevice, ButtplugIoWebsocketDeviceProvider.providerName, this.useDeviceNameAsId); + const knownDevice = this.resolveKnownDevice(buttplugDevice, ButtplugIoWebsocketDeviceProvider.providerName, this.useDeviceNameAsId); + + const device = this.buttplugIoDeviceFactory.create(buttplugDevice, knownDevice); this.connectedDevices.set(buttplugDevice.index, device); @@ -167,7 +167,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { this.logger.info(`Connected devices: ${this.connectedDevices.size}`); } - private resolveAndCreateDevice(buttplugDevice: ButtplugClientDevice, provider: string, useDeviceNameAsId: boolean): Promise { + private resolveKnownDevice(buttplugDevice: ButtplugClientDevice, provider: string, useDeviceNameAsId: boolean): KnownDevice { // 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 @@ -178,7 +178,6 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { deviceId, buttplugDevice.name, provider, - (knownDevice) => this.buttplugIoDeviceFactory.create(buttplugDevice, knownDevice), buttplugDevice.displayName ?? buttplugDevice.name, ); } diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts index 9087f54a..ca22e886 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts @@ -118,29 +118,22 @@ export default class SlvCtrlPlusDeviceFactory implements SerialProtocolFactory { const deviceInfo = await this.getDeviceInfo(transport); const protocol = deviceInfo.protocol; + const knownDevice = this.knownDeviceResolver.resolveOrCreate(deviceId, deviceInfo.deviceType, provider); + const deviceAttributes = await this.getAttributes(transport, protocol); - return this.knownDeviceResolver.resolveOrCreate( - deviceId, + return new GenericSlvCtrlPlusDevice( + deviceInfo.fwVersion, + knownDevice.id, + knownDevice.name, deviceInfo.deviceType, provider, - async (knownDevice) => { - const deviceAttributes = await this.getAttributes(transport, protocol); - - return new GenericSlvCtrlPlusDevice( - deviceInfo.fwVersion, - knownDevice.id, - knownDevice.name, - deviceInfo.deviceType, - provider, - this.dateFactory.now(), - protocol, - transport, - deviceInfo.protocolVersion, - deviceAttributes, - this.eventEmitterFactory.create(), - this.logger, - ); - }, + this.dateFactory.now(), + protocol, + transport, + deviceInfo.protocolVersion, + deviceAttributes, + this.eventEmitterFactory.create(), + this.logger, ); } diff --git a/tests/unit/device/knownDeviceResolver.spec.ts b/tests/unit/device/knownDeviceResolver.spec.ts deleted file mode 100644 index ffc53ec9..00000000 --- a/tests/unit/device/knownDeviceResolver.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import KnownDeviceResolver from '../../../src/device/knownDeviceResolver.js'; -import Settings from '../../../src/settings/settings.js'; -import DeviceNameGenerator from '../../../src/device/deviceNameGenerator.js'; -import Logger from '../../../src/logging/Logger.js'; -import KnownDevice from '../../../src/settings/knownDevice.js'; -import { DeviceId } from '../../../src/device/deviceId.js'; - -describe('KnownDeviceResolver', () => { - let mockSettings: ReturnType>; - let mockNameGenerator: ReturnType>; - let mockLogger: ReturnType>; - let resolver: KnownDeviceResolver; - - beforeEach(() => { - mockSettings = mock(); - mockNameGenerator = mock(); - mockNameGenerator.generateName.mockReturnValue('Generated Name'); - mockLogger = mock(); - mockLogger.child.mockReturnValue(mockLogger); - - resolver = new KnownDeviceResolver(mockSettings, mockNameGenerator, mockLogger); - }); - - it('does not persist a new KnownDevice when buildDevice throws', async () => { - mockSettings.getKnownDeviceById.mockReturnValue(undefined); - - const buildDevice = vi.fn().mockRejectedValue(new Error('handshake failed')); - - await expect( - resolver.resolveOrCreate(DeviceId.create('device-1'), 'testType', 'testProvider', buildDevice) - ).rejects.toThrow('handshake failed'); - - expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); - }); - - it('persists a new KnownDevice only after buildDevice succeeds', async () => { - mockSettings.getKnownDeviceById.mockReturnValue(undefined); - - const deviceId = DeviceId.create('device-1'); - - const buildDevice = vi.fn().mockImplementation((knownDevice: KnownDevice) => { - // addKnownDevice must not have been called yet at the point buildDevice runs - expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); - return `device-for-${knownDevice.id}`; - }); - - const result = await resolver.resolveOrCreate(deviceId, 'testType', 'testProvider', buildDevice); - - expect(result).toBe(`device-for-${deviceId}`); - expect(mockSettings.addKnownDevice).toHaveBeenCalledOnce(); - expect(mockSettings.addKnownDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: deviceId, type: 'testType', source: 'testProvider' }), - ); - }); - - it('uses the provided name over the generated one for a new device', async () => { - mockSettings.getKnownDeviceById.mockReturnValue(undefined); - - await resolver.resolveOrCreate( - DeviceId.create('device-1'), 'testType', 'testProvider', () => 'built', 'Explicit Name' - ); - - expect(mockSettings.addKnownDevice).toHaveBeenCalledWith( - expect.objectContaining({ name: 'Explicit Name' }), - ); - expect(mockNameGenerator.generateName).not.toHaveBeenCalled(); - }); - - it('falls back to a generated name when none is provided', async () => { - mockSettings.getKnownDeviceById.mockReturnValue(undefined); - - await resolver.resolveOrCreate(DeviceId.create('device-1'), 'testType', 'testProvider', () => 'built'); - - expect(mockSettings.addKnownDevice).toHaveBeenCalledWith( - expect.objectContaining({ name: 'Generated Name' }), - ); - }); - - it('reuses an already known device without persisting it again', async () => { - const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Existing Name', 'testType', 'testProvider'); - mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); - - const buildDevice = vi.fn().mockReturnValue('built-device'); - - const result = await resolver.resolveOrCreate(DeviceId.create('device-1'), 'testType', 'testProvider', buildDevice); - - expect(result).toBe('built-device'); - expect(buildDevice).toHaveBeenCalledWith(existingKnownDevice); - expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); - }); - - it('propagates a buildDevice failure for an already known device without touching settings', async () => { - const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Existing Name', 'testType', 'testProvider'); - mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); - - const buildDevice = vi.fn().mockRejectedValue(new Error('boom')); - - await expect( - resolver.resolveOrCreate(DeviceId.create('device-1'), 'testType', 'testProvider', buildDevice) - ).rejects.toThrow('boom'); - - expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); - }); -}); From 42a47c39ea1f72fa22ad7a3b55d77f7be75bccaa Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 12:27:00 +0200 Subject: [PATCH 10/17] Rename KnownDeviceResolver to KnownDeviceRegistry, split resolve/persist The previous callback-based fix (resolveOrCreate(..., buildDevice)) made the resolver responsible for orchestrating device construction, which isn't its job - it conflated 'resolve an identity' with 'decide when construction succeeded'. Splits the API into two plain methods instead: - resolve(deviceId, type, provider, name?): looks up an existing identity or builds a new, not-yet-persisted one. Never has side effects. - persist(knownDevice): explicitly saves it. Safe to call unconditionally after success, even for an already-known device (harmless no-op re-registration). Callers go back to a plain linear flow - resolve, do their protocol-specific work, construct the Device, then persist - which is exactly what the original (pre-refactor, duplicated) per-factory code did. The class is renamed to KnownDeviceRegistry since 'Resolver' undersold what it actually does (look up AND register), and now that persistence is an explicit, separate call, there's no ambiguity about it 'resolving' the whole thing on its own. Callers stay in control of their own control flow instead of the registry owning it via an injected callback. --- src/device/knownDeviceRegistry.ts | 53 +++++++++++++ src/device/knownDeviceResolver.ts | 43 ----------- .../protocol/airotic/airoticDeviceFactory.ts | 16 ++-- .../buttplugIoWebsocketDeviceProvider.ts | 12 +-- ...uttplugIoWebsocketDeviceProviderFactory.ts | 10 +-- .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 16 ++-- src/serviceMap.ts | 4 +- src/serviceProvider/deviceServiceProvider.ts | 10 +-- tests/unit/device/knownDeviceRegistry.spec.ts | 75 +++++++++++++++++++ 9 files changed, 167 insertions(+), 72 deletions(-) create mode 100644 src/device/knownDeviceRegistry.ts delete mode 100644 src/device/knownDeviceResolver.ts create mode 100644 tests/unit/device/knownDeviceRegistry.spec.ts diff --git a/src/device/knownDeviceRegistry.ts b/src/device/knownDeviceRegistry.ts new file mode 100644 index 00000000..2970c7bb --- /dev/null +++ b/src/device/knownDeviceRegistry.ts @@ -0,0 +1,53 @@ +import Settings from '../settings/settings.js'; +import KnownDevice from '../settings/knownDevice.js'; +import DeviceNameGenerator from './deviceNameGenerator.js'; +import Logger from '../logging/Logger.js'; +import { DeviceId } from './deviceId.js'; + +/** + * Looks up and registers the persisted `KnownDevice` identity for a newly detected raw device + * (serial port, BLE peripheral, buttplug.io device, ...). + * + * Centralizes identity lookup/creation logic that used to be duplicated across several device + * providers/factories. Deliberately has no opinion on *when* a newly created identity should be + * persisted - `resolve()` never has side effects, so callers stay in control of only calling + * `persist()` once they've actually finished building the Device successfully. + */ +export default class KnownDeviceRegistry +{ + private readonly settings: Settings; + + private readonly nameGenerator: DeviceNameGenerator; + + private readonly logger: Logger; + + public constructor(settings: Settings, nameGenerator: DeviceNameGenerator, logger: Logger) { + this.settings = settings; + this.nameGenerator = nameGenerator; + this.logger = logger.child({ name: KnownDeviceRegistry.name }); + } + + /** + * Looks up the already-known identity for `deviceId`, or builds a new (not yet persisted) + * one if none exists. + */ + public resolve(deviceId: DeviceId, type: string, provider: string, name?: string): KnownDevice { + const knownDevice = this.settings.getKnownDeviceById(deviceId); + + if (undefined !== knownDevice) { + // Already known (previously detected serial number) + this.logger.debug(`Device is already known: ${knownDevice.id}`); + return knownDevice; + } + + return new KnownDevice(deviceId, name ?? this.nameGenerator.generateName(), type, provider); + } + + /** + * Persists a resolved identity. Safe to call unconditionally after successfully building a + * Device, even for an already-known identity (a harmless no-op re-registration). + */ + public persist(knownDevice: KnownDevice): void { + this.settings.addKnownDevice(knownDevice); + } +} diff --git a/src/device/knownDeviceResolver.ts b/src/device/knownDeviceResolver.ts deleted file mode 100644 index 0d766181..00000000 --- a/src/device/knownDeviceResolver.ts +++ /dev/null @@ -1,43 +0,0 @@ -import Settings from '../settings/settings.js'; -import KnownDevice from '../settings/knownDevice.js'; -import DeviceNameGenerator from './deviceNameGenerator.js'; -import Logger from '../logging/Logger.js'; -import { DeviceId } from './deviceId.js'; - -/** - * Resolves the persisted `KnownDevice` identity for a newly detected raw device (serial port, - * BLE peripheral, buttplug.io device, ...), creating and persisting a new entry if none exists yet. - * - * Centralizes logic that used to be duplicated across several device providers/factories. - */ -export default class KnownDeviceResolver -{ - private readonly settings: Settings; - - private readonly nameGenerator: DeviceNameGenerator; - - private readonly logger: Logger; - - public constructor(settings: Settings, nameGenerator: DeviceNameGenerator, logger: Logger) { - this.settings = settings; - this.nameGenerator = nameGenerator; - this.logger = logger.child({ name: KnownDeviceResolver.name }); - } - - public resolveOrCreate(deviceId: DeviceId, type: string, provider: string, name?: string): KnownDevice { - const knownDevice = this.settings.getKnownDeviceById(deviceId); - - if (undefined !== knownDevice) { - // Return already existing device if already known (previously detected serial number) - this.logger.debug(`Device is already known: ${knownDevice.id}`); - return knownDevice; - } - - // Create a new device and persist it if not yet known - const newKnownDevice = new KnownDevice(deviceId, name ?? this.nameGenerator.generateName(), type, provider); - - this.settings.addKnownDevice(newKnownDevice); - - return newKnownDevice; - } -} diff --git a/src/device/protocol/airotic/airoticDeviceFactory.ts b/src/device/protocol/airotic/airoticDeviceFactory.ts index 053de318..f91d145a 100644 --- a/src/device/protocol/airotic/airoticDeviceFactory.ts +++ b/src/device/protocol/airotic/airoticDeviceFactory.ts @@ -13,7 +13,7 @@ import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import FloatDeviceAttribute from '../../attribute/floatDeviceAttribute.js'; import BleProtocolFactory from '../../provider/bleProtocolFactory.js'; import { hsvByteToRgb } from '../../../util/color.js'; -import KnownDeviceResolver from '../../knownDeviceResolver.js'; +import KnownDeviceRegistry from '../../knownDeviceRegistry.js'; import { DeviceId } from '../../deviceId.js'; export default class AiroticDeviceFactory implements BleProtocolFactory @@ -25,12 +25,12 @@ export default class AiroticDeviceFactory implements BleProtocolFactory { const deviceInfo = await this.getDeviceInfo(transport); const protocol = deviceInfo.protocol; - const knownDevice = this.knownDeviceResolver.resolveOrCreate(deviceId, deviceInfo.deviceType, provider); + const knownDevice = this.knownDeviceRegistry.resolve(deviceId, deviceInfo.deviceType, provider); const deviceAttributes = await this.getAttributes(transport, protocol); - return new GenericSlvCtrlPlusDevice( + const device = new GenericSlvCtrlPlusDevice( deviceInfo.fwVersion, knownDevice.id, knownDevice.name, @@ -135,6 +135,10 @@ export default class SlvCtrlPlusDeviceFactory implements SerialProtocolFactory diff --git a/src/serviceMap.ts b/src/serviceMap.ts index 2129c450..eea747ed 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -47,7 +47,7 @@ import ButtplugIoWebsocketDeviceProvider from './device/protocol/buttplugIo/butt import AiroticDeviceFactory from './device/protocol/airotic/airoticDeviceFactory.js'; import BleDeviceProvider from './device/provider/bleDeviceProvider.js'; import SerialDeviceProvider from './device/provider/serialDeviceProvider.js'; -import KnownDeviceResolver from './device/knownDeviceResolver.js'; +import KnownDeviceRegistry from './device/knownDeviceRegistry.js'; type ServiceMap = { @@ -71,7 +71,7 @@ type ServiceMap = { 'device.virtual.provider': VirtualDeviceProvider, 'device.virtual.factory': VirtualDeviceFactory, 'device.uniqueNameGenerator': DeviceNameGenerator, - 'device.knownDeviceResolver': KnownDeviceResolver, + 'device.knownDeviceRegistry': KnownDeviceRegistry, 'device.updater': DeviceUpdaterInterface, 'device.provider.serial': SerialDeviceProvider, 'device.provider.ble': BleDeviceProvider, diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index bc2773fe..3608c803 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -36,7 +36,7 @@ import AiroticDeviceFactory from '../device/protocol/airotic/airoticDeviceFactor import SerialDeviceProvider from '../device/provider/serialDeviceProvider.js'; import DeviceProviderFactory from '../device/provider/deviceProviderFactory.js'; import { DeviceId } from '../device/deviceId.js'; -import KnownDeviceResolver from '../device/knownDeviceResolver.js'; +import KnownDeviceRegistry from '../device/knownDeviceRegistry.js'; export default class DeviceServiceProvider implements ServiceProvider { public register(container: Pimple): void { @@ -50,7 +50,7 @@ export default class DeviceServiceProvider implements ServiceProvider new ButtplugIoWebsocketDeviceProviderFactory( container.get('device.manager'), container.get('factory.eventEmitter').create(), - container.get('device.knownDeviceResolver'), + container.get('device.knownDeviceRegistry'), container.get('device.serial.factory.buttplugIo'), container.get('logger.default'), ) @@ -75,7 +75,7 @@ export default class DeviceServiceProvider implements ServiceProvider new KnownDeviceResolver( + container.set('device.knownDeviceRegistry', () => new KnownDeviceRegistry( container.get('settings'), container.get('device.uniqueNameGenerator'), container.get('logger.default'), @@ -84,7 +84,7 @@ export default class DeviceServiceProvider implements ServiceProvider new SlvCtrlPlusDeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), - container.get('device.knownDeviceResolver'), + container.get('device.knownDeviceRegistry'), container.get('device.serial.transport.factory'), container.get('logger.default'), )); @@ -114,7 +114,7 @@ export default class DeviceServiceProvider implements ServiceProvider new AiroticDeviceFactory( - container.get('device.knownDeviceResolver'), + container.get('device.knownDeviceRegistry'), container.get('logger.default'), )); diff --git a/tests/unit/device/knownDeviceRegistry.spec.ts b/tests/unit/device/knownDeviceRegistry.spec.ts new file mode 100644 index 00000000..8eab3366 --- /dev/null +++ b/tests/unit/device/knownDeviceRegistry.spec.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import KnownDeviceRegistry from '../../../src/device/knownDeviceRegistry.js'; +import Settings from '../../../src/settings/settings.js'; +import DeviceNameGenerator from '../../../src/device/deviceNameGenerator.js'; +import Logger from '../../../src/logging/Logger.js'; +import KnownDevice from '../../../src/settings/knownDevice.js'; +import { DeviceId } from '../../../src/device/deviceId.js'; + +describe('KnownDeviceRegistry', () => { + let mockSettings: ReturnType>; + let mockNameGenerator: ReturnType>; + let mockLogger: ReturnType>; + let registry: KnownDeviceRegistry; + + beforeEach(() => { + mockSettings = mock(); + mockNameGenerator = mock(); + mockNameGenerator.generateName.mockReturnValue('Generated Name'); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + + registry = new KnownDeviceRegistry(mockSettings, mockNameGenerator, mockLogger); + }); + + describe('resolve', () => { + it('returns the already known device without persisting anything', () => { + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Existing Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + const result = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); + + expect(result).toBe(existingKnownDevice); + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('builds a new, not-yet-persisted KnownDevice when none exists', () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const deviceId = DeviceId.create('device-1'); + const result = registry.resolve(deviceId, 'testType', 'testProvider'); + + expect(result).toMatchObject({ id: deviceId, type: 'testType', source: 'testProvider' }); + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('uses the provided name over the generated one for a new device', () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const result = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider', 'Explicit Name'); + + expect(result.name).toBe('Explicit Name'); + expect(mockNameGenerator.generateName).not.toHaveBeenCalled(); + }); + + it('falls back to a generated name when none is provided', () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const result = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); + + expect(result.name).toBe('Generated Name'); + }); + }); + + describe('persist', () => { + it('delegates to settings.addKnownDevice', () => { + const knownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + + registry.persist(knownDevice); + + expect(mockSettings.addKnownDevice).toHaveBeenCalledOnce(); + expect(mockSettings.addKnownDevice).toHaveBeenCalledWith(knownDevice); + }); + }); +}); From 98e1af3c31dec816157a7f4353f1f92e142a1b89 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 21:39:51 +0200 Subject: [PATCH 11/17] Use KnownDeviceRegistry for EStim2b device identity/name Estim2bDeviceFactory injected Settings and DeviceNameGenerator directly but never actually persisted a KnownDevice - it just called nameGenerator.generateName() unconditionally on every connect, giving each reconnect a brand new random name instead of a stable one. Unlike Zc95DeviceFactory, which deliberately skips known-device persistence due to a documented upstream firmware issue with unstable USB serial numbers (CrashOverride85/zc95#151), there's no such reason here - this looks like it was just copy-pasted scaffolding that never got wired up. Fixed to resolve/ persist through KnownDeviceRegistry like SlvCtrlPlusDeviceFactory and AiroticDeviceFactory already do, so EStim2b devices keep a stable name across reconnects. --- .../protocol/estim2b/estim2bDeviceFactory.ts | 24 ++++++++++--------- src/serviceProvider/deviceServiceProvider.ts | 3 +-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/device/protocol/estim2b/estim2bDeviceFactory.ts b/src/device/protocol/estim2b/estim2bDeviceFactory.ts index d46e213e..00d23da5 100644 --- a/src/device/protocol/estim2b/estim2bDeviceFactory.ts +++ b/src/device/protocol/estim2b/estim2bDeviceFactory.ts @@ -1,8 +1,6 @@ import { ReadlineParser } from 'serialport'; import { SerialPortStream } from '@serialport/stream'; import { BindingInterface } from '@serialport/bindings-interface'; -import Settings from '../../../settings/settings.js'; -import DeviceNameGenerator from '../../deviceNameGenerator.js'; import DateFactory from '../../../factory/dateFactory.js'; import Logger from '../../../logging/Logger.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; @@ -21,18 +19,19 @@ import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import SerialProtocolFactory, { SerialDeviceInfo, SerialDeviceProviderPortOpenOptions } from '../../provider/serialProtocolFactory.js'; +import KnownDeviceRegistry from '../../knownDeviceRegistry.js'; export default class Estim2bDeviceFactory implements SerialProtocolFactory { public static readonly protocolName = 'estim2bSerial'; + private static readonly deviceType = 'estim2b'; + public readonly protocolName = Estim2bDeviceFactory.protocolName; private readonly dateFactory: DateFactory; - private readonly settings: Settings; - - private readonly nameGenerator: DeviceNameGenerator; + private readonly knownDeviceRegistry: KnownDeviceRegistry; private readonly transportFactory: SerialDeviceTransportFactory; @@ -43,16 +42,14 @@ export default class Estim2bDeviceFactory implements SerialProtocolFactory { + const knownDevice = this.knownDeviceRegistry.resolve(deviceId, Estim2bDeviceFactory.deviceType, provider); const attributes = this.getAttributes(initialStatus); - return new Estim2bDevice( + const device = new Estim2bDevice( deviceId, - this.nameGenerator.generateName(), + knownDevice.name, provider, this.dateFactory.now(), true, @@ -110,6 +108,10 @@ export default class Estim2bDeviceFactory implements SerialProtocolFactory new Estim2bDeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), - container.get('settings'), - container.get('device.uniqueNameGenerator'), + container.get('device.knownDeviceRegistry'), container.get('device.serial.transport.factory'), container.get('logger.default'), )); From a7ffefce57eda7867fc926fe1cf63172882366ae Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 5 Jul 2026 21:45:33 +0200 Subject: [PATCH 12/17] Make create() a pure builder, keep registry calls only in tryConnect() ButtplugIoDeviceFactory.create(buttplugDevice, knownDevice) already had the right shape: takes an already-resolved KnownDevice as a parameter and has no dependency on KnownDeviceRegistry at all - identity resolution/persistence is the calling provider's job, not the factory construction method's. SlvCtrlPlusDeviceFactory and Estim2bDeviceFactory were inconsistent with this: their public create() methods reached into KnownDeviceRegistry themselves (resolve at the top, persist at the bottom), mixing 'orchestrate the connection attempt' with 'construct the device object' in one method. Restructured both (and AiroticDeviceFactory, extracting a matching create() for symmetry) so that: - tryConnect() is the only place that touches KnownDeviceRegistry - it resolves the identity once the protocol-specific type is known (which, for SlvCtrlPlus, only happens after the 'introduce' handshake completes - hence resolve() couldn't move out to the generic SerialDeviceProvider, which has no protocol-specific knowledge), then persists after create() returns successfully. - create() becomes a pure, synchronous builder that takes the already- resolved KnownDevice as a parameter and has no registry dependency. No behavior change - same resolve-before/persist-after-success guarantee, just cleaner responsibility split matching the Buttplug.io factory/provider pair that already did this correctly. --- .../protocol/airotic/airoticDeviceFactory.ts | 20 +++++++++--- .../protocol/estim2b/estim2bDeviceFactory.ts | 31 ++++++++----------- .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 31 ++++++++++--------- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/device/protocol/airotic/airoticDeviceFactory.ts b/src/device/protocol/airotic/airoticDeviceFactory.ts index f91d145a..84e5f2d3 100644 --- a/src/device/protocol/airotic/airoticDeviceFactory.ts +++ b/src/device/protocol/airotic/airoticDeviceFactory.ts @@ -15,6 +15,7 @@ import BleProtocolFactory from '../../provider/bleProtocolFactory.js'; import { hsvByteToRgb } from '../../../util/color.js'; import KnownDeviceRegistry from '../../knownDeviceRegistry.js'; import { DeviceId } from '../../deviceId.js'; +import KnownDevice from '../../../settings/knownDevice.js'; export default class AiroticDeviceFactory implements BleProtocolFactory { @@ -60,9 +61,22 @@ export default class AiroticDeviceFactory implements BleProtocolFactory, + ): AiroticDevice { const advertisedColors = this.parseAdvertisedColors(peripheral.advertisement.manufacturerData); - const device = new AiroticDevice( + return new AiroticDevice( knownDevice.id, knownDevice.name, AiroticDeviceFactory.protocolName, @@ -83,10 +97,6 @@ export default class AiroticDeviceFactory implements BleProtocolFactory { @@ -76,27 +76,26 @@ export default class Estim2bDeviceFactory implements SerialProtocolFactory { - const knownDevice = this.knownDeviceRegistry.resolve(deviceId, Estim2bDeviceFactory.deviceType, provider); + ): Estim2bDevice { const attributes = this.getAttributes(initialStatus); - const device = new Estim2bDevice( - deviceId, + return new Estim2bDevice( + knownDevice.id, knownDevice.name, provider, this.dateFactory.now(), @@ -108,10 +107,6 @@ export default class Estim2bDeviceFactory implements SerialProtocolFactory { - const deviceInfo = await this.getDeviceInfo(transport); - const protocol = deviceInfo.protocol; - const knownDevice = this.knownDeviceRegistry.resolve(deviceId, deviceInfo.deviceType, provider); - const deviceAttributes = await this.getAttributes(transport, protocol); - - const device = new GenericSlvCtrlPlusDevice( + public create( + knownDevice: KnownDevice, + deviceInfo: DeviceInfo & { protocol: SlvCtrlProtocol }, + deviceAttributes: SlvCtrlPlusDeviceAttributes, + transport: DeviceBidirectionalTransport, + provider: string + ): GenericSlvCtrlPlusDevice { + return new GenericSlvCtrlPlusDevice( deviceInfo.fwVersion, knownDevice.id, knownDevice.name, deviceInfo.deviceType, provider, this.dateFactory.now(), - protocol, + deviceInfo.protocol, transport, deviceInfo.protocolVersion, deviceAttributes, this.eventEmitterFactory.create(), this.logger, ); - - this.knownDeviceRegistry.persist(knownDevice); - - return device; } private async getDeviceInfo(transport: DeviceBidirectionalTransport): Promise From f0bcb0b0b24bbbf0c284d283d48bf312152974a8 Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 6 Jul 2026 18:39:52 +0200 Subject: [PATCH 13/17] Proper sepration --- .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts index d85a3393..6233c993 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts @@ -91,11 +91,11 @@ export default class SlvCtrlPlusDeviceFactory implements SerialProtocolFactory + private async getDeviceInfoAndProtocol(transport: DeviceBidirectionalTransport): Promise<{slvCtrlDeviceInfo: DeviceInfo, protocol: SlvCtrlProtocol}> { const infoResponse = await transport.sendAndAwaitReceive( Buffer.from(`introduce`), @@ -173,7 +174,7 @@ export default class SlvCtrlPlusDeviceFactory implements SerialProtocolFactory From e9a47b36c524f1c33652ab4aecb9864b184e0e08 Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 6 Jul 2026 18:44:10 +0200 Subject: [PATCH 14/17] Skip settings write in persist() when the identity is unchanged Settings is wrapped with on-change (see SettingsManager.load()), so any mutating call anywhere in its object graph - including Map.set() on the nested knownDevices map - triggers a full settings.json disk write and a settings-changed WebSocket broadcast to every connected client. persist() was calling settings.addKnownDevice() unconditionally, including for an already-known, completely unchanged identity. That meant every single device connect/reconnect wrote to disk and broadcast a change event, even for a device that's been known for months and never changes - this exact bug exists on main today (SlvCtrlPlusDeviceFactory, AiroticDeviceProvider and ButtplugIoDeviceFactory all unconditionally call settings.addKnownDevice() at the end of their create() methods, regardless of whether the known device lookup returned an existing, unchanged entry). Fixed by skipping the settings.addKnownDevice() call when the given KnownDevice is already the exact stored instance for its id. This is safe because KnownDevice is fully immutable (readonly fields, no setters) and resolve() always returns the same instance for an already-known device, so reference equality is a reliable signal that nothing actually changed. --- src/device/knownDeviceRegistry.ts | 12 ++++- tests/unit/device/knownDeviceRegistry.spec.ts | 47 ++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/device/knownDeviceRegistry.ts b/src/device/knownDeviceRegistry.ts index 2970c7bb..748818f5 100644 --- a/src/device/knownDeviceRegistry.ts +++ b/src/device/knownDeviceRegistry.ts @@ -45,9 +45,19 @@ export default class KnownDeviceRegistry /** * Persists a resolved identity. Safe to call unconditionally after successfully building a - * Device, even for an already-known identity (a harmless no-op re-registration). + * Device, even for an already-known identity - a no-op in that case, since KnownDevice is + * immutable and `resolve()` returns the exact same instance for an already-known device. + * + * This matters beyond just avoiding pointless work: Settings is wrapped with `on-change` to + * auto-save to disk, so an unconditional `settings.addKnownDevice()` call here would trigger a + * settings.json write and a settings-changed WebSocket broadcast on *every* device connect, + * even for a device that has been known and unchanged for months. */ public persist(knownDevice: KnownDevice): void { + if (this.settings.getKnownDeviceById(knownDevice.id) === knownDevice) { + return; + } + this.settings.addKnownDevice(knownDevice); } } diff --git a/tests/unit/device/knownDeviceRegistry.spec.ts b/tests/unit/device/knownDeviceRegistry.spec.ts index 8eab3366..6658fea7 100644 --- a/tests/unit/device/knownDeviceRegistry.spec.ts +++ b/tests/unit/device/knownDeviceRegistry.spec.ts @@ -63,13 +63,58 @@ describe('KnownDeviceRegistry', () => { }); describe('persist', () => { - it('delegates to settings.addKnownDevice', () => { + it('delegates to settings.addKnownDevice for a genuinely new identity', () => { const knownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(undefined); registry.persist(knownDevice); expect(mockSettings.addKnownDevice).toHaveBeenCalledOnce(); expect(mockSettings.addKnownDevice).toHaveBeenCalledWith(knownDevice); }); + + it('does not touch settings when persisting an already-known, unchanged identity', () => { + // This matters beyond avoiding pointless work: Settings is wrapped with on-change to + // auto-save to disk, so calling addKnownDevice() here unconditionally would trigger a + // settings.json write + a settings-changed broadcast on every device (re)connect, even + // for a device that's been known and unchanged for months. + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + registry.persist(existingKnownDevice); + + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('persists when passed a different KnownDevice instance for an already-known id', () => { + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + const differentInstance = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + registry.persist(differentInstance); + + expect(mockSettings.addKnownDevice).toHaveBeenCalledWith(differentInstance); + }); + }); + + describe('resolve + persist integration', () => { + it('does not write to settings when reconnecting an already-known device', () => { + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Existing Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + const knownDevice = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); + registry.persist(knownDevice); + + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('writes to settings exactly once when connecting a genuinely new device', () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const knownDevice = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); + registry.persist(knownDevice); + + expect(mockSettings.addKnownDevice).toHaveBeenCalledOnce(); + }); }); }); From 177c3b60217e824f1de2fd0e69834768f05501fe Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 13 Jul 2026 12:43:05 +0200 Subject: [PATCH 15/17] Revert observer-merge/device-provider-factory restructuring Reverts commits 2d05929..e9a47b3 (the entire 'merge observer into device provider' effort and the KnownDeviceRegistry work built on top of it). The restructuring didn't lead anywhere useful: splitting device providers into one instance per DeviceSource reintroduced uncoordinated, redundant hardware scanning across different protocols on the same transport - a problem the original shared SerialPortObserver/BleObserver design didn't have. Back to main's original architecture. Device-provider config schema validation (added during this work) will be re-applied on top of it separately, since that part is worth keeping. --- src/app.ts | 12 +- src/device/deviceManager.ts | 109 ++++++- src/device/knownDeviceRegistry.ts | 63 ---- ...iceFactory.ts => airoticDeviceProvider.ts} | 86 +++--- .../buttplugIo/buttplugIoDeviceFactory.ts | 39 ++- .../buttplugIoWebsocketDeviceProvider.ts | 29 +- ...uttplugIoWebsocketDeviceProviderFactory.ts | 6 - .../protocol/estim2b/estim2bDeviceFactory.ts | 75 +---- .../estim2b/estim2bSerialDeviceProvider.ts | 71 +++++ .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 142 +++------ .../slvCtrlPlusSerialDeviceProvider.ts | 110 +++++++ src/device/protocol/zc95/zc95DeviceFactory.ts | 79 +---- .../protocol/zc95/zc95SerialDeviceProvider.ts | 104 +++++++ src/device/provider/bleDeviceProvider.ts | 171 ++++------- src/device/provider/bleProtocolFactory.ts | 29 -- src/device/provider/deviceProviderEvent.ts | 0 .../provider/genericDeviceProviderFactory.ts | 21 ++ src/device/provider/serialDeviceProvider.ts | 229 +++++--------- src/device/provider/serialProtocolFactory.ts | 49 --- src/device/transport/bleDeviceTransport.ts | 2 +- src/device/transport/bleObserver.ts | 98 ++++++ src/device/transport/serialPortObserver.ts | 125 ++++++++ src/serviceMap.ts | 20 +- src/serviceProvider/deviceServiceProvider.ts | 117 ++++--- src/settings/knownDevice.ts | 8 +- src/settings/settingsManager.ts | 4 +- .../integration/devices/airoticDevice.spec.ts | 6 +- .../integration/devices/estim2bDevice.spec.ts | 16 +- .../devices/slvCtrlSerialDevice.spec.ts | 16 +- tests/integration/devices/zc95Device.spec.ts | 18 +- .../helpers/mockSerialPortFactory.ts | 2 +- tests/unit/device/deviceManager.spec.ts | 180 ++++++++++- tests/unit/device/knownDeviceRegistry.spec.ts | 120 -------- .../device/provider/bleDeviceProvider.spec.ts | 267 ---------------- .../provider/serialDeviceProvider.spec.ts | 288 ------------------ .../unit/device/transport/bleObserver.spec.ts | 193 ++++++++++++ .../transport/serialPortObserver.spec.ts | 186 +++++++++++ 37 files changed, 1574 insertions(+), 1516 deletions(-) delete mode 100644 src/device/knownDeviceRegistry.ts rename src/device/protocol/airotic/{airoticDeviceFactory.ts => airoticDeviceProvider.ts} (67%) create mode 100644 src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts create mode 100644 src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts create mode 100644 src/device/protocol/zc95/zc95SerialDeviceProvider.ts delete mode 100644 src/device/provider/bleProtocolFactory.ts create mode 100644 src/device/provider/deviceProviderEvent.ts create mode 100644 src/device/provider/genericDeviceProviderFactory.ts delete mode 100644 src/device/provider/serialProtocolFactory.ts create mode 100644 src/device/transport/bleObserver.ts create mode 100644 src/device/transport/serialPortObserver.ts delete mode 100644 tests/unit/device/knownDeviceRegistry.spec.ts delete mode 100644 tests/unit/device/provider/bleDeviceProvider.spec.ts delete mode 100644 tests/unit/device/provider/serialDeviceProvider.spec.ts create mode 100644 tests/unit/device/transport/bleObserver.spec.ts create mode 100644 tests/unit/device/transport/serialPortObserver.spec.ts diff --git a/src/app.ts b/src/app.ts index 12c99974..cd4499b4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -133,8 +133,8 @@ const configureWebsocket = (io: WebsocketServer, container: Container): void => { - const serialDeviceProvider = container.get('device.provider.serial'); - const bleDeviceProvider = container.get('device.provider.ble'); + 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'); @@ -145,8 +145,8 @@ const loadDeviceProviders = (container: Container): void => { .startProviders() .catch(e => logError(logger, `Loading device providers failed`, e)); - serialDeviceProvider.init().catch(e => logError(logger, `Initializing serial device provider failed`, e)); - bleDeviceProvider.init().catch(e => logError(logger, `Initializing BLE device provider 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 => ({ @@ -259,8 +259,8 @@ export const createApp = (container: Container, options: AppOptions) logger.info('Shutting down...'); await container.get('automation.scriptRuntime').stop(); - await container.get('device.provider.serial').stop(); - await container.get('device.provider.ble').stop(); + await container.get('device.observer.serial').stop(); + await container.get('device.observer.ble').stop(); await container.get('device.provider.loader').stopProviders(); container.get('health.metricsCollector').stop(); diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 4791ffa1..44082578 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -5,36 +5,41 @@ 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'; + +export type DeviceInfo = { + type: string; + id: DeviceId; +}; export enum DeviceManagerEvent { deviceConnected = 'deviceConnected', deviceDisconnected = 'deviceDisconnected', deviceRefreshed = 'deviceRefreshed', + deviceDetected = 'deviceDetected', deviceNotification = 'deviceNotification', } +type AcquireResult = + | { successful: true } + | { successful: false, reason: string }; + type DeviceManagerEventMap = { [DeviceManagerEvent.deviceConnected]: [device: Device]; [DeviceManagerEvent.deviceDisconnected]: [device: Device]; [DeviceManagerEvent.deviceRefreshed]: [device: Device]; + [DeviceManagerEvent.deviceDetected]: [deviceInfo: DeviceInfo]; [DeviceManagerEvent.deviceNotification]: [device: Device, notification: DeviceNotification]; } -/** - * Registry of currently connected devices, plus the refresh scheduling and lifecycle events - * (connected/disconnected/refreshed/notification) consumed by sockets and automation scripts. - * - * Detection/arbitration between competing device providers is no longer this class's concern - - * each transport-level DeviceProvider (SerialDeviceProvider, BleDeviceProvider, ...) now owns its - * own discovery and decides for itself, in-process, which registered protocol factory (if any) - * gets to claim a newly discovered device. - */ export default class DeviceManager { private readonly eventEmitter: EventEmitter; private readonly logger: Logger; + private readonly detectedDeviceAcquireQueue: Map void }[]> = new Map(); + private readonly connectedDevices: Map>; public constructor(eventEmitter: EventEmitter, connectedDevices: Map, logger: Logger) { @@ -43,6 +48,74 @@ export default class DeviceManager this.connectedDevices = connectedDevices; } + public announceDetectedDevice(deviceInfo: DeviceInfo): void + { + if (this.detectedDeviceAcquireQueue.has(deviceInfo.id)) { + return; + } + + if (this.connectedDevices.has(deviceInfo.id)) { + this.logger.debug(`Device with id '${deviceInfo.id}' is already connected, not announcing it as detected`); + return; + } + + this.logger.info(`Detected new device with id ${deviceInfo.id}`); + + this.detectedDeviceAcquireQueue.set(deviceInfo.id, []); + + 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); + } + } + + public revokeDetectedDevice(deviceInfo: DeviceInfo): void + { + this.clearDetectedDeviceAcquireQueue(deviceInfo.id, `Device with id '${deviceInfo.id}' has disappeared`); + } + + public async acquireDetectedDevice(deviceId: DeviceId): Promise + { + return new Promise((resolve) => { + const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId); + + if (undefined === deviceQueue) { + resolve({ successful: false, reason: `Device with id '${deviceId}' is not available for claiming` }); + return; + } + + // Always add to queue first + deviceQueue.push({ resolve }); + + // If we're first in line, resolve immediately + if (deviceQueue.length === 1) { + resolve({ successful: true }); + } + }); + } + + public releaseDetectedDevice(deviceId: DeviceId): void + { + const deviceQueue = this.detectedDeviceAcquireQueue.get(deviceId); + + if (undefined === deviceQueue) { + return; + } + + // Release current claimant and hand off the claim to the next waiter + deviceQueue.shift(); + + if (deviceQueue.length === 0) { + this.detectedDeviceAcquireQueue.delete(deviceId); + return; + } + + deviceQueue[0]?.resolve({ successful: true }); + } + public addDevice( device: Device ): void @@ -58,6 +131,11 @@ export default class DeviceManager this.eventEmitter.emit(DeviceManagerEvent.deviceConnected, device); } + public claimDetectedDevice(deviceId: DeviceId): void + { + this.clearDetectedDeviceAcquireQueue(deviceId, `Device with id '${deviceId}' has been claimed by another provider`); + } + public getConnectedDevices(): Device[] { return Array.from(this.connectedDevices.values()); @@ -101,11 +179,24 @@ export default class DeviceManager } } + for (const [deviceId] of this.detectedDeviceAcquireQueue) { + this.clearDetectedDeviceAcquireQueue(deviceId, 'Device manager reset'); + } + if (undefined !== closeError) { throw closeError; } } + private clearDetectedDeviceAcquireQueue(deviceId: string, reason: string): void + { + for (const entry of this.detectedDeviceAcquireQueue.get(deviceId) ?? []) { + entry.resolve({ successful: false, reason }); + } + + this.detectedDeviceAcquireQueue.delete(deviceId); + } + private initDeviceRefresher(device: Device): void { this.logger.info(`Initializing refresher for device '${device.getDeviceName}' (id: ${device.getDeviceId})`); const deviceRefreshIntervalMs = device.getRefreshInterval; diff --git a/src/device/knownDeviceRegistry.ts b/src/device/knownDeviceRegistry.ts deleted file mode 100644 index 748818f5..00000000 --- a/src/device/knownDeviceRegistry.ts +++ /dev/null @@ -1,63 +0,0 @@ -import Settings from '../settings/settings.js'; -import KnownDevice from '../settings/knownDevice.js'; -import DeviceNameGenerator from './deviceNameGenerator.js'; -import Logger from '../logging/Logger.js'; -import { DeviceId } from './deviceId.js'; - -/** - * Looks up and registers the persisted `KnownDevice` identity for a newly detected raw device - * (serial port, BLE peripheral, buttplug.io device, ...). - * - * Centralizes identity lookup/creation logic that used to be duplicated across several device - * providers/factories. Deliberately has no opinion on *when* a newly created identity should be - * persisted - `resolve()` never has side effects, so callers stay in control of only calling - * `persist()` once they've actually finished building the Device successfully. - */ -export default class KnownDeviceRegistry -{ - private readonly settings: Settings; - - private readonly nameGenerator: DeviceNameGenerator; - - private readonly logger: Logger; - - public constructor(settings: Settings, nameGenerator: DeviceNameGenerator, logger: Logger) { - this.settings = settings; - this.nameGenerator = nameGenerator; - this.logger = logger.child({ name: KnownDeviceRegistry.name }); - } - - /** - * Looks up the already-known identity for `deviceId`, or builds a new (not yet persisted) - * one if none exists. - */ - public resolve(deviceId: DeviceId, type: string, provider: string, name?: string): KnownDevice { - const knownDevice = this.settings.getKnownDeviceById(deviceId); - - if (undefined !== knownDevice) { - // Already known (previously detected serial number) - this.logger.debug(`Device is already known: ${knownDevice.id}`); - return knownDevice; - } - - return new KnownDevice(deviceId, name ?? this.nameGenerator.generateName(), type, provider); - } - - /** - * Persists a resolved identity. Safe to call unconditionally after successfully building a - * Device, even for an already-known identity - a no-op in that case, since KnownDevice is - * immutable and `resolve()` returns the exact same instance for an already-known device. - * - * This matters beyond just avoiding pointless work: Settings is wrapped with `on-change` to - * auto-save to disk, so an unconditional `settings.addKnownDevice()` call here would trigger a - * settings.json write and a settings-changed WebSocket broadcast on *every* device connect, - * even for a device that has been known and unchanged for months. - */ - public persist(knownDevice: KnownDevice): void { - if (this.settings.getKnownDeviceById(knownDevice.id) === knownDevice) { - return; - } - - this.settings.addKnownDevice(knownDevice); - } -} diff --git a/src/device/protocol/airotic/airoticDeviceFactory.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts similarity index 67% rename from src/device/protocol/airotic/airoticDeviceFactory.ts rename to src/device/protocol/airotic/airoticDeviceProvider.ts index 84e5f2d3..12f8730f 100644 --- a/src/device/protocol/airotic/airoticDeviceFactory.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -1,48 +1,50 @@ import EventEmitter from 'events'; -import { Peripheral } from '@stoprocent/noble'; import BaseError from 'modern-errors'; +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 BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; import AiroticProtocol from './airtonicProtocol.js'; import MessageResponseHandler from '../messageResponseHandler.js'; import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; +import Settings from '../../../settings/settings.js'; +import KnownDevice from '../../../settings/knownDevice.js'; +import { DeviceId } from '../../deviceId.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import FloatDeviceAttribute from '../../attribute/floatDeviceAttribute.js'; -import BleProtocolFactory from '../../provider/bleProtocolFactory.js'; +import BleDeviceProvider from '../../provider/bleDeviceProvider.js'; import { hsvByteToRgb } from '../../../util/color.js'; -import KnownDeviceRegistry from '../../knownDeviceRegistry.js'; -import { DeviceId } from '../../deviceId.js'; -import KnownDevice from '../../../settings/knownDevice.js'; -export default class AiroticDeviceFactory implements BleProtocolFactory +export default class AiroticDeviceProvider extends BleDeviceProvider { - public static readonly protocolName = 'airotic'; - - public readonly protocolName = AiroticDeviceFactory.protocolName; + public static readonly providerName = 'airotic'; private static readonly UART_RX_CHAR_UUID = '6e400002b5a3f393e0a9e50e24dcca9e'; private static readonly UART_TX_CHAR_UUID = '6e400003b5a3f393e0a9e50e24dcca9e'; - private readonly knownDeviceRegistry: KnownDeviceRegistry; + private readonly settings: Settings; - private readonly logger: Logger; + public constructor(deviceManager: DeviceManager, settings: Settings, eventEmitter: EventEmitter, logger: Logger) { + super(deviceManager, eventEmitter, logger.child({ name: AiroticDeviceProvider.name })); - public constructor(knownDeviceRegistry: KnownDeviceRegistry, logger: Logger) { - this.knownDeviceRegistry = knownDeviceRegistry; - this.logger = logger.child({ name: AiroticDeviceFactory.name }); + this.settings = settings; } - public async tryConnect(deviceId: DeviceId, peripheral: Peripheral): Promise { + public override async init(): Promise { + this.logger.debug('Initialized AiroticDeviceProvider'); + } + + protected override async connectBleDevice(deviceInfo: BleDeviceInfo): Promise { const transport = await promiseWithTimeout(BleUartDeviceTransport.create( - peripheral, - AiroticDeviceFactory.UART_RX_CHAR_UUID, - AiroticDeviceFactory.UART_TX_CHAR_UUID - ), 5000, `Timed out while creating BLE transport for device ${deviceId}`); + deviceInfo.peripheral, + AiroticDeviceProvider.UART_RX_CHAR_UUID, + AiroticDeviceProvider.UART_TX_CHAR_UUID + ), 5000, `Timed out while creating BLE transport for device ${deviceInfo.id}`); - this.logger.debug(`Connected to device: ${deviceId}`); + this.logger.debug(`Connected to device: ${deviceInfo.id}`); const protocol = new AiroticProtocol(); const messageResponseHandler = MessageResponseHandler.create(protocol, transport, this.logger, 2000); @@ -54,33 +56,18 @@ export default class AiroticDeviceFactory implements BleProtocolFactory, - ): AiroticDevice { - const advertisedColors = this.parseAdvertisedColors(peripheral.advertisement.manufacturerData); + const advertisedColors = this.parseAdvertisedColors(deviceInfo.peripheral.advertisement.manufacturerData); - return new AiroticDevice( + const device = new AiroticDevice( knownDevice.id, knownDevice.name, - AiroticDeviceFactory.protocolName, - peripheral, + AiroticDeviceProvider.providerName, + deviceInfo.peripheral, transport, messageResponseHandler, new Date(), @@ -97,6 +84,10 @@ export default class AiroticDeviceFactory implements BleProtocolFactory -{ - public static readonly protocolName = 'estim2bSerial'; - - private static readonly deviceType = 'estim2b'; - - public readonly protocolName = Estim2bDeviceFactory.protocolName; +import { DeviceId } from '../../deviceId.js'; +export default class Estim2bDeviceFactory +{ private readonly dateFactory: DateFactory; - private readonly knownDeviceRegistry: KnownDeviceRegistry; + private readonly settings: Settings; - private readonly transportFactory: SerialDeviceTransportFactory; + private readonly nameGenerator: DeviceNameGenerator; private readonly logger: Logger; @@ -42,61 +30,30 @@ export default class Estim2bDeviceFactory implements SerialProtocolFactory): 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')); - const estim2bProtocol = new EStim2bProtocol(); - - const encodedMessage = estim2bProtocol.encode(estim2bProtocol.createGetStatusCommand()); - const response = await transport.sendAndAwaitReceive(encodedMessage); - const decodedResponse = estim2bProtocol.decode(response); - - if ('error' in decodedResponse) { - throw getErrorFromDecodeResult(decodedResponse.error, response); - } - - const status = decodedResponse.message; - - this.logger.info(`Module detected: E-Stim Systems 2B ${status.firmwareVersion} (${deviceInfo.portInfo.serialNumber})`); - - const knownDevice = this.knownDeviceRegistry.resolve(deviceInfo.id, Estim2bDeviceFactory.deviceType, Estim2bDeviceFactory.protocolName); - - const device = this.create(knownDevice, estim2bProtocol, transport, status, Estim2bDeviceFactory.protocolName); - - this.knownDeviceRegistry.persist(knownDevice); - - return device; - } - - public create( - knownDevice: KnownDevice, + public async create( + deviceId: DeviceId, protocol: EStim2bProtocol, transport: DeviceBidirectionalTransport, initialStatus: EStim2bStatus, provider: string - ): Estim2bDevice { + ): Promise { const attributes = this.getAttributes(initialStatus); return new Estim2bDevice( - knownDevice.id, - knownDevice.name, + deviceId, + this.nameGenerator.generateName(), provider, this.dateFactory.now(), true, diff --git a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts new file mode 100644 index 00000000..e16f64c6 --- /dev/null +++ b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts @@ -0,0 +1,71 @@ +import { ReadlineParser } from 'serialport'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface } from '@serialport/bindings-interface'; +import EventEmitter from 'events'; +import Logger from '../../../logging/Logger.js'; +import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; +import EStim2bProtocol from './estim2bProtocol.js'; +import EStim2bDeviceFactory from './estim2bDeviceFactory.js'; +import SerialPortFactory from '../../../factory/serialPortFactory.js'; +import Estim2bDevice from './estim2bDevice.js'; +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'; + +export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider +{ + public static readonly providerName = 'estim2bSerial'; + + private readonly transportFactory: SerialDeviceTransportFactory; + + private readonly deviceFactory: EStim2bDeviceFactory; + + public constructor( + deviceManager: DeviceManager, + serialPortFactory: SerialPortFactory, + transportFactory: SerialDeviceTransportFactory, + eventEmitter: EventEmitter, + deviceFactory: EStim2bDeviceFactory, + logger: Logger + ) { + super(deviceManager, serialPortFactory, eventEmitter, logger.child({ name: EStim2bSerialDeviceProvider.name })); + + this.transportFactory = transportFactory; + this.deviceFactory = deviceFactory; + } + + protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, 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')); + const estim2bProtocol = new EStim2bProtocol(); + + const encodedMessage = estim2bProtocol.encode(estim2bProtocol.createGetStatusCommand()); + const response = await transport.sendAndAwaitReceive(encodedMessage); + const decodedResponse = estim2bProtocol.decode(response); + + if ('error' in decodedResponse) { + throw getErrorFromDecodeResult(decodedResponse.error, response); + } + + const status = decodedResponse.message; + + this.logger.info(`Module detected: E-Stim Systems 2B ${status.firmwareVersion} (${deviceInfo.portInfo.serialNumber})`); + + const device = await this.deviceFactory.create( + deviceInfo.id, + estim2bProtocol, + transport, + status, + EStim2bSerialDeviceProvider.providerName + ); + + return device; + } + + protected getSerialDeviceProviderPortOpenOptions(): SerialDeviceProviderPortOpenOptions { + return { baudRate: 9600 }; + } +} diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts index 6233c993..bb131470 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts @@ -1,7 +1,6 @@ -import { ReadlineParser, ReadyParser } from 'serialport'; -import { SerialPortStream } from '@serialport/stream'; -import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; -import BaseError from 'modern-errors'; +import Settings from '../../../settings/settings.js'; +import KnownDevice from '../../../settings/knownDevice.js'; +import DeviceNameGenerator from '../../deviceNameGenerator.js'; import GenericSlvCtrlPlusDevice from './genericSlvCtrlPlusDevice.js'; import DateFactory from '../../../factory/dateFactory.js'; import DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; @@ -12,124 +11,41 @@ import SlvCtrlProtocol, { DeviceInfo } from './slvCtrlProtocol.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; import { SlvCtrlPlusDeviceAttributes } from './slvCtrlPlusDevice.js'; -import KnownDeviceRegistry from '../../knownDeviceRegistry.js'; -import KnownDevice from '../../../settings/knownDevice.js'; -import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; -import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; -import SerialProtocolFactory, { SerialDeviceInfo, SerialDeviceProviderPortOpenOptions } from '../../provider/serialProtocolFactory.js'; +import { DeviceId } from '../../deviceId.js'; -export default class SlvCtrlPlusDeviceFactory implements SerialProtocolFactory +export default class SlvCtrlPlusDeviceFactory { - public static readonly protocolName = 'slvCtrlPlusSerial'; - - public readonly protocolName = SlvCtrlPlusDeviceFactory.protocolName; - - private static readonly moduleReadyByte = 0x07; - - private static readonly arduinoVendorId = '2341'; - private readonly dateFactory: DateFactory; protected readonly eventEmitterFactory: EventEmitterFactory; - private readonly knownDeviceRegistry: KnownDeviceRegistry; + private readonly settings: Settings; - private readonly deviceTransportFactory: SerialDeviceTransportFactory; + private readonly nameGenerator: DeviceNameGenerator; private readonly logger: Logger; public constructor( dateFactory: DateFactory, eventEmitterFactory: EventEmitterFactory, - knownDeviceRegistry: KnownDeviceRegistry, - deviceTransportFactory: SerialDeviceTransportFactory, + settings: Settings, + nameGenerator: DeviceNameGenerator, logger: Logger ) { this.dateFactory = dateFactory; this.eventEmitterFactory = eventEmitterFactory; - this.knownDeviceRegistry = knownDeviceRegistry; - this.deviceTransportFactory = deviceTransportFactory; + this.settings = settings; + this.nameGenerator = nameGenerator; this.logger = logger.child({ name: SlvCtrlPlusDeviceFactory.name }); } - public getPortOpenOptions(): SerialDeviceProviderPortOpenOptions { - return { baudRate: 9600 }; - } - - public preparePort(port: SerialPortStream, portInfo: PortInfo): Promise { - return new Promise((resolve, reject) => { - if (portInfo.vendorId !== SlvCtrlPlusDeviceFactory.arduinoVendorId) { - // It's NOT an Arduino - resolve(); - return; - } - - const readyParser = port.pipe(new ReadyParser({ - delimiter: [SlvCtrlPlusDeviceFactory.moduleReadyByte] - })); - - // Let's timeout if we don't receive the ready bytes for a few seconds - const timeout = setTimeout(() => { - port.unpipe(readyParser); - readyParser.destroy(); - reject(new Error(`Timed out while waiting for ready bytes`)); - }, 3000); - - readyParser.once('ready', () => { - clearTimeout(timeout); - port.unpipe(readyParser); - readyParser.destroy(); - resolve(); - }); - }); - } - - public async tryConnect(deviceInfo: SerialDeviceInfo, port: SerialPortStream): Promise { - const parser = port.pipe(new ReadlineParser({ delimiter: SlvCtrlProtocol.EOF })); - const syncPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, this.logger); - const transport = this.deviceTransportFactory.create(syncPort, undefined, Buffer.from(SlvCtrlProtocol.EOF)); - - await this.performHandshakeWithRetries(transport, 4); - - const { slvCtrlDeviceInfo, protocol } = await this.getDeviceInfoAndProtocol(transport); - const knownDevice = this.knownDeviceRegistry.resolve(deviceInfo.id, slvCtrlDeviceInfo.deviceType, SlvCtrlPlusDeviceFactory.protocolName); + public async create(deviceId: DeviceId, transport: DeviceBidirectionalTransport, provider: string): Promise { + const deviceInfo = await this.getDeviceInfo(transport); + const protocol = deviceInfo.protocol; + const knownDevice = this.createKnownDevice(deviceId, deviceInfo.deviceType, provider); const deviceAttributes = await this.getAttributes(transport, protocol); - const device = this.create(knownDevice, slvCtrlDeviceInfo, protocol, deviceAttributes, transport, SlvCtrlPlusDeviceFactory.protocolName); - - this.knownDeviceRegistry.persist(knownDevice); - - this.logger.info(`Module detected: ${device.getDeviceModel} (${deviceInfo.portInfo.serialNumber})`); - - return device; - } - - private async performHandshakeWithRetries(transport: DeviceBidirectionalTransport, maxAttempts: number): Promise { - let lastError; - - for (let i = 1; i <= maxAttempts; i++) { - try { - await transport.sendAndAwaitReceive(Buffer.from(`clear`), 250); - return; - } catch(e: unknown) { - const error = BaseError.normalize(e); - this.logger.info(`Retrying because handshake attempt ${i} failed: ${error.message}`); - if (i === maxAttempts) lastError = e; - } - } - - throw lastError; - } - - public create( - knownDevice: KnownDevice, - deviceInfo: DeviceInfo, - protocol: SlvCtrlProtocol, - deviceAttributes: SlvCtrlPlusDeviceAttributes, - transport: DeviceBidirectionalTransport, - provider: string - ): GenericSlvCtrlPlusDevice { - return new GenericSlvCtrlPlusDevice( + const device = new GenericSlvCtrlPlusDevice( deviceInfo.fwVersion, knownDevice.id, knownDevice.name, @@ -143,9 +59,13 @@ export default class SlvCtrlPlusDeviceFactory implements SerialProtocolFactory + private async getDeviceInfo(transport: DeviceBidirectionalTransport): Promise { const infoResponse = await transport.sendAndAwaitReceive( Buffer.from(`introduce`), @@ -174,7 +94,7 @@ export default class SlvCtrlPlusDeviceFactory implements SerialProtocolFactory @@ -205,4 +125,22 @@ export default class SlvCtrlPlusDeviceFactory implements SerialProtocolFactory +{ + public static readonly providerName = 'slvCtrlPlusSerial'; + + private static readonly moduleReadyByte = 0x07; + + private static readonly arduinoVendorId = '2341'; + + private readonly slvCtrlPlusDeviceFactory: SlvCtrlPlusDeviceFactory; + + private readonly deviceTransportFactory: SerialDeviceTransportFactory; + + public constructor( + deviceManager: DeviceManager, + serialPortFactory: SerialPortFactory, + eventEmitter: EventEmitter, + deviceFactory: SlvCtrlPlusDeviceFactory, + deviceTransportFactory: SerialDeviceTransportFactory, + logger: Logger + ) { + super(deviceManager, serialPortFactory, eventEmitter, logger.child({ name: SlvCtrlPlusSerialDeviceProvider.name })); + this.slvCtrlPlusDeviceFactory = deviceFactory; + this.deviceTransportFactory = deviceTransportFactory; + } + + protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPortStream): Promise + { + const parser = port.pipe(new ReadlineParser({ delimiter: SlvCtrlProtocol.EOF })); + const syncPort = new SynchronousSerialPort(deviceInfo.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.id, + transport, + SlvCtrlPlusSerialDeviceProvider.providerName + ); + + this.logger.info(`Module detected: ${device.getDeviceModel} (${deviceInfo.portInfo.serialNumber})`); + + return device; + } + + private async performHandshakeWithRetries(transport: DeviceBidirectionalTransport, maxAttempts: number): Promise { + let lastError; + + for (let i = 1; i <= maxAttempts; i++) { + try { + await transport.sendAndAwaitReceive(Buffer.from(`clear`), 250); + return; + } catch(e: unknown) { + const error = BaseError.normalize(e); + this.logger.info(`Retrying because handshake attempt ${i} failed: ${error.message}`); + if (i === maxAttempts) lastError = e; + } + } + + throw lastError; + } + + protected getSerialDeviceProviderPortOpenOptions(): SerialDeviceProviderPortOpenOptions { + return { baudRate: 9600 }; + } + + protected override preparePort(port: SerialPortStream, portInfo: PortInfo): Promise { + return new Promise((resolve, reject) => { + if (portInfo.vendorId !== SlvCtrlPlusSerialDeviceProvider.arduinoVendorId) { + // It's NOT an Arduino + resolve(); + return; + } + + const readyParser = port.pipe(new ReadyParser({ + delimiter: [SlvCtrlPlusSerialDeviceProvider.moduleReadyByte] + })); + + // Let's timeout if we don't receive the ready bytes for a few seconds + const timeout = setTimeout(() => { + port.unpipe(readyParser); + readyParser.destroy(); + reject(new Error(`Timed out while waiting for ready bytes`)); + }, 3000); + + readyParser.once('ready', () => { + clearTimeout(timeout); + port.unpipe(readyParser); + readyParser.destroy(); + resolve(); + }); + }); + } +} diff --git a/src/device/protocol/zc95/zc95DeviceFactory.ts b/src/device/protocol/zc95/zc95DeviceFactory.ts index 1ccc4f53..3b02f57e 100644 --- a/src/device/protocol/zc95/zc95DeviceFactory.ts +++ b/src/device/protocol/zc95/zc95DeviceFactory.ts @@ -1,5 +1,3 @@ -import { SerialPortStream } from '@serialport/stream'; -import { BindingInterface } from '@serialport/bindings-interface'; import Settings from '../../../settings/settings.js'; import DeviceNameGenerator from '../../deviceNameGenerator.js'; import DateFactory from '../../../factory/dateFactory.js'; @@ -16,17 +14,9 @@ import MessageResponseHandler from '../messageResponseHandler.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; import { logError } from '../../../util/error.js'; import { DeviceId } from '../../deviceId.js'; -import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; -import { FrameParser } from '../../../serial/frameParser.js'; -import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; -import SerialProtocolFactory, { SerialDeviceInfo, SerialDeviceProviderPortOpenOptions } from '../../provider/serialProtocolFactory.js'; -export default class Zc95DeviceFactory implements SerialProtocolFactory +export default class Zc95DeviceFactory { - public static readonly protocolName = 'zc95Serial'; - - public readonly protocolName = Zc95DeviceFactory.protocolName; - private readonly dateFactory: DateFactory; private readonly eventEmitterFactory: EventEmitterFactory; @@ -35,8 +25,6 @@ export default class Zc95DeviceFactory implements SerialProtocolFactory): 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 transport = this.transportFactory.create( - serialPort, Buffer.from([Zc95Protocol.STX]), Buffer.from([Zc95Protocol.ETX]) - ); - const protocol = new Zc95Protocol(); - const messageFactory = new Zc95MessageFactory(); - - const messageResponseHandler = MessageResponseHandler.create( - protocol, - transport, - this.logger, - ); - - this.logger.debug(`Reset device connection`); - await this.reset(port, false); - const versionDetails = await messageResponseHandler.send(messageFactory.createGetVersionDetails()); - - this.logger.info(`Module detected: ZC95 ${versionDetails.ZC95} (${deviceInfo.portInfo.serialNumber})`); - - return this.create( - deviceInfo.id, - versionDetails, - protocol, - transport, - messageFactory, - messageResponseHandler, - Zc95DeviceFactory.protocolName - ); - } - - private async reset(port: SerialPortStream, close: boolean = false): Promise { - return new Promise((resolve, reject) => { - port.write(Buffer.from([Zc95Protocol.EOT]), (writeErr: Error | null | undefined) => { - if (null != writeErr) { - reject(writeErr); - return; - } - - this.logger.trace('> EOT'); - - if (close) { - port.close((closeErr: Error | null) => { - if (null != closeErr) { - reject(closeErr); - return; - } - setTimeout(resolve, 250); - }); - } else { - setTimeout(resolve, 250); - } - }); - }); - } - public async create( deviceId: DeviceId, versionDetails: VersionMsgResponse, diff --git a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts new file mode 100644 index 00000000..71703ee3 --- /dev/null +++ b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts @@ -0,0 +1,104 @@ +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface } from '@serialport/bindings-interface'; +import EventEmitter from 'events'; +import Logger from '../../../logging/Logger.js'; +import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../provider/serialDeviceProvider.js'; +import Zc95DeviceFactory from './zc95DeviceFactory.js'; +import Zc95Device from './zc95Device.js'; +import SerialPortFactory from '../../../factory/serialPortFactory.js'; +import { FrameParser } from '../../../serial/frameParser.js'; +import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; +import Zc95Protocol from './zc95Protocol.js'; +import MessageResponseHandler from '../messageResponseHandler.js'; +import Zc95MessageFactory from './zc95MessageFactory.js'; +import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; +import DeviceManager from '../../deviceManager.js'; +import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; + +export default class Zc95SerialDeviceProvider extends SerialDeviceProvider +{ + public static readonly providerName = 'zc95Serial'; + + private readonly transportFactory: SerialDeviceTransportFactory; + + private readonly deviceFactory: Zc95DeviceFactory; + + public constructor( + deviceManager: DeviceManager, + serialPortFactory: SerialPortFactory, + transportFactory: SerialDeviceTransportFactory, + eventEmitter: EventEmitter, + deviceFactory: Zc95DeviceFactory, + logger: Logger + ) { + super(deviceManager, serialPortFactory, eventEmitter, logger.child({ name: Zc95SerialDeviceProvider.name })); + + this.transportFactory = transportFactory; + this.deviceFactory = deviceFactory; + } + + protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, 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 transport = this.transportFactory.create( + serialPort, Buffer.from([Zc95Protocol.STX]), Buffer.from([Zc95Protocol.ETX]) + ); + const protocol = new Zc95Protocol(); + const messageFactory = new Zc95MessageFactory(); + + const messageResponseHandler = MessageResponseHandler.create( + protocol, + transport, + this.logger, + ); + + this.logger.debug(`Reset device connection`); + await this.reset(port, false); + const versionDetails = await messageResponseHandler.send(messageFactory.createGetVersionDetails()); + + this.logger.info(`Module detected: ZC95 ${versionDetails.ZC95} (${deviceInfo.portInfo.serialNumber})`); + + const device = await this.deviceFactory.create( + deviceInfo.id, + versionDetails, + protocol, + transport, + messageFactory, + messageResponseHandler, + Zc95SerialDeviceProvider.providerName + ); + + return device; + } + + protected getSerialDeviceProviderPortOpenOptions(): SerialDeviceProviderPortOpenOptions { + return { baudRate: 115200 }; + } + + private async reset(port: SerialPortStream, close: boolean = false): Promise { + return new Promise((resolve, reject) => { + port.write(Buffer.from([Zc95Protocol.EOT]), (writeErr: Error | null | undefined) => { + if (null != writeErr) { + reject(writeErr); + return; + } + + this.logger.trace('> EOT'); + + if (close) { + port.close((closeErr: Error | null) => { + if (null != closeErr) { + reject(closeErr); + return; + } + setTimeout(resolve, 250); + }); + } else { + setTimeout(resolve, 250); + } + }); + }); + } +} diff --git a/src/device/provider/bleDeviceProvider.ts b/src/device/provider/bleDeviceProvider.ts index 56150fd0..b7b36009 100644 --- a/src/device/provider/bleDeviceProvider.ts +++ b/src/device/provider/bleDeviceProvider.ts @@ -1,144 +1,77 @@ import EventEmitter from 'events'; -import noble, { Peripheral } from '@stoprocent/noble'; +import { Peripheral } from '@stoprocent/noble'; import DeviceProvider from './deviceProvider.js'; -import DeviceManager from '../deviceManager.js'; +import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager.js'; import Logger from '../../logging/Logger.js'; import { asyncHandler, promiseWithTimeout } from '../../util/async.js'; import { logError } from '../../util/error.js'; -import BleDevice from '../bleDevice.js'; -import { DeviceId } from '../deviceId.js'; -import BleProtocolFactory from './bleProtocolFactory.js'; - -/** - * Owns BLE discovery (via noble) and, for every newly discovered peripheral, tries every - * registered protocol factory in registration order until one of them successfully connects. - * - * Absorbs what used to be a separate `BleObserver` transport class - since there's only ever - * one BLE radio to scan with, there's no need for a shared broker to arbitrate between - * independently-scanning observers/providers; this class is both the sole observer and the - * sole consumer of what it observes. - */ -export default class BleDeviceProvider extends DeviceProvider +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'; + +export default abstract class BleDeviceProvider< + D extends BleDevice, + TAttributes extends DeviceAttributes = InferBleDeviceAttributes, + TNotifications extends DeviceNotifications = InferDeviceNotifications, + TConfig extends AnyDeviceConfig = InferBleDeviceConfig +> extends DeviceProvider { - private static readonly MIN_RSSI = -70; - private static readonly UART_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; - - private readonly factories: BleProtocolFactory[] = []; - - private readonly connectedDevices: Set> = new Set(); - - private readonly inFlightDeviceIds: Set = new Set(); - - private isScanning = false; - - public constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { - super(deviceManager, eventEmitter, logger.child({ name: BleDeviceProvider.name })); + private connectedDevices: Set = new Set(); + + 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) + ) + ); } - public registerFactory(factory: BleProtocolFactory): this { - this.factories.push(factory); - - return this; - } - - public override async init(): Promise { - noble.on('discover', asyncHandler( - this.onDiscover.bind(this), - (err: unknown) => logError(this.logger, 'Error in discover handler', err) - )); - - 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(); - } - - public override async stop(): Promise { - noble.removeAllListeners(); - - if (this.isScanning) { - await noble.stopScanningAsync(); - this.isScanning = false; + private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { + if (!this.isBleDeviceInfo(deviceInfo)) { + return; } - noble.stop(); + this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); - for (const device of this.connectedDevices) { - await device.close(); - } - this.connectedDevices.clear(); - } + const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - private async observe(): Promise { - if (this.isScanning) { + if (!acquireResult.successful) { + this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); return; } try { - // Wait for Adapter poweredOn state - await noble.waitForPoweredOnAsync(); + const device = await this.connectBleDevice(deviceInfo); - this.isScanning = true; - await noble.startScanningAsync([BleDeviceProvider.UART_SERVICE_UUID], true); + if (undefined === device) { + this.deviceManager.releaseDetectedDevice(deviceInfo.id); + return; + } - this.logger.info('Looking for BLE UART devices'); - } catch (error: unknown) { - logError(this.logger, 'BLE device discovery error', error); - this.isScanning = false; - await noble.stopScanningAsync(); + this.connectedDevices.add(device); + 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); + await this.disconnectPeripheral(deviceInfo.peripheral); } } - private async onDiscover(peripheral: Peripheral): Promise { - if (peripheral.rssi < BleDeviceProvider.MIN_RSSI) { - // Ignore devices with very weak signal, as they are likely not in range or just noise - this.logger.debug(`Ignoring device ${peripheral.id} with weak signal (RSSI: ${peripheral.rssi})`); - return; - } - - const deviceId = DeviceId.create(peripheral.id); - - if (this.inFlightDeviceIds.has(deviceId) || null !== this.deviceManager.getConnectedDevice(deviceId)) { - // Already being attempted or already connected - ignore repeated advertisements - return; + public override async stop(): Promise { + for (const device of this.connectedDevices) { + await device.close(); } + this.connectedDevices.clear(); + } - this.inFlightDeviceIds.add(deviceId); - - try { - for (const factory of this.factories) { - let device: BleDevice | undefined; - - try { - device = await factory.tryConnect(deviceId, peripheral); - } catch (e: unknown) { - logError(this.logger, `Error while connecting to BLE device via '${factory.protocolName}'`, e); - continue; - } - - if (undefined === device) { - continue; - } - - this.connectedDevices.add(device); - this.deviceManager.addDevice(device); - - return; - } - - // No registered factory recognized this peripheral's protocol - await this.disconnectPeripheral(peripheral); - } finally { - this.inFlightDeviceIds.delete(deviceId); - } + private isBleDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo { + return deviceInfo.type === 'ble'; } private async disconnectPeripheral(peripheral: Peripheral): Promise { @@ -156,4 +89,6 @@ export default class BleDeviceProvider extends DeviceProvider peripheral.cancelConnect(); } } + + protected abstract connectBleDevice(deviceInfo: BleDeviceInfo): Promise; } diff --git a/src/device/provider/bleProtocolFactory.ts b/src/device/provider/bleProtocolFactory.ts deleted file mode 100644 index 4026ce55..00000000 --- a/src/device/provider/bleProtocolFactory.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Peripheral } from '@stoprocent/noble'; -import BleDevice, { InferBleDeviceAttributes, InferBleDeviceConfig } from '../bleDevice.js'; -import { DeviceAttributes, DeviceNotifications, InferDeviceNotifications } from '../device.js'; -import { AnyDeviceConfig } from '../deviceConfig.js'; -import { DeviceId } from '../deviceId.js'; - -/** - * Implemented by protocol-specific factories that know how to probe a freshly discovered BLE - * peripheral and, if it speaks their protocol, construct the resulting Device. - * - * `BleDeviceProvider` tries every registered factory (in registration order) against a newly - * discovered peripheral until one of them returns a Device, or none of them do. - */ -export default interface BleProtocolFactory< - D extends BleDevice, - TAttributes extends DeviceAttributes = InferBleDeviceAttributes, - TNotifications extends DeviceNotifications = InferDeviceNotifications, - TConfig extends AnyDeviceConfig = InferBleDeviceConfig -> -{ - readonly protocolName: string; - - /** - * Attempt to connect to and identify the given peripheral as this factory's protocol. - * Resolve with `undefined` (rather than throwing) when the peripheral simply doesn't speak - * this protocol, so the provider can move on to the next registered factory. - */ - tryConnect(deviceId: DeviceId, peripheral: Peripheral): Promise; -} diff --git a/src/device/provider/deviceProviderEvent.ts b/src/device/provider/deviceProviderEvent.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/device/provider/genericDeviceProviderFactory.ts b/src/device/provider/genericDeviceProviderFactory.ts new file mode 100644 index 00000000..abcfb9f6 --- /dev/null +++ b/src/device/provider/genericDeviceProviderFactory.ts @@ -0,0 +1,21 @@ +import DeviceProvider from './deviceProvider.js'; +import DeviceProviderFactory from './deviceProviderFactory.js'; + +type ConcreteCtor = new (...args: any[]) => T; + +export default class GenericDeviceProviderFactory< + DP extends DeviceProvider +> implements DeviceProviderFactory +{ + private readonly ctor: ConcreteCtor; + private readonly args: ConstructorParameters>; + + public constructor(ctor: ConcreteCtor, ...args: ConstructorParameters>) { + this.ctor = ctor; + this.args = args; + } + + public create(): DP { + return new this.ctor(...this.args); + } +} diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index eb971362..c3d0769e 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -2,195 +2,93 @@ import DeviceProvider from './deviceProvider.js'; import EventEmitter from 'events'; import Logger from '../../logging/Logger.js'; import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; -import { SerialPort } from 'serialport'; +import { SerialPortOpenOptions } from 'serialport'; import { SerialPortStream } from '@serialport/stream'; import SerialPortFactory from '../../factory/serialPortFactory.js'; +import { AutoDetectTypes } from '@serialport/bindings-cpp'; import BaseError from 'modern-errors'; -import DeviceManager from '../deviceManager.js'; +import DeviceManager, { DeviceInfo, DeviceManagerEvent } from '../deviceManager.js'; +import { asyncHandler } from '../../util/async.js'; import { logError } from '../../util/error.js'; -import { usb } from 'usb'; -import PeripheralDevice from '../peripheralDevice.js'; -import { DeviceId } from '../deviceId.js'; -import SerialProtocolFactory, { SerialDeviceInfo } from './serialProtocolFactory.js'; - -/** - * Owns serial port discovery (USB list + hotplug events) and, for every newly discovered port, - * tries every registered protocol factory in registration order until one of them successfully - * connects. - * - * Absorbs what used to be a separate `SerialPortObserver` transport class. Since multiple - * protocols compete for the same physical serial ports, connection attempts for a given port are - * tried strictly one factory at a time (in registration order), reopening the port fresh with - * each factory's own port settings (e.g. baud rate) between attempts. - */ -export default class SerialDeviceProvider extends DeviceProvider +import { SerialDeviceInfo } from '../transport/serialPortObserver.js'; +import PeripheralDevice, { InferPeripheralDeviceAttributes, InferPeripheralDeviceConfig } from '../peripheralDevice.js'; +import { DeviceAttributes } from '../device.js'; +import { AnyDeviceConfig } from '../deviceConfig.js'; + +export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; + +export default abstract class SerialDeviceProvider< + D extends PeripheralDevice, + TAttributes extends DeviceAttributes = InferPeripheralDeviceAttributes, + TConfig extends AnyDeviceConfig = InferPeripheralDeviceConfig +> extends DeviceProvider { private readonly serialPortFactory: SerialPortFactory; - private readonly factories: SerialProtocolFactory[] = []; + private connectedDevices: Map = new Map(); - private readonly connectedDevices: Map> = new Map(); + private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; - private readonly inFlightDeviceIds: Set = new Set(); - - private readonly managedPortIds: Set = new Set(); - - private onUsbEventRef?: () => void; - - private rescanTimer?: NodeJS.Timeout; - - private discoveryInFlight = false; - - public constructor(deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, eventEmitter: EventEmitter, logger: Logger) { - super(deviceManager, eventEmitter, logger.child({ name: SerialDeviceProvider.name })); + protected constructor(deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, eventEmitter: EventEmitter, logger: Logger) { + super(deviceManager, eventEmitter, logger); this.serialPortFactory = serialPortFactory; - } - - public registerFactory(factory: SerialProtocolFactory): this { - this.factories.push(factory); - return this; - } - - public override async init(): Promise { - await this.discoverSerialDevices(); + this.deviceDetectedListener = asyncHandler( + this.handleDeviceDetection.bind(this), + (err: unknown) => logError(this.logger, 'Error in device detection handler', err) + ); - this.onUsbEventRef = (): void => { - this.logger.debug('USB event detected, scanning for serial devices in 1s...'); - - if (this.rescanTimer !== undefined) { - clearTimeout(this.rescanTimer); - } - - this.rescanTimer = setTimeout(() => { - if (this.discoveryInFlight) { - return; - } - this.discoveryInFlight = true; - this.discoverSerialDevices() - .catch(e => logError(this.logger, 'Error while scanning for new serial devices', e)) - .finally(() => { - this.discoveryInFlight = false; - }); - }, 1000); - }; - - usb.addEventListener('connect', this.onUsbEventRef); - usb.addEventListener('disconnect', this.onUsbEventRef); + this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); } - public async discoverSerialDevices(): Promise { - const foundPortIds: Set = new Set(); - const connectAttempts: Promise[] = []; - - try { - const ports = await SerialPort.list(); - - for (const portInfo of ports) { - if (undefined === portInfo.vendorId || undefined === portInfo.productId) { - continue; - } - - // If the serial number is not defined, create a "unique" one based on vendorId and productId - if (undefined === portInfo.serialNumber) { - portInfo.serialNumber = `serial-${portInfo.vendorId}-${portInfo.productId}-${portInfo.locationId}`; - } - - foundPortIds.add(portInfo.serialNumber); - - if (!this.managedPortIds.has(portInfo.serialNumber)) { - this.managedPortIds.add(portInfo.serialNumber); - this.logger.debug(`Managed devices: ${this.managedPortIds.size}`); - - const deviceInfo: SerialDeviceInfo = { id: DeviceId.create(portInfo.serialNumber), portInfo }; - - connectAttempts.push( - this.attemptConnect(deviceInfo) - .catch((err: unknown) => logError(this.logger, `Error while connecting to serial device '${portInfo.path}'`, err)) - ); - } - } - - // Forget devices that are no longer present, so they can be tried again if they reappear - for (const portId of this.managedPortIds) { - if (!foundPortIds.has(portId)) { - this.managedPortIds.delete(portId); - this.logger.info(`Managed devices: ${this.managedPortIds.size}`); - } - } - } catch (err) { - logError(this.logger, 'Could not list serial ports', err); + private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { + if (!this.isSerialDeviceInfo(deviceInfo)) { + return; } - await Promise.all(connectAttempts); - } + this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); - public override async stop(): Promise { - if (this.rescanTimer !== undefined) { - clearTimeout(this.rescanTimer); - this.rescanTimer = undefined; - } - - if (this.onUsbEventRef !== undefined) { - usb.removeEventListener('connect', this.onUsbEventRef); - usb.removeEventListener('disconnect', this.onUsbEventRef); - this.onUsbEventRef = undefined; - } - } + const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - private async attemptConnect(deviceInfo: SerialDeviceInfo): Promise { - if (this.inFlightDeviceIds.has(deviceInfo.id) || null !== this.deviceManager.getConnectedDevice(deviceInfo.id)) { + if (false === acquireResult.successful) { + this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); return; } - this.inFlightDeviceIds.add(deviceInfo.id); - try { - for (const factory of this.factories) { - let device: PeripheralDevice | undefined; - - try { - device = await this.connectWithFactory(factory, deviceInfo); - } catch (e: unknown) { - logError(this.logger, `Error while connecting to serial device '${deviceInfo.portInfo.path}' via '${factory.protocolName}'`, e); - continue; - } - - if (undefined === device) { - continue; - } - - this.connectedDevices.set(device.getDeviceId, device); - this.deviceManager.addDevice(device); - - this.logger.debug(`Assigned device id: ${device.getDeviceId} (${deviceInfo.portInfo.path})`); - this.logger.info(`Connected devices: ${this.connectedDevices.size}`); + const device = await this.connectToDevice(deviceInfo); + if (undefined === device) { + this.deviceManager.releaseDetectedDevice(deviceInfo.id); return; } - this.logger.info(`Could not identify serial device '${deviceInfo.portInfo.path}': no matching protocol found`); - } finally { - this.inFlightDeviceIds.delete(deviceInfo.id); + this.deviceManager.addDevice(device); + this.deviceManager.claimDetectedDevice(deviceInfo.id); + } catch (e: unknown) { + logError(this.logger, `Error while connecting to device`, e); + this.deviceManager.releaseDetectedDevice(deviceInfo.id); } } - private async connectWithFactory( - factory: SerialProtocolFactory, - deviceInfo: SerialDeviceInfo - ): Promise | undefined> { + private isSerialDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is SerialDeviceInfo + { + return deviceInfo.type === 'serial'; + } + + private async connectToDevice(deviceInfo: SerialDeviceInfo): Promise { const portInfo = deviceInfo.portInfo; - this.logger.info(`Connection attempt for serial device '${portInfo.path}' via '${factory.protocolName}' (s/n: ${portInfo.serialNumber})`); + this.logger.info(`Connection attempt for serial device '${portInfo.path}' (s/n: ${portInfo.serialNumber})`); const port = this.serialPortFactory.create({ path: portInfo.path, autoOpen: false, - ...factory.getPortOpenOptions(portInfo) + ...this.getSerialDeviceProviderPortOpenOptions(portInfo) }); - let device: PeripheralDevice | undefined; + let device: D | undefined; let attemptFailureReason = 'unknown'; try { @@ -198,10 +96,10 @@ export default class SerialDeviceProvider extends DeviceProvider port.open(err => err ? reject(err) : resolve()); }); - await (factory.preparePort ?? SerialDeviceProvider.noopPreparePort)(port, portInfo); + await this.preparePort(port, portInfo); - device = await factory.tryConnect(deviceInfo, port); - } catch (e: unknown) { + device = await this.connectSerialDevice(deviceInfo, port); + } catch(e: unknown) { if (undefined !== device) { try { await device.close(); @@ -219,16 +117,19 @@ export default class SerialDeviceProvider extends DeviceProvider port.close(err => err ? reject(err) : resolve()); }); } - this.logger.info(`Could not connect to serial device '${portInfo.path}' via '${factory.protocolName}': ${attemptFailureReason}`); + this.logger.info(`Could not connect to serial device '${portInfo.path}': ${attemptFailureReason}`); } else { - this.logger.info(`Successfully connected to serial device '${portInfo.path}' via '${factory.protocolName}'`); + this.logger.info(`Successfully connected to serial device '${portInfo.path}'`); - const connectedDevice = device; + 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(connectedDevice.getDeviceId); + this.connectedDevices.delete(device.getDeviceId); - this.logger.info(`Lost serial device: ${connectedDevice.getDeviceId}`); + this.logger.info(`Lost serial device: ${device.getDeviceId}`); this.logger.info(`Connected devices: ${this.connectedDevices.size}`); }); } @@ -237,7 +138,15 @@ export default class SerialDeviceProvider extends DeviceProvider } // eslint-disable-next-line @typescript-eslint/no-unused-vars - private static noopPreparePort(port: SerialPortStream, portInfo: PortInfo): Promise { + protected preparePort(port: SerialPortStream, portInfo: PortInfo): Promise { return Promise.resolve(); } + + public override async stop(): Promise { + this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + } + + protected abstract connectSerialDevice(deviceInfo: DeviceInfo, port: SerialPortStream): Promise; + + protected abstract getSerialDeviceProviderPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; } diff --git a/src/device/provider/serialProtocolFactory.ts b/src/device/provider/serialProtocolFactory.ts deleted file mode 100644 index 1dc04179..00000000 --- a/src/device/provider/serialProtocolFactory.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { BindingInterface, PortInfo } from '@serialport/bindings-interface'; -import { SerialPortStream } from '@serialport/stream'; -import { SerialPortOpenOptions } from 'serialport'; -import { AutoDetectTypes } from '@serialport/bindings-cpp'; -import PeripheralDevice, { InferPeripheralDeviceAttributes, InferPeripheralDeviceConfig } from '../peripheralDevice.js'; -import { DeviceAttributes } from '../device.js'; -import { AnyDeviceConfig } from '../deviceConfig.js'; -import { DeviceId } from '../deviceId.js'; - -export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; - -export type SerialDeviceInfo = { - id: DeviceId; - portInfo: PortInfo; -}; - -/** - * Implemented by protocol-specific factories that know how to probe a freshly discovered serial - * port and, if it speaks their protocol, construct the resulting Device. - * - * `SerialDeviceProvider` tries every registered factory (in registration order) against a newly - * discovered port until one of them successfully connects, or none of them do. Since each - * protocol may require different port settings (e.g. baud rate), the port is (re-)opened fresh - * with `getPortOpenOptions()` before every attempt. - */ -export default interface SerialProtocolFactory< - D extends PeripheralDevice, - TAttributes extends DeviceAttributes = InferPeripheralDeviceAttributes, - TConfig extends AnyDeviceConfig = InferPeripheralDeviceConfig -> -{ - readonly protocolName: string; - - getPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; - - /** - * Attempt to connect to and identify the device at the other end of an already-open port as - * this factory's protocol. Resolve with `undefined` (rather than throwing) when the device - * simply doesn't speak this protocol, so the provider can move on to the next registered - * factory. - */ - tryConnect(deviceInfo: SerialDeviceInfo, port: SerialPortStream): Promise; - - /** - * Optional hook to prepare a freshly opened port before `tryConnect()` is called (e.g. - * waiting for a module-ready signal). Defaults to a no-op when omitted. - */ - preparePort?(port: SerialPortStream, portInfo: PortInfo): Promise; -} diff --git a/src/device/transport/bleDeviceTransport.ts b/src/device/transport/bleDeviceTransport.ts index 506779ef..042c8923 100644 --- a/src/device/transport/bleDeviceTransport.ts +++ b/src/device/transport/bleDeviceTransport.ts @@ -60,7 +60,7 @@ export default class BleUartDeviceTransport implements DeviceBidirectionalTransp } const { characteristics } = await this.peripheral.discoverSomeServicesAndCharacteristicsAsync( - [/* AiroticDeviceFactory.UART_SERVICE_UUID */], + [/* AiroticDeviceProvider.UART_SERVICE_UUID */], [this.uartRxCharacteristicUuid, this.uartTxCharacteristicUuid], ); diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts new file mode 100644 index 00000000..8374bb18 --- /dev/null +++ b/src/device/transport/bleObserver.ts @@ -0,0 +1,98 @@ +import noble, { Peripheral } from '@stoprocent/noble'; +import Logger from '../../logging/Logger.js'; +import DeviceManager, { DeviceInfo } from '../deviceManager.js'; +import { logError } from '../../util/error.js'; +import { DeviceId } from '../deviceId.js'; +import { asyncHandler } from '../../util/async.js'; + +export type BleDeviceInfo = DeviceInfo & { + type: 'ble'; + peripheral: Peripheral; +}; + +export default class BleObserver +{ + private static readonly MIN_RSSI = -70; + private static readonly UART_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; + + private readonly deviceManager: DeviceManager; + + private readonly logger: Logger; + + private isScanning = false; + + public constructor( + deviceManager: DeviceManager, + logger: Logger + ) { + this.deviceManager = deviceManager; + this.logger = logger.child({ name: BleObserver.name }); + } + + public async init(): 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(); + } + + public async stop(): Promise + { + noble.removeAllListeners(); + + if (this.isScanning) { + await noble.stopScanningAsync(); + this.isScanning = false; + } + + noble.stop(); + } + + private onDiscover(peripheral: Peripheral): void + { + if (peripheral.rssi < BleObserver.MIN_RSSI) { + // Ignore devices with very weak signal, as they are likely not in range or just noise + this.logger.debug(`Ignoring device ${peripheral.id} with weak signal (RSSI: ${peripheral.rssi})`); + return; + } + + const deviceInfo: BleDeviceInfo = { + type: 'ble', + id: DeviceId.create(peripheral.id), + peripheral, + }; + + this.deviceManager.announceDetectedDevice(deviceInfo); + } + + private async observe(): Promise { + if (this.isScanning) { + return; + } + + try { + // Wait for Adapter poweredOn state + await noble.waitForPoweredOnAsync(); + + this.isScanning = true; + await noble.startScanningAsync([BleObserver.UART_SERVICE_UUID], true); + + this.logger.info('Looking for BLE UART devices'); + } catch (error: unknown) { + logError(this.logger, 'BLE device discovery error', error); + this.isScanning = false; + await noble.stopScanningAsync(); + } + } +} diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts new file mode 100644 index 00000000..a3d78f69 --- /dev/null +++ b/src/device/transport/serialPortObserver.ts @@ -0,0 +1,125 @@ +import { SerialPort } from 'serialport'; +import { PortInfo } from '@serialport/bindings-interface'; +import Logger from '../../logging/Logger.js'; +import DeviceManager, { DeviceInfo } from '../deviceManager.js'; +import { usb } from 'usb'; +import { logError } from '../../util/error.js'; +import { DeviceId } from '../deviceId.js'; + +export type SerialDeviceInfo = DeviceInfo & { + type: 'serial'; + portInfo: PortInfo; +}; + +export default class SerialPortObserver +{ + protected readonly logger: Logger; + + protected readonly deviceManager: DeviceManager; + + public static readonly name = 'serial'; + + private managedDevices: Map = new Map(); + + private onUsbEventRef?: () => void; + + private rescanTimer?: NodeJS.Timeout; + + private discoveryInFlight = false; + + public constructor( + deviceManager: DeviceManager, + logger: Logger + ) { + this.deviceManager = deviceManager; + this.logger = logger.child({ name: SerialPortObserver.name }); + } + + public async start(): Promise + { + await this.discoverSerialDevices(); + + this.onUsbEventRef = (): void => { + this.logger.debug('USB event detected, scanning for serial devices in 1s...'); + + if (this.rescanTimer !== undefined) { + clearTimeout(this.rescanTimer); + } + + this.rescanTimer = setTimeout(() => { + if (this.discoveryInFlight) { + return; + } + this.discoveryInFlight = true; + this.discoverSerialDevices() + .catch(e => logError(this.logger, 'Error while scanning for new serial devices', e)) + .finally(() => { + this.discoveryInFlight = false; + }); + }, 1000); + }; + + usb.addEventListener('connect', this.onUsbEventRef); + usb.addEventListener('disconnect', this.onUsbEventRef); + } + + public async discoverSerialDevices(): Promise + { + const foundDevices: Map = new Map(); + + try { + const ports = await SerialPort.list(); + + // Iterate through all serial ports and add them to the managed devices and try to connect + for (const portInfo of ports) { + if (undefined === portInfo.vendorId || undefined === portInfo.productId) { + continue; + } + + // If the serial number is not defined, create a "unique" one based on vendorId and productId + if (undefined === portInfo.serialNumber) { + portInfo.serialNumber = `serial-${portInfo.vendorId}-${portInfo.productId}-${portInfo.locationId}`; + } + + foundDevices.set(portInfo.serialNumber, null); + + if (!this.managedDevices.has(portInfo.serialNumber)) { + const deviceInfo: SerialDeviceInfo = { + type: 'serial', + id: DeviceId.create(portInfo.serialNumber), + portInfo + }; + + this.managedDevices.set(portInfo.serialNumber, deviceInfo); + this.logger.debug(`Managed devices: ${this.managedDevices.size}`); + + this.deviceManager.announceDetectedDevice(deviceInfo); + } + } + + // Remove devices that are no longer present + for (const [key, deviceInfo] of this.managedDevices) { + if (!foundDevices.has(key)) { + this.deviceManager.revokeDetectedDevice(deviceInfo); + this.managedDevices.delete(key); + this.logger.info(`Managed devices: ${this.managedDevices.size}`); + } + } + } catch (err) { + logError(this.logger, 'Could not list serial ports', err); + } + } + + public async stop(): Promise { + if (this.rescanTimer !== undefined) { + clearTimeout(this.rescanTimer); + this.rescanTimer = undefined; + } + + if (this.onUsbEventRef !== undefined) { + usb.removeEventListener('connect', this.onUsbEventRef); + usb.removeEventListener('disconnect', this.onUsbEventRef); + this.onUsbEventRef = undefined; + } + } +} diff --git a/src/serviceMap.ts b/src/serviceMap.ts index eea747ed..e27d557c 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -38,16 +38,18 @@ import PutSettingsController from './controller/settings/putSettingsController.j 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'; import VirtualDeviceFactory from './device/protocol/virtual/virtualDeviceFactory.js'; import SerialPortFactory from './factory/serialPortFactory.js'; import Estim2bDeviceFactory from './device/protocol/estim2b/estim2bDeviceFactory.js'; import EventEmitterFactory from './factory/eventEmitterFactory.js'; +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 AiroticDeviceFactory from './device/protocol/airotic/airoticDeviceFactory.js'; -import BleDeviceProvider from './device/provider/bleDeviceProvider.js'; -import SerialDeviceProvider from './device/provider/serialDeviceProvider.js'; -import KnownDeviceRegistry from './device/knownDeviceRegistry.js'; +import AiroticDeviceProvider from './device/protocol/airotic/airoticDeviceProvider.js'; type ServiceMap = { @@ -65,16 +67,18 @@ type ServiceMap = { 'device.serial.factory.slvCtrlPlus': SlvCtrlPlusDeviceFactory, 'device.factory.zc95': Zc95DeviceFactory, 'device.factory.estim2b': Estim2bDeviceFactory, + 'device.provider.factory.slvCtrlPlusSerial': DeviceProviderFactory, + 'device.provider.factory.zc95Serial': DeviceProviderFactory, + 'device.provider.factory.estim2bSerial': DeviceProviderFactory, 'device.provider.factory.buttplugIoWebsocket': DeviceProviderFactory, - 'device.factory.airotic': AiroticDeviceFactory, + 'device.provider.factory.airotic': DeviceProviderFactory, 'device.serial.factory.buttplugIo': ButtplugIoDeviceFactory, 'device.virtual.provider': VirtualDeviceProvider, 'device.virtual.factory': VirtualDeviceFactory, 'device.uniqueNameGenerator': DeviceNameGenerator, - 'device.knownDeviceRegistry': KnownDeviceRegistry, 'device.updater': DeviceUpdaterInterface, - 'device.provider.serial': SerialDeviceProvider, - 'device.provider.ble': BleDeviceProvider, + 'device.observer.serial': SerialPortObserver, + 'device.observer.ble': BleObserver, /* factoryServiceProvider */ 'factory.uuid': UuidFactory, diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index 46dda484..ffe303ac 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -9,6 +9,7 @@ import GenericDeviceUpdater from '../device/genericDeviceUpdater.js'; import SerialDeviceTransportFactory from '../device/transport/serialDeviceTransportFactory.js'; import Device 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'; @@ -21,6 +22,8 @@ import DisplayVirtualDeviceLogic from '../device/protocol/virtual/display/displa import RandomGeneratorVirtualDeviceLogic from '../device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceLogic.js'; import TtsVirtualDeviceLogic from '../device/protocol/virtual/audio/ttsVirtualDeviceLogic.js'; +import Zc95SerialDeviceProvider from '../device/protocol/zc95/zc95SerialDeviceProvider.js'; +import SerialPortObserver from '../device/transport/serialPortObserver.js'; import Zc95DeviceFactory from '../device/protocol/zc95/zc95DeviceFactory.js'; import PiperVirtualDeviceLogic from '../device/protocol/virtual/audio/piperVirtualDeviceLogic.js'; import { piperVirtualDeviceConfigSchema } from '../device/protocol/virtual/audio/piperVirtualDeviceConfig.js'; @@ -30,13 +33,13 @@ import { } from '../device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceConfig.js'; import { ttsVirtualDeviceConfigSchema } from '../device/protocol/virtual/audio/ttsVirtualDeviceConfig.js'; import GenericVirtualDeviceLogicFactory from '../device/protocol/virtual/genericVirtualDeviceLogicFactory.js'; +import GenericDeviceProviderFactory from '../device/provider/genericDeviceProviderFactory.js'; +import EStim2bSerialDeviceProvider from '../device/protocol/estim2b/estim2bSerialDeviceProvider.js'; import Estim2bDeviceFactory from '../device/protocol/estim2b/estim2bDeviceFactory.js'; -import BleDeviceProvider from '../device/provider/bleDeviceProvider.js'; -import AiroticDeviceFactory from '../device/protocol/airotic/airoticDeviceFactory.js'; -import SerialDeviceProvider from '../device/provider/serialDeviceProvider.js'; +import BleObserver from '../device/transport/bleObserver.js'; +import AiroticDeviceProvider from '../device/protocol/airotic/airoticDeviceProvider.js'; import DeviceProviderFactory from '../device/provider/deviceProviderFactory.js'; import { DeviceId } from '../device/deviceId.js'; -import KnownDeviceRegistry from '../device/knownDeviceRegistry.js'; export default class DeviceServiceProvider implements ServiceProvider { public register(container: Pimple): void { @@ -45,12 +48,24 @@ export default class DeviceServiceProvider implements ServiceProvider new SerialDeviceTransportFactory() ); + container.set( + 'device.provider.factory.slvCtrlPlusSerial', + () => new GenericDeviceProviderFactory( + SlvCtrlPlusSerialDeviceProvider, + container.get('device.manager'), + container.get('factory.serialPort'), + container.get('factory.eventEmitter').create(), + container.get('device.serial.factory.slvCtrlPlus'), + container.get('device.serial.transport.factory'), + container.get('logger.default'), + ) + ); + container.set( 'device.provider.factory.buttplugIoWebsocket', () => new ButtplugIoWebsocketDeviceProviderFactory( container.get('device.manager'), container.get('factory.eventEmitter').create(), - container.get('device.knownDeviceRegistry'), container.get('device.serial.factory.buttplugIo'), container.get('logger.default'), ) @@ -75,23 +90,18 @@ export default class DeviceServiceProvider implements ServiceProvider new KnownDeviceRegistry( - container.get('settings'), - container.get('device.uniqueNameGenerator'), - container.get('logger.default'), - )); - container.set('device.serial.factory.slvCtrlPlus', () => new SlvCtrlPlusDeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), - container.get('device.knownDeviceRegistry'), - container.get('device.serial.transport.factory'), + container.get('settings'), + container.get('device.uniqueNameGenerator'), container.get('logger.default'), )); container.set('device.serial.factory.buttplugIo', () => new ButtplugIoDeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), + container.get('settings'), container.get('logger.default'), )); @@ -100,20 +110,14 @@ export default class DeviceServiceProvider implements ServiceProvider new Estim2bDeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), - container.get('device.knownDeviceRegistry'), - container.get('device.serial.transport.factory'), - container.get('logger.default'), - )); - - container.set('device.factory.airotic', () => new AiroticDeviceFactory( - container.get('device.knownDeviceRegistry'), + container.get('settings'), + container.get('device.uniqueNameGenerator'), container.get('logger.default'), )); @@ -168,6 +172,10 @@ export default class DeviceServiceProvider implements ServiceProvider { return new DeviceProviderManager( new Map>([ + [ + SlvCtrlPlusSerialDeviceProvider.providerName, + container.get('device.provider.factory.slvCtrlPlusSerial'), + ], [ ButtplugIoWebsocketDeviceProvider.providerName, container.get('device.provider.factory.buttplugIoWebsocket'), @@ -176,48 +184,69 @@ export default class DeviceServiceProvider implements ServiceProvider { - const provider = new SerialDeviceProvider( + container.set('device.provider.factory.zc95Serial', () => { + return new GenericDeviceProviderFactory( + Zc95SerialDeviceProvider, container.get('device.manager'), container.get('factory.serialPort'), + container.get('device.serial.transport.factory'), container.get('factory.eventEmitter').create(), + container.get('device.factory.zc95'), container.get('logger.default'), ); - - const serialFactoriesByProtocolName = new Map void>([ - [SlvCtrlPlusDeviceFactory.protocolName, (): void => { provider.registerFactory(container.get('device.serial.factory.slvCtrlPlus')); }], - [Zc95DeviceFactory.protocolName, (): void => { provider.registerFactory(container.get('device.factory.zc95')); }], - [Estim2bDeviceFactory.protocolName, (): void => { provider.registerFactory(container.get('device.factory.estim2b')); }], - ]); - - for (const [, deviceSource] of container.get('settings').getDeviceSources()) { - serialFactoriesByProtocolName.get(deviceSource.type)?.(); - } - - return provider; }); - container.set('device.provider.ble', () => { - const provider = new BleDeviceProvider( + container.set('device.provider.factory.estim2bSerial', () => { + return new GenericDeviceProviderFactory( + EStim2bSerialDeviceProvider, container.get('device.manager'), + container.get('factory.serialPort'), + container.get('device.serial.transport.factory'), container.get('factory.eventEmitter').create(), + container.get('device.factory.estim2b'), container.get('logger.default'), ); + }); - const bleFactoriesByProtocolName = new Map void>([ - [AiroticDeviceFactory.protocolName, (): void => { provider.registerFactory(container.get('device.factory.airotic')); }], - ]); + container.set('device.provider.factory.airotic', () => { + return new GenericDeviceProviderFactory( + AiroticDeviceProvider, + container.get('device.manager'), + container.get('settings'), + container.get('factory.eventEmitter').create(), + container.get('logger.default'), + ); + }); - for (const [, deviceSource] of container.get('settings').getDeviceSources()) { - bleFactoriesByProtocolName.get(deviceSource.type)?.(); - } + container.set('device.observer.serial', () => { + return new SerialPortObserver( + container.get('device.manager'), + container.get('logger.default') + ); + }); - return provider; + container.set('device.observer.ble', () => { + return new BleObserver( + container.get('device.manager'), + container.get('logger.default') + ); }); } } diff --git a/src/settings/knownDevice.ts b/src/settings/knownDevice.ts index 99fbcf83..bbf224b3 100644 --- a/src/settings/knownDevice.ts +++ b/src/settings/knownDevice.ts @@ -15,18 +15,18 @@ export default class KnownDevice private readonly _type: string; @Expose({ name: 'source' }) - private readonly _provider: string; + private readonly _source: string; @Expose({ name: 'config' }) private readonly _config: JsonObject; public constructor( - id: DeviceId, name: string, type: string, provider: string, config: JsonObject = {} + id: DeviceId, name: string, type: string, source: string, config: JsonObject = {} ) { this._id = id; this._name = name; this._type = type; - this._provider = provider; + this._source = source; this._config = config; } @@ -43,7 +43,7 @@ export default class KnownDevice } public get source(): string { - return this._provider; + return this._source; } public get config(): JsonObject { diff --git a/src/settings/settingsManager.ts b/src/settings/settingsManager.ts index 6b0bb16e..ee05964e 100644 --- a/src/settings/settingsManager.ts +++ b/src/settings/settingsManager.ts @@ -5,7 +5,7 @@ import Settings from './settings.js'; import type { SettingsSchema } from './settings.js'; import onChange from 'on-change'; import DeviceSource from './deviceSource.js'; -import SlvCtrlPlusDeviceFactory from '../device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.js'; +import SlvCtrlPlusSerialDeviceProvider from '../device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; import Logger from '../logging/Logger.js'; import JsonSchemaValidator from '../schemaValidation/JsonSchemaValidator.js'; import EventEmitter from 'events'; @@ -117,7 +117,7 @@ export default class SettingsManager settings.addDeviceSource(new DeviceSource( 'b6a0f45e-c3d0-4dca-ab81-7daac0764291', - SlvCtrlPlusDeviceFactory.protocolName, + SlvCtrlPlusSerialDeviceProvider.providerName, {} )); diff --git a/tests/integration/devices/airoticDevice.spec.ts b/tests/integration/devices/airoticDevice.spec.ts index 2585cc22..120cda11 100644 --- a/tests/integration/devices/airoticDevice.spec.ts +++ b/tests/integration/devices/airoticDevice.spec.ts @@ -1,7 +1,7 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import request from 'supertest'; import { io as ioClient } from 'socket.io-client'; -import AiroticDeviceFactory from '../../../src/device/protocol/airotic/airoticDeviceFactory.js'; +import AiroticDeviceProvider from '../../../src/device/protocol/airotic/airoticDeviceProvider.js'; import WebSocketEvent from '../../../src/device/webSocketEvent.js'; import { AiroticDeviceSimulator, mockNoble } from '../helpers/airoticDeviceSimulator.js'; import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; @@ -25,7 +25,7 @@ const bleSettings = { deviceSources: { [BLE_SOURCE_ID]: { id: BLE_SOURCE_ID, - type: AiroticDeviceFactory.protocolName, + type: AiroticDeviceProvider.providerName, config: {}, }, }, @@ -66,7 +66,7 @@ describe('Airotic BLE device provider', () => { const [payload] = await deviceConnected; const expectedDeviceObject = { - provider: AiroticDeviceFactory.protocolName, + provider: AiroticDeviceProvider.providerName, type: 'airotic', attributes: { restColor: { diff --git a/tests/integration/devices/estim2bDevice.spec.ts b/tests/integration/devices/estim2bDevice.spec.ts index dbb751b6..866bfb1c 100644 --- a/tests/integration/devices/estim2bDevice.spec.ts +++ b/tests/integration/devices/estim2bDevice.spec.ts @@ -1,7 +1,7 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import request from 'supertest'; import { io as ioClient } from 'socket.io-client'; -import Estim2bDeviceFactory from '../../../src/device/protocol/estim2b/estim2bDeviceFactory.js'; +import EStim2bSerialDeviceProvider from '../../../src/device/protocol/estim2b/estim2bSerialDeviceProvider.js'; import WebSocketEvent from '../../../src/device/webSocketEvent.js'; import { Estim2bDeviceSimulator } from '../helpers/estim2bDeviceSimulator.js'; import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; @@ -17,7 +17,7 @@ const serialSettings = { deviceSources: { [SERIAL_SOURCE_ID]: { id: SERIAL_SOURCE_ID, - type: Estim2bDeviceFactory.protocolName, + type: EStim2bSerialDeviceProvider.providerName, config: {}, }, }, @@ -45,7 +45,7 @@ describe('E-Stim Systems 2B serial device provider', () => { beforeEach(async () => { await app.container.get('device.manager').reset(); app.mockSerialPortFactory.reset(); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); wsEmitSpy.mockClear(); }); @@ -55,12 +55,12 @@ describe('E-Stim Systems 2B serial device provider', () => { const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const expectedDeviceObject = { - provider: Estim2bDeviceFactory.protocolName, + provider: EStim2bSerialDeviceProvider.providerName, type: 'estim2b', attributes: { mode: { @@ -136,7 +136,7 @@ describe('E-Stim Systems 2B serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -165,7 +165,7 @@ describe('E-Stim Systems 2B serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -203,7 +203,7 @@ describe('E-Stim Systems 2B serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; diff --git a/tests/integration/devices/slvCtrlSerialDevice.spec.ts b/tests/integration/devices/slvCtrlSerialDevice.spec.ts index 6bd4e610..e0330590 100644 --- a/tests/integration/devices/slvCtrlSerialDevice.spec.ts +++ b/tests/integration/devices/slvCtrlSerialDevice.spec.ts @@ -1,7 +1,7 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import request from 'supertest'; import { io as ioClient } from 'socket.io-client'; -import SlvCtrlPlusDeviceFactory from '../../../src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.js'; +import SlvCtrlPlusSerialDeviceProvider from '../../../src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; import WebSocketEvent from '../../../src/device/webSocketEvent.js'; import { SlvCtrlPlusDeviceSimulator } from '../helpers/slvCtrlPlusDeviceSimulator.js'; import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; @@ -18,7 +18,7 @@ const serialSettings = { deviceSources: { [SERIAL_SOURCE_ID]: { id: SERIAL_SOURCE_ID, - type: SlvCtrlPlusDeviceFactory.protocolName, + type: SlvCtrlPlusSerialDeviceProvider.providerName, config: {}, }, }, @@ -50,7 +50,7 @@ describe('SlvCtrl serial device provider', () => { // timer alive and floods the event loop with I/O errors after the binding is torn down. await app.container.get('device.manager').reset(); app.mockSerialPortFactory.reset(); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); wsEmitSpy.mockClear(); }); @@ -64,12 +64,12 @@ describe('SlvCtrl serial device provider', () => { const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const expectedDeviceObject = { - provider: SlvCtrlPlusDeviceFactory.protocolName, + provider: SlvCtrlPlusSerialDeviceProvider.providerName, type: 'slvCtrlPlus', attributes: { connected: { @@ -188,7 +188,7 @@ describe('SlvCtrl serial device provider', () => { app.mockSerialPortFactory.attachDevice(portPath, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -225,7 +225,7 @@ describe('SlvCtrl serial device provider', () => { app.mockSerialPortFactory.attachDevice(portPath, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -268,7 +268,7 @@ describe('SlvCtrl serial device provider', () => { app.mockSerialPortFactory.attachDevice(portPath, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; diff --git a/tests/integration/devices/zc95Device.spec.ts b/tests/integration/devices/zc95Device.spec.ts index 2d2e8f21..95934dd8 100644 --- a/tests/integration/devices/zc95Device.spec.ts +++ b/tests/integration/devices/zc95Device.spec.ts @@ -1,7 +1,7 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import request from 'supertest'; import { io as ioClient } from 'socket.io-client'; -import Zc95DeviceFactory from '../../../src/device/protocol/zc95/zc95DeviceFactory.js'; +import Zc95SerialDeviceProvider from '../../../src/device/protocol/zc95/zc95SerialDeviceProvider.js'; import WebSocketEvent from '../../../src/device/webSocketEvent.js'; import { Zc95DeviceSimulator } from '../helpers/zc95DeviceSimulator.js'; import { createTestApp, teardownTestApp, waitForNextWsEvent, createWsClient, TestApp } from '../helpers/appHelper.js'; @@ -17,7 +17,7 @@ const serialSettings = { deviceSources: { [SERIAL_SOURCE_ID]: { id: SERIAL_SOURCE_ID, - type: Zc95DeviceFactory.protocolName, + type: Zc95SerialDeviceProvider.providerName, config: {}, }, }, @@ -45,7 +45,7 @@ describe('Zc95 serial device provider', () => { beforeEach(async () => { await app.container.get('device.manager').reset(); app.mockSerialPortFactory.reset(); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); wsEmitSpy.mockClear(); }); @@ -55,12 +55,12 @@ describe('Zc95 serial device provider', () => { const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const expectedDeviceObject = { - provider: Zc95DeviceFactory.protocolName, + provider: Zc95SerialDeviceProvider.providerName, type: 'zc95', attributes: { activePattern: { @@ -98,7 +98,7 @@ describe('Zc95 serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -161,7 +161,7 @@ describe('Zc95 serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -246,7 +246,7 @@ describe('Zc95 serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; @@ -306,7 +306,7 @@ describe('Zc95 serial device provider', () => { app.mockSerialPortFactory.attachDevice(PORT_PATH, simulator); const deviceConnected = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceConnected); - await app.container.get('device.provider.serial').discoverSerialDevices(); + await app.container.get('device.observer.serial').discoverSerialDevices(); const [payload] = await deviceConnected; const deviceId = payload.deviceId; diff --git a/tests/integration/helpers/mockSerialPortFactory.ts b/tests/integration/helpers/mockSerialPortFactory.ts index 22f91cca..1175bff2 100644 --- a/tests/integration/helpers/mockSerialPortFactory.ts +++ b/tests/integration/helpers/mockSerialPortFactory.ts @@ -19,7 +19,7 @@ export default class MockSerialPortFactory extends SerialPortFactory { private readonly simulators: Map = new Map(); // Incremented on every attachDevice call so each mock port gets a unique serialNumber. - // Without this the SerialDeviceProvider synthesizes the same "serial-1234-5678-undefined" + // Without this the SerialPortObserver synthesizes the same "serial-1234-5678-undefined" // for every mock port (vendorId + productId + missing locationId), causing all devices // across all test iterations to share a single UUID via createKnownDevice. A unique // serial per connection means each device gets its own UUID and stale-device async-close diff --git a/tests/unit/device/deviceManager.spec.ts b/tests/unit/device/deviceManager.spec.ts index b35e68d6..3e4c0e22 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 } from "../../../src/device/deviceManager.js"; +import DeviceManager, { DeviceManagerEvent, DeviceInfo } from "../../../src/device/deviceManager.js"; import {EventEmitter} from "events"; import Device from "../../../src/device/device.js"; import TestDevice from "./testDevice.js"; @@ -109,4 +109,182 @@ describe('deviceManager', () => { expect(manager.getConnectedDevice('unknown-uuid')).toBeNull(); }); }); + + describe('announceDetectedDevice', () => { + let mockedLogger: ReturnType>; + let mockedEventEmitter: ReturnType>; + const deviceId = DeviceId.create('device-1'); + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + mockedEventEmitter = mock(); + }); + + it('emits deviceDetected event for a newly seen device', () => { + mockedEventEmitter.emit.mockReturnValue(true); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + + expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + }); + + it('does not re-announce a device already in the acquire queue', () => { + mockedEventEmitter.emit.mockReturnValue(true); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + manager.announceDetectedDevice(deviceInfo); + + expect(mockedEventEmitter.emit).toHaveBeenCalledOnce(); + expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + }); + + it('does not emit event when device is already connected', () => { + const connectedDevices = new Map([[deviceId, mock()]]); + const manager = new DeviceManager(mockedEventEmitter, connectedDevices, mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + + expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + }); + + it('removes device from queue when no listeners respond to deviceDetected', async () => { + mockedEventEmitter.emit.mockReturnValue(false); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + + const result = await manager.acquireDetectedDevice(deviceId); + expect(result.successful).toBe(false); + }); + }); + + describe('acquireDetectedDevice', () => { + let mockedLogger: ReturnType>; + let mockedEventEmitter: ReturnType>; + const deviceId = DeviceId.create('device-2'); + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + }); + + it('returns failure when device is not in the detect queue', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + + const result = await manager.acquireDetectedDevice(deviceId); + + expect(result.successful).toBe(false); + }); + + it('resolves immediately with success for the first caller', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + manager.announceDetectedDevice(deviceInfo); + + const result = await manager.acquireDetectedDevice(deviceId); + + expect(result).toStrictEqual({ successful: true }); + }); + + it('queues the second caller until the first releases', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + manager.announceDetectedDevice(deviceInfo); + + await manager.acquireDetectedDevice(deviceId); + const secondCallerPromise = manager.acquireDetectedDevice(deviceId); + manager.releaseDetectedDevice(deviceId); + + const result = await secondCallerPromise; + expect(result).toStrictEqual({ successful: true }); + }); + }); + + describe('releaseDetectedDevice', () => { + let mockedLogger: ReturnType>; + let mockedEventEmitter: ReturnType>; + const deviceId = DeviceId.create('device-3'); + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + }); + + it('is a no-op when device is not in the acquire queue', () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), 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); + manager.announceDetectedDevice(deviceInfo); + await manager.acquireDetectedDevice(deviceId); + + manager.releaseDetectedDevice(deviceId); + + const result = await manager.acquireDetectedDevice(deviceId); + expect(result.successful).toBe(false); + }); + }); + + describe('revokeDetectedDevice', () => { + let mockedLogger: ReturnType>; + let mockedEventEmitter: ReturnType>; + const deviceId = DeviceId.create('device-4'); + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + }); + + it('resolves a pending second caller with failure', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + manager.announceDetectedDevice(deviceInfo); + await manager.acquireDetectedDevice(deviceId); // first caller holds + const pendingPromise = manager.acquireDetectedDevice(deviceId); // second waits + + manager.revokeDetectedDevice(deviceInfo); + + const result = await pendingPromise; + expect(result.successful).toBe(false); + }); + }); + + describe('claimDetectedDevice', () => { + let mockedLogger: ReturnType>; + let mockedEventEmitter: ReturnType>; + const deviceId = DeviceId.create('device-5'); + const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + }); + + it('resolves a pending caller with failure', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + manager.announceDetectedDevice(deviceInfo); + await manager.acquireDetectedDevice(deviceId); // first caller holds + const pendingPromise = manager.acquireDetectedDevice(deviceId); // second waits + + manager.claimDetectedDevice(deviceId); + + const result = await pendingPromise; + expect(result.successful).toBe(false); + }); + }); }); diff --git a/tests/unit/device/knownDeviceRegistry.spec.ts b/tests/unit/device/knownDeviceRegistry.spec.ts deleted file mode 100644 index 6658fea7..00000000 --- a/tests/unit/device/knownDeviceRegistry.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import KnownDeviceRegistry from '../../../src/device/knownDeviceRegistry.js'; -import Settings from '../../../src/settings/settings.js'; -import DeviceNameGenerator from '../../../src/device/deviceNameGenerator.js'; -import Logger from '../../../src/logging/Logger.js'; -import KnownDevice from '../../../src/settings/knownDevice.js'; -import { DeviceId } from '../../../src/device/deviceId.js'; - -describe('KnownDeviceRegistry', () => { - let mockSettings: ReturnType>; - let mockNameGenerator: ReturnType>; - let mockLogger: ReturnType>; - let registry: KnownDeviceRegistry; - - beforeEach(() => { - mockSettings = mock(); - mockNameGenerator = mock(); - mockNameGenerator.generateName.mockReturnValue('Generated Name'); - mockLogger = mock(); - mockLogger.child.mockReturnValue(mockLogger); - - registry = new KnownDeviceRegistry(mockSettings, mockNameGenerator, mockLogger); - }); - - describe('resolve', () => { - it('returns the already known device without persisting anything', () => { - const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Existing Name', 'testType', 'testProvider'); - mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); - - const result = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); - - expect(result).toBe(existingKnownDevice); - expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); - }); - - it('builds a new, not-yet-persisted KnownDevice when none exists', () => { - mockSettings.getKnownDeviceById.mockReturnValue(undefined); - - const deviceId = DeviceId.create('device-1'); - const result = registry.resolve(deviceId, 'testType', 'testProvider'); - - expect(result).toMatchObject({ id: deviceId, type: 'testType', source: 'testProvider' }); - expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); - }); - - it('uses the provided name over the generated one for a new device', () => { - mockSettings.getKnownDeviceById.mockReturnValue(undefined); - - const result = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider', 'Explicit Name'); - - expect(result.name).toBe('Explicit Name'); - expect(mockNameGenerator.generateName).not.toHaveBeenCalled(); - }); - - it('falls back to a generated name when none is provided', () => { - mockSettings.getKnownDeviceById.mockReturnValue(undefined); - - const result = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); - - expect(result.name).toBe('Generated Name'); - }); - }); - - describe('persist', () => { - it('delegates to settings.addKnownDevice for a genuinely new identity', () => { - const knownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); - mockSettings.getKnownDeviceById.mockReturnValue(undefined); - - registry.persist(knownDevice); - - expect(mockSettings.addKnownDevice).toHaveBeenCalledOnce(); - expect(mockSettings.addKnownDevice).toHaveBeenCalledWith(knownDevice); - }); - - it('does not touch settings when persisting an already-known, unchanged identity', () => { - // This matters beyond avoiding pointless work: Settings is wrapped with on-change to - // auto-save to disk, so calling addKnownDevice() here unconditionally would trigger a - // settings.json write + a settings-changed broadcast on every device (re)connect, even - // for a device that's been known and unchanged for months. - const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); - mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); - - registry.persist(existingKnownDevice); - - expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); - }); - - it('persists when passed a different KnownDevice instance for an already-known id', () => { - const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); - const differentInstance = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); - mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); - - registry.persist(differentInstance); - - expect(mockSettings.addKnownDevice).toHaveBeenCalledWith(differentInstance); - }); - }); - - describe('resolve + persist integration', () => { - it('does not write to settings when reconnecting an already-known device', () => { - const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Existing Name', 'testType', 'testProvider'); - mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); - - const knownDevice = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); - registry.persist(knownDevice); - - expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); - }); - - it('writes to settings exactly once when connecting a genuinely new device', () => { - mockSettings.getKnownDeviceById.mockReturnValue(undefined); - - const knownDevice = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); - registry.persist(knownDevice); - - expect(mockSettings.addKnownDevice).toHaveBeenCalledOnce(); - }); - }); -}); diff --git a/tests/unit/device/provider/bleDeviceProvider.spec.ts b/tests/unit/device/provider/bleDeviceProvider.spec.ts deleted file mode 100644 index 629ca230..00000000 --- a/tests/unit/device/provider/bleDeviceProvider.spec.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import EventEmitter from 'events'; -import { Peripheral } from '@stoprocent/noble'; -import DeviceManager from '../../../../src/device/deviceManager.js'; -import Logger from '../../../../src/logging/Logger.js'; -import BleDeviceProvider from '../../../../src/device/provider/bleDeviceProvider.js'; -import BleProtocolFactory from '../../../../src/device/provider/bleProtocolFactory.js'; -import BleDevice from '../../../../src/device/bleDevice.js'; -import { DeviceId } from '../../../../src/device/deviceId.js'; - -const mockNoble = vi.hoisted(() => ({ - on: vi.fn(), - removeAllListeners: vi.fn(), - waitForPoweredOnAsync: vi.fn(), - startScanningAsync: vi.fn(), - stopScanningAsync: vi.fn(), - stop: vi.fn(), -})); - -vi.mock('@stoprocent/noble', () => ({ default: mockNoble })); - -describe('BleDeviceProvider', () => { - let mockDeviceManager: ReturnType>; - let mockLogger: ReturnType>; - - function createProvider(): BleDeviceProvider { - return new BleDeviceProvider(mockDeviceManager, new EventEmitter(), mockLogger); - } - - function getNobleListener(event: string) { - return mockNoble.on.mock.calls.find(([e]) => e === event)?.[1]; - } - - function createPeripheral(rssi: number, id: string): ReturnType> { - const peripheral = mock(); - Object.defineProperty(peripheral, 'rssi', { get: () => rssi, configurable: true }); - Object.defineProperty(peripheral, 'id', { get: () => id, configurable: true }); - setPeripheralState(peripheral, 'disconnected'); - return peripheral; - } - - function setPeripheralState(peripheral: Peripheral, state: Peripheral['state']): void { - Object.defineProperty(peripheral, 'state', { value: state, configurable: true }); - } - - function createFactory(protocolName: string): ReturnType>> { - const factory = mock>(); - Object.defineProperty(factory, 'protocolName', { get: () => protocolName, configurable: true }); - return factory; - } - - beforeEach(() => { - vi.resetAllMocks(); - - mockDeviceManager = mock(); - mockDeviceManager.getConnectedDevice.mockReturnValue(null); - mockLogger = mock(); - mockLogger.child.mockReturnValue(mockLogger); - - mockNoble.waitForPoweredOnAsync.mockResolvedValue(undefined); - mockNoble.startScanningAsync.mockResolvedValue(undefined); - mockNoble.stopScanningAsync.mockResolvedValue(undefined); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('creates a child logger with the provider class name', () => { - createProvider(); - - expect(mockLogger.child).toHaveBeenCalledWith({ name: BleDeviceProvider.name }); - }); - }); - - describe('init', () => { - it('registers discover, stateChange and scanStop listeners on noble', async () => { - const provider = createProvider(); - - await provider.init(); - - expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); - expect(mockNoble.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); - expect(mockNoble.on).toHaveBeenCalledWith('scanStop', expect.any(Function)); - }); - - it('calls waitForPoweredOnAsync and startScanningAsync with the UART UUID', async () => { - const provider = createProvider(); - - await provider.init(); - - expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledOnce(); - expect(mockNoble.startScanningAsync).toHaveBeenCalledWith( - ['6e400001b5a3f393e0a9e50e24dcca9e'], - true, - ); - }); - - it('does not scan again when stateChange poweredOn fires while already scanning', async () => { - const provider = createProvider(); - await provider.init(); - - getNobleListener('stateChange')?.('poweredOn'); - await vi.waitFor(() => expect(mockNoble.startScanningAsync).toHaveBeenCalledTimes(1)); - }); - - it('logs info when scanStop fires', async () => { - const provider = createProvider(); - await provider.init(); - - getNobleListener('scanStop')?.(); - - expect(mockLogger.info).toHaveBeenCalledWith('Noble scanning stopped'); - }); - - it('handles waitForPoweredOnAsync rejection gracefully', async () => { - mockNoble.waitForPoweredOnAsync.mockRejectedValue(new Error('BLE unavailable')); - const provider = createProvider(); - - await expect(provider.init()).resolves.not.toThrow(); - expect(mockLogger.error).toHaveBeenCalled(); - }); - }); - - describe('onDiscover (via discover event)', () => { - it('ignores a peripheral whose RSSI is below the minimum threshold', async () => { - const provider = createProvider(); - const factory = createFactory('test'); - provider.registerFactory(factory); - await provider.init(); - - await getNobleListener('discover')?.(createPeripheral(-80, 'weak-device')); - - expect(factory.tryConnect).not.toHaveBeenCalled(); - }); - - it('tries registered factories in registration order until one connects', async () => { - const provider = createProvider(); - const failingFactory = createFactory('failing'); - failingFactory.tryConnect.mockResolvedValue(undefined); - const successfulDevice = mock>(); - const successfulFactory = createFactory('successful'); - successfulFactory.tryConnect.mockResolvedValue(successfulDevice); - const untriedFactory = createFactory('untried'); - - provider.registerFactory(failingFactory).registerFactory(successfulFactory).registerFactory(untriedFactory); - await provider.init(); - - const peripheral = createPeripheral(-50, 'device-1'); - getNobleListener('discover')?.(peripheral); - - await vi.waitFor(() => expect(mockDeviceManager.addDevice).toHaveBeenCalledWith(successfulDevice)); - - expect(failingFactory.tryConnect).toHaveBeenCalledWith(DeviceId.create('device-1'), peripheral); - expect(successfulFactory.tryConnect).toHaveBeenCalledWith(DeviceId.create('device-1'), peripheral); - expect(untriedFactory.tryConnect).not.toHaveBeenCalled(); - }); - - it('disconnects the peripheral when no registered factory recognizes it', async () => { - const provider = createProvider(); - const factory = createFactory('test'); - factory.tryConnect.mockResolvedValue(undefined); - provider.registerFactory(factory); - await provider.init(); - - const peripheral = createPeripheral(-50, 'device-1'); - setPeripheralState(peripheral, 'connected'); - - getNobleListener('discover')?.(peripheral); - - await vi.waitFor(() => expect(peripheral.disconnectAsync).toHaveBeenCalledOnce()); - expect(mockDeviceManager.addDevice).not.toHaveBeenCalled(); - }); - - it('cancels an in-progress connection attempt when no factory recognizes the peripheral', async () => { - const provider = createProvider(); - const factory = createFactory('test'); - factory.tryConnect.mockResolvedValue(undefined); - provider.registerFactory(factory); - await provider.init(); - - const peripheral = createPeripheral(-50, 'device-1'); - setPeripheralState(peripheral, 'connecting'); - - getNobleListener('discover')?.(peripheral); - - await vi.waitFor(() => expect(peripheral.cancelConnect).toHaveBeenCalledOnce()); - }); - - it('logs and disconnects when a factory throws', async () => { - const provider = createProvider(); - const factory = createFactory('broken'); - factory.tryConnect.mockRejectedValue(new Error('handshake exploded')); - provider.registerFactory(factory); - await provider.init(); - - const peripheral = createPeripheral(-50, 'device-1'); - setPeripheralState(peripheral, 'connected'); - - getNobleListener('discover')?.(peripheral); - - await vi.waitFor(() => expect(peripheral.disconnectAsync).toHaveBeenCalledOnce()); - expect(mockLogger.error).toHaveBeenCalled(); - }); - - it('does not re-attempt a peripheral that is already connected', async () => { - mockDeviceManager.getConnectedDevice.mockReturnValue(mock>()); - const provider = createProvider(); - const factory = createFactory('test'); - provider.registerFactory(factory); - await provider.init(); - - await getNobleListener('discover')?.(createPeripheral(-50, 'already-connected')); - - expect(factory.tryConnect).not.toHaveBeenCalled(); - }); - - it('does not start a second attempt while one is already in flight for the same peripheral', async () => { - const provider = createProvider(); - const factory = createFactory('slow'); - let resolveTryConnect: (device: undefined) => void = () => {}; - factory.tryConnect.mockReturnValue(new Promise((resolve) => { resolveTryConnect = resolve; })); - provider.registerFactory(factory); - await provider.init(); - - const peripheral = createPeripheral(-50, 'device-1'); - const firstAttempt = getNobleListener('discover')?.(peripheral); - await getNobleListener('discover')?.(peripheral); - - expect(factory.tryConnect).toHaveBeenCalledOnce(); - - resolveTryConnect(undefined); - await firstAttempt; - }); - }); - - describe('stop', () => { - it('removes noble listeners and stops scanning', async () => { - const provider = createProvider(); - await provider.init(); - - await provider.stop(); - - expect(mockNoble.removeAllListeners).toHaveBeenCalledOnce(); - expect(mockNoble.stopScanningAsync).toHaveBeenCalledOnce(); - expect(mockNoble.stop).toHaveBeenCalledOnce(); - }); - - it('closes all connected devices', async () => { - const provider = createProvider(); - const device = mock>(); - const factory = createFactory('test'); - factory.tryConnect.mockResolvedValue(device); - provider.registerFactory(factory); - await provider.init(); - - getNobleListener('discover')?.(createPeripheral(-50, 'device-1')); - await vi.waitFor(() => expect(mockDeviceManager.addDevice).toHaveBeenCalledWith(device)); - - await provider.stop(); - - expect(device.close).toHaveBeenCalledOnce(); - }); - }); -}); diff --git a/tests/unit/device/provider/serialDeviceProvider.spec.ts b/tests/unit/device/provider/serialDeviceProvider.spec.ts deleted file mode 100644 index e5dfb750..00000000 --- a/tests/unit/device/provider/serialDeviceProvider.spec.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import EventEmitter from 'events'; -import { SerialPort } from 'serialport'; -import DeviceManager from '../../../../src/device/deviceManager.js'; -import Logger from '../../../../src/logging/Logger.js'; -import SerialDeviceProvider from '../../../../src/device/provider/serialDeviceProvider.js'; -import SerialProtocolFactory from '../../../../src/device/provider/serialProtocolFactory.js'; -import SerialPortFactory from '../../../../src/factory/serialPortFactory.js'; -import PeripheralDevice from '../../../../src/device/peripheralDevice.js'; -import { DeviceId } from '../../../../src/device/deviceId.js'; - -type PortInfoLike = { - path: string; - manufacturer: string | undefined; - serialNumber: string | undefined; - pnpId: string | undefined; - locationId: string | undefined; - productId: string | undefined; - vendorId: string | undefined; -}; - -class FakeSerialPort extends EventEmitter { - public isOpen = false; - - private readonly openError?: Error; - - public constructor(openError?: Error) { - super(); - this.openError = openError; - } - - public open(cb: (err?: Error | null) => void): void { - if (undefined !== this.openError) { - cb(this.openError); - return; - } - this.isOpen = true; - cb(null); - } - - public close(cb: (err: Error | null) => void): void { - this.isOpen = false; - this.emit('close'); - cb(null); - } - - public pipe(dest: T): T { - return dest; - } - - public unpipe(): void { - // no-op - } -} - -describe('SerialDeviceProvider', () => { - let mockDeviceManager: ReturnType>; - let mockLogger: ReturnType>; - let mockSerialPortFactory: ReturnType>; - let createdPorts: FakeSerialPort[]; - - function makePortInfo(overrides: Partial & { path: string }): PortInfoLike { - return { - manufacturer: undefined, - serialNumber: undefined, - pnpId: undefined, - locationId: undefined, - productId: undefined, - vendorId: undefined, - ...overrides, - }; - } - - function createProvider(): SerialDeviceProvider { - return new SerialDeviceProvider(mockDeviceManager, mockSerialPortFactory, new EventEmitter(), mockLogger); - } - - function createFactory(protocolName: string, openError?: Error): ReturnType>> { - const factory = mock>(); - Object.defineProperty(factory, 'protocolName', { get: () => protocolName, configurable: true }); - factory.getPortOpenOptions.mockReturnValue({ baudRate: 9600 }); - factory.preparePort = undefined; - void openError; - return factory; - } - - beforeEach(() => { - vi.resetAllMocks(); - - mockDeviceManager = mock(); - mockDeviceManager.getConnectedDevice.mockReturnValue(null); - mockLogger = mock(); - mockLogger.child.mockReturnValue(mockLogger); - - createdPorts = []; - mockSerialPortFactory = mock(); - mockSerialPortFactory.create.mockImplementation(() => { - const port = new FakeSerialPort(); - createdPorts.push(port); - return port as any; - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('creates a child logger with the provider class name', () => { - createProvider(); - - expect(mockLogger.child).toHaveBeenCalledWith({ name: SerialDeviceProvider.name }); - }); - }); - - describe('discoverSerialDevices', () => { - it('does not attempt any port when none are found', async () => { - vi.spyOn(SerialPort, 'list').mockResolvedValue([]); - const provider = createProvider(); - const factory = createFactory('test'); - provider.registerFactory(factory); - - await provider.discoverSerialDevices(); - - expect(factory.tryConnect).not.toHaveBeenCalled(); - }); - - it('skips a port with no vendorId', async () => { - vi.spyOn(SerialPort, 'list').mockResolvedValue([ - makePortInfo({ path: '/dev/tty1', serialNumber: 'SN001', vendorId: undefined, productId: '6001' }), - ]); - const provider = createProvider(); - const factory = createFactory('test'); - provider.registerFactory(factory); - - await provider.discoverSerialDevices(); - - expect(factory.tryConnect).not.toHaveBeenCalled(); - }); - - it('skips a port with no productId', async () => { - vi.spyOn(SerialPort, 'list').mockResolvedValue([ - makePortInfo({ path: '/dev/tty1', serialNumber: 'SN001', vendorId: '0403', productId: undefined }), - ]); - const provider = createProvider(); - const factory = createFactory('test'); - provider.registerFactory(factory); - - await provider.discoverSerialDevices(); - - expect(factory.tryConnect).not.toHaveBeenCalled(); - }); - - it('handles a SerialPort.list error gracefully without throwing', async () => { - vi.spyOn(SerialPort, 'list').mockRejectedValue(new Error('USB stack error')); - const provider = createProvider(); - - await expect(provider.discoverSerialDevices()).resolves.not.toThrow(); - expect(mockLogger.error).toHaveBeenCalled(); - }); - - it('tries registered factories in registration order until one connects', async () => { - const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); - vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); - - const provider = createProvider(); - const failingFactory = createFactory('failing'); - failingFactory.tryConnect.mockResolvedValue(undefined); - const successfulDevice = mock>(); - const successfulFactory = createFactory('successful'); - successfulFactory.tryConnect.mockResolvedValue(successfulDevice); - const untriedFactory = createFactory('untried'); - - provider.registerFactory(failingFactory).registerFactory(successfulFactory).registerFactory(untriedFactory); - - await provider.discoverSerialDevices(); - - expect(failingFactory.tryConnect).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN001') }), - expect.anything(), - ); - expect(successfulFactory.tryConnect).toHaveBeenCalled(); - expect(untriedFactory.tryConnect).not.toHaveBeenCalled(); - expect(mockDeviceManager.addDevice).toHaveBeenCalledWith(successfulDevice); - }); - - it('continues to the next factory when one throws', async () => { - const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); - vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); - - const provider = createProvider(); - const brokenFactory = createFactory('broken'); - brokenFactory.tryConnect.mockRejectedValue(new Error('handshake exploded')); - const successfulDevice = mock>(); - const successfulFactory = createFactory('successful'); - successfulFactory.tryConnect.mockResolvedValue(successfulDevice); - - provider.registerFactory(brokenFactory).registerFactory(successfulFactory); - - await provider.discoverSerialDevices(); - - expect(mockDeviceManager.addDevice).toHaveBeenCalledWith(successfulDevice); - }); - - it('closes the port again when no factory recognizes the device', async () => { - const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); - vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); - - const provider = createProvider(); - const factory = createFactory('test'); - factory.tryConnect.mockResolvedValue(undefined); - provider.registerFactory(factory); - - await provider.discoverSerialDevices(); - - expect(mockDeviceManager.addDevice).not.toHaveBeenCalled(); - }); - - it('generates a synthetic serial number when serialNumber is undefined', async () => { - const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: undefined, vendorId: '0403', productId: '6001', locationId: 'port1' }); - vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); - const provider = createProvider(); - const factory = createFactory('test'); - provider.registerFactory(factory); - - await provider.discoverSerialDevices(); - - expect(factory.tryConnect).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('serial-0403-6001-port1') }), - expect.anything(), - ); - }); - - it('does not re-attempt a port that is still present on a later discovery run', async () => { - const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); - vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); - const provider = createProvider(); - const factory = createFactory('test'); - factory.tryConnect.mockResolvedValue(undefined); - provider.registerFactory(factory); - - await provider.discoverSerialDevices(); - await provider.discoverSerialDevices(); - - expect(factory.tryConnect).toHaveBeenCalledOnce(); - }); - - it('attempts a port again once it disappears and reappears', async () => { - const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); - vi.spyOn(SerialPort, 'list') - .mockResolvedValueOnce([port]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([port]); - const provider = createProvider(); - const factory = createFactory('test'); - factory.tryConnect.mockResolvedValue(undefined); - provider.registerFactory(factory); - - await provider.discoverSerialDevices(); - await provider.discoverSerialDevices(); - await provider.discoverSerialDevices(); - - expect(factory.tryConnect).toHaveBeenCalledTimes(2); - }); - - it('does not re-attempt a port that is already connected', async () => { - mockDeviceManager.getConnectedDevice.mockReturnValue(mock>()); - const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); - vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); - const provider = createProvider(); - const factory = createFactory('test'); - provider.registerFactory(factory); - - await provider.discoverSerialDevices(); - - expect(factory.tryConnect).not.toHaveBeenCalled(); - }); - }); - - describe('stop', () => { - it('does not throw when called without a prior start()', async () => { - const provider = createProvider(); - - await expect(provider.stop()).resolves.not.toThrow(); - }); - }); -}); diff --git a/tests/unit/device/transport/bleObserver.spec.ts b/tests/unit/device/transport/bleObserver.spec.ts new file mode 100644 index 00000000..cf45f5b7 --- /dev/null +++ b/tests/unit/device/transport/bleObserver.spec.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { Peripheral } from '@stoprocent/noble'; +import DeviceManager from '../../../../src/device/deviceManager.js'; +import Logger from '../../../../src/logging/Logger.js'; +import BleObserver from '../../../../src/device/transport/bleObserver.js'; +import { DeviceId } from '../../../../src/device/deviceId.js'; + +const mockNoble = vi.hoisted(() => ({ + on: vi.fn(), + waitForPoweredOnAsync: vi.fn(), + startScanningAsync: vi.fn(), + stopScanningAsync: vi.fn(), +})); + +vi.mock('@stoprocent/noble', () => ({ default: mockNoble })); + +describe('BleObserver', () => { + let mockDeviceManager: ReturnType>; + let mockLogger: ReturnType>; + + function createObserver(): BleObserver { + return new BleObserver(mockDeviceManager, mockLogger); + } + + function getNobleListener(event: string) { + return mockNoble.on.mock.calls.find(([e]) => e === event)?.[1]; + } + + beforeEach(() => { + vi.resetAllMocks(); + + mockDeviceManager = mock(); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + + mockNoble.waitForPoweredOnAsync.mockResolvedValue(undefined); + mockNoble.startScanningAsync.mockResolvedValue(undefined); + mockNoble.stopScanningAsync.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('creates a child logger with the observer class name', () => { + createObserver(); + + expect(mockLogger.child).toHaveBeenCalledWith({ name: BleObserver.name }); + }); + }); + + describe('init', () => { + it('registers a discover listener on noble', async () => { + const observer = createObserver(); + + await observer.init(); + + expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); + }); + + it('registers a stateChange listener on noble', async () => { + const observer = createObserver(); + + await observer.init(); + + expect(mockNoble.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); + }); + + it('registers a scanStop listener on noble', async () => { + const observer = createObserver(); + + await observer.init(); + + expect(mockNoble.on).toHaveBeenCalledWith('scanStop', expect.any(Function)); + }); + + it('calls waitForPoweredOnAsync and startScanningAsync with the UART UUID', async () => { + const observer = createObserver(); + + await observer.init(); + + expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledOnce(); + 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'); + + // 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'); + + expect(mockNoble.waitForPoweredOnAsync).not.toHaveBeenCalled(); + }); + + it('calls stopScanningAsync and allows retry when waitForPoweredOnAsync rejects', async () => { + mockNoble.waitForPoweredOnAsync.mockRejectedValue(new Error('BLE unavailable')); + const observer = createObserver(); + + await expect(observer.init()).resolves.not.toThrow(); + + expect(mockNoble.stopScanningAsync).toHaveBeenCalledOnce(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('logs info when scanStop event fires', async () => { + const observer = createObserver(); + await observer.init(); + + getNobleListener('scanStop')?.(); + + expect(mockLogger.info).toHaveBeenCalledWith('Noble scanning stopped'); + }); + }); + + describe('onDiscover (via discover event)', () => { + function createPeripheral(rssi: number, id: string): ReturnType> { + const peripheral = mock(); + Object.defineProperty(peripheral, 'rssi', { get: () => rssi, configurable: true }); + Object.defineProperty(peripheral, 'id', { get: () => id, configurable: true }); + return peripheral; + } + + it('ignores a peripheral whose RSSI is below the minimum threshold', async () => { + const observer = createObserver(); + await observer.init(); + + getNobleListener('discover')?.(createPeripheral(-80, 'weak-device')); + + expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); + }); + + it('announces a peripheral whose RSSI is exactly at the minimum threshold (-70)', async () => { + const observer = createObserver(); + await observer.init(); + const peripheral = createPeripheral(-70, 'at-threshold'); + + getNobleListener('discover')?.(peripheral); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ble', peripheral }), + ); + }); + + it('announces a peripheral whose RSSI is above the minimum threshold', async () => { + const observer = createObserver(); + await observer.init(); + const peripheral = createPeripheral(-50, 'strong-device'); + + getNobleListener('discover')?.(peripheral); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); + }); + + it('uses the peripheral id to build the DeviceId passed to announceDetectedDevice', async () => { + const observer = createObserver(); + await observer.init(); + const peripheral = createPeripheral(-60, 'abc-123'); + + getNobleListener('discover')?.(peripheral); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create('abc-123') }), + ); + }); + + it('logs a debug message when ignoring a weak-signal peripheral', async () => { + const observer = createObserver(); + await observer.init(); + + getNobleListener('discover')?.(createPeripheral(-80, 'noisy-device')); + + expect(mockLogger.debug).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/device/transport/serialPortObserver.spec.ts b/tests/unit/device/transport/serialPortObserver.spec.ts new file mode 100644 index 00000000..c3e27676 --- /dev/null +++ b/tests/unit/device/transport/serialPortObserver.spec.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { SerialPort } from 'serialport'; +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'; + +type PortInfoLike = { + path: string; + manufacturer: string | undefined; + serialNumber: string | undefined; + pnpId: string | undefined; + locationId: string | undefined; + productId: string | undefined; + vendorId: string | undefined; +}; + +describe('SerialPortObserver', () => { + let mockDeviceManager: ReturnType>; + let mockLogger: ReturnType>; + + function makePortInfo(overrides: Partial & { path: string }): PortInfoLike { + return { + manufacturer: undefined, + serialNumber: undefined, + pnpId: undefined, + locationId: undefined, + productId: undefined, + vendorId: undefined, + ...overrides, + }; + } + + function createObserver(): SerialPortObserver { + return new SerialPortObserver(mockDeviceManager, mockLogger); + } + + beforeEach(() => { + vi.useFakeTimers(); + + mockDeviceManager = mock(); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('creates a child logger with the observer class name', () => { + createObserver(); + + expect(mockLogger.child).toHaveBeenCalledWith({ name: SerialPortObserver.name }); + }); + }); + + describe('init', () => { + it('does not announce any device when no serial ports are found', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const observer = createObserver(); + + await observer.start(); + + expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); + }); + + it('skips a port that has no vendorId', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([ + makePortInfo({ path: '/dev/tty1', serialNumber: 'SN001', vendorId: undefined, productId: '6001' }), + ]); + const observer = createObserver(); + + await observer.start(); + + expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); + }); + + it('skips a port that has no productId', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([ + makePortInfo({ path: '/dev/tty1', serialNumber: 'SN001', vendorId: '0403', productId: undefined }), + ]); + const observer = createObserver(); + + await observer.start(); + + expect(mockDeviceManager.announceDetectedDevice).not.toHaveBeenCalled(); + }); + + it('announces a newly discovered port to the device manager', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); + const observer = createObserver(); + + await observer.start(); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create('SN001'), portInfo: port }), + ); + }); + + it('generates a synthetic serial number when serialNumber is undefined', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: undefined, vendorId: '0403', productId: '6001', locationId: 'port1' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); + const observer = createObserver(); + + await observer.start(); + + const expectedSn = 'serial-0403-6001-port1'; + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create(expectedSn) }), + ); + }); + + it('does not re-announce a device that is already managed on the next discovery run', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port]); + const observer = createObserver(); + + await observer.start(); + await vi.advanceTimersByTimeAsync(3000); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); + }); + + it('revokes a device that disappears from the port list', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list') + .mockResolvedValueOnce([port]) + .mockResolvedValueOnce([]); + const observer = createObserver(); + + await observer.start(); + await observer.discoverSerialDevices(); // manually trigger a discovery run + + expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledOnce(); + expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create('SN001') }), + ); + }); + + it('announces a previously revoked device again when it reappears', async () => { + const port = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + vi.spyOn(SerialPort, 'list') + .mockResolvedValueOnce([port]) // first run: present + .mockResolvedValueOnce([]) // second run: gone – revoked + .mockResolvedValueOnce([port]); // third run: back + const observer = createObserver(); + + + await observer.start(); + await observer.discoverSerialDevices(); // manually trigger a discovery run + await observer.discoverSerialDevices(); // manually trigger a discovery run + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledTimes(2); + }); + + it('handles a SerialPort.list error gracefully without throwing', async () => { + vi.spyOn(SerialPort, 'list').mockRejectedValue(new Error('USB stack error')); + const observer = createObserver(); + + await expect(observer.start()).resolves.not.toThrow(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('handles multiple valid ports in a single discovery run', async () => { + const port1 = makePortInfo({ path: '/dev/ttyUSB0', serialNumber: 'SN001', vendorId: '0403', productId: '6001' }); + const port2 = makePortInfo({ path: '/dev/ttyUSB1', serialNumber: 'SN002', vendorId: '0403', productId: '6015' }); + vi.spyOn(SerialPort, 'list').mockResolvedValue([port1, port2]); + const observer = createObserver(); + + await observer.start(); + + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledTimes(2); + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create('SN001') }), + ); + expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( + expect.objectContaining({ id: DeviceId.create('SN002') }), + ); + }); + }); +}); From feb1ef893eea62cacb9a15914947493aaced077f Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 13 Jul 2026 18:22:21 +0200 Subject: [PATCH 16/17] Add TypeBox/AJV config validation for device providers Re-applies device-provider config validation on top of main's original architecture (after reverting the observer-merge/factory restructuring in the previous commit): - DeviceProvider is generic again; config is the FIRST constructor parameter across every provider, since that lets GenericDeviceProviderFactory capture the remaining ("dependency") constructor arguments as a plain rest-inferred tuple. - DeviceProviderFactory carries a configSchema (TSchema tied to ConfigOf via TypeBox's phantom static property), validated by DeviceProviderManager before a provider is constructed. - GenericDeviceProviderFactory now uniformly handles all 6 providers (previously ButtplugIoWebsocketDeviceProvider and VirtualDeviceProvider needed their own dedicated factory classes, since the old generic factory ignored config entirely) - both dedicated factories are deleted. - DeviceProviderManager hydrates missing config fields with their schema's TypeBox "default" (Value.Default) before validating, so e.g. VirtualDeviceProvider no longer needs its own scanIntervalMs fallback logic. Uses a JSON round-trip (not structuredClone) to clone DeviceSource.config first, since Settings is wrapped in an on-change Proxy that structuredClone can't handle. - Same config-schema-on-factory pattern applied to GenericVirtualDeviceFactory/VirtualDeviceLogicFactory, replacing the old LogicFactoryAndConfigTuple bidirectional-assignability type and GenericVirtualDeviceLogicFactory's private-constructor-plus-static- from() indirection with a plain public constructor. - New NoDeviceProviderConfig (distinct from the existing NoDeviceConfig, which is for device configs, not provider configs) for the 4 providers that take no configuration. Full gate green: typecheck, lint, 355/355 tests. --- .../protocol/airotic/airoticDeviceProvider.ts | 7 +- .../buttplugIo/buttplugIoWebsocketConfig.ts | 12 + .../buttplugIoWebsocketDeviceProvider.ts | 30 +-- ...uttplugIoWebsocketDeviceProviderFactory.ts | 48 ---- .../estim2b/estim2bSerialDeviceProvider.ts | 6 +- .../slvCtrlPlusSerialDeviceProvider.ts | 6 +- .../virtual/genericVirtualDeviceFactory.ts | 33 +-- .../genericVirtualDeviceLogicFactory.ts | 20 +- .../virtual/virtualDeviceLogicFactory.ts | 16 ++ .../protocol/virtual/virtualDeviceProvider.ts | 17 +- .../virtual/virtualDeviceProviderConfig.ts | 15 ++ .../virtual/virtualDeviceProviderFactory.ts | 55 ----- .../protocol/zc95/zc95SerialDeviceProvider.ts | 6 +- src/device/provider/bleDeviceProvider.ts | 11 +- src/device/provider/deviceProvider.ts | 22 +- src/device/provider/deviceProviderConfig.ts | 12 + src/device/provider/deviceProviderFactory.ts | 37 ++- src/device/provider/deviceProviderManager.ts | 29 ++- .../provider/genericDeviceProviderFactory.ts | 35 ++- src/device/provider/serialDeviceProvider.ts | 17 +- src/serviceMap.ts | 3 +- src/serviceProvider/deviceServiceProvider.ts | 49 ++-- .../provider/deviceProviderManager.spec.ts | 225 ++++++++++++++++++ tests/unit/device/testDeviceProvider.ts | 7 +- 24 files changed, 491 insertions(+), 227 deletions(-) create mode 100644 src/device/protocol/buttplugIo/buttplugIoWebsocketConfig.ts delete mode 100644 src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts create mode 100644 src/device/protocol/virtual/virtualDeviceProviderConfig.ts delete mode 100644 src/device/protocol/virtual/virtualDeviceProviderFactory.ts create mode 100644 src/device/provider/deviceProviderConfig.ts create mode 100644 tests/unit/device/provider/deviceProviderManager.spec.ts diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index 12f8730f..0d05f33f 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -17,8 +17,9 @@ import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import FloatDeviceAttribute from '../../attribute/floatDeviceAttribute.js'; import BleDeviceProvider from '../../provider/bleDeviceProvider.js'; import { hsvByteToRgb } from '../../../util/color.js'; +import { NoDeviceProviderConfig } from '../../provider/deviceProviderConfig.js'; -export default class AiroticDeviceProvider extends BleDeviceProvider +export default class AiroticDeviceProvider extends BleDeviceProvider { public static readonly providerName = 'airotic'; @@ -27,8 +28,8 @@ export default class AiroticDeviceProvider extends BleDeviceProvider; diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 6438929d..f4942fec 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -9,40 +9,32 @@ import SlvCtrlPlusButtplugWebsocketClientConnector from './slvCtrlPlusButtplugWe import DeviceManager from '../../deviceManager.js'; import { logError } from '../../../util/error.js'; import { hasProperty } from '../../../util/objects.js'; +import { ButtplugIoWebsocketConfig } from './buttplugIoWebsocketConfig.js'; -export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { +export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { public static readonly providerName = 'buttplugIoWebsocket'; private connectedDevices: Map = new Map(); - private buttplugConnector: ButtplugNodeWebsocketClientConnector; - private buttplugClient: ButtplugClient; + private readonly buttplugConnector: ButtplugNodeWebsocketClientConnector; + private readonly buttplugClient: ButtplugClient; private readonly buttplugIoDeviceFactory: ButtplugIoDeviceFactory; - private readonly websocketAddress: string; - private readonly autoScan: boolean; - private readonly useDeviceNameAsId: boolean; - private connectionIntervalRef?: NodeJS.Timeout; private autoScanningIntervalRef?: NodeJS.Timeout; public constructor( + config: ButtplugIoWebsocketConfig, deviceManager: DeviceManager, eventEmitter: EventEmitter, deviceFactory: ButtplugIoDeviceFactory, - websocketAddress: string, - autoScan: boolean, - useDeviceNameAsId: boolean, logger: Logger ) { - super(deviceManager, eventEmitter, logger.child({ name: ButtplugIoWebsocketDeviceProvider.name })); + super(config, deviceManager, eventEmitter, logger.child({ name: ButtplugIoWebsocketDeviceProvider.name })); this.buttplugIoDeviceFactory = deviceFactory; - this.websocketAddress = websocketAddress; - this.autoScan = autoScan; - this.useDeviceNameAsId = useDeviceNameAsId; - const url = `ws://${this.websocketAddress}/buttplug`; + const url = `ws://${this.config.address}/buttplug`; this.buttplugConnector = new SlvCtrlPlusButtplugWebsocketClientConnector(url); this.buttplugClient = new ButtplugClient('SlvCtrlPlus'); @@ -66,7 +58,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { return; } - const url = `ws://${this.websocketAddress}/buttplug`; + const url = `ws://${this.config.address}/buttplug`; try { await this.buttplugClient.connect(this.buttplugConnector); @@ -75,7 +67,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { clearInterval(this.connectionIntervalRef); this.connectionIntervalRef = undefined; - if (this.autoScan) { + if (this.config.autoScan) { this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, 60000); } } catch (e: unknown) { @@ -108,7 +100,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.buttplugClient.isScanning) { return; } @@ -122,7 +114,7 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { this.logger.info(`Device detected: ${buttplugDevice.name}`, buttplugDevice); try { - const device = this.buttplugIoDeviceFactory.create(buttplugDevice, ButtplugIoWebsocketDeviceProvider.providerName, this.useDeviceNameAsId); + const device = this.buttplugIoDeviceFactory.create(buttplugDevice, ButtplugIoWebsocketDeviceProvider.providerName, this.config.useDeviceNameAsId); this.connectedDevices.set(buttplugDevice.index, device); diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts deleted file mode 100644 index 8f7064d6..00000000 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 DeviceManager from '../../deviceManager.js'; - -type ButtplugIoWebsocketConfig = { - address: string, - autoScan: boolean, - useDeviceNameAsId: boolean -} - -export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceProviderFactory -{ - private readonly deviceManager: DeviceManager; - - private readonly eventEmitter: EventEmitter; - - private readonly deviceFactory: ButtplugIoDeviceFactory; - - private readonly logger: Logger; - - public constructor( - deviceManager: DeviceManager, - eventEmitter: EventEmitter, - deviceFactory: ButtplugIoDeviceFactory, - logger: Logger - ) { - this.deviceManager = deviceManager; - this.eventEmitter = eventEmitter; - this.deviceFactory = deviceFactory; - this.logger = logger; - } - - public create(config: ButtplugIoWebsocketConfig): ButtplugIoWebsocketDeviceProvider - { - return new ButtplugIoWebsocketDeviceProvider( - this.deviceManager, - this.eventEmitter, - this.deviceFactory, - config.address, - config.autoScan, - config.useDeviceNameAsId, - this.logger - ); - } -} diff --git a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts index e16f64c6..08592ca7 100644 --- a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts +++ b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts @@ -13,8 +13,9 @@ import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportF import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import DeviceManager from '../../deviceManager.js'; import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; +import { NoDeviceProviderConfig } from '../../provider/deviceProviderConfig.js'; -export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider +export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { public static readonly providerName = 'estim2bSerial'; @@ -23,6 +24,7 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider +export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvider { public static readonly providerName = 'slvCtrlPlusSerial'; @@ -28,6 +29,7 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide private readonly deviceTransportFactory: SerialDeviceTransportFactory; public constructor( + config: NoDeviceProviderConfig, deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, eventEmitter: EventEmitter, @@ -35,7 +37,7 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide deviceTransportFactory: SerialDeviceTransportFactory, logger: Logger ) { - super(deviceManager, serialPortFactory, eventEmitter, logger.child({ name: SlvCtrlPlusSerialDeviceProvider.name })); + super(config, deviceManager, serialPortFactory, eventEmitter, logger.child({ name: SlvCtrlPlusSerialDeviceProvider.name })); this.slvCtrlPlusDeviceFactory = deviceFactory; this.deviceTransportFactory = deviceTransportFactory; } diff --git a/src/device/protocol/virtual/genericVirtualDeviceFactory.ts b/src/device/protocol/virtual/genericVirtualDeviceFactory.ts index bf95e56f..1a1f4d95 100644 --- a/src/device/protocol/virtual/genericVirtualDeviceFactory.ts +++ b/src/device/protocol/virtual/genericVirtualDeviceFactory.ts @@ -1,4 +1,3 @@ -import { Static, TObject } from '@sinclair/typebox'; import VirtualDeviceLogic from './virtualDeviceLogic.js'; import DateFactory from '../../../factory/dateFactory.js'; import JsonSchemaValidatorFactory from '../../../schemaValidation/JsonSchemaValidatorFactory.js'; @@ -9,19 +8,6 @@ import VirtualDeviceLogicFactory from './virtualDeviceLogicFactory.js'; import Logger from '../../../logging/Logger.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; -type ExtractConfig> = T extends VirtualDeviceLogic ? C : never; - -type LogicFactoryAndConfigTuple, TConfigSchema extends TObject> = { - deviceLogicFactory: VirtualDeviceLogicFactory, - deviceConfigSchema: TConfigSchema & ( - Static extends ExtractConfig - ? ExtractConfig extends Static - ? unknown - : never - : never - ), -}; - export default class GenericVirtualDeviceFactory implements VirtualDeviceFactory { private readonly dateFactory: DateFactory; @@ -29,7 +15,7 @@ export default class GenericVirtualDeviceFactory implements VirtualDeviceFactory private readonly jsonSchemaValidatorFactory: JsonSchemaValidatorFactory; - private readonly logicFactories: Map, TObject>> = new Map(); + private readonly logicFactories: Map>> = new Map(); private readonly logger: Logger; @@ -45,17 +31,10 @@ export default class GenericVirtualDeviceFactory implements VirtualDeviceFactory this.logger = logger; } - public addLogicFactory< - TLogic extends VirtualDeviceLogic, - TConfigSchema extends TObject - >( - virtualDeviceLogicFactory: LogicFactoryAndConfigTuple['deviceLogicFactory'], - deviceConfigSchema: LogicFactoryAndConfigTuple['deviceConfigSchema'], + public addLogicFactory>( + virtualDeviceLogicFactory: VirtualDeviceLogicFactory, ): this { - this.logicFactories.set(virtualDeviceLogicFactory.forDeviceType(), { - deviceLogicFactory: virtualDeviceLogicFactory, - deviceConfigSchema, - }); + this.logicFactories.set(virtualDeviceLogicFactory.forDeviceType(), virtualDeviceLogicFactory); return this; } @@ -69,7 +48,7 @@ export default class GenericVirtualDeviceFactory implements VirtualDeviceFactory throw new Error(`Could not find a factory for virtual device logic '${factoryName}'`); } - const jsonSchemaValidator = this.jsonSchemaValidatorFactory.create(factory.deviceConfigSchema); + const jsonSchemaValidator = this.jsonSchemaValidatorFactory.create(factory.configSchema); const isConfigValid = jsonSchemaValidator.validate(knownDevice.config); if (!isConfigValid) { @@ -77,7 +56,7 @@ export default class GenericVirtualDeviceFactory implements VirtualDeviceFactory throw new Error(`Config for device is not valid: ${JSON.stringify(validationErrors, null, 2)}`); } - const deviceLogic = factory.deviceLogicFactory.create(knownDevice.config); + const deviceLogic = factory.create(knownDevice.config); const device = new VirtualDevice( '1.0.0', diff --git a/src/device/protocol/virtual/genericVirtualDeviceLogicFactory.ts b/src/device/protocol/virtual/genericVirtualDeviceLogicFactory.ts index f971252d..4cc69331 100644 --- a/src/device/protocol/virtual/genericVirtualDeviceLogicFactory.ts +++ b/src/device/protocol/virtual/genericVirtualDeviceLogicFactory.ts @@ -1,3 +1,4 @@ +import { TSchema } from '@sinclair/typebox'; import VirtualDeviceLogic from './virtualDeviceLogic.js'; import Logger from '../../../logging/Logger.js'; import VirtualDeviceLogicFactory from './virtualDeviceLogicFactory.js'; @@ -9,25 +10,22 @@ export default class GenericVirtualDeviceLogicFactory< TDeviceLogic extends VirtualDeviceLogic > implements VirtualDeviceLogicFactory { + public readonly configSchema: TSchema & { static: ExtractConfig }; + private readonly ctor: Constructor; private readonly logger: Logger; - private constructor(ctor: Constructor, logger: Logger) { + public constructor( + ctor: Constructor, + configSchema: TSchema & { static: ExtractConfig }, + logger: Logger + ) { this.ctor = ctor; + this.configSchema = configSchema; this.logger = logger; } - public static from>( - genericVirtualDeviceLogicLogicConstructor: Constructor, - logger: Logger - ): GenericVirtualDeviceLogicFactory { - return new GenericVirtualDeviceLogicFactory( - genericVirtualDeviceLogicLogicConstructor, - logger, - ); - } - public create(config: ExtractConfig): TDeviceLogic { return new this.ctor(config, this.logger); } diff --git a/src/device/protocol/virtual/virtualDeviceLogicFactory.ts b/src/device/protocol/virtual/virtualDeviceLogicFactory.ts index 1b002d31..d420645b 100644 --- a/src/device/protocol/virtual/virtualDeviceLogicFactory.ts +++ b/src/device/protocol/virtual/virtualDeviceLogicFactory.ts @@ -1,9 +1,25 @@ +import { TSchema } from '@sinclair/typebox'; import VirtualDeviceLogic from './virtualDeviceLogic.js'; type ExtractConfig> = T extends VirtualDeviceLogic ? C : never; export default interface VirtualDeviceLogicFactory> { + /** + * Known limitation: this only catches a `configSchema` that doesn't match `TDeviceLogic`'s + * own config when that config has at least one *required* property (verified: e.g. pairing + * `PiperVirtualDeviceLogic`, whose config requires `model`, with `noDeviceConfigSchema` is + * correctly rejected). If every property is optional (e.g. `TtsVirtualDeviceConfig`'s `{ + * voice?: string }`), that type is structurally indistinguishable from `{}` under TS's + * assignability rules, so a wrong-but-also-all-optional schema slips through unnoticed at + * compile time (verified: pairing `TtsVirtualDeviceLogic` with `noDeviceConfigSchema` compiles + * without error). Low practical impact: `GenericVirtualDeviceFactory.create()` still validates + * the real config against `configSchema` via AJV at runtime, so a wrong pairing fails loudly + * (a validation error) the first time it's actually exercised, rather than silently + * misbehaving. + */ + readonly configSchema: TSchema & { static: ExtractConfig }; + create(config: ExtractConfig): TDeviceLogic; forDeviceType(): string; diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index fbcaa97a..bc106c6a 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -9,8 +9,9 @@ import VirtualDeviceFactory from './virtualDeviceFactory.js'; import DeviceManager from '../../deviceManager.js'; import { asyncHandler, setImmediateInterval } from '../../../util/async.js'; import { logError } from '../../../util/error.js'; +import { VirtualDeviceProviderConfig } from './virtualDeviceProviderConfig.js'; -export default class VirtualDeviceProvider extends DeviceProvider +export default class VirtualDeviceProvider extends DeviceProvider { public static readonly providerName = 'virtual'; @@ -21,33 +22,33 @@ export default class VirtualDeviceProvider extends DeviceProvider private readonly settingsManager: SettingsManager; - private readonly scanIntervalMs: number; - private discoveryInterval?: NodeJS.Timeout; private stopped: boolean = false; public constructor( + config: VirtualDeviceProviderConfig, deviceManager: DeviceManager, eventEmitter: EventEmitter, deviceFactory: VirtualDeviceFactory, settingsManager: SettingsManager, - logger: Logger, - scanIntervalMs: number + logger: Logger ) { - super(deviceManager, eventEmitter, logger.child({ name: VirtualDeviceProvider.name })); + super(config, deviceManager, eventEmitter, logger.child({ name: VirtualDeviceProvider.name })); this.deviceFactory = deviceFactory; this.settingsManager = settingsManager; - this.scanIntervalMs = scanIntervalMs; } public override async init(): Promise { this.stopped = false; + // `DeviceProviderManager` hydrates missing config fields with their schema `default` + // before validating/constructing, so `scanIntervalMs` is always present here - no + // fallback needed. this.discoveryInterval ??= setImmediateInterval(asyncHandler( this.discoverVirtualDevices.bind(this), (e: unknown) => this.logger.error('Error while scanning for new virtual devices', e) - ), this.scanIntervalMs); + ), this.config.scanIntervalMs); } public override async stop(): Promise { diff --git a/src/device/protocol/virtual/virtualDeviceProviderConfig.ts b/src/device/protocol/virtual/virtualDeviceProviderConfig.ts new file mode 100644 index 00000000..3f332519 --- /dev/null +++ b/src/device/protocol/virtual/virtualDeviceProviderConfig.ts @@ -0,0 +1,15 @@ +import { Type, Static } from '@sinclair/typebox'; + +/** + * `scanIntervalMs` defaults to 3000 when missing - `DeviceProviderManager` hydrates missing + * fields with their schema `default` before validating/constructing, so `VirtualDeviceProvider` + * itself can treat this as always present, without its own fallback logic. + */ +export const virtualDeviceProviderConfigSchema = Type.Object({ + scanIntervalMs: Type.Number({ minimum: 1, default: 3000 }), +}, { + additionalProperties: false, +}); + +export type VirtualDeviceProviderConfigSchema = typeof virtualDeviceProviderConfigSchema; +export type VirtualDeviceProviderConfig = Static; diff --git a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts deleted file mode 100644 index a3e6a419..00000000 --- a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts +++ /dev/null @@ -1,55 +0,0 @@ -import DeviceProviderFactory from '../../provider/deviceProviderFactory.js'; -import Logger from '../../../logging/Logger.js'; -import VirtualDeviceProvider from './virtualDeviceProvider.js'; -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, -} - -export default class VirtualDeviceProviderFactory implements DeviceProviderFactory -{ - private static readonly DEFAULT_SCAN_INTERVAL_MS = 3000; - - private readonly deviceManager: DeviceManager; - - private readonly eventEmitterFactory: EventEmitterFactory; - - private readonly deviceFactory: VirtualDeviceFactory; - - private readonly settingsManager: SettingsManager; - - private readonly logger: Logger; - - public constructor( - deviceManager: DeviceManager, - eventEmitterFactory: EventEmitterFactory, - deviceFactory: VirtualDeviceFactory, - settingsManager: SettingsManager, - logger: Logger - ) { - this.deviceManager = deviceManager; - this.eventEmitterFactory = eventEmitterFactory; - this.deviceFactory = deviceFactory; - this.settingsManager = settingsManager; - this.logger = logger; - } - - public create(config: VirtualDeviceProviderConfig): VirtualDeviceProvider { - const scanIntervalMs = typeof config.scanIntervalMs === 'number' && config.scanIntervalMs > 0 - ? config.scanIntervalMs - : VirtualDeviceProviderFactory.DEFAULT_SCAN_INTERVAL_MS; - - 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 71703ee3..d432b28b 100644 --- a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts +++ b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts @@ -14,8 +14,9 @@ import Zc95MessageFactory from './zc95MessageFactory.js'; import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; import DeviceManager from '../../deviceManager.js'; import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; +import { NoDeviceProviderConfig } from '../../provider/deviceProviderConfig.js'; -export default class Zc95SerialDeviceProvider extends SerialDeviceProvider +export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { public static readonly providerName = 'zc95Serial'; @@ -24,6 +25,7 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider, + D extends BleDevice, + TProviderConfig, TAttributes extends DeviceAttributes = InferBleDeviceAttributes, TNotifications extends DeviceNotifications = InferDeviceNotifications, - TConfig extends AnyDeviceConfig = InferBleDeviceConfig -> extends DeviceProvider + TDeviceConfig extends AnyDeviceConfig = InferBleDeviceConfig +> extends DeviceProvider { private connectedDevices: Set = new Set(); - protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { - super(deviceManager, eventEmitter, logger); + protected constructor(config: TProviderConfig, deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { + super(config, deviceManager, eventEmitter, logger); this.deviceManager.on( DeviceManagerEvent.deviceDetected, diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index 5292c4ad..615dab7d 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -2,7 +2,22 @@ import EventEmitter from 'events'; import Logger from '../../logging/Logger.js'; import DeviceManager from '../deviceManager.js'; -export default abstract class DeviceProvider +/** + * `TConfig` is the provider's own settings.json `DeviceSource.config` shape, named/typed by each + * concrete provider rather than passed around as a loose `JsonObject`. Every provider is paired + * with a TypeBox schema for `TConfig` on its `DeviceProviderFactory`, which validates a + * `DeviceSource`'s raw config against it before constructing the provider. Providers that don't + * need any configuration still use a config type (the shared `NoDeviceProviderConfig`) for + * uniformity - see + * `SlvCtrlPlusSerialDeviceProvider`/`Zc95SerialDeviceProvider`/`EStim2bSerialDeviceProvider`/ + * `AiroticDeviceProvider`. + * + * `config` is deliberately the FIRST constructor parameter across every `DeviceProvider` + * subclass - `GenericDeviceProviderFactory` relies on that fixed convention to generically + * prepend the validated config to whatever other ("dependency") constructor arguments were + * captured when the factory itself was wired up. + */ +export default abstract class DeviceProvider { protected readonly deviceManager: DeviceManager; @@ -10,7 +25,10 @@ export default abstract class DeviceProvider protected readonly logger: Logger; - protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { + protected readonly config: TConfig; + + protected constructor(config: TConfig, deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { + this.config = config; this.deviceManager = deviceManager; this.eventEmitter = eventEmitter; this.logger = logger; diff --git a/src/device/provider/deviceProviderConfig.ts b/src/device/provider/deviceProviderConfig.ts new file mode 100644 index 00000000..5f16e2e1 --- /dev/null +++ b/src/device/provider/deviceProviderConfig.ts @@ -0,0 +1,12 @@ +import { Type, Static } from '@sinclair/typebox'; + +/** + * Distinct from `NoDeviceConfig` (src/device/deviceConfig.ts) even though it's the same + * underlying shape - that one is for a *device's* own config (e.g. `DisplayVirtualDeviceLogic`), + * this one is for a *provider's* config (e.g. `SlvCtrlPlusSerialDeviceProvider`, which has no + * settings.json `DeviceSource.config` of its own). Kept separate for clarity at call sites, even + * though nothing behaviorally distinguishes the two. + */ +export const noDeviceProviderConfigSchema = Type.Object({}, { additionalProperties: false }); +export type NoDeviceProviderConfigSchema = typeof noDeviceProviderConfigSchema; +export type NoDeviceProviderConfig = Static; diff --git a/src/device/provider/deviceProviderFactory.ts b/src/device/provider/deviceProviderFactory.ts index 4aa6c595..871c1792 100644 --- a/src/device/provider/deviceProviderFactory.ts +++ b/src/device/provider/deviceProviderFactory.ts @@ -1,7 +1,38 @@ +import { TSchema } from '@sinclair/typebox'; import DeviceProvider from './deviceProvider.js'; -import { JsonObject } from '../../types.js'; -export default interface DeviceProviderFactory +/** + * Extracts the `TConfig` a concrete `DeviceProvider` subclass was declared with, so + * `DeviceProviderFactory` only needs a single type parameter instead of repeating the config + * type separately. + */ +export type ConfigOf = DP extends DeviceProvider ? TConfig : never; + +/** + * Constructs one `DeviceProvider` instance for a single `DeviceSource` config entry. + * + * `configSchema` is the TypeBox schema for `ConfigOf` - `DeviceProviderManager` validates a + * `DeviceSource`'s raw config against it before calling `create()`, so factories don't need to + * defensively parse raw JSON themselves. TypeBox schemas carry their inferred type as a phantom + * `static` property, so intersecting it with `{ static: ConfigOf }` is enough for TS to catch + * a factory whose schema doesn't actually match its own `DP`'s config. + * + * Bounded to `TSchema` rather than `TObject`: `TObject` is itself generic with a recursive + * default type parameter, and using it bare as a field type (rather than only ever as a generic + * parameter bound, substituted with a concrete narrow type) blows TS's instantiation depth limit. + * `TSchema` is a plain, non-generic marker interface, so it doesn't have this problem. + * + * Known limitation: this only catches a mismatched `configSchema` when `ConfigOf` has at + * least one *required* property. If every property is optional (e.g. `{ scanIntervalMs?: number + * }`), that type is structurally indistinguishable from `{}` under TS's assignability rules (each + * is assignable to the other), so a wrong-but-also-all-optional schema slips through unnoticed at + * compile time. Low practical impact: `DeviceProviderManager` still validates the real config + * against `configSchema` via AJV at runtime, so a wrong pairing fails loudly (a validation error) + * the first time it's actually exercised, rather than silently misbehaving. + */ +export default interface DeviceProviderFactory> { - create(config: JsonObject): DP; + readonly configSchema: TSchema & { static: ConfigOf }; + + create(config: ConfigOf): DP; } diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index 1620a8e1..daaf5dc0 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -1,21 +1,27 @@ +import { Value } from '@sinclair/typebox/value'; import Settings from '../../settings/settings.js'; import DeviceProviderFactory from './deviceProviderFactory.js'; import Logger from '../../logging/Logger.js'; import DeviceProvider from './deviceProvider.js'; +import JsonSchemaValidatorFactory from '../../schemaValidation/JsonSchemaValidatorFactory.js'; export default class DeviceProviderManager { private factories: Map>; + private readonly jsonSchemaValidatorFactory: JsonSchemaValidatorFactory; + private readonly logger: Logger; - private providers: DeviceProvider[] = []; + private providers: DeviceProvider[] = []; public constructor( factories: Map>, + jsonSchemaValidatorFactory: JsonSchemaValidatorFactory, logger: Logger ) { this.factories = factories; + this.jsonSchemaValidatorFactory = jsonSchemaValidatorFactory; this.logger = logger.child({ name: DeviceProviderManager.name }); } @@ -33,7 +39,26 @@ export default class DeviceProviderManager continue; } - const provider = factory.create(deviceSource.config); + // Clone before hydrating: `Value.Default()` mutates in place, and we don't want to + // write resolved defaults back into `deviceSource.config` itself (Settings auto-saves + // on mutation, so that would trigger a spurious settings.json write/broadcast). + // `structuredClone()` doesn't work here: `Settings` is wrapped in an `on-change` Proxy + // that deep-proxies nested objects too (including `deviceSource.config`), and the + // structured clone algorithm can't clone a Proxy. `JsonObject` is JSON-safe by + // definition, so a plain JSON round-trip clones it fine while transparently reading + // through the proxy (JSON.stringify just does normal property access). + const config = Value.Default(factory.configSchema, JSON.parse(JSON.stringify(deviceSource.config))); + + const configValidator = this.jsonSchemaValidatorFactory.create(factory.configSchema); + + if (!configValidator.validate(config)) { + throw new Error( + `Config for device source '${id}' (type '${deviceSource.type}') is not valid: ` + + configValidator.getValidationErrorsAsText() + ); + } + + const provider = factory.create(config); this.providers.push(provider); } diff --git a/src/device/provider/genericDeviceProviderFactory.ts b/src/device/provider/genericDeviceProviderFactory.ts index abcfb9f6..ac77c8ee 100644 --- a/src/device/provider/genericDeviceProviderFactory.ts +++ b/src/device/provider/genericDeviceProviderFactory.ts @@ -1,21 +1,36 @@ +import { TSchema } from '@sinclair/typebox'; import DeviceProvider from './deviceProvider.js'; -import DeviceProviderFactory from './deviceProviderFactory.js'; - -type ConcreteCtor = new (...args: any[]) => T; +import DeviceProviderFactory, { ConfigOf } from './deviceProviderFactory.js'; +/** + * Every `DeviceProvider` constructor starts with `config: ConfigOf` as its first parameter + * (see `DeviceProvider`'s own doc comment) - this is what lets `GenericDeviceProviderFactory` + * capture every other constructor argument once, at DI-wiring time (`TDependencyArgs` - the + * "always the same, regardless of `DeviceSource`" deps, as opposed to `config`, which varies per + * `DeviceSource`), and prepend the actual validated config later, once per `DeviceSource`, in + * `create()`. + */ export default class GenericDeviceProviderFactory< - DP extends DeviceProvider + DP extends DeviceProvider, + TDependencyArgs extends any[] = any[] > implements DeviceProviderFactory { - private readonly ctor: ConcreteCtor; - private readonly args: ConstructorParameters>; + public readonly configSchema: TSchema & { static: ConfigOf }; + + private readonly ctor: new (config: ConfigOf, ...dependencyArgs: TDependencyArgs) => DP; + private readonly dependencyArgs: TDependencyArgs; - public constructor(ctor: ConcreteCtor, ...args: ConstructorParameters>) { + public constructor( + configSchema: TSchema & { static: ConfigOf }, + ctor: new (config: ConfigOf, ...dependencyArgs: TDependencyArgs) => DP, + ...dependencyArgs: TDependencyArgs + ) { + this.configSchema = configSchema; this.ctor = ctor; - this.args = args; + this.dependencyArgs = dependencyArgs; } - public create(): DP { - return new this.ctor(...this.args); + public create(config: ConfigOf): DP { + return new this.ctor(config, ...this.dependencyArgs); } } diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index c3d0769e..51c5221d 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -18,10 +18,11 @@ import { AnyDeviceConfig } from '../deviceConfig.js'; export type SerialDeviceProviderPortOpenOptions = Omit, 'path' | 'autoOpen'>; export default abstract class SerialDeviceProvider< - D extends PeripheralDevice, + D extends PeripheralDevice, + TProviderConfig, TAttributes extends DeviceAttributes = InferPeripheralDeviceAttributes, - TConfig extends AnyDeviceConfig = InferPeripheralDeviceConfig -> extends DeviceProvider + TDeviceConfig extends AnyDeviceConfig = InferPeripheralDeviceConfig +> extends DeviceProvider { private readonly serialPortFactory: SerialPortFactory; @@ -29,8 +30,14 @@ export default abstract class SerialDeviceProvider< private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; - protected constructor(deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, eventEmitter: EventEmitter, logger: Logger) { - super(deviceManager, eventEmitter, logger); + protected constructor( + config: TProviderConfig, + deviceManager: DeviceManager, + serialPortFactory: SerialPortFactory, + eventEmitter: EventEmitter, + logger: Logger + ) { + super(config, deviceManager, eventEmitter, logger); this.serialPortFactory = serialPortFactory; diff --git a/src/serviceMap.ts b/src/serviceMap.ts index e27d557c..3d0b91bb 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -32,7 +32,6 @@ import RunScriptController from './controller/automation/runScriptController.js' import StopScriptController from './controller/automation/stopScriptController.js'; import StatusScriptController from './controller/automation/statusScriptController.js'; import VirtualDeviceProvider from './device/protocol/virtual/virtualDeviceProvider.js'; -import VirtualDeviceProviderFactory from './device/protocol/virtual/virtualDeviceProviderFactory.js'; import GetSettingsController from './controller/settings/getSettingsController.js'; import PutSettingsController from './controller/settings/putSettingsController.js'; import JsonSchemaValidatorFactory from './schemaValidation/JsonSchemaValidatorFactory.js'; @@ -63,7 +62,7 @@ type ServiceMap = { /* deviceServiceProvider */ 'device.manager': DeviceManager, 'device.serial.transport.factory': SerialDeviceTransportFactory, - 'device.provider.factory.virtual': VirtualDeviceProviderFactory, + 'device.provider.factory.virtual': DeviceProviderFactory, 'device.serial.factory.slvCtrlPlus': SlvCtrlPlusDeviceFactory, 'device.factory.zc95': Zc95DeviceFactory, 'device.factory.estim2b': Estim2bDeviceFactory, diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index ffe303ac..94f39129 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -11,12 +11,11 @@ import Device 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 { buttplugIoWebsocketConfigSchema } from '../device/protocol/buttplugIo/buttplugIoWebsocketConfig.js'; import ButtplugIoDeviceFactory from '../device/protocol/buttplugIo/buttplugIoDeviceFactory.js'; import ServiceMap from '../serviceMap.js'; import VirtualDeviceProvider from '../device/protocol/virtual/virtualDeviceProvider.js'; -import VirtualDeviceProviderFactory from '../device/protocol/virtual/virtualDeviceProviderFactory.js'; +import { virtualDeviceProviderConfigSchema } from '../device/protocol/virtual/virtualDeviceProviderConfig.js'; import GenericVirtualDeviceFactory from '../device/protocol/virtual/genericVirtualDeviceFactory.js'; import DisplayVirtualDeviceLogic from '../device/protocol/virtual/display/displayVirtualDeviceLogic.js'; import RandomGeneratorVirtualDeviceLogic @@ -28,6 +27,7 @@ import Zc95DeviceFactory from '../device/protocol/zc95/zc95DeviceFactory.js'; import PiperVirtualDeviceLogic from '../device/protocol/virtual/audio/piperVirtualDeviceLogic.js'; import { piperVirtualDeviceConfigSchema } from '../device/protocol/virtual/audio/piperVirtualDeviceConfig.js'; import { noDeviceConfigSchema } from '../device/deviceConfig.js'; +import { noDeviceProviderConfigSchema } from '../device/provider/deviceProviderConfig.js'; import { randomGeneratorVirtualDeviceConfigSchema } from '../device/protocol/virtual/randomGenerator/randomGeneratorVirtualDeviceConfig.js'; @@ -51,6 +51,7 @@ export default class DeviceServiceProvider implements ServiceProvider new GenericDeviceProviderFactory( + noDeviceProviderConfigSchema, SlvCtrlPlusSerialDeviceProvider, container.get('device.manager'), container.get('factory.serialPort'), @@ -63,7 +64,9 @@ export default class DeviceServiceProvider implements ServiceProvider new ButtplugIoWebsocketDeviceProviderFactory( + () => new GenericDeviceProviderFactory( + buttplugIoWebsocketConfigSchema, + ButtplugIoWebsocketDeviceProvider, container.get('device.manager'), container.get('factory.eventEmitter').create(), container.get('device.serial.factory.buttplugIo'), @@ -121,9 +124,11 @@ export default class DeviceServiceProvider implements ServiceProvider new VirtualDeviceProviderFactory( + container.set('device.provider.factory.virtual', () => new GenericDeviceProviderFactory( + virtualDeviceProviderConfigSchema, + VirtualDeviceProvider, container.get('device.manager'), - container.get('factory.eventEmitter'), + container.get('factory.eventEmitter').create(), container.get('device.virtual.factory'), container.get('settings.manager'), container.get('logger.default'), @@ -140,22 +145,26 @@ export default class DeviceServiceProvider implements ServiceProvider { return new GenericDeviceProviderFactory( + noDeviceProviderConfigSchema, Zc95SerialDeviceProvider, container.get('device.manager'), container.get('factory.serialPort'), @@ -215,6 +226,7 @@ export default class DeviceServiceProvider implements ServiceProvider { return new GenericDeviceProviderFactory( + noDeviceProviderConfigSchema, EStim2bSerialDeviceProvider, container.get('device.manager'), container.get('factory.serialPort'), @@ -227,6 +239,7 @@ export default class DeviceServiceProvider implements ServiceProvider { return new GenericDeviceProviderFactory( + noDeviceProviderConfigSchema, AiroticDeviceProvider, container.get('device.manager'), container.get('settings'), diff --git a/tests/unit/device/provider/deviceProviderManager.spec.ts b/tests/unit/device/provider/deviceProviderManager.spec.ts new file mode 100644 index 00000000..20b70670 --- /dev/null +++ b/tests/unit/device/provider/deviceProviderManager.spec.ts @@ -0,0 +1,225 @@ +import EventEmitter from 'events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { Type } from '@sinclair/typebox'; +import { Ajv2020 } from 'ajv/dist/2020.js'; +import ajvFormatsPlugin from 'ajv-formats'; +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 JsonSchemaValidatorFactory from '../../../../src/schemaValidation/JsonSchemaValidatorFactory.js'; +import Settings from '../../../../src/settings/settings.js'; +import DeviceSource from '../../../../src/settings/deviceSource.js'; +import Logger from '../../../../src/logging/Logger.js'; +import DeviceManager from '../../../../src/device/deviceManager.js'; + +type FakeProviderConfig = { greeting: string }; + +const fakeProviderConfigSchema = Type.Object({ + greeting: Type.String(), +}, { additionalProperties: false }); + +type FakeDefaultedProviderConfig = { retries: number }; + +const fakeDefaultedProviderConfigSchema = Type.Object({ + retries: Type.Number({ default: 3 }), +}, { additionalProperties: false }); + +class FakeDeviceProvider extends DeviceProvider { + public readonly initMock = vi.fn().mockResolvedValue(undefined); + public readonly stopMock = vi.fn().mockResolvedValue(undefined); + + public constructor(logger: Logger, config: FakeProviderConfig) { + super(config, mock(), new EventEmitter(), logger); + } + + public override init(): Promise { + return this.initMock(); + } + + public override stop(): Promise { + return this.stopMock(); + } +} + +class FakeDeviceProviderFactory implements DeviceProviderFactory { + public readonly configSchema = fakeProviderConfigSchema; + + public readonly created: FakeDeviceProvider[] = []; + + public constructor(private readonly logger: Logger) { + } + + public create(config: FakeProviderConfig): FakeDeviceProvider { + const provider = new FakeDeviceProvider(this.logger, config); + this.created.push(provider); + return provider; + } +} + +class FakeDefaultedDeviceProvider extends DeviceProvider { + public constructor(logger: Logger, config: FakeDefaultedProviderConfig) { + super(config, mock(), new EventEmitter(), logger); + } +} + +class FakeDefaultedDeviceProviderFactory implements DeviceProviderFactory { + public readonly configSchema = fakeDefaultedProviderConfigSchema; + + public readonly created: FakeDefaultedDeviceProvider[] = []; + + public constructor(private readonly logger: Logger) { + } + + public create(config: FakeDefaultedProviderConfig): FakeDefaultedDeviceProvider { + const provider = new FakeDefaultedDeviceProvider(this.logger, config); + this.created.push(provider); + return provider; + } +} + +describe('DeviceProviderManager', () => { + let mockSettings: ReturnType>; + let mockLogger: ReturnType>; + let jsonSchemaValidatorFactory: JsonSchemaValidatorFactory; + let fakeFactory: FakeDeviceProviderFactory; + let fakeDefaultedFactory: FakeDefaultedDeviceProviderFactory; + let manager: DeviceProviderManager; + + beforeEach(() => { + mockSettings = mock(); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + + const ajv = new Ajv2020({ allErrors: true, strict: true }); + ajvFormatsPlugin.default(ajv); + jsonSchemaValidatorFactory = new JsonSchemaValidatorFactory(ajv); + + fakeFactory = new FakeDeviceProviderFactory(mockLogger); + fakeDefaultedFactory = new FakeDefaultedDeviceProviderFactory(mockLogger); + + manager = new DeviceProviderManager( + new Map>([ + ['fake', fakeFactory], + ['fakeDefaulted', fakeDefaultedFactory], + ]), + jsonSchemaValidatorFactory, + mockLogger, + ); + }); + + describe('loadFromSettings', () => { + it('constructs one provider instance per matching device source, even for the same type', () => { + mockSettings.getDeviceSources.mockReturnValue(new Map([ + ['source-1', new DeviceSource('source-1', 'fake', { greeting: 'hi' })], + ['source-2', new DeviceSource('source-2', 'fake', { greeting: 'hello' })], + ])); + + manager.loadFromSettings(mockSettings); + + expect(fakeFactory.created).toHaveLength(2); + }); + + it('skips a device source whose type has no registered factory, logging a warning', () => { + mockSettings.getDeviceSources.mockReturnValue(new Map([ + ['source-1', new DeviceSource('source-1', 'unsupportedType', {})], + ])); + + manager.loadFromSettings(mockSettings); + + expect(fakeFactory.created).toHaveLength(0); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('unsupportedType')); + }); + + it('throws when a device source config fails schema validation (wrong type)', () => { + mockSettings.getDeviceSources.mockReturnValue(new Map([ + ['source-1', new DeviceSource('source-1', 'fake', { greeting: 123 })], + ])); + + expect(() => manager.loadFromSettings(mockSettings)).toThrow(/not valid/); + expect(fakeFactory.created).toHaveLength(0); + }); + + it('throws when a device source config is missing a required field', () => { + mockSettings.getDeviceSources.mockReturnValue(new Map([ + ['source-1', new DeviceSource('source-1', 'fake', {})], + ])); + + expect(() => manager.loadFromSettings(mockSettings)).toThrow(/not valid/); + }); + + it('throws when a device source config has additional, unknown properties', () => { + mockSettings.getDeviceSources.mockReturnValue(new Map([ + ['source-1', new DeviceSource('source-1', 'fake', { greeting: 'hi', extra: true })], + ])); + + expect(() => manager.loadFromSettings(mockSettings)).toThrow(/not valid/); + }); + + it('hydrates a missing config field with its schema default before validating/constructing', () => { + mockSettings.getDeviceSources.mockReturnValue(new Map([ + ['source-1', new DeviceSource('source-1', 'fakeDefaulted', {})], + ])); + + manager.loadFromSettings(mockSettings); + + expect(fakeDefaultedFactory.created).toHaveLength(1); + expect(fakeDefaultedFactory.created[0]).toMatchObject({ config: { retries: 3 } }); + }); + + it('does not mutate the DeviceSource.config object itself while hydrating defaults', () => { + const rawConfig = {}; + mockSettings.getDeviceSources.mockReturnValue(new Map([ + ['source-1', new DeviceSource('source-1', 'fakeDefaulted', rawConfig)], + ])); + + manager.loadFromSettings(mockSettings); + + expect(rawConfig).toEqual({}); + }); + + it('keeps an explicitly provided value over the schema default', () => { + mockSettings.getDeviceSources.mockReturnValue(new Map([ + ['source-1', new DeviceSource('source-1', 'fakeDefaulted', { retries: 7 })], + ])); + + manager.loadFromSettings(mockSettings); + + expect(fakeDefaultedFactory.created[0]).toMatchObject({ config: { retries: 7 } }); + }); + }); + + describe('startProviders / stopProviders', () => { + beforeEach(() => { + mockSettings.getDeviceSources.mockReturnValue(new Map([ + ['source-1', new DeviceSource('source-1', 'fake', { greeting: 'hi' })], + ['source-2', new DeviceSource('source-2', 'fake', { greeting: 'hello' })], + ])); + manager.loadFromSettings(mockSettings); + }); + + it('initializes every constructed provider', async () => { + await manager.startProviders(); + + for (const provider of fakeFactory.created) { + expect(provider.initMock).toHaveBeenCalledOnce(); + } + }); + + it('stops every provider', async () => { + await manager.stopProviders(); + + for (const provider of fakeFactory.created) { + expect(provider.stopMock).toHaveBeenCalledOnce(); + } + }); + + it('collects errors from failing providers and still stops the rest, then throws', async () => { + fakeFactory.created[0].stopMock.mockRejectedValueOnce(new Error('boom')); + + await expect(manager.stopProviders()).rejects.toThrow(/Failed to stop 1 device provider/); + + expect(fakeFactory.created[1].stopMock).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/tests/unit/device/testDeviceProvider.ts b/tests/unit/device/testDeviceProvider.ts index 712baae1..51f581be 100644 --- a/tests/unit/device/testDeviceProvider.ts +++ b/tests/unit/device/testDeviceProvider.ts @@ -2,12 +2,13 @@ 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"; +import { NoDeviceProviderConfig } from "../../../src/device/provider/deviceProviderConfig.js"; -export default class TestDeviceProvider extends DeviceProvider +export default class TestDeviceProvider extends DeviceProvider { - public constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) + public constructor(config: NoDeviceProviderConfig, deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { - super(deviceManager, eventEmitter, logger); + super(config, deviceManager, eventEmitter, logger); } public override init(): Promise From c027bb6d7e8898bb6b74055d7b2ee6bd1dc5ccc2 Mon Sep 17 00:00:00 2001 From: HRS Date: Mon, 13 Jul 2026 20:19:16 +0200 Subject: [PATCH 17/17] Fix settings.json write/broadcast on every device reconnect SlvCtrlPlusDeviceFactory, AiroticDeviceProvider, and ButtplugIoDeviceFactory were unconditionally calling settings.addKnownDevice() on every successful device connect, even for an already-known, unchanged device. Since Settings is wrapped with on-change to auto-save to disk, this triggered a full settings.json write plus a settings-changed WebSocket broadcast on every single reconnect, not just for genuinely new devices. Restores KnownDeviceRegistry (resolve()/persist()), previously removed as part of a larger architectural revert earlier in this branch, since it's a self-contained fix unrelated to that restructuring: - resolve() is pure (no side effects) - looks up an existing identity or builds a new, not-yet-persisted one. - persist() is only called once a Device has actually been built successfully, not right after resolve(). If device construction throws in between, the newly-resolved-but-unpersisted KnownDevice is simply discarded instead of leaking a phantom entry into settings.json. - persist() is a no-op (reference-equality check) for an already-known, unchanged identity, which is what actually prevents the spurious writes/broadcasts on reconnect. Zc95 already has known-device persistence disabled intentionally (unstable USB serial numbers across firmware resets), and EStim2b doesn't persist known devices at all on this architecture - neither is affected by this bug. Full gate green: typecheck, lint, 364/364 tests. --- src/device/knownDeviceRegistry.ts | 63 +++++++++ .../protocol/airotic/airoticDeviceProvider.ts | 33 ++--- .../buttplugIo/buttplugIoDeviceFactory.ts | 47 +++---- .../slvCtrlPlus/slvCtrlPlusDeviceFactory.ts | 36 +----- src/serviceMap.ts | 2 + src/serviceProvider/deviceServiceProvider.ts | 14 +- tests/unit/device/knownDeviceRegistry.spec.ts | 120 ++++++++++++++++++ 7 files changed, 232 insertions(+), 83 deletions(-) create mode 100644 src/device/knownDeviceRegistry.ts create mode 100644 tests/unit/device/knownDeviceRegistry.spec.ts diff --git a/src/device/knownDeviceRegistry.ts b/src/device/knownDeviceRegistry.ts new file mode 100644 index 00000000..748818f5 --- /dev/null +++ b/src/device/knownDeviceRegistry.ts @@ -0,0 +1,63 @@ +import Settings from '../settings/settings.js'; +import KnownDevice from '../settings/knownDevice.js'; +import DeviceNameGenerator from './deviceNameGenerator.js'; +import Logger from '../logging/Logger.js'; +import { DeviceId } from './deviceId.js'; + +/** + * Looks up and registers the persisted `KnownDevice` identity for a newly detected raw device + * (serial port, BLE peripheral, buttplug.io device, ...). + * + * Centralizes identity lookup/creation logic that used to be duplicated across several device + * providers/factories. Deliberately has no opinion on *when* a newly created identity should be + * persisted - `resolve()` never has side effects, so callers stay in control of only calling + * `persist()` once they've actually finished building the Device successfully. + */ +export default class KnownDeviceRegistry +{ + private readonly settings: Settings; + + private readonly nameGenerator: DeviceNameGenerator; + + private readonly logger: Logger; + + public constructor(settings: Settings, nameGenerator: DeviceNameGenerator, logger: Logger) { + this.settings = settings; + this.nameGenerator = nameGenerator; + this.logger = logger.child({ name: KnownDeviceRegistry.name }); + } + + /** + * Looks up the already-known identity for `deviceId`, or builds a new (not yet persisted) + * one if none exists. + */ + public resolve(deviceId: DeviceId, type: string, provider: string, name?: string): KnownDevice { + const knownDevice = this.settings.getKnownDeviceById(deviceId); + + if (undefined !== knownDevice) { + // Already known (previously detected serial number) + this.logger.debug(`Device is already known: ${knownDevice.id}`); + return knownDevice; + } + + return new KnownDevice(deviceId, name ?? this.nameGenerator.generateName(), type, provider); + } + + /** + * Persists a resolved identity. Safe to call unconditionally after successfully building a + * Device, even for an already-known identity - a no-op in that case, since KnownDevice is + * immutable and `resolve()` returns the exact same instance for an already-known device. + * + * This matters beyond just avoiding pointless work: Settings is wrapped with `on-change` to + * auto-save to disk, so an unconditional `settings.addKnownDevice()` call here would trigger a + * settings.json write and a settings-changed WebSocket broadcast on *every* device connect, + * even for a device that has been known and unchanged for months. + */ + public persist(knownDevice: KnownDevice): void { + if (this.settings.getKnownDeviceById(knownDevice.id) === knownDevice) { + return; + } + + this.settings.addKnownDevice(knownDevice); + } +} diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index 0d05f33f..dca1649a 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -10,9 +10,7 @@ import AiroticProtocol from './airtonicProtocol.js'; import MessageResponseHandler from '../messageResponseHandler.js'; import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; -import Settings from '../../../settings/settings.js'; -import KnownDevice from '../../../settings/knownDevice.js'; -import { DeviceId } from '../../deviceId.js'; +import KnownDeviceRegistry from '../../knownDeviceRegistry.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import FloatDeviceAttribute from '../../attribute/floatDeviceAttribute.js'; import BleDeviceProvider from '../../provider/bleDeviceProvider.js'; @@ -26,12 +24,18 @@ export default class AiroticDeviceProvider extends BleDeviceProvider { @@ -57,8 +61,10 @@ export default class AiroticDeviceProvider extends BleDeviceProvider { const deviceInfo = await this.getDeviceInfo(transport); const protocol = deviceInfo.protocol; - const knownDevice = this.createKnownDevice(deviceId, deviceInfo.deviceType, provider); + const knownDevice = this.knownDeviceRegistry.resolve(deviceId, deviceInfo.deviceType, provider); const deviceAttributes = await this.getAttributes(transport, protocol); const device = new GenericSlvCtrlPlusDevice( @@ -60,7 +54,7 @@ export default class SlvCtrlPlusDeviceFactory this.logger, ); - this.settings.addKnownDevice(knownDevice); + this.knownDeviceRegistry.persist(knownDevice); return device; } @@ -125,22 +119,4 @@ export default class SlvCtrlPlusDeviceFactory return new SlvCtrlProtocolV1(); } - - private createKnownDevice(deviceId: DeviceId, deviceType: string, provider: string): KnownDevice { - const knownDevice = this.settings.getKnownDeviceById(deviceId) - - if (undefined !== knownDevice) { - // Return already existing device if already known (previously detected serial number) - this.logger.debug(`Device is already known: ${knownDevice.id}`); - return knownDevice; - } - - // Create a new device and return if not yet known (new serial number) - return new KnownDevice( - deviceId, - this.nameGenerator.generateName(), - deviceType, - provider - ); - } } diff --git a/src/serviceMap.ts b/src/serviceMap.ts index 3d0b91bb..c4ebe319 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -49,6 +49,7 @@ import Zc95SerialDeviceProvider from './device/protocol/zc95/zc95SerialDevicePro import EStim2bSerialDeviceProvider from './device/protocol/estim2b/estim2bSerialDeviceProvider.js'; import ButtplugIoWebsocketDeviceProvider from './device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; import AiroticDeviceProvider from './device/protocol/airotic/airoticDeviceProvider.js'; +import KnownDeviceRegistry from './device/knownDeviceRegistry.js'; type ServiceMap = { @@ -75,6 +76,7 @@ type ServiceMap = { 'device.virtual.provider': VirtualDeviceProvider, 'device.virtual.factory': VirtualDeviceFactory, 'device.uniqueNameGenerator': DeviceNameGenerator, + 'device.knownDeviceRegistry': KnownDeviceRegistry, 'device.updater': DeviceUpdaterInterface, 'device.observer.serial': SerialPortObserver, 'device.observer.ble': BleObserver, diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index 94f39129..7177a06a 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -40,6 +40,7 @@ import BleObserver from '../device/transport/bleObserver.js'; import AiroticDeviceProvider from '../device/protocol/airotic/airoticDeviceProvider.js'; import DeviceProviderFactory from '../device/provider/deviceProviderFactory.js'; import { DeviceId } from '../device/deviceId.js'; +import KnownDeviceRegistry from '../device/knownDeviceRegistry.js'; export default class DeviceServiceProvider implements ServiceProvider { public register(container: Pimple): void { @@ -93,18 +94,23 @@ export default class DeviceServiceProvider implements ServiceProvider new KnownDeviceRegistry( + container.get('settings'), + container.get('device.uniqueNameGenerator'), + container.get('logger.default'), + )); + container.set('device.serial.factory.slvCtrlPlus', () => new SlvCtrlPlusDeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), - container.get('settings'), - container.get('device.uniqueNameGenerator'), + container.get('device.knownDeviceRegistry'), container.get('logger.default'), )); container.set('device.serial.factory.buttplugIo', () => new ButtplugIoDeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), - container.get('settings'), + container.get('device.knownDeviceRegistry'), container.get('logger.default'), )); @@ -242,7 +248,7 @@ export default class DeviceServiceProvider implements ServiceProvider { + let mockSettings: ReturnType>; + let mockNameGenerator: ReturnType>; + let mockLogger: ReturnType>; + let registry: KnownDeviceRegistry; + + beforeEach(() => { + mockSettings = mock(); + mockNameGenerator = mock(); + mockNameGenerator.generateName.mockReturnValue('Generated Name'); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + + registry = new KnownDeviceRegistry(mockSettings, mockNameGenerator, mockLogger); + }); + + describe('resolve', () => { + it('returns the already known device without persisting anything', () => { + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Existing Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + const result = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); + + expect(result).toBe(existingKnownDevice); + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('builds a new, not-yet-persisted KnownDevice when none exists', () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const deviceId = DeviceId.create('device-1'); + const result = registry.resolve(deviceId, 'testType', 'testProvider'); + + expect(result).toMatchObject({ id: deviceId, type: 'testType', source: 'testProvider' }); + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('uses the provided name over the generated one for a new device', () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const result = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider', 'Explicit Name'); + + expect(result.name).toBe('Explicit Name'); + expect(mockNameGenerator.generateName).not.toHaveBeenCalled(); + }); + + it('falls back to a generated name when none is provided', () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const result = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); + + expect(result.name).toBe('Generated Name'); + }); + }); + + describe('persist', () => { + it('delegates to settings.addKnownDevice for a genuinely new identity', () => { + const knownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + registry.persist(knownDevice); + + expect(mockSettings.addKnownDevice).toHaveBeenCalledOnce(); + expect(mockSettings.addKnownDevice).toHaveBeenCalledWith(knownDevice); + }); + + it('does not touch settings when persisting an already-known, unchanged identity', () => { + // This matters beyond avoiding pointless work: Settings is wrapped with on-change to + // auto-save to disk, so calling addKnownDevice() here unconditionally would trigger a + // settings.json write + a settings-changed broadcast on every device (re)connect, even + // for a device that's been known and unchanged for months. + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + registry.persist(existingKnownDevice); + + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('persists when passed a different KnownDevice instance for an already-known id', () => { + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + const differentInstance = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + registry.persist(differentInstance); + + expect(mockSettings.addKnownDevice).toHaveBeenCalledWith(differentInstance); + }); + }); + + describe('resolve + persist integration', () => { + it('does not write to settings when reconnecting an already-known device', () => { + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Existing Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + const knownDevice = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); + registry.persist(knownDevice); + + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('writes to settings exactly once when connecting a genuinely new device', () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const knownDevice = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); + registry.persist(knownDevice); + + expect(mockSettings.addKnownDevice).toHaveBeenCalledOnce(); + }); + }); +});