diff --git a/src/app.ts b/src/app.ts index cd4499b4..b246450a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,7 +14,7 @@ import { ClientToServerEvents, ServerToClientEvents, WebsocketServer } from './s import { SerializedDevice } from './device/serializedTypes.js'; import { SerializedSettings } from './settings/serializedTypes.js'; import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js'; -import Device from './device/device.js'; +import { AnyDevice } from './device/device.js'; import WebSocketEvent from './device/webSocketEvent.js'; import AutomationEventType from './automation/automationEventType.js'; import LoggerServiceProvider from './serviceProvider/loggerServiceProvider.js'; @@ -104,28 +104,36 @@ const configureWebsocket = (io: WebsocketServer, container: Container deviceUpdateHandler.handle(data)); }); - deviceManager.on(DeviceManagerEvent.deviceConnected, (device: Device) => { + deviceManager.on(DeviceManagerEvent.deviceConnected, (device: AnyDevice) => { io.emit(WebSocketEvent.deviceConnected, serializer.transform(device, deviceDiscriminator)); void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceConnected, device, args: [] }); }); - deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: Device) => { + deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: AnyDevice) => { io.emit(WebSocketEvent.deviceDisconnected, serializer.transform(device, deviceDiscriminator)); void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceDisconnected, device, args: [] }); }); - deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: Device) => { + deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: AnyDevice) => { io.emit(WebSocketEvent.deviceRefreshed, serializer.transform(device, deviceDiscriminator)); void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceRefreshed, device, args: [] }); }); - deviceManager.on(DeviceManagerEvent.deviceNotification, (device: Device, notification) => { + deviceManager.on(DeviceManagerEvent.deviceNotification, (device: AnyDevice, notification) => { io.emit(WebSocketEvent.deviceNotification, serializer.transform(device, deviceDiscriminator), notification); void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceNotification, device, args: [notification] }); }); settingsManager.on(SettingsEventType.changed, (settings: Settings) => { io.emit(SettingsEventType.changed, serializer.transform(settings)); + + deviceManager + .onSettingsChanged() + .catch(e => logError(logger, 'Failed to apply device enabled/disabled changes', e)); + + container.get('device.provider.loader') + .loadFromSettings(settings) + .catch(e => logError(logger, 'Failed to reload device sources after settings change', e)); }); // Automation events @@ -133,20 +141,13 @@ const configureWebsocket = (io: WebsocketServer, container: Container): void => { - const serialPortObserver = container.get('device.observer.serial'); - const bleObserver = container.get('device.observer.ble'); const logger = container.get('logger.default'); const settings = container.get('settings'); const deviceProviderManager = container.get('device.provider.loader'); - deviceProviderManager.loadFromSettings(settings); - deviceProviderManager - .startProviders() + .loadFromSettings(settings) .catch(e => logError(logger, `Loading device providers failed`, e)); - - serialPortObserver.start().catch(e => logError(logger, `Initializing serial port observer failed`, e)); - bleObserver.init().catch(e => logError(logger, `Initializing BLE observer failed`, e)); }; const buildCorsOptions = (allowedOrigins: string[]): CorsOptions => ({ @@ -259,8 +260,6 @@ export const createApp = (container: Container, options: AppOptions) logger.info('Shutting down...'); await container.get('automation.scriptRuntime').stop(); - await container.get('device.observer.serial').stop(); - await container.get('device.observer.ble').stop(); await container.get('device.provider.loader').stopProviders(); container.get('health.metricsCollector').stop(); diff --git a/src/automation/scriptRuntime.ts b/src/automation/scriptRuntime.ts index 5e4a0d7b..8f065c82 100644 --- a/src/automation/scriptRuntime.ts +++ b/src/automation/scriptRuntime.ts @@ -1,6 +1,6 @@ import ivm from 'isolated-vm'; import { transform } from 'sucrase'; -import Device, { DeviceNotification } from '../device/device.js'; +import { AnyDevice, DeviceNotification } from '../device/device.js'; import DeviceRepositoryInterface from '../repository/deviceRepositoryInterface.js'; import fs, { WriteStream } from 'fs'; import readLastLines from 'read-last-lines/dist/index.js'; @@ -11,8 +11,8 @@ import { AttributeValue } from '../device/attribute/deviceAttribute.js'; import Logger from '../logging/Logger.js'; export type SupportedDeviceEvent = - | { type: DeviceManagerEvent.deviceConnected | DeviceManagerEvent.deviceDisconnected | DeviceManagerEvent.deviceRefreshed; device: Device; args: [] } - | { type: DeviceManagerEvent.deviceNotification; device: Device; args: [notification: DeviceNotification] }; + | { type: DeviceManagerEvent.deviceConnected | DeviceManagerEvent.deviceDisconnected | DeviceManagerEvent.deviceRefreshed; device: AnyDevice; args: [] } + | { type: DeviceManagerEvent.deviceNotification; device: AnyDevice; args: [notification: DeviceNotification] }; type ScriptRuntimeEvents = { [AutomationEventType.consoleLog]: (data: string) => void, diff --git a/src/controller/settings/putSettingsController.ts b/src/controller/settings/putSettingsController.ts index aa745f9b..50a857ba 100644 --- a/src/controller/settings/putSettingsController.ts +++ b/src/controller/settings/putSettingsController.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import ControllerInterface from '../controllerInterface.js'; import SettingsManager from '../../settings/settingsManager.js'; import Settings, { SettingsSchema } from '../../settings/settings.js'; -import JsonSchemaValidator from '../../schemaValidation/JsonSchemaValidator.js'; +import SchemaValidationError from '../../schemaValidation/schemaValidationError.js'; import PlainToClassSerializer from '../../serialization/plainToClassSerializer.js'; import ClassToPlainSerializer from '../../serialization/classToPlainSerializer.js'; import { JsonObject } from '../../types.js'; @@ -17,35 +17,34 @@ export default class PutSettingsController implements ControllerInterface private classToPlainSerializer: ClassToPlainSerializer; - private settingsSchemaValidator: JsonSchemaValidator; - public constructor( settingsManager: SettingsManager, classToPlainSerializer: ClassToPlainSerializer, - plainToClassSerializer: PlainToClassSerializer, - settingsSchemaValidator: JsonSchemaValidator + plainToClassSerializer: PlainToClassSerializer ) { this.settingsManager = settingsManager; - this.settingsSchemaValidator = settingsSchemaValidator; this.plainToClassSerializer = plainToClassSerializer; this.classToPlainSerializer = classToPlainSerializer; } public execute(req: PutSettingsRequest, res: Response): void { - const valid = this.settingsSchemaValidator.validate(req.body); + let settings: Settings; + + try { + settings = this.plainToClassSerializer.transform(Settings, req.body, SettingsSchema); + } catch (e: unknown) { + if (!(e instanceof SchemaValidationError)) { + throw e; + } - if (!valid) { - const validationErrors = this.settingsSchemaValidator.getValidationErrors(); res.status(400).json({ message: `Settings are not in a valid format`, - errors: [...validationErrors] + errors: e.validationErrors }); return; } - const settings = this.plainToClassSerializer.transform(Settings, req.body); - this.settingsManager.replace(settings); res.send(JSON.stringify(this.classToPlainSerializer.transform( diff --git a/src/device/bleDevice.ts b/src/device/bleDevice.ts index 6420918a..961b531b 100644 --- a/src/device/bleDevice.ts +++ b/src/device/bleDevice.ts @@ -2,6 +2,7 @@ import { Peripheral } from '@stoprocent/noble'; import BaseError from 'modern-errors'; import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications } from './device.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; +import { AttributeValue } from './attribute/deviceAttribute.js'; import { Expose } from 'class-transformer'; import { EventEmitter } from 'events'; import { DeviceId } from './deviceId.js'; @@ -9,11 +10,9 @@ import { logError } from '../util/error.js'; import Logger from '../logging/Logger.js'; import { asyncHandler, promiseWithTimeout } from '../util/async.js'; -export type InferBleDeviceAttributes> = - D extends BleDevice ? TAttrs : DeviceAttributes; - -export type InferBleDeviceConfig> = - D extends BleDevice ? TCfg : AnyDeviceConfig; +export type AnyBleDevice = Omit & { + setAttribute(attributeName: string, value: AttributeValue): Promise; +}; export default abstract class BleDevice< TAttributes extends DeviceAttributes = DeviceAttributes, @@ -78,6 +77,10 @@ export default abstract class BleDevice< this.peripheral.on('disconnect', this.reconnectHandler); } + public getPeripheral(): Peripheral { + return this.peripheral; + } + private async requestRssiUpdate(): Promise { if (this.closing || this.peripheral.state === 'disconnected') { return; diff --git a/src/device/device.ts b/src/device/device.ts index 14fef55e..03d1787c 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -1,21 +1,12 @@ import { Exclude, Expose } from 'class-transformer'; import DeviceState from './deviceState.js'; -import DeviceAttribute from './attribute/deviceAttribute.js'; +import DeviceAttribute, { AttributeValue } from './attribute/deviceAttribute.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import { EventEmitter } from 'events'; import type { DeviceId } from './deviceId.js'; import type { JsonObject } from '../types.js'; import { DropFirst } from '../types.js'; -export type InferDeviceAttributes> = - D extends Device ? TAttrs : DeviceAttributes; - -export type InferDeviceNotifications> = - D extends Device ? TNotifs : AnyDeviceNotifications; - -export type InferDeviceConfig> = - D extends Device ? TCfg : AnyDeviceConfig; - // An attribute value can be DeviceAttribute or undefined because we want to allow Partial<> export type DeviceAttributes = Record; @@ -58,6 +49,10 @@ export type DeviceEventMap< [DeviceEvent.deviceNotification]: [device: TDevice, notification: DeviceNotification]; } +export type AnyDevice = Omit & { + setAttribute(attributeName: string, value: AttributeValue): Promise; +}; + @Exclude() export default abstract class Device< TAttributes extends DeviceAttributes = DeviceAttributes, diff --git a/src/device/deviceManager.ts b/src/device/deviceManager.ts index 44082578..86f8507a 100644 --- a/src/device/deviceManager.ts +++ b/src/device/deviceManager.ts @@ -1,15 +1,15 @@ -import Device, { DeviceAttributes, DeviceEvent, DeviceNotification, DeviceNotifications } from './device.js'; +import { AnyDevice, DeviceEvent, DeviceNotification } from './device.js'; import EventEmitter from 'events'; import DeviceState from './deviceState.js'; import { setIntervalAsync } from '../util/async.js'; import Logger from '../logging/Logger.js'; -import { AnyDeviceConfig } from './deviceConfig.js'; import { logError } from '../util/error.js'; import { DeviceId } from './deviceId.js'; +import SettingsManager from '../settings/settingsManager.js'; -export type DeviceInfo = { +export type DeviceDetectionInfo = { type: string; - id: DeviceId; + detectionId: DeviceId; }; export enum DeviceManagerEvent { @@ -25,11 +25,11 @@ type AcquireResult = | { 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]; + [DeviceManagerEvent.deviceConnected]: [device: AnyDevice]; + [DeviceManagerEvent.deviceDisconnected]: [device: AnyDevice]; + [DeviceManagerEvent.deviceRefreshed]: [device: AnyDevice]; + [DeviceManagerEvent.deviceDetected]: [deviceInfo: DeviceDetectionInfo]; + [DeviceManagerEvent.deviceNotification]: [device: AnyDevice, notification: DeviceNotification]; } export default class DeviceManager @@ -40,41 +40,79 @@ export default class DeviceManager private readonly detectedDeviceAcquireQueue: Map void }[]> = new Map(); - private readonly connectedDevices: Map>; - - public constructor(eventEmitter: EventEmitter, connectedDevices: Map, logger: Logger) { + private readonly connectedDevices: Map; + + private readonly settingsManager: SettingsManager; + + /** + * Devices that were announced as detected while belonging to a disabled known device (or + * whose registration was rejected by `addDevice()` after connecting, for protocols where the + * final device id can only be determined post-handshake). Re-announced once their known + * device gets (re-)enabled, see `onSettingsChanged()`. + * + * `canonicalId` is the id whose enablement gates the retry: it is the device's final, + * canonical id (which may differ from the preliminary `deviceInfo.detectionId` for protocols + * that only learn their real id during a handshake), so a retry only happens once *that* + * device is enabled - not on every unrelated settings change. `deviceInfo` is what gets + * re-announced. + */ + private readonly pendingDisabledDevices: Map = new Map(); + + public constructor( + eventEmitter: EventEmitter, + connectedDevices: Map, + settingsManager: SettingsManager, + logger: Logger + ) { this.eventEmitter = eventEmitter; this.logger = logger.child({ name: DeviceManager.name }); this.connectedDevices = connectedDevices; + this.settingsManager = settingsManager; + } + + public isDeviceEnabled(deviceId: DeviceId): boolean { + return this.settingsManager.getSettings()?.getKnownDeviceById(deviceId)?.enabled ?? true; } - public announceDetectedDevice(deviceInfo: DeviceInfo): void + public announceDetectedDevice(deviceInfo: DeviceDetectionInfo): void { - if (this.detectedDeviceAcquireQueue.has(deviceInfo.id)) { + if (this.detectedDeviceAcquireQueue.has(deviceInfo.detectionId)) { + return; + } + + if (this.connectedDevices.has(deviceInfo.detectionId)) { + this.logger.debug(`Device with id '${deviceInfo.detectionId}' is already connected, not announcing it as detected`); return; } - if (this.connectedDevices.has(deviceInfo.id)) { - this.logger.debug(`Device with id '${deviceInfo.id}' is already connected, not announcing it as detected`); + if (!this.isDeviceEnabled(deviceInfo.detectionId)) { + this.logger.debug(`Device with id '${deviceInfo.detectionId}' is disabled, not announcing it as detected`); + // At announcement time no connection has happened yet, so the preliminary detection id + // is the only id we have; it also doubles as the canonical id here. + this.registerPendingRetry(deviceInfo, deviceInfo.detectionId); return; } - this.logger.info(`Detected new device with id ${deviceInfo.id}`); + this.logger.info(`Detected new device with id ${deviceInfo.detectionId}`); - this.detectedDeviceAcquireQueue.set(deviceInfo.id, []); + this.detectedDeviceAcquireQueue.set(deviceInfo.detectionId, []); const hadListeners = this.eventEmitter.emit(DeviceManagerEvent.deviceDetected, deviceInfo); if (!hadListeners) { // no subscribed providers, remove empty list from acquire queue for this device - this.logger.info(`No provider available for detected device with id '${deviceInfo.id}'`); - this.detectedDeviceAcquireQueue.delete(deviceInfo.id); + this.logger.info(`No provider available for detected device with id '${deviceInfo.detectionId}'`); + this.detectedDeviceAcquireQueue.delete(deviceInfo.detectionId); } } - public revokeDetectedDevice(deviceInfo: DeviceInfo): void + public revokeDetectedDevice(deviceInfo: DeviceDetectionInfo): void { - this.clearDetectedDeviceAcquireQueue(deviceInfo.id, `Device with id '${deviceInfo.id}' has disappeared`); + // A device that has physically disappeared should no longer be retried once its known + // device gets re-enabled, so drop any pending-retry entry alongside the acquire queue. + // The pending map is keyed by the preliminary detection id (deviceInfo.detectionId). + this.pendingDisabledDevices.delete(deviceInfo.detectionId); + this.clearDetectedDeviceAcquireQueue(deviceInfo.detectionId, `Device with id '${deviceInfo.detectionId}' has disappeared`); } public async acquireDetectedDevice(deviceId: DeviceId): Promise @@ -116,10 +154,29 @@ export default class DeviceManager deviceQueue[0]?.resolve({ successful: true }); } - public addDevice( - device: Device - ): void + /** + * Registers a fully connected device, unless the known device it belongs to (identified by + * its final `getDeviceId`) has been disabled - in that case, the device is closed right away + * and never registered. Returns whether the device was actually added. + * + * `deviceInfo` is the original info this device was detected with (as passed to + * `announceDetectedDevice()`), used to resolve that pipeline's bookkeeping: claiming it on + * success, or releasing it and registering it for retry on rejection. + */ + public addDevice(deviceInfo: DeviceDetectionInfo, device: AnyDevice): boolean { + if (!this.isDeviceEnabled(device.getDeviceId)) { + this.logger.info(`Not adding device '${device.getDeviceId}' since it is disabled`); + device.close().catch((e: unknown) => logError(this.logger, `Failed to close disabled device '${device.getDeviceId}'`, e)); + + // The final, canonical id (device.getDeviceId) is the one that was found disabled and + // must therefore gate the retry - not the preliminary detection id. + this.registerPendingRetry(deviceInfo, device.getDeviceId); + this.releaseDetectedDevice(deviceInfo.detectionId); + + return false; + } + this.connectedDevices.set(device.getDeviceId, device); device.on(DeviceEvent.deviceRefreshed, (d) => this.refreshDevice(d)); @@ -129,6 +186,44 @@ export default class DeviceManager this.initDeviceRefresher(device); this.eventEmitter.emit(DeviceManagerEvent.deviceConnected, device); + + this.claimDetectedDevice(deviceInfo.detectionId); + + return true; + } + + /** + * Registers a device for retry once the known device identified by `canonicalId` gets + * (re-)enabled. Keyed by the preliminary detection id so `revokeDetectedDevice()` (which only + * has that id) can still drop it when the device disappears. + */ + private registerPendingRetry(deviceInfo: DeviceDetectionInfo, canonicalId: DeviceId): void { + this.pendingDisabledDevices.set(deviceInfo.detectionId, { deviceInfo, canonicalId }); + } + + public async onSettingsChanged(): Promise { + for (const device of this.connectedDevices.values()) { + if (this.isDeviceEnabled(device.getDeviceId)) { + continue; + } + + this.logger.info(`Closing device '${device.getDeviceId}' since it has been disabled`); + + try { + await device.close(); + } catch (e: unknown) { + logError(this.logger, `Failed to close device '${device.getDeviceId}'`, e); + } + } + + for (const [detectionId, { deviceInfo, canonicalId }] of this.pendingDisabledDevices) { + if (!this.isDeviceEnabled(canonicalId)) { + continue; + } + + this.pendingDisabledDevices.delete(detectionId); + this.announceDetectedDevice(deviceInfo); + } } public claimDetectedDevice(deviceId: DeviceId): void @@ -136,12 +231,12 @@ export default class DeviceManager this.clearDetectedDeviceAcquireQueue(deviceId, `Device with id '${deviceId}' has been claimed by another provider`); } - public getConnectedDevices(): Device[] + public getConnectedDevices(): AnyDevice[] { return Array.from(this.connectedDevices.values()); } - public getConnectedDevice(deviceId: string): Device|null + public getConnectedDevice(deviceId: string): AnyDevice|null { const device = this.connectedDevices.get(deviceId); @@ -183,6 +278,8 @@ export default class DeviceManager this.clearDetectedDeviceAcquireQueue(deviceId, 'Device manager reset'); } + this.pendingDisabledDevices.clear(); + if (undefined !== closeError) { throw closeError; } @@ -197,7 +294,7 @@ export default class DeviceManager this.detectedDeviceAcquireQueue.delete(deviceId); } - private initDeviceRefresher(device: Device): void { + private initDeviceRefresher(device: AnyDevice): void { this.logger.info(`Initializing refresher for device '${device.getDeviceName}' (id: ${device.getDeviceId})`); const deviceRefreshIntervalMs = device.getRefreshInterval; @@ -225,13 +322,13 @@ export default class DeviceManager device.on(DeviceEvent.deviceDisconnected, () => deviceRefreshInterval.clear()); } - private removeDevice(device: Device): void + private removeDevice(device: AnyDevice): void { this.connectedDevices.delete(device.getDeviceId); this.eventEmitter.emit(DeviceManagerEvent.deviceDisconnected, device); } - private refreshDevice(device: Device): void + private refreshDevice(device: AnyDevice): void { this.eventEmitter.emit(DeviceManagerEvent.deviceRefreshed, device); } diff --git a/src/device/genericDeviceUpdater.ts b/src/device/genericDeviceUpdater.ts index 700c4945..e8acec94 100644 --- a/src/device/genericDeviceUpdater.ts +++ b/src/device/genericDeviceUpdater.ts @@ -1,6 +1,6 @@ import AbstractDeviceUpdater from './updater/abstractDeviceUpdater.js'; import PlainToClassSerializer from '../serialization/plainToClassSerializer.js'; -import Device, { DeviceData } from './device.js'; +import { AnyDevice, DeviceData } from './device.js'; import Logger from '../logging/Logger.js'; import { getTypedKeys } from '../util/objects.js'; import { logError } from '../util/error.js'; @@ -17,7 +17,7 @@ export default class GenericDeviceUpdater extends AbstractDeviceUpdater this.logger = logger.child({ name: GenericDeviceUpdater.name }); } - public async update(device: Device, rawData: DeviceData): Promise { + public async update(device: AnyDevice, rawData: DeviceData): Promise { let hadFailure = false; // Queue update for later to not reject if device is busy diff --git a/src/device/peripheralDevice.ts b/src/device/peripheralDevice.ts index 6afd3e8b..51144a09 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -2,14 +2,13 @@ import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications } import BidirectionalDeviceTransport from './transport/deviceBidirectionalTransport.js'; import DeviceProtocol, { MessageWithResponse } from './protocol/deviceProtocol.js'; import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; +import { AttributeValue } from './attribute/deviceAttribute.js'; import EventEmitter from 'events'; import { DeviceId } from './deviceId.js'; -export type InferPeripheralDeviceAttributes> = - D extends PeripheralDevice ? TAttrs : DeviceAttributes; - -export type InferPeripheralDeviceConfig> = - D extends PeripheralDevice ? TCfg : AnyDeviceConfig; +export type AnyPeripheralDevice = Omit>>, 'setAttribute'> & { + setAttribute(attributeName: string, value: AttributeValue): Promise; +}; export default abstract class PeripheralDevice< TProtocol extends DeviceProtocol>, @@ -42,6 +41,10 @@ export default abstract class PeripheralDevice< this.transport.onClose(async () => await this.close()); } + public getTransport(): BidirectionalDeviceTransport { + return this.transport; + } + protected override async doClose(): Promise { if (this.transport.isOpen()) { await this.transport.close(); diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index 5dd5c05c..fa54cb63 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -4,7 +4,7 @@ import DeviceManager from '../../deviceManager.js'; import AiroticDevice from './airoticDevice.js'; import Logger from '../../../logging/Logger.js'; import { promiseWithTimeout } from '../../../util/async.js'; -import { BleDeviceInfo } from '../../transport/bleObserver.js'; +import BleObserver, { BleDeviceDetectionInfo } from '../../transport/bleObserver.js'; import BleUartDeviceTransport from '../../transport/bleDeviceTransport.js'; import AiroticProtocol from './airoticProtocol.js'; import MessageResponseHandler from '../messageResponseHandler.js'; @@ -20,24 +20,26 @@ export default class AiroticDeviceProvider extends BleDeviceProvider { - this.logger.debug('Initialized AiroticDeviceProvider'); - } - - protected override async connectBleDevice(deviceInfo: BleDeviceInfo): Promise { + protected override async connectBleDevice(deviceInfo: BleDeviceDetectionInfo): Promise { const transport = await promiseWithTimeout(BleUartDeviceTransport.create( deviceInfo.peripheral, AiroticDeviceProvider.UART_RX_CHAR_UUID, AiroticDeviceProvider.UART_TX_CHAR_UUID - ), 5000, `Timed out while creating BLE transport for device ${deviceInfo.id}`); + ), 5000, `Timed out while creating BLE transport for device ${deviceInfo.detectionId}`); - this.logger.debug(`Connected to device: ${deviceInfo.id}`); + this.logger.debug(`Connected to device: ${deviceInfo.detectionId}`); const protocol = new AiroticProtocol(); const messageResponseHandler = MessageResponseHandler.create(protocol, transport, this.logger, 2000); @@ -50,7 +52,7 @@ export default class AiroticDeviceProvider extends BleDeviceProvider; +type AttributeValue = AttributeValueOf; + @Exclude() export default class ButtplugIoDevice extends Device { @@ -26,6 +29,8 @@ export default class ButtplugIoDevice extends Device @Expose() private deviceModel: string; + private readonly deviceRemovedHandler: () => void; + public constructor( deviceId: DeviceId, deviceName: string, @@ -39,10 +44,19 @@ export default class ButtplugIoDevice extends Device super(deviceId, deviceName, provider, connectedSince, true, attributes, {}, eventEmitter); this.buttplugClientDevice = buttplugClientDevice; this.deviceModel = deviceModel; + + this.deviceRemovedHandler = asyncHandler(async () => { await this.close(); }, console.error); + this.buttplugClientDevice.on('deviceremoved', this.deviceRemovedHandler); } - public override get getRefreshInterval(): number { - return 100; + protected override async doClose(): Promise { + this.buttplugClientDevice.off('deviceremoved', this.deviceRemovedHandler); + } + + public override get getRefreshInterval(): number | undefined { + const sensorCount = this.buttplugClientDevice.messageAttributes.SensorReadCmd?.length ?? 0; + + return (sensorCount === 0) ? undefined : 100; } protected override async doRefresh(): Promise { @@ -53,9 +67,8 @@ export default class ButtplugIoDevice extends Device } public async setAttribute< - K extends AttributeKeyOf, - V extends AttributeValueOf - >(attributeName: K, value: V): Promise { + K extends AttributeKeyOf + >(attributeName: K, value: AttributeValue): Promise> { const attribute = this.attributes[attributeName]; if (undefined === attribute) { diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index e79656ec..689df152 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -23,7 +23,12 @@ export default class ButtplugIoDeviceFactory private readonly eventEmitterFactory: EventEmitterFactory; - public constructor(dateFactory: DateFactory, eventEmitterFactory: EventEmitterFactory, knownDeviceRegistry: KnownDeviceRegistry, logger: Logger) { + public constructor( + dateFactory: DateFactory, + eventEmitterFactory: EventEmitterFactory, + knownDeviceRegistry: KnownDeviceRegistry, + logger: Logger + ) { this.dateFactory = dateFactory; this.eventEmitterFactory = eventEmitterFactory; @@ -31,8 +36,8 @@ export default class ButtplugIoDeviceFactory this.logger = logger; } - public create(buttplugDevice: ButtplugClientDevice, provider: string, useDeviceNameAsId: boolean): ButtplugIoDevice { - const knownDevice = this.resolveKnownDevice(buttplugDevice, provider, useDeviceNameAsId); + public create(deviceId: DeviceId, buttplugDevice: ButtplugClientDevice, provider: string): ButtplugIoDevice { + const knownDevice = this.resolveKnownDevice(deviceId, buttplugDevice, provider); const deviceAttrs = ButtplugIoDeviceFactory.parseDeviceAttributes(buttplugDevice); @@ -110,13 +115,7 @@ export default class ButtplugIoDeviceFactory return attributes; } - 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 - const nameString = buttplugDevice.name.replace(/[^a-zA-Z0-9]/g, ''); - const deviceId = DeviceId.create(useDeviceNameAsId ? `buttplugio-${nameString}` : `buttplugio-${buttplugDevice.index}`); - + private resolveKnownDevice(deviceId: DeviceId, buttplugDevice: ButtplugClientDevice, provider: string): KnownDevice { return this.knownDeviceRegistry.resolve( deviceId, buttplugDevice.name, diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index 6438929d..64aad7b7 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -6,14 +6,32 @@ import ButtplugIoDeviceFactory from './buttplugIoDeviceFactory.js'; import Logger from '../../../logging/Logger.js'; import { asyncHandler, setImmediateInterval } from '../../../util/async.js'; import SlvCtrlPlusButtplugWebsocketClientConnector from './slvCtrlPlusButtplugWebsocketClientConnector.js'; -import DeviceManager from '../../deviceManager.js'; +import DeviceManager, { DeviceDetectionInfo } from '../../deviceManager.js'; import { logError } from '../../../util/error.js'; import { hasProperty } from '../../../util/objects.js'; +import { DeviceId } from '../../deviceId.js'; -export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { +export type ButtplugIoDeviceDetectionInfo = DeviceDetectionInfo & { + type: 'buttplugIo'; + buttplugClientDevice: ButtplugClientDevice; +}; + +export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider< + ButtplugIoDeviceDetectionInfo, + ButtplugIoDevice +> { public static readonly providerName = 'buttplugIoWebsocket'; - private connectedDevices: Map = new Map(); + // How often to (re)attempt connecting to the Intiface/buttplug.io server while disconnected. + private static readonly CONNECT_RETRY_INTERVAL_MS = 1_000; + + // How often a fresh scan cycle is kicked off while `autoScan` is enabled and we're connected. + private static readonly AUTO_SCAN_INTERVAL_MS = 60_000; + + // How long a scan window stays open before we stop it again. For the desktop websocket setup + // the server keeps scanning until told to stop, so we bound each scan ourselves rather than + // relying on a server-sent 'scanningfinished' (which that setup does not reliably emit). + private static readonly SCAN_DURATION_MS = 30_000; private buttplugConnector: ButtplugNodeWebsocketClientConnector; private buttplugClient: ButtplugClient; @@ -50,19 +68,44 @@ 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('deviceremoved', asyncHandler( - this.removeButtplugIoDevice.bind(this), - (e: unknown) => logError(this.logger, `Error in deviceremoved handler`, e) - )); + this.buttplugClient.on('deviceadded', this.announceButtplugIoDevice.bind(this)); + this.buttplugClient.on('deviceremoved', this.revokePendingButtplugIoDevice.bind(this)); } - public override async init(): Promise { - this.connectionIntervalRef ??= setImmediateInterval(() => void this.connectToServer(), 1000); + public override async start(): Promise { + this.connectionIntervalRef ??= setImmediateInterval( + () => void this.connectToServer(), + ButtplugIoWebsocketDeviceProvider.CONNECT_RETRY_INTERVAL_MS + ); } - private async connectToServer(): Promise { + public override async stop(): Promise { + // Marks the provider stopped (isStopped()) and closes/clears the registered devices - + // must run before we tear down the buttplug client listeners below (see the comment + // there for why). + await super.stop(); + + clearInterval(this.connectionIntervalRef); + this.connectionIntervalRef = undefined; + + clearInterval(this.autoScanningIntervalRef); + this.autoScanningIntervalRef = undefined; + + // Drop the buttplug client's own listeners before disconnecting so the resulting + // 'disconnect' event can't run handleLostConnection() and bring the provider back up. + this.buttplugClient.removeAllListeners(); + if (this.buttplugClient.connected) { + try { + await this.buttplugClient.disconnect(); + } catch (e: unknown) { + logError(this.logger, 'Could not disconnect from buttplug.io server', e); + } + } + } + + private async connectToServer(): Promise { + if (this.isStopped() || this.buttplugClient.connected) { return; } @@ -76,26 +119,36 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { this.connectionIntervalRef = undefined; if (this.autoScan) { - this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, 60000); + this.autoScanningIntervalRef ??= setImmediateInterval(() => { this.discoverButtplugIoDevices() }, ButtplugIoWebsocketDeviceProvider.AUTO_SCAN_INTERVAL_MS); } } catch (e: unknown) { logError(this.logger, `Could not connect to buttplug.io server (${url})`, hasProperty(e, 'message') ? e.message : 'unknown'); } } + /** + * The whole connection to the buttplug.io server was lost - close every device this provider + * currently has connected, since none of them are reachable anymore. This is a distinct + * scenario from a single device being reported as removed (see + * `revokePendingButtplugIoDevice()`): the buttplug protocol never emits per-device removal + * messages once the connection itself is already gone, so an already-connected + * `ButtplugIoDevice` would never notice on its own and relies entirely on this cleanup. + */ private async handleLostConnection(url: string): Promise { this.logger.info(`Lost connection to buttplug.io server (${url})`); - // As the whole websocket connection is lost there aren't any 'deviceremoved' events for the - // connected Buttplug.io devices. They need to be removed manually instead. - for (const device of this.connectedDevices.values()) { - await this.removeButtplugIoDevice(device.getButtplugClientDevice); + for (const device of [...this.getConnectedDevices()]) { + await device.close(); } clearInterval(this.autoScanningIntervalRef); this.autoScanningIntervalRef = undefined; - await this.init(); + if (this.isStopped()) { + return; + } + + await this.start(); } private discoverButtplugIoDevices(): void { @@ -108,52 +161,58 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider { .catch((e: unknown) => this.logger.error(`Could not start scanning for buttplug.io devices`, e)); setTimeout(() => { - if (undefined === this.buttplugClient || !this.buttplugClient.isScanning) { + if (this.isStopped() || !this.buttplugClient.connected || !this.buttplugClient.isScanning) { return; } this.buttplugClient.stopScanning() .then(() => this.logger.info('Stop scanning for Buttplug.io devices')) .catch((e: unknown) => this.logger.error(`Could not stop scanning for buttplug.io devices`, e)); - }, 30000); + }, ButtplugIoWebsocketDeviceProvider.SCAN_DURATION_MS); } - private addButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { - this.logger.info(`Device detected: ${buttplugDevice.name}`, buttplugDevice); - - try { - const device = this.buttplugIoDeviceFactory.create(buttplugDevice, ButtplugIoWebsocketDeviceProvider.providerName, this.useDeviceNameAsId); + private createDeviceDetectionInfo(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceDetectionInfo { + // Since we don't get a unique identifier for the Bluetooth device from Intiface, + // we need to use the index assigned to the device by Intiface. It's the best we have. + // or the name if using Intiface-engine without id persistence + const nameString = buttplugDevice.name.replace(/[^a-zA-Z0-9]/g, ''); + const deviceId = DeviceId.create(this.useDeviceNameAsId ? `buttplugio-${nameString}` : `buttplugio-${buttplugDevice.index}`); - this.connectedDevices.set(buttplugDevice.index, device); + return { type: 'buttplugIo', detectionId: deviceId, buttplugClientDevice: buttplugDevice }; + } - this.deviceManager.addDevice(device); + /** + * Announces a device reported by the Buttplug.io server to the device manager, which runs + * the enabled/disabled check centrally and takes care of retrying once a currently disabled + * device gets re-enabled - see `createDevice()` for the actual construction step. + */ + private announceButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { + this.logger.info(`Device detected: ${buttplugDevice.name}`, buttplugDevice); - this.logger.debug(`Assigned device id: ${device.getDeviceId} (${buttplugDevice.name}@${buttplugDevice.index})`); - this.logger.info(`Connected devices: ${this.connectedDevices.size}`); - } catch (e: unknown) { - logError(this.logger, `Could not connect to device '${buttplugDevice.name}'`, e); - } + this.deviceManager.announceDetectedDevice(this.createDeviceDetectionInfo(buttplugDevice)); } - private async removeButtplugIoDevice(buttplugDevice: ButtplugClientDevice): Promise { - const device = this.connectedDevices.get(buttplugDevice.index); - - if (undefined === device) { - this.logger.warn( - `Could not find device to remove: ${buttplugDevice.name}@${buttplugDevice.index}` - ); - return; - } + /** + * Drops any pending detection/retry bookkeeping for a device the buttplug.io server reported + * as removed. A no-op if the device is actually connected - nothing is pending for it there, + * since the live `ButtplugIoDevice` closes itself directly off this same server event (see + * its constructor). + */ + private revokePendingButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void { + this.deviceManager.revokeDetectedDevice(this.createDeviceDetectionInfo(buttplugDevice)); + } - try { - await device.close(); - this.connectedDevices.delete(buttplugDevice.index); + protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is ButtplugIoDeviceDetectionInfo { + return deviceInfo.type === 'buttplugIo'; + } - this.logger.info(`Device removed: ${device.getDeviceId} (${buttplugDevice.name}@${buttplugDevice.index})`); - } catch (e: unknown) { - logError(this.logger, `Could not remove device '${device.getDeviceId}' (${buttplugDevice.name}@${buttplugDevice.index})`, e); - } + protected override createDevice(deviceInfo: ButtplugIoDeviceDetectionInfo): Promise { + const device = this.buttplugIoDeviceFactory.create( + deviceInfo.detectionId, + deviceInfo.buttplugClientDevice, + ButtplugIoWebsocketDeviceProvider.providerName + ); - this.logger.info(`Connected devices: ${this.connectedDevices.size}`); + return Promise.resolve(device); } } diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts index 8f7064d6..00ac3d00 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts @@ -5,7 +5,7 @@ import ButtplugIoDeviceFactory from './buttplugIoDeviceFactory.js'; import ButtplugIoWebsocketDeviceProvider from './buttplugIoWebsocketDeviceProvider.js'; import DeviceManager from '../../deviceManager.js'; -type ButtplugIoWebsocketConfig = { +type ButtplugIoWebsocketDeviceProviderConfig = { address: string, autoScan: boolean, useDeviceNameAsId: boolean @@ -33,7 +33,7 @@ export default class ButtplugIoWebsocketDeviceProviderFactory implements DeviceP this.logger = logger; } - public create(config: ButtplugIoWebsocketConfig): ButtplugIoWebsocketDeviceProvider + public create(config: ButtplugIoWebsocketDeviceProviderConfig): ButtplugIoWebsocketDeviceProvider { return new ButtplugIoWebsocketDeviceProvider( this.deviceManager, diff --git a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts index e16f64c6..5c90271a 100644 --- a/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts +++ b/src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts @@ -12,7 +12,7 @@ import SynchronousSerialPort from '../../../serial/synchronousSerialPort.js'; import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import DeviceManager from '../../deviceManager.js'; -import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; +import SerialPortObserver, { SerialDeviceDetectionInfo } from '../../transport/serialPortObserver.js'; export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { @@ -25,18 +25,19 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider): Promise { + protected async connectSerialDevice(deviceInfo: SerialDeviceDetectionInfo, port: SerialPortStream): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: '\n' })); const syncPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, this.logger); const transport = this.transportFactory.create(syncPort, undefined, Buffer.from('\r')); @@ -55,7 +56,7 @@ export default class EStim2bSerialDeviceProvider extends SerialDeviceProvider { @@ -30,17 +30,18 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide public constructor( deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, + serialPortObserver: SerialPortObserver, eventEmitter: EventEmitter, deviceFactory: SlvCtrlPlusDeviceFactory, deviceTransportFactory: SerialDeviceTransportFactory, logger: Logger ) { - super(deviceManager, serialPortFactory, eventEmitter, logger.child({ name: SlvCtrlPlusSerialDeviceProvider.name })); + super(deviceManager, serialPortFactory, serialPortObserver, eventEmitter, logger.child({ name: SlvCtrlPlusSerialDeviceProvider.name })); this.slvCtrlPlusDeviceFactory = deviceFactory; this.deviceTransportFactory = deviceTransportFactory; } - protected async connectSerialDevice(deviceInfo: SerialDeviceInfo, port: SerialPortStream): Promise + protected async connectSerialDevice(deviceInfo: SerialDeviceDetectionInfo, port: SerialPortStream): Promise { const parser = port.pipe(new ReadlineParser({ delimiter: SlvCtrlProtocol.EOF })); const syncPort = new SynchronousSerialPort(deviceInfo.portInfo, parser, port, this.logger); @@ -49,7 +50,7 @@ export default class SlvCtrlPlusSerialDeviceProvider extends SerialDeviceProvide await this.performHandshakeWithRetries(transport, 4); const device = await this.slvCtrlPlusDeviceFactory.create( - deviceInfo.id, + deviceInfo.detectionId, transport, SlvCtrlPlusSerialDeviceProvider.providerName ); diff --git a/src/device/protocol/virtual/virtualDeviceProvider.ts b/src/device/protocol/virtual/virtualDeviceProvider.ts index fbcaa97a..82be6c7b 100644 --- a/src/device/protocol/virtual/virtualDeviceProvider.ts +++ b/src/device/protocol/virtual/virtualDeviceProvider.ts @@ -4,134 +4,95 @@ import Logger from '../../../logging/Logger.js'; import VirtualDevice from './virtualDevice.js'; import KnownDevice from '../../../settings/knownDevice.js'; import SettingsManager from '../../../settings/settingsManager.js'; -import Device from '../../device.js'; +import SettingsEventType from '../../../settings/settingsEventType.js'; +import type Settings from '../../../settings/settings.js'; +import { DeviceDetectionInfo } from '../../deviceManager.js'; import VirtualDeviceFactory from './virtualDeviceFactory.js'; import DeviceManager from '../../deviceManager.js'; -import { asyncHandler, setImmediateInterval } from '../../../util/async.js'; +import { asyncHandler } from '../../../util/async.js'; import { logError } from '../../../util/error.js'; -export default class VirtualDeviceProvider extends DeviceProvider +export type VirtualDeviceDetectionInfo = DeviceDetectionInfo & { + type: 'virtual'; + knownDevice: KnownDevice; +}; + +export default class VirtualDeviceProvider extends DeviceProvider> { public static readonly providerName = 'virtual'; - private attemptedDevices: Set = new Set(); - private connectedDevices: Map> = new Map(); - private readonly deviceFactory: VirtualDeviceFactory; private readonly settingsManager: SettingsManager; - private readonly scanIntervalMs: number; - - private discoveryInterval?: NodeJS.Timeout; - - private stopped: boolean = false; + private readonly settingsChangedListener: (settings: Settings) => void; public constructor( deviceManager: DeviceManager, eventEmitter: EventEmitter, deviceFactory: VirtualDeviceFactory, settingsManager: SettingsManager, - logger: Logger, - scanIntervalMs: number + logger: Logger ) { super(deviceManager, eventEmitter, logger.child({ name: VirtualDeviceProvider.name })); this.deviceFactory = deviceFactory; this.settingsManager = settingsManager; - this.scanIntervalMs = scanIntervalMs; + + this.settingsChangedListener = asyncHandler( + async (): Promise => this.discoverVirtualDevices(), + (e: unknown) => logError(this.logger, 'Error while scanning for virtual devices after a settings change', e) + ); } - public override async init(): Promise { - this.stopped = false; + public override async start(): Promise { + this.settingsManager.on(SettingsEventType.changed, this.settingsChangedListener); - this.discoveryInterval ??= setImmediateInterval(asyncHandler( - this.discoverVirtualDevices.bind(this), - (e: unknown) => this.logger.error('Error while scanning for new virtual devices', e) - ), this.scanIntervalMs); + await this.discoverVirtualDevices(); } public override async stop(): Promise { - this.stopped = true; + this.settingsManager.off(SettingsEventType.changed, this.settingsChangedListener); - if (this.discoveryInterval !== undefined) { - clearInterval(this.discoveryInterval); - this.discoveryInterval = undefined; - } + await super.stop(); + } - for (const device of this.connectedDevices.values()) { - await this.removeDevice(device); - } + protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is VirtualDeviceDetectionInfo { + return deviceInfo.type === 'virtual'; } - private async discoverVirtualDevices(): Promise { - if (this.stopped) { - return; - } + protected override createDevice(deviceInfo: VirtualDeviceDetectionInfo): Promise | undefined> { + this.logger.info(`Virtual device detected: ${deviceInfo.knownDevice.name}`, deviceInfo.knownDevice); + + return this.deviceFactory.create(deviceInfo.knownDevice, VirtualDeviceProvider.providerName); + } + private async discoverVirtualDevices(): Promise { const settings = this.settingsManager.getSettings(); if (undefined === settings) { - // Settings not loaded yet return; } const virtualDevices = settings.getKnownDevicesBySource(VirtualDeviceProvider.providerName); - // Check if devices have been removed - for (const [k, v] of this.connectedDevices) { - if (!virtualDevices.has(k)) { - await this.removeDevice(v) + // Close devices whose known device has been removed from the configuration entirely. + // Snapshot first, since closing a device mutates the underlying connected-devices map. + for (const device of [...this.getConnectedDevices()]) { + if (!virtualDevices.has(device.getDeviceId)) { + try { + await device.close(); + } catch (e: unknown) { + logError(this.logger, `Failed to close removed virtual device '${device.getDeviceId}'`, e); + } } } - // Load all currently configured devices - for (const [k, v] of virtualDevices) { - if (this.stopped) { - return; - } + // Announce all currently configured devices - the device manager takes care of skipping + // disabled ones (and re-announcing them once re-enabled) as well as ones already connected. + for (const knownDevice of virtualDevices.values()) { + const deviceInfo: VirtualDeviceDetectionInfo = { type: 'virtual', detectionId: knownDevice.id, knownDevice }; - if (this.attemptedDevices.has(k) || this.connectedDevices.has(k)) { - continue; - } - - this.attemptedDevices.add(k); - - await this.addDevice(v); + this.deviceManager.announceDetectedDevice(deviceInfo); } } - - private async addDevice(knowDevice: KnownDevice): Promise { - this.logger.info(`Virtual device detected: ${knowDevice.name}`, knowDevice); - - try { - const device = await this.deviceFactory.create(knowDevice, VirtualDeviceProvider.providerName); - - if (this.stopped) { - await device.close(); - this.attemptedDevices.delete(knowDevice.id); - return; - } - - this.deviceManager.addDevice(device); - this.connectedDevices.set(knowDevice.id, device); - - this.logger.info(`Connected virtual devices: ${this.connectedDevices.size}`); - } catch (e: unknown) { - logError(this.logger, `Could not initiate virtual device '${knowDevice.id}'`, e); - } - } - - private async removeDevice(device: Device): Promise { - const deviceId = device.getDeviceId; - - try { - await device.close(); - } finally { - this.connectedDevices.delete(deviceId); - this.attemptedDevices.delete(deviceId); - } - - this.logger.info(`Device removed: ${deviceId} (${device.getDeviceName})`); - this.logger.info(`Connected virtual devices: ${this.connectedDevices.size}`); - } } diff --git a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts index a3e6a419..a92a2e55 100644 --- a/src/device/protocol/virtual/virtualDeviceProviderFactory.ts +++ b/src/device/protocol/virtual/virtualDeviceProviderFactory.ts @@ -6,14 +6,8 @@ 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; @@ -38,18 +32,13 @@ export default class VirtualDeviceProviderFactory implements DeviceProviderFacto this.logger = logger; } - public create(config: VirtualDeviceProviderConfig): VirtualDeviceProvider { - const scanIntervalMs = typeof config.scanIntervalMs === 'number' && config.scanIntervalMs > 0 - ? config.scanIntervalMs - : VirtualDeviceProviderFactory.DEFAULT_SCAN_INTERVAL_MS; - + public create(): VirtualDeviceProvider { return new VirtualDeviceProvider( this.deviceManager, this.eventEmitterFactory.create(), this.deviceFactory, this.settingsManager, this.logger, - scanIntervalMs, ); } } diff --git a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts index 71703ee3..ff47ae2d 100644 --- a/src/device/protocol/zc95/zc95SerialDeviceProvider.ts +++ b/src/device/protocol/zc95/zc95SerialDeviceProvider.ts @@ -13,7 +13,7 @@ import MessageResponseHandler from '../messageResponseHandler.js'; import Zc95MessageFactory from './zc95MessageFactory.js'; import SerialDeviceTransportFactory from '../../transport/serialDeviceTransportFactory.js'; import DeviceManager from '../../deviceManager.js'; -import { SerialDeviceInfo } from '../../transport/serialPortObserver.js'; +import SerialPortObserver, { SerialDeviceDetectionInfo } from '../../transport/serialPortObserver.js'; export default class Zc95SerialDeviceProvider extends SerialDeviceProvider { @@ -26,18 +26,19 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider): Promise { + protected async connectSerialDevice(deviceInfo: SerialDeviceDetectionInfo, port: SerialPortStream): Promise { const serialLogger = this.logger.child({ name: Zc95Device.name }) const parser = port.pipe(new FrameParser({ stx: Zc95Protocol.STX, etx: Zc95Protocol.ETX })); @@ -61,7 +62,7 @@ export default class Zc95SerialDeviceProvider extends SerialDeviceProvider, - TAttributes extends DeviceAttributes = InferBleDeviceAttributes, - TNotifications extends DeviceNotifications = InferDeviceNotifications, - TConfig extends AnyDeviceConfig = InferBleDeviceConfig -> extends DeviceProvider +export default abstract class BleDeviceProvider extends DeviceProvider { - private connectedDevices: Set = new Set(); + private readonly bleObserver: BleObserver; - protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { + protected constructor(deviceManager: DeviceManager, bleObserver: BleObserver, 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) - ) - ); + this.bleObserver = bleObserver; } - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { - if (!this.isBleDeviceInfo(deviceInfo)) { - return; - } - - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); - - const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - - if (!acquireResult.successful) { - this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); - return; - } - - try { - const device = await this.connectBleDevice(deviceInfo); - - if (undefined === device) { - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - return; - } - - 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); - } + public override async start(): Promise { + await super.start(); + await this.bleObserver.start(); } public override async stop(): Promise { - for (const device of this.connectedDevices) { - await device.close(); - } - this.connectedDevices.clear(); + await super.stop(); + await this.bleObserver.stop(); } - private isBleDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is BleDeviceInfo { + protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is BleDeviceDetectionInfo { return deviceInfo.type === 'ble'; } + protected override createDevice(deviceInfo: BleDeviceDetectionInfo): Promise { + return this.connectBleDevice(deviceInfo); + } + + protected override async onConnectFailed(deviceInfo: BleDeviceDetectionInfo): Promise { + await this.disconnectPeripheral(deviceInfo.peripheral); + } + private async disconnectPeripheral(peripheral: Peripheral): Promise { if (peripheral.state === 'connected') { try { @@ -90,5 +55,5 @@ export default abstract class BleDeviceProvider< } } - protected abstract connectBleDevice(deviceInfo: BleDeviceInfo): Promise; + protected abstract connectBleDevice(deviceInfo: BleDeviceDetectionInfo): Promise; } diff --git a/src/device/provider/deviceProvider.ts b/src/device/provider/deviceProvider.ts index 5292c4ad..469ac3ad 100644 --- a/src/device/provider/deviceProvider.ts +++ b/src/device/provider/deviceProvider.ts @@ -1,8 +1,14 @@ import EventEmitter from 'events'; +import DeviceManager, { DeviceDetectionInfo, DeviceManagerEvent } from '../deviceManager.js'; import Logger from '../../logging/Logger.js'; -import DeviceManager from '../deviceManager.js'; +import { asyncHandler } from '../../util/async.js'; +import { logError } from '../../util/error.js'; +import { AnyDevice, DeviceEvent } from '../device.js'; +import { DeviceId } from '../deviceId.js'; -export default abstract class DeviceProvider +export type AnyDeviceProvider = DeviceProvider; + +export default abstract class DeviceProvider { protected readonly deviceManager: DeviceManager; @@ -10,17 +16,122 @@ export default abstract class DeviceProvider protected readonly logger: Logger; + private readonly connectedDevices: Map = new Map(); + + private readonly deviceDetectedListener: (deviceInfo: DeviceDetectionInfo) => void; + + private stopped: boolean = false; + protected constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) { this.deviceManager = deviceManager; this.eventEmitter = eventEmitter; this.logger = logger; + + this.deviceDetectedListener = asyncHandler( + this.handleDeviceDetection.bind(this), + (err: unknown) => logError(this.logger, 'Error in device detection handler', err) + ); + + this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); } - public async init(): Promise { + public async start(): Promise { return Promise.resolve(); } public async stop(): Promise { + this.stopped = true; + + this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + + // A rejected close() must not abort the loop or leave stop() itself rejected: DeviceProviderManager + // keeps a provider whose stop() throws around (assuming it may still be partially running), which + // would make this instance permanently unusable - already stopped and detached above, yet never + // replaced since the manager thinks a re-enable of this source doesn't need a fresh provider. + for (const device of this.connectedDevices.values()) { + try { + await device.close(); + } catch (e: unknown) { + logError(this.logger, `Failed to close device '${device.getDeviceId}' while stopping provider`, e); + } + } + this.connectedDevices.clear(); + } + + protected isStopped(): boolean { + return this.stopped; + } + + protected getConnectedDevices(): IterableIterator { + return this.connectedDevices.values(); + } + + protected getConnectedDevice(deviceId: DeviceId): D | undefined { + return this.connectedDevices.get(deviceId); + } + + private async handleDeviceDetection(deviceInfo: DeviceDetectionInfo): Promise { + if (!this.canHandleDeviceDetectionInfo(deviceInfo)) { + return; + } + + this.logger.debug(`Requesting to acquire device: ${deviceInfo.detectionId}`); + + const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.detectionId); + + if (!acquireResult.successful) { + this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); + return; + } + + let device: D | undefined; + + try { + device = await this.createDevice(deviceInfo); + } catch (e: unknown) { + logError(this.logger, `Error while connecting to device '${deviceInfo.detectionId}'`, e); + await this.abortDetection(deviceInfo); + return; + } + + if (undefined === device || this.stopped) { + try { + if (undefined !== device) { + await device.close(); + } + } finally { + await this.abortDetection(deviceInfo); + } + return; + } + + device.on(DeviceEvent.deviceDisconnected, (d) => this.connectedDevices.delete(d.getDeviceId)); + + if (!this.deviceManager.addDevice(deviceInfo, device)) { + // The device has not been added by the device manager. + // For example, it may be a disabled device. + return; + } + + this.connectedDevices.set(device.getDeviceId, device); + + this.logger.info(`Connected devices: ${this.connectedDevices.size}`); + } + + private async abortDetection(deviceInfo: DDI): Promise { + try { + await this.onConnectFailed(deviceInfo); + } finally { + this.deviceManager.releaseDetectedDevice(deviceInfo.detectionId); + } + } + + protected abstract canHandleDeviceDetectionInfo(deviceDetectionInfo: DeviceDetectionInfo): deviceDetectionInfo is DDI; + + protected abstract createDevice(deviceInfo: DDI): Promise; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async onConnectFailed(deviceInfo: DDI): Promise { return Promise.resolve(); } } diff --git a/src/device/provider/deviceProviderFactory.ts b/src/device/provider/deviceProviderFactory.ts index 4aa6c595..a6e38cd0 100644 --- a/src/device/provider/deviceProviderFactory.ts +++ b/src/device/provider/deviceProviderFactory.ts @@ -1,7 +1,7 @@ -import DeviceProvider from './deviceProvider.js'; +import { AnyDeviceProvider } from './deviceProvider.js'; import { JsonObject } from '../../types.js'; -export default interface DeviceProviderFactory +export default interface DeviceProviderFactory { create(config: JsonObject): DP; } diff --git a/src/device/provider/deviceProviderManager.ts b/src/device/provider/deviceProviderManager.ts index 1620a8e1..fc353031 100644 --- a/src/device/provider/deviceProviderManager.ts +++ b/src/device/provider/deviceProviderManager.ts @@ -1,7 +1,9 @@ +import { SequentialTaskQueue } from 'sequential-task-queue'; import Settings from '../../settings/settings.js'; import DeviceProviderFactory from './deviceProviderFactory.js'; import Logger from '../../logging/Logger.js'; -import DeviceProvider from './deviceProvider.js'; +import { AnyDeviceProvider } from './deviceProvider.js'; +import { logError } from '../../util/error.js'; export default class DeviceProviderManager { @@ -9,7 +11,16 @@ export default class DeviceProviderManager private readonly logger: Logger; - private providers: DeviceProvider[] = []; + private readonly providers: Map = new Map(); + + /** + * `reload()` and `stopProviders()` mutate the shared `providers` map asynchronously. + * Since settings can change in rapid succession (e.g. a device source being disabled and + * immediately re-enabled), overlapping calls need to be serialized to avoid racing on that + * map, otherwise a later call could observe a half-finished earlier one and reach the wrong + * conclusion about whether a provider is already running. + */ + private readonly operationQueue: SequentialTaskQueue = new SequentialTaskQueue(); public constructor( factories: Map>, @@ -19,42 +30,95 @@ export default class DeviceProviderManager this.logger = logger.child({ name: DeviceProviderManager.name }); } - public loadFromSettings(settings: Settings): void + public loadFromSettings(settings: Settings): Promise + { + return this.enqueueOperation(() => this.doLoadFromSettings(settings)); + } + + public stopProviders(): Promise { + return this.enqueueOperation(() => this.doStopProviders()); + } + + private async enqueueOperation(operation: () => Promise): Promise { + await this.operationQueue.push(operation); + } + + private async doLoadFromSettings(settings: Settings): Promise { const configuredDeviceSources = settings.getDeviceSources(); this.logger.debug(`Found ${configuredDeviceSources.size} configured device source(s)`); - for (const [id, deviceSource] of configuredDeviceSources) { - const factory = this.factories.get(deviceSource.type) + // Snapshotted upfront (rather than iterated live) so each provider's stop()/start() can run + // concurrently below without one slow provider delaying every other, unrelated device source + const providersToStop = [...this.providers.entries()].filter(([id]) => { + const deviceSource = configuredDeviceSources.get(id); + + return undefined === deviceSource || !deviceSource.enabled; + }); + + await Promise.allSettled(providersToStop.map(async ([id, provider]) => { + const deviceSource = configuredDeviceSources.get(id); + const reason = undefined === deviceSource ? 'removed from config' : 'disabled'; + + this.logger.info(`Stopping device source '${id}' (${reason})`); + + try { + await provider.stop(); + // Only forget the provider once it actually stopped. A provider that failed to + // stop may still be running, so keeping it recorded prevents a duplicate from + // being started for the same source on a later reload. + this.providers.delete(id); + } catch (error: unknown) { + logError(this.logger, `Failed to stop device provider for device source '${id}'`, error); + } + })); + + const sourcesToStart = [...configuredDeviceSources.entries()].filter(([id, deviceSource]) => { + return deviceSource.enabled && !this.providers.has(id); + }); + + await Promise.allSettled(sourcesToStart.map(async ([id, deviceSource]) => { + const factory = this.factories.get(deviceSource.type); if (undefined === factory) { this.logger.warn(`Device source with id ${id} and type ${deviceSource.type} is not supported`); - continue; + return; } const provider = factory.create(deviceSource.config); - this.providers.push(provider); - } - } + try { + await provider.start(); + // Only record the provider once it started successfully, so a failed start + // doesn't leave a stuck entry that blocks all future retries for this source. + this.providers.set(id, provider); + } catch (error: unknown) { + logError(this.logger, `Failed to start device provider for device source '${id}'`, error); - public async startProviders(): Promise { - for (const provider of this.providers) { - await provider.init(); - } + try { + await provider.stop(); + } catch (cleanupError: unknown) { + logError(this.logger, `Failed to clean up half-started device provider for device source '${id}'`, cleanupError); + } + } + })); } - public async stopProviders(): Promise { - const errors: unknown[] = []; + private async doStopProviders(): Promise { + const results = await Promise.allSettled([...this.providers.entries()].map(async ([id, provider]) => { + await provider.stop(); + // Remove only providers that actually stopped; a failed stop stays recorded so it + // isn't mistaken for a free slot on a later reload. + this.providers.delete(id); + })); - for (const provider of this.providers) { - try { - await provider.stop(); - } catch (error: unknown) { - errors.push(error); - this.logger.error('Failed to stop device provider', error); - } + const errors = results + .filter((result): result is PromiseRejectedResult => 'rejected' === result.status) + .map((result) => result.reason); + + for (const error of errors) { + this.logger.error('Failed to stop device provider', error); } if (errors.length > 0) { diff --git a/src/device/provider/genericDeviceProviderFactory.ts b/src/device/provider/genericDeviceProviderFactory.ts index abcfb9f6..74f87b1c 100644 --- a/src/device/provider/genericDeviceProviderFactory.ts +++ b/src/device/provider/genericDeviceProviderFactory.ts @@ -1,11 +1,9 @@ -import DeviceProvider from './deviceProvider.js'; +import { AnyDeviceProvider } from './deviceProvider.js'; import DeviceProviderFactory from './deviceProviderFactory.js'; type ConcreteCtor = new (...args: any[]) => T; -export default class GenericDeviceProviderFactory< - DP extends DeviceProvider -> implements DeviceProviderFactory +export default class GenericDeviceProviderFactory implements DeviceProviderFactory { private readonly ctor: ConcreteCtor; private readonly args: ConstructorParameters>; diff --git a/src/device/provider/serialDeviceProvider.ts b/src/device/provider/serialDeviceProvider.ts index c3d0769e..fd118926 100644 --- a/src/device/provider/serialDeviceProvider.ts +++ b/src/device/provider/serialDeviceProvider.ts @@ -7,77 +7,47 @@ 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, { DeviceDetectionInfo } 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'; +import SerialPortObserver, { SerialDeviceDetectionInfo } from '../transport/serialPortObserver.js'; +import { AnyPeripheralDevice } from '../peripheralDevice.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 +export default abstract class SerialDeviceProvider extends DeviceProvider { private readonly serialPortFactory: SerialPortFactory; - private connectedDevices: Map = new Map(); + private readonly serialPortObserver: SerialPortObserver; - private readonly deviceDetectedListener: (deviceInfo: DeviceInfo) => void; - - protected constructor(deviceManager: DeviceManager, serialPortFactory: SerialPortFactory, eventEmitter: EventEmitter, logger: Logger) { + protected constructor( + deviceManager: DeviceManager, + serialPortFactory: SerialPortFactory, + serialPortObserver: SerialPortObserver, + eventEmitter: EventEmitter, + logger: Logger + ) { super(deviceManager, eventEmitter, logger); this.serialPortFactory = serialPortFactory; - - this.deviceDetectedListener = asyncHandler( - this.handleDeviceDetection.bind(this), - (err: unknown) => logError(this.logger, 'Error in device detection handler', err) - ); - - this.deviceManager.on(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener); + this.serialPortObserver = serialPortObserver; } - private async handleDeviceDetection(deviceInfo: DeviceInfo): Promise { - if (!this.isSerialDeviceInfo(deviceInfo)) { - return; - } - - this.logger.debug(`Requesting to acquire device: ${deviceInfo.id}`); - - const acquireResult = await this.deviceManager.acquireDetectedDevice(deviceInfo.id); - - if (false === acquireResult.successful) { - this.logger.debug(`Could not acquire device: ${acquireResult.reason}`); - return; - } - - try { - const device = await this.connectToDevice(deviceInfo); - - if (undefined === device) { - this.deviceManager.releaseDetectedDevice(deviceInfo.id); - return; - } + public override async start(): Promise { + await super.start(); + await this.serialPortObserver.start(); + } - 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); - } + public override async stop(): Promise { + await super.stop(); + await this.serialPortObserver.stop(); } - private isSerialDeviceInfo(deviceInfo: DeviceInfo): deviceInfo is SerialDeviceInfo - { + protected override canHandleDeviceDetectionInfo(deviceInfo: DeviceDetectionInfo): deviceInfo is SerialDeviceDetectionInfo { return deviceInfo.type === 'serial'; } - private async connectToDevice(deviceInfo: SerialDeviceInfo): Promise { + protected override async createDevice(deviceInfo: SerialDeviceDetectionInfo): Promise { const portInfo = deviceInfo.portInfo; this.logger.info(`Connection attempt for serial device '${portInfo.path}' (s/n: ${portInfo.serialNumber})`); @@ -120,18 +90,7 @@ export default abstract class SerialDeviceProvider< this.logger.info(`Could not connect to serial device '${portInfo.path}': ${attemptFailureReason}`); } else { this.logger.info(`Successfully connected to serial device '${portInfo.path}'`); - - this.connectedDevices.set(device.getDeviceId, device); - this.logger.debug(`Assigned device id: ${device.getDeviceId} (${portInfo.path})`); - this.logger.info(`Connected devices: ${this.connectedDevices.size}`); - - port.on('close', () => { - this.connectedDevices.delete(device.getDeviceId); - - this.logger.info(`Lost serial device: ${device.getDeviceId}`); - this.logger.info(`Connected devices: ${this.connectedDevices.size}`); - }); } return device; @@ -142,11 +101,7 @@ export default abstract class SerialDeviceProvider< 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 connectSerialDevice(deviceInfo: SerialDeviceDetectionInfo, port: SerialPortStream): Promise; protected abstract getSerialDeviceProviderPortOpenOptions(portInfo: PortInfo): SerialDeviceProviderPortOpenOptions; } diff --git a/src/device/transport/bleObserver.ts b/src/device/transport/bleObserver.ts index 8374bb18..12a78bf0 100644 --- a/src/device/transport/bleObserver.ts +++ b/src/device/transport/bleObserver.ts @@ -1,54 +1,52 @@ import noble, { Peripheral } from '@stoprocent/noble'; import Logger from '../../logging/Logger.js'; -import DeviceManager, { DeviceInfo } from '../deviceManager.js'; +import DeviceManager, { DeviceDetectionInfo } from '../deviceManager.js'; import { logError } from '../../util/error.js'; import { DeviceId } from '../deviceId.js'; -import { asyncHandler } from '../../util/async.js'; +import SharedObserver from './sharedObserver.js'; -export type BleDeviceInfo = DeviceInfo & { +export type BleDeviceDetectionInfo = DeviceDetectionInfo & { type: 'ble'; peripheral: Peripheral; }; -export default class BleObserver +export default class BleObserver extends SharedObserver { private static readonly MIN_RSSI = -70; private static readonly UART_SERVICE_UUID = '6e400001b5a3f393e0a9e50e24dcca9e'; - private readonly deviceManager: DeviceManager; + private static readonly POWER_ON_WAIT_CHUNK_MS = 10 * 60 * 1000; // 10 minutes - private readonly logger: Logger; + private readonly deviceManager: DeviceManager; private isScanning = false; + // Resolves the in-flight wait-for-power-on loop immediately when stop() is called, instead + // of waiting out the rest of the current POWER_ON_WAIT_CHUNK_MS chunk. + private cancelPowerOnWait?: () => void; + public constructor( deviceManager: DeviceManager, logger: Logger ) { + super(logger.child({ name: BleObserver.name })); this.deviceManager = deviceManager; - this.logger = logger.child({ name: BleObserver.name }); } - public async init(): Promise + protected async onFirstStart(): Promise { noble.on('discover', this.onDiscover.bind(this)); - - noble.on('stateChange', asyncHandler( - async (state) => { - if (state === 'poweredOn') { - await this.observe(); - } - }, - (err: unknown) => logError(this.logger, 'Error in stateChange handler', err) - )); - noble.on('scanStop', () => { this.logger.info('Noble scanning stopped'); }); - await this.observe(); + // Waiting for the adapter to power on can take an arbitrarily long time (or never happen + // at all, e.g. no BLE hardware present), so it must not block start()/stop() + void this.startScanningOncePoweredOn(); } - public async stop(): Promise + protected async onLastStop(): Promise { + this.cancelPowerOnWait?.(); + noble.removeAllListeners(); if (this.isScanning) { @@ -67,23 +65,22 @@ export default class BleObserver return; } - const deviceInfo: BleDeviceInfo = { + const deviceInfo: BleDeviceDetectionInfo = { type: 'ble', - id: DeviceId.create(peripheral.id), + detectionId: DeviceId.create(peripheral.id), peripheral, }; this.deviceManager.announceDetectedDevice(deviceInfo); } - private async observe(): Promise { - if (this.isScanning) { - return; - } - + private async startScanningOncePoweredOn(): Promise { try { - // Wait for Adapter poweredOn state - await noble.waitForPoweredOnAsync(); + const stopped = await this.waitForPoweredOnUnlessStopped(); + + if (stopped) { + return; + } this.isScanning = true; await noble.startScanningAsync([BleObserver.UART_SERVICE_UUID], true); @@ -92,7 +89,42 @@ export default class BleObserver } catch (error: unknown) { logError(this.logger, 'BLE device discovery error', error); this.isScanning = false; - await noble.stopScanningAsync(); + + try { + await noble.stopScanningAsync(); + } catch (stopError: unknown) { + logError(this.logger, 'Could not stop BLE scanning after a discovery error', stopError); + } } } + + private async waitForPoweredOnUnlessStopped(): Promise { + let stopped = false; + + const stopRequested = new Promise((resolve) => { + this.cancelPowerOnWait = (): void => { + stopped = true; + resolve(); + }; + }); + + while (!stopped) { + const outcome = await Promise.race([ + noble.waitForPoweredOnAsync(BleObserver.POWER_ON_WAIT_CHUNK_MS) + .then(() => 'poweredOn' as const) + .catch(() => 'timeout' as const), + stopRequested.then(() => 'stopped' as const), + ]); + + if ('timeout' !== outcome) { + break; + } + + this.logger.debug('Still waiting for the BLE adapter to power on...'); + } + + this.cancelPowerOnWait = undefined; + + return stopped; + } } diff --git a/src/device/transport/serialPortObserver.ts b/src/device/transport/serialPortObserver.ts index a3d78f69..336d3591 100644 --- a/src/device/transport/serialPortObserver.ts +++ b/src/device/transport/serialPortObserver.ts @@ -1,25 +1,22 @@ import { SerialPort } from 'serialport'; import { PortInfo } from '@serialport/bindings-interface'; import Logger from '../../logging/Logger.js'; -import DeviceManager, { DeviceInfo } from '../deviceManager.js'; +import DeviceManager, { DeviceDetectionInfo } from '../deviceManager.js'; import { usb } from 'usb'; import { logError } from '../../util/error.js'; import { DeviceId } from '../deviceId.js'; +import SharedObserver from './sharedObserver.js'; -export type SerialDeviceInfo = DeviceInfo & { +export type SerialDeviceDetectionInfo = DeviceDetectionInfo & { type: 'serial'; portInfo: PortInfo; }; -export default class SerialPortObserver +export default class SerialPortObserver extends SharedObserver { - protected readonly logger: Logger; - protected readonly deviceManager: DeviceManager; - public static readonly name = 'serial'; - - private managedDevices: Map = new Map(); + private managedDevices: Map = new Map(); private onUsbEventRef?: () => void; @@ -31,11 +28,11 @@ export default class SerialPortObserver deviceManager: DeviceManager, logger: Logger ) { + super(logger.child({ name: SerialPortObserver.name })); this.deviceManager = deviceManager; - this.logger = logger.child({ name: SerialPortObserver.name }); } - public async start(): Promise + protected async onFirstStart(): Promise { await this.discoverSerialDevices(); @@ -84,9 +81,9 @@ export default class SerialPortObserver foundDevices.set(portInfo.serialNumber, null); if (!this.managedDevices.has(portInfo.serialNumber)) { - const deviceInfo: SerialDeviceInfo = { + const deviceInfo: SerialDeviceDetectionInfo = { type: 'serial', - id: DeviceId.create(portInfo.serialNumber), + detectionId: DeviceId.create(portInfo.serialNumber), portInfo }; @@ -110,7 +107,7 @@ export default class SerialPortObserver } } - public async stop(): Promise { + protected async onLastStop(): Promise { if (this.rescanTimer !== undefined) { clearTimeout(this.rescanTimer); this.rescanTimer = undefined; diff --git a/src/device/transport/sharedObserver.ts b/src/device/transport/sharedObserver.ts new file mode 100644 index 00000000..586a3788 --- /dev/null +++ b/src/device/transport/sharedObserver.ts @@ -0,0 +1,62 @@ +import Logger from '../../logging/Logger.js'; + +export default abstract class SharedObserver +{ + protected readonly logger: Logger; + + private activeUsers = 0; + + // Used to let all providers who want to acquire this observer wait for the first start() to finish, without having to re-run onFirstStart() for each of them. + private startupPromise: Promise | undefined; + + protected constructor(logger: Logger) { + this.logger = logger; + } + + public async start(): Promise { + this.activeUsers++; + + if (this.activeUsers > 1) { + this.logger.debug(`Already running, now used by ${this.activeUsers} provider(s)`); + + if (this.startupPromise !== undefined) { + await this.startupPromise; + } + + return; + } + + this.startupPromise = this.onFirstStart(); + + try { + await this.startupPromise; + } finally { + this.startupPromise = undefined; + } + } + + public async stop(): Promise { + if (this.activeUsers === 0) { + return; + } + + this.activeUsers--; + + if (this.activeUsers > 0) { + this.logger.debug(`Still used by ${this.activeUsers} provider(s), not stopping`); + return; + } + + await this.onLastStop(); + } + + /** + * Runs once, when the first caller acquires this observer. + */ + protected abstract onFirstStart(): Promise; + + /** + * Runs once, when the last remaining caller releases this observer. + */ + protected abstract onLastStop(): Promise; +} diff --git a/src/device/updater/abstractDeviceUpdater.ts b/src/device/updater/abstractDeviceUpdater.ts index 6c396499..a41bb352 100644 --- a/src/device/updater/abstractDeviceUpdater.ts +++ b/src/device/updater/abstractDeviceUpdater.ts @@ -1,6 +1,6 @@ import PlainToClassSerializer from '../../serialization/plainToClassSerializer.js'; import DeviceUpdaterInterface from './deviceUpdaterInterface.js'; -import Device, { DeviceData } from '../device.js'; +import { AnyDevice, DeviceData } from '../device.js'; export default abstract class AbstractDeviceUpdater implements DeviceUpdaterInterface { @@ -11,5 +11,5 @@ export default abstract class AbstractDeviceUpdater implements DeviceUpdaterInte this.serializer = serializer; } - public abstract update(device: Device, deviceData: DeviceData): Promise; + public abstract update(device: AnyDevice, deviceData: DeviceData): Promise; } diff --git a/src/device/updater/bufferedDeviceUpdater.ts b/src/device/updater/bufferedDeviceUpdater.ts index c38e8a71..1f21669c 100644 --- a/src/device/updater/bufferedDeviceUpdater.ts +++ b/src/device/updater/bufferedDeviceUpdater.ts @@ -1,4 +1,4 @@ -import Device, { DeviceData } from '../device.js'; +import { AnyDevice, DeviceData } from '../device.js'; import DeviceUpdaterInterface from './deviceUpdaterInterface.js'; import { SequentialTaskQueue } from 'sequential-task-queue'; @@ -13,14 +13,14 @@ export default class BufferedDeviceUpdater implements DeviceUpdaterInterface this.queue = new SequentialTaskQueue(); } - public async update(device: Device, deviceData: DeviceData): Promise { + public async update(device: AnyDevice, deviceData: DeviceData): Promise { await this.queue.push(BufferedDeviceUpdater.handleUpdate, { args: [this.decoratedDeviceUpdater, device, deviceData] }); } private static async handleUpdate( this: void, deviceUpdater: DeviceUpdaterInterface, - device: Device, + device: AnyDevice, deviceData: DeviceData ): Promise { await deviceUpdater.update(device, deviceData); diff --git a/src/device/updater/deviceUpdaterInterface.ts b/src/device/updater/deviceUpdaterInterface.ts index b0551f65..60f14596 100644 --- a/src/device/updater/deviceUpdaterInterface.ts +++ b/src/device/updater/deviceUpdaterInterface.ts @@ -1,6 +1,6 @@ -import Device, { DeviceData } from '../device.js'; +import { AnyDevice, DeviceData } from '../device.js'; export default interface DeviceUpdaterInterface { - update(device: Device, rawData: DeviceData): Promise; + update(device: AnyDevice, rawData: DeviceData): Promise; } diff --git a/src/entity/deviceList.ts b/src/entity/deviceList.ts index 19a3e236..34a345bc 100644 --- a/src/entity/deviceList.ts +++ b/src/entity/deviceList.ts @@ -1,11 +1,11 @@ import { Exclude, Type } from 'class-transformer'; -import Device from '../device/device.js'; +import Device, { AnyDevice } from '../device/device.js'; import List from './list.js'; import DeviceDiscriminator from '../serialization/discriminator/deviceDiscriminator.js'; @Exclude() -export default class DeviceList extends List +export default class DeviceList extends List { @Type(() => Device, DeviceDiscriminator.createClassTransformerTypeDiscriminator('type')) - protected readonly declare items: Device[]; + protected readonly declare items: AnyDevice[]; } diff --git a/src/repository/connectedDeviceRepository.ts b/src/repository/connectedDeviceRepository.ts index 5ad090fe..285d05e6 100644 --- a/src/repository/connectedDeviceRepository.ts +++ b/src/repository/connectedDeviceRepository.ts @@ -1,6 +1,6 @@ import DeviceRepositoryInterface from './deviceRepositoryInterface.js'; import DeviceManager from '../device/deviceManager.js'; -import Device from '../device/device.js'; +import { AnyDevice } from '../device/device.js'; import { DeviceId } from '../device/deviceId.js'; export default class ConnectedDeviceRepository implements DeviceRepositoryInterface @@ -11,12 +11,12 @@ export default class ConnectedDeviceRepository implements DeviceRepositoryInterf this.deviceManager = deviceManager; } - public getAll(): Device[] + public getAll(): AnyDevice[] { return this.deviceManager.getConnectedDevices(); } - public getById(uuid: DeviceId): Device | null { + public getById(uuid: DeviceId): AnyDevice | null { return this.deviceManager.getConnectedDevice(uuid); } } diff --git a/src/repository/deviceRepositoryInterface.ts b/src/repository/deviceRepositoryInterface.ts index 0e568db3..d0cc3aa0 100644 --- a/src/repository/deviceRepositoryInterface.ts +++ b/src/repository/deviceRepositoryInterface.ts @@ -1,8 +1,8 @@ -import Device from '../device/device.js'; +import { AnyDevice } from '../device/device.js'; export default interface DeviceRepositoryInterface { - getAll(): Device[]; + getAll(): AnyDevice[]; - getById(uuid: string): Device|null; + getById(uuid: string): AnyDevice|null; } diff --git a/src/schemaValidation/schemaValidationError.ts b/src/schemaValidation/schemaValidationError.ts new file mode 100644 index 00000000..5a0275bb --- /dev/null +++ b/src/schemaValidation/schemaValidationError.ts @@ -0,0 +1,18 @@ +import { ErrorObject } from 'ajv'; + +/** + * Thrown by PlainToClassSerializer.transform() when a schema is passed and the plain value fails + * validation against it. Callers that don't need the details can just let it propagate/log its + * message; callers that do (e.g. to return a structured 400 response) can catch it and read + * `validationErrors` directly, same shape as `Ajv.errors`. + */ +export default class SchemaValidationError extends Error +{ + public readonly validationErrors: ErrorObject[]; + + public constructor(message: string, validationErrors: ErrorObject[]) { + super(message); + this.name = 'SchemaValidationError'; + this.validationErrors = validationErrors; + } +} diff --git a/src/serialization/plainToClassSerializer.ts b/src/serialization/plainToClassSerializer.ts index 56d7a5b9..36db8d1f 100644 --- a/src/serialization/plainToClassSerializer.ts +++ b/src/serialization/plainToClassSerializer.ts @@ -1,16 +1,34 @@ import { ClassConstructor, ClassTransformOptions, plainToInstance } from 'class-transformer'; +import { TSchema } from '@sinclair/typebox'; +import { Value } from '@sinclair/typebox/value'; +import { Ajv } from 'ajv'; +import SchemaValidationError from '../schemaValidation/schemaValidationError.js'; export default class PlainToClassSerializer { + private readonly ajv: Ajv; + private readonly options?: ClassTransformOptions; - public constructor(options?: ClassTransformOptions) + public constructor(ajv: Ajv, options?: ClassTransformOptions) { + this.ajv = ajv; this.options = options; } - public transform(cls: ClassConstructor, plain: V): T + public transform(cls: ClassConstructor, plain: V, schema?: TSchema): T { + if (undefined !== schema) { + if (!this.ajv.validate(schema, plain)) { + throw new SchemaValidationError( + this.ajv.errorsText(this.ajv.errors), + this.ajv.errors ?? [] + ); + } + + Value.Default(schema, plain); + } + return plainToInstance(cls, plain, this.options); } } diff --git a/src/serviceMap.ts b/src/serviceMap.ts index 53f23393..b209c38b 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -11,7 +11,7 @@ import DeviceNameGenerator from './device/deviceNameGenerator.js'; import DeviceUpdaterInterface from './device/updater/deviceUpdaterInterface.js'; import UuidFactory from './factory/uuidFactory.js'; import DateFactory from './factory/dateFactory.js'; -import Settings, { SettingsSchema } from './settings/settings.js'; +import Settings from './settings/settings.js'; import SettingsManager from './settings/settingsManager.js'; import ScriptRuntime from './automation/scriptRuntime.js'; import ConnectedDeviceRepository from './repository/connectedDeviceRepository.js'; @@ -36,7 +36,6 @@ import VirtualDeviceProviderFactory from './device/protocol/virtual/virtualDevic import GetSettingsController from './controller/settings/getSettingsController.js'; import PutSettingsController from './controller/settings/putSettingsController.js'; import JsonSchemaValidatorFactory from './schemaValidation/JsonSchemaValidatorFactory.js'; -import JsonSchemaValidator from './schemaValidation/JsonSchemaValidator.js'; import VersionController from './controller/versionController.js'; import SerialPortObserver from './device/transport/serialPortObserver.js'; import Zc95DeviceFactory from './device/protocol/zc95/zc95DeviceFactory.js'; @@ -97,7 +96,6 @@ type ServiceMap = { /* settingsServiceProvider */ 'settings': Settings, 'settings.manager': SettingsManager, - 'settings.schema.validator': JsonSchemaValidator, /* automationServiceProvider */ 'automation.scriptRuntime': ScriptRuntime, diff --git a/src/serviceProvider/controllerServiceProvider.ts b/src/serviceProvider/controllerServiceProvider.ts index 092aa3a4..e4370708 100644 --- a/src/serviceProvider/controllerServiceProvider.ts +++ b/src/serviceProvider/controllerServiceProvider.ts @@ -106,7 +106,6 @@ export default class ControllerServiceProvider implements ServiceProvider { return new DeviceManager( container.get('factory.eventEmitter').create(), - new Map(), + new Map(), + container.get('settings.manager'), container.get('logger.default') ); }); @@ -218,6 +220,7 @@ export default class DeviceServiceProvider implements ServiceProvider): void { container.set('serializer.plainToClass', () => { - return new PlainToClassSerializer({ excludeExtraneousValues: true }); + return new PlainToClassSerializer(container.get('ajv'), { excludeExtraneousValues: true }); }); container.set('serializer.classToPlain', () => { diff --git a/src/serviceProvider/settingsServiceProvider.ts b/src/serviceProvider/settingsServiceProvider.ts index 836e97c1..1ddb3b03 100644 --- a/src/serviceProvider/settingsServiceProvider.ts +++ b/src/serviceProvider/settingsServiceProvider.ts @@ -3,7 +3,6 @@ import SettingsManager from '../settings/settingsManager.js'; import os from 'os'; import fs from 'fs'; import ServiceMap from '../serviceMap.js'; -import { SettingsSchema } from '../settings/settings.js'; export default class SettingsServiceProvider implements ServiceProvider { @@ -14,12 +13,6 @@ export default class SettingsServiceProvider implements ServiceProvider): void { - container.set('settings.schema.validator', () => { - const jsonSchemaValidatorFactory = container.get('factory.validator.schema.json'); - - return jsonSchemaValidatorFactory.create(SettingsSchema); - }); - container.set('settings.manager', () => { const dataPath = this.dataPath ?? `${os.homedir()}/.slvctrlplus`; @@ -33,7 +26,6 @@ export default class SettingsServiceProvider implements ServiceProvider; - public constructor( settingsFilePath: string, plainToClassSerializer: PlainToClassSerializer, classToPlainSerializer: ClassToPlainSerializer, - settingsSchemaValidator: JsonSchemaValidator, eventEmitter: EventEmitter, logger: Logger ) { this.settingsFilePath = settingsFilePath; this.plainToClassSerializer = plainToClassSerializer; this.classToPlainSerializer = classToPlainSerializer; - this.settingsSchemaValidator = settingsSchemaValidator; this.eventEmitter = eventEmitter; this.logger = logger; } @@ -61,15 +56,18 @@ export default class SettingsManager } else { const plainJsonSettings: JsonObject = JSON.parse(fs.readFileSync(this.settingsFilePath, 'utf8')); - if (!this.settingsSchemaValidator.validate(plainJsonSettings)) { - const validationErrors = this.settingsSchemaValidator.getValidationErrorsAsText(); - const invalidFormatMsg = `Settings are not in a valid format: ${validationErrors}`; + try { + this.settings = this.plainToClassSerializer.transform(Settings, plainJsonSettings, SettingsSchema); + } catch (e: unknown) { + if (!(e instanceof SchemaValidationError)) { + throw e; + } + + const invalidFormatMsg = `Settings are not in a valid format: ${e.message}`; this.logger.error(invalidFormatMsg); - throw new Error(invalidFormatMsg); + throw new Error(invalidFormatMsg, { cause: e }); } - this.settings = this.plainToClassSerializer.transform(Settings, plainJsonSettings); - this.logger.info(`Settings loaded from file: ${this.settingsFilePath}`); } @@ -90,6 +88,12 @@ export default class SettingsManager return this; } + public off (event: E, listener: SettingsEvents[E]): this + { + this.eventEmitter.off(event, listener); + return this; + } + public getSettings(): Settings|undefined { return this.settings; } diff --git a/tests/integration/deviceEvents.spec.ts b/tests/integration/deviceEvents.spec.ts index 60f08456..4d8af9f3 100644 --- a/tests/integration/deviceEvents.spec.ts +++ b/tests/integration/deviceEvents.spec.ts @@ -1,6 +1,7 @@ import { afterAll, assert, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { DeviceManagerEvent } from '../../src/device/deviceManager.js'; -import Device from '../../src/device/device.js'; +import { AnyDevice } from '../../src/device/device.js'; +import { AttributeValue } from '../../src/device/attribute/deviceAttribute.js'; import Settings from '../../src/settings/settings.js'; import KnownDevice from '../../src/settings/knownDevice.js'; import DeviceSource from '../../src/settings/deviceSource.js'; @@ -47,8 +48,8 @@ describe('Device events', () => { const deviceManager = app.container.get('device.manager'); const device = deviceManager.getConnectedDevices()[0]; - let observedValue: number | undefined; - let changedValue: number | undefined; + let observedValue: AttributeValue; + let changedValue: AttributeValue; await new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -106,7 +107,7 @@ describe('Device events', () => { const deviceDisconnected = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); - const listener = (device: Device) => { + const listener = (device: AnyDevice) => { if (device.getDeviceId === NEW_DEVICE_ID) { clearTimeout(timeout); deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); @@ -124,6 +125,116 @@ describe('Device events', () => { await deviceDisconnected; }, 1000); + it('disabling a known device closes it and re-enabling it reconnects it', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = app.container.get('device.manager'); + const settingsManager = app.container.get('settings.manager'); + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + + const disabledSettings = new Settings(); + disabledSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); + disabledSettings.addKnownDevice( + new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 }, false) + ); + + const deviceDisconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); + const listener = (device: AnyDevice) => { + if (device.getDeviceId === TEST_DEVICE_ID) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceDisconnected, listener); + }); + + settingsManager.replace(disabledSettings); + await deviceDisconnected; + + expect(deviceManager.getConnectedDevices()).toHaveLength(0); + + const enabledSettings = new Settings(); + enabledSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); + enabledSettings.addKnownDevice( + new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 }, true) + ); + + const deviceReconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to reconnect')), 1000); + const listener = (device: AnyDevice) => { + if (device.getDeviceId === TEST_DEVICE_ID) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceConnected, listener); + }); + + settingsManager.replace(enabledSettings); + await deviceReconnected; + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + }); + + it('disabling a device source stops its provider and removes its devices', async () => { + await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); + + const deviceManager = app.container.get('device.manager'); + const settingsManager = app.container.get('settings.manager'); + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + + const disabledSourceSettings = new Settings(); + disabledSourceSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {}, false)); + disabledSourceSettings.addKnownDevice( + new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 }) + ); + + const deviceDisconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to disconnect')), 1000); + const listener = (device: AnyDevice) => { + if (device.getDeviceId === TEST_DEVICE_ID) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceDisconnected, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceDisconnected, listener); + }); + + settingsManager.replace(disabledSourceSettings); + await deviceDisconnected; + + expect(deviceManager.getConnectedDevices()).toHaveLength(0); + + const reenabledSourceSettings = new Settings(); + reenabledSourceSettings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {}, true)); + reenabledSourceSettings.addKnownDevice( + new KnownDevice(TEST_DEVICE_ID, 'Test Random Generator', 'randomGenerator', 'virtual', { min: 0, max: 100 }) + ); + + const deviceReconnected = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for device to reconnect')), 1000); + const listener = (device: AnyDevice) => { + if (device.getDeviceId === TEST_DEVICE_ID) { + clearTimeout(timeout); + deviceManager.off(DeviceManagerEvent.deviceConnected, listener); + resolve(); + } + }; + deviceManager.on(DeviceManagerEvent.deviceConnected, listener); + }); + + settingsManager.replace(reenabledSourceSettings); + await deviceReconnected; + + expect(deviceManager.getConnectedDevices()).toHaveLength(1); + }); + it('virtual device disconnected', async () => { await connectDevices(app.container, [{ id: TEST_DEVICE_ID, name: 'Test Random Generator' }]); diff --git a/tests/integration/devices/buttplugIoDevice.spec.ts b/tests/integration/devices/buttplugIoDevice.spec.ts index 5b6c7312..f9a26526 100644 --- a/tests/integration/devices/buttplugIoDevice.spec.ts +++ b/tests/integration/devices/buttplugIoDevice.spec.ts @@ -132,15 +132,17 @@ describe('Buttplug.io device lifecycle', () => { // via WebSocket event simulator.receivedScalarCmds = []; - const deviceRefreshed = waitForNextWsEvent(wsEmitSpy, WebSocketEvent.deviceRefreshed); - + // MockVibe is actuator-only (no sensors), so it has no periodic refresh cycle and + // ButtplugIoDevice.setAttribute() doesn't emit deviceRefreshed on its own either - + // poll for the resulting scalar command instead of waiting for a WS event that will + // never come. See the equivalent airotic test for the same pattern (there polling the + // REST endpoint instead, since that's what it has to observe). wsClient.emit(WebSocketEvent.deviceUpdateReceived, { deviceId: payload.deviceId, data: { 'Vibrate-0': 5 } }); - const [payloadDeviceRefreshed] = await deviceRefreshed; + await vi.waitFor(() => { + expect(simulator.receivedScalarCmds).toHaveLength(1); + }, { timeout: 3000, interval: 50 }); - expect(payloadDeviceRefreshed).toMatchObject({ deviceId: payload.deviceId }); - - expect(simulator.receivedScalarCmds).toHaveLength(1); const wsCmd = simulator.receivedScalarCmds[0]; expect(wsCmd?.actuatorType).toBe('Vibrate'); expect(wsCmd?.index).toBe(0); diff --git a/tests/integration/helpers/appHelper.ts b/tests/integration/helpers/appHelper.ts index f6e251a7..b333980d 100644 --- a/tests/integration/helpers/appHelper.ts +++ b/tests/integration/helpers/appHelper.ts @@ -3,7 +3,7 @@ import os from 'os'; import path from 'path'; import { io as ioClient } from 'socket.io-client'; import { createApp, AppInstance, createContainer, AppOptions } from '../../../src/app.js'; -import Device from '../../../src/device/device.js'; +import { AnyDevice } from '../../../src/device/device.js'; import { DeviceManagerEvent } from '../../../src/device/deviceManager.js'; import { ServerToClientEvents } from '../../../src/socket/types.js'; type WsEmitCall = { [E in keyof ServerToClientEvents]: [E, ...Parameters] }[keyof ServerToClientEvents]; @@ -27,7 +27,7 @@ export type DeviceSpec = { id: DeviceId, name: string, config?: { min: number, m function makeBaseSettings(): Settings { const settings = new Settings(); - settings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', { scanIntervalMs: 50 })); + settings.addDeviceSource(new DeviceSource(TEST_SOURCE_ID, 'virtual', {})); return settings; } @@ -37,7 +37,7 @@ const baseSettingsJson = { [TEST_SOURCE_ID]: { id: TEST_SOURCE_ID, type: 'virtual', - config: { scanIntervalMs: 50 }, + config: {}, }, }, }; @@ -145,9 +145,9 @@ export const resetTestApp = async (app: TestApp): Promise => { export function getConnectedDevice( container: Container, - predicate: (device: Device) => boolean, + predicate: (device: AnyDevice) => boolean, description: string, -): Device { +): AnyDevice { const device = container.get('device.manager').getConnectedDevices().find(predicate); if (undefined === device) { throw new Error(`No connected device found: ${description}`); @@ -155,17 +155,17 @@ export function getConnectedDevice( return device; } -export function waitForNDevicesConnected(container: Container, deviceCount: number, timeoutMs = 5000): Promise { +export function waitForNDevicesConnected(container: Container, deviceCount: number, timeoutMs = 5000): Promise { return new Promise((resolve, reject) => { const deviceManager = container.get('device.manager'); - const connected: Device[] = []; + const connected: AnyDevice[] = []; const timeout = setTimeout(() => { deviceManager.off(DeviceManagerEvent.deviceConnected, listener); reject(new Error(`Timed out waiting for ${deviceCount} device(s) to connect (>${timeoutMs}ms), got ${connected.length}`)); }, timeoutMs); - const listener = (device: Device): void => { + const listener = (device: AnyDevice): void => { connected.push(device); if (connected.length >= deviceCount) { clearTimeout(timeout); diff --git a/tests/unit/automation/scriptRuntime.spec.ts b/tests/unit/automation/scriptRuntime.spec.ts index 43045fe2..61383daa 100644 --- a/tests/unit/automation/scriptRuntime.spec.ts +++ b/tests/unit/automation/scriptRuntime.spec.ts @@ -6,7 +6,7 @@ import ScriptRuntime, { SupportedDeviceEvent } from '../../../src/automation/scr import AutomationEventType from '../../../src/automation/automationEventType.js'; import { DeviceManagerEvent } from '../../../src/device/deviceManager.js'; import Device, { AttributeKeyOf, AttributeValueOf, DeviceAttributes } from '../../../src/device/device.js'; -import { DeviceAttributeModifier } from '../../../src/device/attribute/deviceAttribute.js'; +import { AttributeValue, DeviceAttributeModifier } from '../../../src/device/attribute/deviceAttribute.js'; import DeviceRepositoryInterface from '../../../src/repository/deviceRepositoryInterface.js'; import StrDeviceAttribute from '../../../src/device/attribute/strDeviceAttribute.js'; import Logger from '../../../src/logging/Logger.js'; @@ -17,7 +17,7 @@ import { DeviceId } from '../../../src/device/deviceId.js'; // --------------------------------------------------------------------------- class StubDevice extends Device { - public readonly setAttributeCalls: Array<[string, unknown]> = []; + public readonly setAttributeCalls: Array<[string, AttributeValue]> = []; public constructor(id: DeviceId, name: string) { super( diff --git a/tests/unit/device/deviceManager.spec.ts b/tests/unit/device/deviceManager.spec.ts index 3e4c0e22..77901675 100644 --- a/tests/unit/device/deviceManager.spec.ts +++ b/tests/unit/device/deviceManager.spec.ts @@ -1,13 +1,19 @@ import {describe, it, expect, beforeEach} from "vitest"; import {mock,mockClear} from "vitest-mock-extended"; -import DeviceManager, { DeviceManagerEvent, DeviceInfo } from "../../../src/device/deviceManager.js"; +import DeviceManager, { DeviceManagerEvent, DeviceDetectionInfo } from "../../../src/device/deviceManager.js"; import {EventEmitter} from "events"; import Device from "../../../src/device/device.js"; import TestDevice from "./testDevice.js"; import Logger from "../../../src/logging/Logger.js"; import { DeviceId } from "../../../src/device/deviceId.js"; +import SettingsManager from "../../../src/settings/settingsManager.js"; +import Settings from "../../../src/settings/settings.js"; +import KnownDevice from "../../../src/settings/knownDevice.js"; describe('deviceManager', () => { + // Returns `undefined` settings by default, which makes `isDeviceEnabled()` treat every + // device as enabled - the desired default for tests unrelated to the enable/disable feature. + const mockedSettingsManager = mock(); it('it adds device to managed devices and emits an event', async () => { @@ -16,14 +22,15 @@ describe('deviceManager', () => { const mockedLogger = mock(); mockedLogger.child.mockReturnValue(mockedLogger); - const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, new Map(), mockedLogger); + const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, new Map(), mockedSettingsManager, mockedLogger); - const device = new TestDevice(DeviceId.create('test-device-id'), 'Foo', new Date(), false, new EventEmitter()); + const deviceId = DeviceId.create('test-device-id'); + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); // New device connected expect(deviceManager.getConnectedDevices().length).toBe(0); - deviceManager.addDevice(device); + deviceManager.addDevice({ type: 'test', detectionId: deviceId }, device); let actualDevices = deviceManager.getConnectedDevices(); @@ -39,16 +46,17 @@ describe('deviceManager', () => { it('it removes device from managed devices and emits event on disconnect', async () => { const connectedDevices = new Map(); - const device = new TestDevice(DeviceId.create('test-device-id'), 'Foo', new Date(), false, new EventEmitter()); + const deviceId = DeviceId.create('test-device-id'); + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); const mockedDeviceManagerEventEmitter = mock(); const mockedLogger = mock(); mockedLogger.child.mockReturnValue(mockedLogger); - const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedLogger); + const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedSettingsManager, mockedLogger); - deviceManager.addDevice(device); + deviceManager.addDevice({ type: 'test', detectionId: deviceId }, device); // Connected device refreshed await device.refresh(); @@ -64,16 +72,17 @@ describe('deviceManager', () => { it('it emits an event on device update', async () => { const connectedDevices = new Map(); - const device = new TestDevice(DeviceId.create('test-device-id'), 'Foo', new Date(), false, new EventEmitter()); + const deviceId = DeviceId.create('test-device-id'); + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); const mockedDeviceManagerEventEmitter = mock(); const mockedLogger = mock(); mockedLogger.child.mockReturnValue(mockedLogger); - const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedLogger); + const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedSettingsManager, mockedLogger); - deviceManager.addDevice(device); + deviceManager.addDevice({ type: 'test', detectionId: deviceId }, device); // Connected device closed await device.close(); @@ -98,13 +107,13 @@ describe('deviceManager', () => { const uuid = 'known-device-uuid'; const device = mock(); const connectedDevices = new Map([[uuid, device]]); - const manager = new DeviceManager(mock(), connectedDevices, mockedLogger); + const manager = new DeviceManager(mock(), connectedDevices, mockedSettingsManager, mockedLogger); expect(manager.getConnectedDevice(uuid)).toBe(device); }); it('returns null when device is not found', () => { - const manager = new DeviceManager(mock(), new Map(), mockedLogger); + const manager = new DeviceManager(mock(), new Map(), mockedSettingsManager, mockedLogger); expect(manager.getConnectedDevice('unknown-uuid')).toBeNull(); }); @@ -114,7 +123,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-1'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -124,7 +133,7 @@ describe('deviceManager', () => { it('emits deviceDetected event for a newly seen device', () => { mockedEventEmitter.emit.mockReturnValue(true); - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); @@ -133,7 +142,7 @@ describe('deviceManager', () => { it('does not re-announce a device already in the acquire queue', () => { mockedEventEmitter.emit.mockReturnValue(true); - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); manager.announceDetectedDevice(deviceInfo); @@ -144,7 +153,7 @@ describe('deviceManager', () => { it('does not emit event when device is already connected', () => { const connectedDevices = new Map([[deviceId, mock()]]); - const manager = new DeviceManager(mockedEventEmitter, connectedDevices, mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, connectedDevices, mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); @@ -153,20 +162,35 @@ describe('deviceManager', () => { it('removes device from queue when no listeners respond to deviceDetected', async () => { mockedEventEmitter.emit.mockReturnValue(false); - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); const result = await manager.acquireDetectedDevice(deviceId); expect(result.successful).toBe(false); }); + + it('does not emit deviceDetected for a device belonging to a disabled known device', () => { + mockedEventEmitter.emit.mockReturnValue(true); + + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + + expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + }); }); describe('acquireDetectedDevice', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-2'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -176,7 +200,7 @@ describe('deviceManager', () => { }); it('returns failure when device is not in the detect queue', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); const result = await manager.acquireDetectedDevice(deviceId); @@ -184,7 +208,7 @@ describe('deviceManager', () => { }); it('resolves immediately with success for the first caller', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); const result = await manager.acquireDetectedDevice(deviceId); @@ -193,7 +217,7 @@ describe('deviceManager', () => { }); it('queues the second caller until the first releases', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); await manager.acquireDetectedDevice(deviceId); @@ -209,7 +233,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-3'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -219,13 +243,13 @@ describe('deviceManager', () => { }); it('is a no-op when device is not in the acquire queue', () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); expect(() => manager.releaseDetectedDevice(DeviceId.create('unknown'))).not.toThrow(); }); it('removes device from queue after the only waiter releases', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); await manager.acquireDetectedDevice(deviceId); @@ -240,7 +264,7 @@ describe('deviceManager', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-4'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -250,7 +274,7 @@ describe('deviceManager', () => { }); it('resolves a pending second caller with failure', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); await manager.acquireDetectedDevice(deviceId); // first caller holds const pendingPromise = manager.acquireDetectedDevice(deviceId); // second waits @@ -260,13 +284,36 @@ describe('deviceManager', () => { const result = await pendingPromise; expect(result.successful).toBe(false); }); + + it('drops a disabled device from pending retry so it is not re-announced after re-enabling', async () => { + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); + + // Announced while disabled -> parked in pending retry, no deviceDetected emitted. + manager.announceDetectedDevice(deviceInfo); + expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + + // Device physically disappears while still disabled. + manager.revokeDetectedDevice(deviceInfo); + + // Re-enabling it must NOT resurrect the gone device. + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + await manager.onSettingsChanged(); + + expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + }); }); describe('claimDetectedDevice', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-5'); - const deviceInfo: DeviceInfo = { type: 'test', id: deviceId }; + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; beforeEach(() => { mockedLogger = mock(); @@ -276,7 +323,7 @@ describe('deviceManager', () => { }); it('resolves a pending caller with failure', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); await manager.acquireDetectedDevice(deviceId); // first caller holds const pendingPromise = manager.acquireDetectedDevice(deviceId); // second waits @@ -287,4 +334,231 @@ describe('deviceManager', () => { expect(result.successful).toBe(false); }); }); + + describe('isDeviceEnabled', () => { + let mockedLogger: ReturnType>; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + }); + + it('treats an unknown device as enabled', () => { + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(new Settings()); + + const manager = new DeviceManager(mock(), new Map(), settingsManager, mockedLogger); + + expect(manager.isDeviceEnabled(DeviceId.create('unknown'))).toBe(true); + }); + + it('treats a device as enabled when settings have not been loaded yet', () => { + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(undefined); + + const manager = new DeviceManager(mock(), new Map(), settingsManager, mockedLogger); + + expect(manager.isDeviceEnabled(DeviceId.create('unknown'))).toBe(true); + }); + + it('reflects a known device\'s enabled state', () => { + const deviceId = DeviceId.create('known-device'); + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const manager = new DeviceManager(mock(), new Map(), settingsManager, mockedLogger); + + expect(manager.isDeviceEnabled(deviceId)).toBe(false); + }); + }); + + describe('addDevice - disabled devices', () => { + let mockedLogger: ReturnType>; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + }); + + it('does not register a device belonging to a disabled known device and closes it', () => { + const deviceId = DeviceId.create('disabled-device'); + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const connectedDevices = new Map(); + const manager = new DeviceManager(mock(), connectedDevices, settingsManager, mockedLogger); + + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + + const added = manager.addDevice({ type: 'test', detectionId: deviceId }, device); + + expect(added).toBe(false); + expect(manager.getConnectedDevices()).toHaveLength(0); + }); + + it('registers a device belonging to an enabled known device', () => { + const deviceId = DeviceId.create('enabled-device'); + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const manager = new DeviceManager(mock(), new Map(), settingsManager, mockedLogger); + + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + + const added = manager.addDevice({ type: 'test', detectionId: deviceId }, device); + + expect(added).toBe(true); + expect(manager.getConnectedDevices()).toHaveLength(1); + }); + }); + + describe('onSettingsChanged', () => { + let mockedLogger: ReturnType>; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + }); + + it('closes connected devices whose known device has been disabled', async () => { + const deviceId = DeviceId.create('device-to-disable'); + const enabledSettings = new Settings(); + enabledSettings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(enabledSettings); + + const connectedDevices = new Map(); + const manager = new DeviceManager(mock(), connectedDevices, settingsManager, mockedLogger); + + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + manager.addDevice({ type: 'test', detectionId: deviceId }, device); + expect(manager.getConnectedDevices()).toHaveLength(1); + + const disabledSettings = new Settings(); + disabledSettings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + settingsManager.getSettings.mockReturnValue(disabledSettings); + + await manager.onSettingsChanged(); + + expect(manager.getConnectedDevices()).toHaveLength(0); + }); + + it('leaves devices belonging to still-enabled known devices connected', async () => { + const deviceId = DeviceId.create('device-still-enabled'); + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const connectedDevices = new Map(); + const manager = new DeviceManager(mock(), connectedDevices, settingsManager, mockedLogger); + + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + manager.addDevice({ type: 'test', detectionId: deviceId }, device); + + await manager.onSettingsChanged(); + + expect(manager.getConnectedDevices()).toHaveLength(1); + }); + + it('re-announces a device rejected by announceDetectedDevice once its known device gets re-enabled', async () => { + const deviceId = DeviceId.create('device-pending-1'); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; + + const disabledSettings = new Settings(); + disabledSettings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(disabledSettings); + + const mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + + const enabledSettings = new Settings(); + enabledSettings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + settingsManager.getSettings.mockReturnValue(enabledSettings); + + await manager.onSettingsChanged(); + + expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + }); + + it('re-announces a device rejected by addDevice() only once its canonical known device gets re-enabled', async () => { + // The device is detected under a preliminary id, but its final/canonical id (only + // known after connecting, e.g. a serial number read during a handshake) is different. + const detectionId = DeviceId.create('device-pending-2-detected'); + const canonicalId = DeviceId.create('device-pending-2-canonical'); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId }; + + const settings = new Settings(); + // Only the canonical device is a known, disabled device. + settings.addKnownDevice(new KnownDevice(canonicalId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); + + // Simulate a provider that connected a device via the detected-device pipeline whose + // final id turns out to belong to a disabled device. + const device = new TestDevice(canonicalId, 'Foo', new Date(), false, new EventEmitter()); + const added = manager.addDevice(deviceInfo, device); + expect(added).toBe(false); + + // An unrelated settings change while the canonical device is still disabled must NOT + // retry it (it would if the retry were gated by the still-unknown detection id). + await manager.onSettingsChanged(); + expect(mockedEventEmitter.emit).not.toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + + // Enabling the canonical device does re-announce, under the original detection info. + settings.addKnownDevice(new KnownDevice(canonicalId, 'Foo', 'test', 'test', {}, true)); + await manager.onSettingsChanged(); + + expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + }); + + it('does not re-announce a still-disabled pending device', async () => { + const deviceId = DeviceId.create('device-pending-3'); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; + + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); + + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + manager.addDevice(deviceInfo, device); + + mockClear(mockedEventEmitter); + + await manager.onSettingsChanged(); + + expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts index bf73271c..b5fcc314 100644 --- a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts @@ -229,4 +229,51 @@ describe('ButtplugIoDevice', () => { // Assert expect(buttplugDeviceMock.sensorRead).not.toHaveBeenCalled(); }); + + it('it reports a refresh interval when the device has sensors', () => { + + // Arrange + const buttplugDeviceMock = mock(); + const device = createDevice(buttplugDeviceMock, {}); + + const sensorAttrDef = new SensorDeviceMessageAttributes({ Index: 0 }); + sensorAttrDef.SensorType = SensorType.Battery; + Object.defineProperty(buttplugDeviceMock, 'messageAttributes', { + get: () => new MessageAttributes({ SensorReadCmd: [sensorAttrDef] }), + configurable: true, + }); + + // Act & Assert + expect(device.getRefreshInterval).toBe(100); + }); + + it('it reports no refresh interval when the device has no sensors (actuator-only)', () => { + + // Arrange + const buttplugDeviceMock = mock(); + const device = createDevice(buttplugDeviceMock, {}); + + Object.defineProperty(buttplugDeviceMock, 'messageAttributes', { + get: () => new MessageAttributes({}), + configurable: true, + }); + + // Act & Assert + expect(device.getRefreshInterval).toBeUndefined(); + }); + + it('it reports no refresh interval when SensorReadCmd is an empty array', () => { + + // Arrange + const buttplugDeviceMock = mock(); + const device = createDevice(buttplugDeviceMock, {}); + + Object.defineProperty(buttplugDeviceMock, 'messageAttributes', { + get: () => new MessageAttributes({ SensorReadCmd: [] }), + configurable: true, + }); + + // Act & Assert + expect(device.getRefreshInterval).toBeUndefined(); + }); }); diff --git a/tests/unit/device/provider/deviceProviderManager.spec.ts b/tests/unit/device/provider/deviceProviderManager.spec.ts new file mode 100644 index 00000000..8f95e77f --- /dev/null +++ b/tests/unit/device/provider/deviceProviderManager.spec.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import EventEmitter from 'events'; +import DeviceProviderManager from '../../../../src/device/provider/deviceProviderManager.js'; +import DeviceProviderFactory from '../../../../src/device/provider/deviceProviderFactory.js'; +import DeviceProvider from '../../../../src/device/provider/deviceProvider.js'; +import DeviceManager, { DeviceDetectionInfo } from '../../../../src/device/deviceManager.js'; +import { AnyDevice } from '../../../../src/device/device.js'; +import Logger from '../../../../src/logging/Logger.js'; +import Settings from '../../../../src/settings/settings.js'; +import DeviceSource from '../../../../src/settings/deviceSource.js'; +import { JsonObject } from '../../../../src/types.js'; + +class RecordingDeviceProvider extends DeviceProvider +{ + public startCalls = 0; + public stopCalls = 0; + public stopResolved = false; + + // Allows tests to control when start()/stop() resolve, to simulate slow-running operations. + private startGate: Promise = Promise.resolve(); + private stopGate: Promise = Promise.resolve(); + + public constructor() { + super(mock(), new EventEmitter(), mock()); + } + + public setStartGate(gate: Promise): void { + this.startGate = gate; + } + + public setStopGate(gate: Promise): void { + this.stopGate = gate; + } + + public override async start(): Promise { + this.startCalls++; + await this.startGate; + } + + public override async stop(): Promise { + this.stopCalls++; + await this.stopGate; + this.stopResolved = true; + } + + // This test double never actually detects devices; it only exercises the lifecycle. + protected canHandleDeviceDetectionInfo(_deviceInfo: DeviceDetectionInfo): _deviceInfo is DeviceDetectionInfo { + return false; + } + + protected createDevice(_deviceInfo: DeviceDetectionInfo): Promise { + return Promise.resolve(undefined); + } +} + +function makeLogger(): Logger { + const logger = mock(); + logger.child.mockReturnValue(logger); + return logger; +} + +function makeSettings(sources: { id: string, type: string, enabled?: boolean }[]): Settings { + const settings = new Settings(); + + for (const source of sources) { + settings.addDeviceSource(new DeviceSource(source.id, source.type, {}, source.enabled ?? true)); + } + + return settings; +} + +function makeFactoryMap(providersById: Record): Map> { + const factories = new Map>(); + + for (const [type, provider] of Object.entries(providersById)) { + factories.set(type, { + create: (_config: JsonObject) => provider, + }); + } + + return factories; +} + +describe('DeviceProviderManager', () => { + it('creates and initializes a provider for an enabled device source', async () => { + const provider = new RecordingDeviceProvider(); + const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); + + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + + expect(provider.startCalls).toBe(1); + expect(provider.stopCalls).toBe(0); + }); + + it('does not create a provider for a disabled device source', async () => { + const provider = new RecordingDeviceProvider(); + const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); + + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); + + expect(provider.startCalls).toBe(0); + }); + + it('stops a running provider once its device source is disabled', async () => { + const provider = new RecordingDeviceProvider(); + const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); + + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + expect(provider.startCalls).toBe(1); + + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); + expect(provider.stopCalls).toBe(1); + }); + + it('stops a running provider once its device source is removed from config', async () => { + const provider = new RecordingDeviceProvider(); + const manager = new DeviceProviderManager(makeFactoryMap({ virtual: provider }), makeLogger()); + + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + expect(provider.startCalls).toBe(1); + + await manager.loadFromSettings(makeSettings([])); + expect(provider.stopCalls).toBe(1); + }); + + it('serializes overlapping reload() calls so a disable immediately followed by a re-enable ends up running', async () => { + // Use two distinct provider instances so we can tell which one ends up "running" and + // reproduce the manager's internal bookkeeping the same way distinct factory.create() + // calls would in production. + const providerA = new RecordingDeviceProvider(); + const providerB = new RecordingDeviceProvider(); + + let creationCount = 0; + const factories = new Map>([ + ['virtual', { create: (): RecordingDeviceProvider => (creationCount++ === 0 ? providerA : providerB) }], + ]); + + const manager = new DeviceProviderManager(factories, makeLogger()); + + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + expect(providerA.startCalls).toBe(1); + + // Make the disabling reload()'s stop() call slow, so it's still in-flight when the very + // next reload() (re-enabling the same source) is triggered without awaiting the first. + let releaseStop: () => void = () => undefined; + providerA.setStopGate(new Promise((resolve) => { releaseStop = resolve; })); + + const disablePromise = manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: false }])); + const reenablePromise = manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + + // Let the slow stop() call finish now that both reload() calls have been queued. + releaseStop(); + + await disablePromise; + await reenablePromise; + + expect(providerA.stopCalls).toBe(1); + // Without serialization, the re-enable reload() would have (incorrectly) assumed + // providerA was still valid and never created providerB. + expect(providerB.startCalls).toBe(1); + }); + + it('initializes multiple newly-enabled device sources concurrently, not one at a time', async () => { + const slowProvider = new RecordingDeviceProvider(); + const fastProvider = new RecordingDeviceProvider(); + + let releaseSlowStart: () => void = () => undefined; + slowProvider.setStartGate(new Promise((resolve) => { releaseSlowStart = resolve; })); + + const manager = new DeviceProviderManager(makeFactoryMap({ slow: slowProvider, fast: fastProvider }), makeLogger()); + + // 'source-slow' is listed first - with a sequential loop, the still-pending slow provider + // would block 'source-fast' from even starting its own start() call. + const reloadPromise = manager.loadFromSettings(makeSettings([ + { id: 'source-slow', type: 'slow', enabled: true }, + { id: 'source-fast', type: 'fast', enabled: true }, + ])); + + await vi.waitFor(() => { + expect(slowProvider.startCalls).toBe(1); + expect(fastProvider.startCalls).toBe(1); + }); + + releaseSlowStart(); + await reloadPromise; + }); + + it('stopProviders stops all running providers and clears internal state', async () => { + const provider = new RecordingDeviceProvider(); + const provider2 = new RecordingDeviceProvider(); + let creationCount = 0; + const factories = new Map>([ + ['virtual', { create: (): RecordingDeviceProvider => (creationCount++ === 0 ? provider : provider2) }], + ]); + const manager = new DeviceProviderManager(factories, makeLogger()); + + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + await manager.stopProviders(); + + expect(provider.stopResolved).toBe(true); + + // After stopProviders(), a subsequent reload() on the SAME manager with the same enabled + // source must create a fresh provider - proving stopProviders() cleared its internal state. + await manager.loadFromSettings(makeSettings([{ id: 'source-1', type: 'virtual', enabled: true }])); + expect(provider2.startCalls).toBe(1); + }); +}); diff --git a/tests/unit/device/testDeviceProvider.ts b/tests/unit/device/testDeviceProvider.ts deleted file mode 100644 index 712baae1..00000000 --- a/tests/unit/device/testDeviceProvider.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {EventEmitter} from "events"; -import DeviceProvider from "../../../src/device/provider/deviceProvider.js"; -import Logger from "../../../src/logging/Logger.js"; -import DeviceManager from "../../../src/device/deviceManager.js"; - -export default class TestDeviceProvider extends DeviceProvider -{ - public constructor(deviceManager: DeviceManager, eventEmitter: EventEmitter, logger: Logger) - { - super(deviceManager, eventEmitter, logger); - } - - public override init(): Promise - { - // noop - return new Promise((resolve) => resolve()); - } -} diff --git a/tests/unit/device/transport/bleObserver.spec.ts b/tests/unit/device/transport/bleObserver.spec.ts index cf45f5b7..a36ee42b 100644 --- a/tests/unit/device/transport/bleObserver.spec.ts +++ b/tests/unit/device/transport/bleObserver.spec.ts @@ -11,6 +11,8 @@ const mockNoble = vi.hoisted(() => ({ waitForPoweredOnAsync: vi.fn(), startScanningAsync: vi.fn(), stopScanningAsync: vi.fn(), + removeAllListeners: vi.fn(), + stop: vi.fn(), })); vi.mock('@stoprocent/noble', () => ({ default: mockNoble })); @@ -55,73 +57,84 @@ describe('BleObserver', () => { it('registers a discover listener on noble', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); }); - it('registers a stateChange listener on noble', async () => { + it('registers a scanStop listener on noble', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); - expect(mockNoble.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); + expect(mockNoble.on).toHaveBeenCalledWith('scanStop', expect.any(Function)); }); - it('registers a scanStop listener on noble', async () => { + it('does not block start() while waiting for the BLE adapter to power on', async () => { + let resolvePowerOn: () => void = () => undefined; + mockNoble.waitForPoweredOnAsync.mockImplementation(() => new Promise((resolve) => { + resolvePowerOn = resolve; + })); + const observer = createObserver(); - await observer.init(); + await expect(observer.start()).resolves.toBeUndefined(); - expect(mockNoble.on).toHaveBeenCalledWith('scanStop', expect.any(Function)); + // start() already resolved, even though the power-on wait is still pending. + expect(mockNoble.startScanningAsync).not.toHaveBeenCalled(); + + resolvePowerOn(); + await vi.waitFor(() => { + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + }); }); - it('calls waitForPoweredOnAsync and startScanningAsync with the UART UUID', async () => { + it('calls startScanningAsync with the UART UUID once the adapter reports poweredOn', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); - expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledOnce(); - expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + }); expect(mockNoble.startScanningAsync).toHaveBeenCalledWith( ['6e400001b5a3f393e0a9e50e24dcca9e'], true, ); }); - it('does not call startScanningAsync a second time when stateChange poweredOn fires', async () => { - const observer = createObserver(); - await observer.init(); + it('retries waitForPoweredOnAsync in 10-minute chunks after a timeout until the adapter powers on', async () => { + mockNoble.waitForPoweredOnAsync + .mockRejectedValueOnce(new Error('Timeout waiting for Noble to be powered on')) + .mockResolvedValueOnce(undefined); - 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'); + await observer.start(); - expect(mockNoble.waitForPoweredOnAsync).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledTimes(2); + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + }); + + expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledWith(10 * 60 * 1000); }); - it('calls stopScanningAsync and allows retry when waitForPoweredOnAsync rejects', async () => { - mockNoble.waitForPoweredOnAsync.mockRejectedValue(new Error('BLE unavailable')); + it('calls stopScanningAsync and logs an error when startScanningAsync rejects', async () => { + mockNoble.startScanningAsync.mockRejectedValue(new Error('BLE unavailable')); const observer = createObserver(); - await expect(observer.init()).resolves.not.toThrow(); + await expect(observer.start()).resolves.not.toThrow(); - expect(mockNoble.stopScanningAsync).toHaveBeenCalledOnce(); - expect(mockLogger.error).toHaveBeenCalled(); + await vi.waitFor(() => { + expect(mockNoble.stopScanningAsync).toHaveBeenCalledOnce(); + expect(mockLogger.error).toHaveBeenCalled(); + }); }); it('logs info when scanStop event fires', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); getNobleListener('scanStop')?.(); @@ -139,7 +152,7 @@ describe('BleObserver', () => { it('ignores a peripheral whose RSSI is below the minimum threshold', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); getNobleListener('discover')?.(createPeripheral(-80, 'weak-device')); @@ -148,7 +161,7 @@ describe('BleObserver', () => { it('announces a peripheral whose RSSI is exactly at the minimum threshold (-70)', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); const peripheral = createPeripheral(-70, 'at-threshold'); getNobleListener('discover')?.(peripheral); @@ -161,7 +174,7 @@ describe('BleObserver', () => { it('announces a peripheral whose RSSI is above the minimum threshold', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); const peripheral = createPeripheral(-50, 'strong-device'); getNobleListener('discover')?.(peripheral); @@ -171,23 +184,118 @@ describe('BleObserver', () => { it('uses the peripheral id to build the DeviceId passed to announceDetectedDevice', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); const peripheral = createPeripheral(-60, 'abc-123'); getNobleListener('discover')?.(peripheral); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('abc-123') }), + expect.objectContaining({ detectionId: DeviceId.create('abc-123') }), ); }); it('logs a debug message when ignoring a weak-signal peripheral', async () => { const observer = createObserver(); - await observer.init(); + await observer.start(); getNobleListener('discover')?.(createPeripheral(-80, 'noisy-device')); expect(mockLogger.debug).toHaveBeenCalled(); }); }); + + describe('reference counting (multiple BleDeviceProviders sharing one observer)', () => { + it('does not touch noble at all when stop() is called without a matching start()', async () => { + const observer = createObserver(); + + await observer.stop(); + + expect(mockNoble.removeAllListeners).not.toHaveBeenCalled(); + expect(mockNoble.stop).not.toHaveBeenCalled(); + }); + + it('only wires up noble once when start() is called by two providers', async () => { + const observer = createObserver(); + + await observer.start(); + await observer.start(); + + expect(mockNoble.on).toHaveBeenCalledTimes(2); // discover + scanStop, not doubled + await vi.waitFor(() => { + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + }); + }); + + it('stop() cancels a pending power-on wait without ever starting to scan', async () => { + mockNoble.waitForPoweredOnAsync.mockImplementation(() => new Promise(() => { + // Never resolves on its own - only stop() should be able to end this wait. + })); + + const observer = createObserver(); + await observer.start(); + + await vi.waitFor(() => { + expect(mockNoble.waitForPoweredOnAsync).toHaveBeenCalledOnce(); + }); + + await observer.stop(); + + expect(mockNoble.startScanningAsync).not.toHaveBeenCalled(); + expect(mockNoble.removeAllListeners).toHaveBeenCalledOnce(); + expect(mockNoble.stop).toHaveBeenCalledOnce(); + }); + + it('keeps scanning after one of two providers stops', async () => { + const observer = createObserver(); + await observer.start(); + await observer.start(); + + await observer.stop(); + + expect(mockNoble.removeAllListeners).not.toHaveBeenCalled(); + expect(mockNoble.stop).not.toHaveBeenCalled(); + }); + + it('stops scanning only once every provider that started it has also stopped it', async () => { + const observer = createObserver(); + await observer.start(); + await observer.start(); + + await observer.stop(); + await observer.stop(); + + expect(mockNoble.removeAllListeners).toHaveBeenCalledOnce(); + expect(mockNoble.stop).toHaveBeenCalledOnce(); + }); + + it('does not go negative or re-stop noble when stop() is called more times than start()', async () => { + const observer = createObserver(); + await observer.start(); + + await observer.stop(); + mockNoble.removeAllListeners.mockClear(); + mockNoble.stop.mockClear(); + + await observer.stop(); + + expect(mockNoble.removeAllListeners).not.toHaveBeenCalled(); + expect(mockNoble.stop).not.toHaveBeenCalled(); + }); + + it('starts scanning again after a full stop and a fresh start() (e.g. the last provider stopped, then a new one started)', async () => { + const observer = createObserver(); + await observer.start(); + await observer.stop(); + + mockNoble.on.mockClear(); + mockNoble.startScanningAsync.mockClear(); + + await observer.start(); + + expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); + await vi.waitFor(() => { + expect(mockNoble.startScanningAsync).toHaveBeenCalledOnce(); + }); + }); + }); }); diff --git a/tests/unit/device/transport/serialPortObserver.spec.ts b/tests/unit/device/transport/serialPortObserver.spec.ts index c3e27676..934ba529 100644 --- a/tests/unit/device/transport/serialPortObserver.spec.ts +++ b/tests/unit/device/transport/serialPortObserver.spec.ts @@ -1,10 +1,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { SerialPort } from 'serialport'; +import { usb } from 'usb'; import DeviceManager from '../../../../src/device/deviceManager.js'; import Logger from '../../../../src/logging/Logger.js'; import SerialPortObserver from '../../../../src/device/transport/serialPortObserver.js'; import { DeviceId } from '../../../../src/device/deviceId.js'; +import { waitTicks } from '../../helper/async.js'; + +// usb is a real, module-wide EventTarget - without mocking it, addEventListener() calls made in +// one test would still be registered when the next test runs, eventually tripping Node's +// MaxListenersExceededWarning. +const mockUsb = vi.hoisted(() => ({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +})); + +vi.mock('usb', () => ({ usb: mockUsb })); type PortInfoLike = { path: string; @@ -38,6 +50,7 @@ describe('SerialPortObserver', () => { beforeEach(() => { vi.useFakeTimers(); + vi.resetAllMocks(); mockDeviceManager = mock(); mockLogger = mock(); @@ -98,7 +111,7 @@ describe('SerialPortObserver', () => { expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledOnce(); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN001'), portInfo: port }), + expect.objectContaining({ detectionId: DeviceId.create('SN001'), portInfo: port }), ); }); @@ -111,7 +124,7 @@ describe('SerialPortObserver', () => { const expectedSn = 'serial-0403-6001-port1'; expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create(expectedSn) }), + expect.objectContaining({ detectionId: DeviceId.create(expectedSn) }), ); }); @@ -138,7 +151,7 @@ describe('SerialPortObserver', () => { expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledOnce(); expect(mockDeviceManager.revokeDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN001') }), + expect.objectContaining({ detectionId: DeviceId.create('SN001') }), ); }); @@ -176,11 +189,94 @@ describe('SerialPortObserver', () => { expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledTimes(2); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN001') }), + expect.objectContaining({ detectionId: DeviceId.create('SN001') }), ); expect(mockDeviceManager.announceDetectedDevice).toHaveBeenCalledWith( - expect.objectContaining({ id: DeviceId.create('SN002') }), + expect.objectContaining({ detectionId: DeviceId.create('SN002') }), ); }); }); + + describe('reference counting (multiple SerialDeviceProviders sharing one observer)', () => { + it('does not run a discovery pass when stop() is called without a matching start()', async () => { + const listSpy = vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const observer = createObserver(); + + await observer.stop(); + + expect(listSpy).not.toHaveBeenCalled(); + }); + + it('only runs one discovery pass when start() is called by two providers', async () => { + const listSpy = vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const observer = createObserver(); + + await observer.start(); + await observer.start(); + + expect(listSpy).toHaveBeenCalledOnce(); + }); + + it('a concurrent start() call waits for the in-flight discovery to finish instead of returning early', async () => { + let resolveList: (ports: []) => void = () => undefined; + vi.spyOn(SerialPort, 'list').mockImplementation(() => new Promise((resolve) => { + resolveList = resolve; + })); + + const observer = createObserver(); + + let secondStartResolved = false; + const firstStart = observer.start(); + const secondStart = observer.start().then(() => { secondStartResolved = true; }); + + // Exactly 1 microtask tick: enough for start()'s own promise to settle (were the + // buggy early-return path taken) and notify our `.then()` below, but no more + await waitTicks(1); + + // Without waiting for the in-flight discovery, the second start() would have already + // resolved here, before the port list has even actually been fetched. + expect(secondStartResolved).toBe(false); + + resolveList([]); + await Promise.all([firstStart, secondStart]); + + expect(secondStartResolved).toBe(true); + }); + + it('keeps the USB listeners registered after one of two providers stops', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const removeListenerSpy = vi.spyOn(usb, 'removeEventListener'); + const observer = createObserver(); + await observer.start(); + await observer.start(); + + await observer.stop(); + + expect(removeListenerSpy).not.toHaveBeenCalled(); + }); + + it('removes the USB listeners only once every provider that started it has also stopped it', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const removeListenerSpy = vi.spyOn(usb, 'removeEventListener'); + const observer = createObserver(); + await observer.start(); + await observer.start(); + + await observer.stop(); + await observer.stop(); + + expect(removeListenerSpy).toHaveBeenCalledWith('connect', expect.any(Function)); + expect(removeListenerSpy).toHaveBeenCalledWith('disconnect', expect.any(Function)); + }); + + it('does not error when stop() is called more times than start()', async () => { + vi.spyOn(SerialPort, 'list').mockResolvedValue([]); + const observer = createObserver(); + await observer.start(); + + await observer.stop(); + + await expect(observer.stop()).resolves.not.toThrow(); + }); + }); }); diff --git a/tests/unit/helper/async.ts b/tests/unit/helper/async.ts new file mode 100644 index 00000000..d69f8bcc --- /dev/null +++ b/tests/unit/helper/async.ts @@ -0,0 +1,5 @@ +export async function waitTicks(ticks: number): Promise { + for (let i = 0; i < ticks; i++) { + await Promise.resolve(); // a tick + } +} diff --git a/tests/unit/settings/deviceSource.spec.ts b/tests/unit/settings/deviceSource.spec.ts new file mode 100644 index 00000000..6a35b6d0 --- /dev/null +++ b/tests/unit/settings/deviceSource.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { plainToInstance } from 'class-transformer'; +import DeviceSource from '../../../src/settings/deviceSource.js'; + +describe('DeviceSource', () => { + it('is enabled by default when constructed without an explicit value', () => { + const deviceSource = new DeviceSource('test-id', 'virtual', {}); + + expect(deviceSource.enabled).toBe(true); + }); + + it('can be constructed as disabled', () => { + const deviceSource = new DeviceSource('test-id', 'virtual', {}, false); + + expect(deviceSource.enabled).toBe(false); + }); + + it('is disabled when deserialized from plain JSON with "enabled": false', () => { + const deviceSource = plainToInstance(DeviceSource, { + id: 'test-id', + type: 'virtual', + config: {}, + enabled: false, + }); + + expect(deviceSource.enabled).toBe(false); + }); + + it('is enabled when deserialized from plain JSON with "enabled": true', () => { + const deviceSource = plainToInstance(DeviceSource, { + id: 'test-id', + type: 'virtual', + config: {}, + enabled: true, + }); + + expect(deviceSource.enabled).toBe(true); + }); +}); diff --git a/tests/unit/settings/knownDevice.spec.ts b/tests/unit/settings/knownDevice.spec.ts new file mode 100644 index 00000000..7ddaafc8 --- /dev/null +++ b/tests/unit/settings/knownDevice.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { plainToInstance } from 'class-transformer'; +import KnownDevice from '../../../src/settings/knownDevice.js'; +import { DeviceId } from '../../../src/device/deviceId.js'; + +describe('KnownDevice', () => { + it('is enabled by default when constructed without an explicit value', () => { + const knownDevice = new KnownDevice(DeviceId.create('test'), 'Test Device', 'randomGenerator', 'virtual'); + + expect(knownDevice.enabled).toBe(true); + }); + + it('can be constructed as disabled', () => { + const knownDevice = new KnownDevice(DeviceId.create('test'), 'Test Device', 'randomGenerator', 'virtual', {}, false); + + expect(knownDevice.enabled).toBe(false); + }); + + it('is disabled when deserialized from plain JSON with "enabled": false', () => { + const knownDevice = plainToInstance(KnownDevice, { + id: DeviceId.create('test'), + name: 'Test Device', + type: 'randomGenerator', + source: 'virtual', + config: {}, + enabled: false, + }); + + expect(knownDevice.enabled).toBe(false); + }); + + it('is enabled when deserialized from plain JSON with "enabled": true', () => { + const knownDevice = plainToInstance(KnownDevice, { + id: DeviceId.create('test'), + name: 'Test Device', + type: 'randomGenerator', + source: 'virtual', + config: {}, + enabled: true, + }); + + expect(knownDevice.enabled).toBe(true); + }); +}); diff --git a/tests/unit/settings/settings.spec.ts b/tests/unit/settings/settings.spec.ts new file mode 100644 index 00000000..6d4fce0d --- /dev/null +++ b/tests/unit/settings/settings.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { plainToInstance } from 'class-transformer'; +import { Value } from '@sinclair/typebox/value'; +import Settings, { SettingsSchema } from '../../../src/settings/settings.js'; +import { DeviceId } from '../../../src/device/deviceId.js'; + +describe('Settings', () => { + // KnownDevice/DeviceSource no longer default a missing 'enabled' themselves - that + // responsibility moved to SettingsManager.load()/PutSettingsController hydrating the schema's + // defaults into the plain JSON before it gets deserialized. This is the same hydration step, + // exercised directly against the schema/class pairing to guard the underlying invariant: + // legacy/hand-edited settings.json entries missing 'enabled' must still come out enabled. + it('defaults a known device missing "enabled" to enabled once hydrated against the schema', () => { + const plain = { + knownDevices: { + [DeviceId.create('test')]: { + id: DeviceId.create('test'), + name: 'Test Device', + type: 'randomGenerator', + source: 'virtual', + config: {}, + }, + }, + deviceSources: {}, + }; + + Value.Default(SettingsSchema, plain); + + const settings = plainToInstance(Settings, plain); + + expect(settings.getKnownDeviceById(DeviceId.create('test'))?.enabled).toBe(true); + }); + + it('defaults a device source missing "enabled" to enabled once hydrated against the schema', () => { + const plain = { + knownDevices: {}, + deviceSources: { + 'source-1': { + id: 'source-1', + type: 'virtual', + config: {}, + }, + }, + }; + + Value.Default(SettingsSchema, plain); + + const settings = plainToInstance(Settings, plain); + + expect(settings.getDeviceSources().get('source-1')?.enabled).toBe(true); + }); + + it('leaves an explicit "enabled": false untouched when hydrated', () => { + const plain = { + knownDevices: { + [DeviceId.create('test')]: { + id: DeviceId.create('test'), + name: 'Test Device', + type: 'randomGenerator', + source: 'virtual', + config: {}, + enabled: false, + }, + }, + deviceSources: {}, + }; + + Value.Default(SettingsSchema, plain); + + const settings = plainToInstance(Settings, plain); + + expect(settings.getKnownDeviceById(DeviceId.create('test'))?.enabled).toBe(false); + }); +});