diff --git a/src/device/knownDeviceRegistry.ts b/src/device/knownDeviceRegistry.ts new file mode 100644 index 00000000..5b8e3c41 --- /dev/null +++ b/src/device/knownDeviceRegistry.ts @@ -0,0 +1,46 @@ +import Settings from '../settings/settings.js'; +import KnownDevice from '../settings/knownDevice.js'; +import DeviceNameGenerator from './deviceNameGenerator.js'; +import Logger from '../logging/Logger.js'; +import { DeviceId } from './deviceId.js'; + +export default class KnownDeviceRegistry +{ + private readonly settings: Settings; + + private readonly nameGenerator: DeviceNameGenerator; + + private readonly logger: Logger; + + public constructor(settings: Settings, nameGenerator: DeviceNameGenerator, logger: Logger) { + this.settings = settings; + this.nameGenerator = nameGenerator; + this.logger = logger.child({ name: KnownDeviceRegistry.name }); + } + + public resolve(deviceId: DeviceId, type: string, provider: string, name?: string): KnownDevice { + const knownDevice = this.settings.getKnownDeviceById(deviceId); + + if (undefined !== knownDevice && knownDevice.type === type) { + // Already known (previously detected serial number) + this.logger.debug(`Device is already known: ${knownDevice.id}`); + return knownDevice; + } + + if (undefined !== knownDevice) { + this.logger.warn( + `Device id ${knownDevice.id} is known, but as type '${knownDevice.type}' instead of '${type}' - treating as an unknown device` + ); + } + + return new KnownDevice(deviceId, name ?? this.nameGenerator.generateName(), type, provider); + } + + public persist(knownDevice: KnownDevice): void { + if (this.settings.getKnownDeviceById(knownDevice.id) === knownDevice) { + return; + } + + this.settings.addKnownDevice(knownDevice); + } +} diff --git a/src/device/protocol/airotic/airoticDeviceProvider.ts b/src/device/protocol/airotic/airoticDeviceProvider.ts index 12f8730f..3a70b504 100644 --- a/src/device/protocol/airotic/airoticDeviceProvider.ts +++ b/src/device/protocol/airotic/airoticDeviceProvider.ts @@ -10,9 +10,7 @@ import AiroticProtocol from './airtonicProtocol.js'; import MessageResponseHandler from '../messageResponseHandler.js'; import StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; -import Settings from '../../../settings/settings.js'; -import KnownDevice from '../../../settings/knownDevice.js'; -import { DeviceId } from '../../deviceId.js'; +import KnownDeviceRegistry from '../../knownDeviceRegistry.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import FloatDeviceAttribute from '../../attribute/floatDeviceAttribute.js'; import BleDeviceProvider from '../../provider/bleDeviceProvider.js'; @@ -25,12 +23,12 @@ export default class AiroticDeviceProvider extends BleDeviceProvider { @@ -56,8 +54,10 @@ export default class AiroticDeviceProvider extends BleDeviceProvider { const attributes = this.getAttributes(initialStatus); + const knownDevice = this.knownDeviceRegistry.resolve(deviceId, 'estim2b', provider); + + // KnownDevice is not persisted as we cannot determine a unique device id for the estim2b device, + // so we cannot reliably identify it on future connections. return new Estim2bDevice( - deviceId, - this.nameGenerator.generateName(), + knownDevice.id, + knownDevice.name, provider, this.dateFactory.now(), true, diff --git a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts index bb131470..a13de621 100644 --- a/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts +++ b/src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts @@ -1,6 +1,4 @@ -import Settings from '../../../settings/settings.js'; -import KnownDevice from '../../../settings/knownDevice.js'; -import DeviceNameGenerator from '../../deviceNameGenerator.js'; +import KnownDeviceRegistry from '../../knownDeviceRegistry.js'; import GenericSlvCtrlPlusDevice from './genericSlvCtrlPlusDevice.js'; import DateFactory from '../../../factory/dateFactory.js'; import DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; @@ -19,30 +17,26 @@ export default class SlvCtrlPlusDeviceFactory protected readonly eventEmitterFactory: EventEmitterFactory; - private readonly settings: Settings; - - private readonly nameGenerator: DeviceNameGenerator; + private readonly knownDeviceRegistry: KnownDeviceRegistry; private readonly logger: Logger; public constructor( dateFactory: DateFactory, eventEmitterFactory: EventEmitterFactory, - settings: Settings, - nameGenerator: DeviceNameGenerator, + knownDeviceRegistry: KnownDeviceRegistry, logger: Logger ) { this.dateFactory = dateFactory; this.eventEmitterFactory = eventEmitterFactory; - this.settings = settings; - this.nameGenerator = nameGenerator; + this.knownDeviceRegistry = knownDeviceRegistry; this.logger = logger.child({ name: SlvCtrlPlusDeviceFactory.name }); } public async create(deviceId: DeviceId, transport: DeviceBidirectionalTransport, provider: string): Promise { const deviceInfo = await this.getDeviceInfo(transport); const protocol = deviceInfo.protocol; - const knownDevice = this.createKnownDevice(deviceId, deviceInfo.deviceType, provider); + const knownDevice = this.knownDeviceRegistry.resolve(deviceId, deviceInfo.deviceType, provider); const deviceAttributes = await this.getAttributes(transport, protocol); const device = new GenericSlvCtrlPlusDevice( @@ -60,7 +54,7 @@ export default class SlvCtrlPlusDeviceFactory this.logger, ); - this.settings.addKnownDevice(knownDevice); + this.knownDeviceRegistry.persist(knownDevice); return device; } @@ -125,22 +119,4 @@ export default class SlvCtrlPlusDeviceFactory return new SlvCtrlProtocolV1(); } - - private createKnownDevice(deviceId: DeviceId, deviceType: string, provider: string): KnownDevice { - const knownDevice = this.settings.getKnownDeviceById(deviceId) - - if (undefined !== knownDevice) { - // Return already existing device if already known (previously detected serial number) - this.logger.debug(`Device is already known: ${knownDevice.id}`); - return knownDevice; - } - - // Create a new device and return if not yet known (new serial number) - return new KnownDevice( - deviceId, - this.nameGenerator.generateName(), - deviceType, - provider - ); - } } diff --git a/src/device/protocol/zc95/zc95DeviceFactory.ts b/src/device/protocol/zc95/zc95DeviceFactory.ts index 4d13e840..51b8b43d 100644 --- a/src/device/protocol/zc95/zc95DeviceFactory.ts +++ b/src/device/protocol/zc95/zc95DeviceFactory.ts @@ -1,5 +1,4 @@ -import Settings from '../../../settings/settings.js'; -import DeviceNameGenerator from '../../deviceNameGenerator.js'; +import KnownDeviceRegistry from '../../knownDeviceRegistry.js'; import DateFactory from '../../../factory/dateFactory.js'; import Logger from '../../../logging/Logger.js'; import Zc95Device, { Zc95DeviceAttributes } from './zc95Device.js'; @@ -14,7 +13,6 @@ import MessageResponseHandler from '../messageResponseHandler.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; import { logError } from '../../../util/error.js'; import { DeviceId } from '../../deviceId.js'; -import KnownDevice from '../../../settings/knownDevice.js'; export default class Zc95DeviceFactory { @@ -22,23 +20,19 @@ export default class Zc95DeviceFactory private readonly eventEmitterFactory: EventEmitterFactory; - private readonly settings: Settings; - - private readonly nameGenerator: DeviceNameGenerator; + private readonly knownDeviceRegistry: KnownDeviceRegistry; private readonly logger: Logger; public constructor( dateFactory: DateFactory, eventEmitterFactory: EventEmitterFactory, - settings: Settings, - nameGenerator: DeviceNameGenerator, + knownDeviceRegistry: KnownDeviceRegistry, logger: Logger ) { this.dateFactory = dateFactory; this.eventEmitterFactory = eventEmitterFactory; - this.settings = settings; - this.nameGenerator = nameGenerator; + this.knownDeviceRegistry = knownDeviceRegistry; this.logger = logger; } @@ -62,8 +56,9 @@ export default class Zc95DeviceFactory ); // We only receive serial no. info for ZC95 devices with fw >=2.0 - const knownDevice = this.createKnownDevice( + const knownDevice = this.knownDeviceRegistry.resolve( versionDetails.SerialNo !== undefined ? DeviceId.create(versionDetails.SerialNo) : deviceId, + 'zc95', provider, ); @@ -86,7 +81,7 @@ export default class Zc95DeviceFactory // Only store the known device if we have a deterministic device id based on serial no. info of the zc95 fw if (versionDetails.SerialNo !== undefined) { - this.settings.addKnownDevice(knownDevice); + this.knownDeviceRegistry.persist(knownDevice); } return device; @@ -110,22 +105,4 @@ export default class Zc95DeviceFactory patternStarted: patternStartedAttr, }; } - - private createKnownDevice(deviceId: DeviceId, provider: string): KnownDevice { - const knownDevice = this.settings.getKnownDeviceById(deviceId) - - if (undefined !== knownDevice) { - // Return already existing device if already known (previously detected serial number) - this.logger.debug(`Device is already known: ${knownDevice.id}`); - return knownDevice; - } - - // Create a new device and return if not yet known (new serial number) - return new KnownDevice( - deviceId, - this.nameGenerator.generateName(), - 'zc95', - provider - ); - } } diff --git a/src/serviceMap.ts b/src/serviceMap.ts index e27d557c..3554fc7b 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -50,6 +50,7 @@ import Zc95SerialDeviceProvider from './device/protocol/zc95/zc95SerialDevicePro import EStim2bSerialDeviceProvider from './device/protocol/estim2b/estim2bSerialDeviceProvider.js'; import ButtplugIoWebsocketDeviceProvider from './device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; import AiroticDeviceProvider from './device/protocol/airotic/airoticDeviceProvider.js'; +import KnownDeviceRegistry from './device/knownDeviceRegistry.js'; type ServiceMap = { @@ -76,6 +77,7 @@ type ServiceMap = { 'device.virtual.provider': VirtualDeviceProvider, 'device.virtual.factory': VirtualDeviceFactory, 'device.uniqueNameGenerator': DeviceNameGenerator, + 'device.knownDeviceRegistry': KnownDeviceRegistry, 'device.updater': DeviceUpdaterInterface, 'device.observer.serial': SerialPortObserver, 'device.observer.ble': BleObserver, diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index ffe303ac..df31d59a 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -40,6 +40,7 @@ import BleObserver from '../device/transport/bleObserver.js'; import AiroticDeviceProvider from '../device/protocol/airotic/airoticDeviceProvider.js'; import DeviceProviderFactory from '../device/provider/deviceProviderFactory.js'; import { DeviceId } from '../device/deviceId.js'; +import KnownDeviceRegistry from '../device/knownDeviceRegistry.js'; export default class DeviceServiceProvider implements ServiceProvider { public register(container: Pimple): void { @@ -90,34 +91,37 @@ export default class DeviceServiceProvider implements ServiceProvider new KnownDeviceRegistry( + container.get('settings'), + container.get('device.uniqueNameGenerator'), + container.get('logger.default'), + )); + container.set('device.serial.factory.slvCtrlPlus', () => new SlvCtrlPlusDeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), - container.get('settings'), - container.get('device.uniqueNameGenerator'), + container.get('device.knownDeviceRegistry'), container.get('logger.default'), )); container.set('device.serial.factory.buttplugIo', () => new ButtplugIoDeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), - container.get('settings'), + container.get('device.knownDeviceRegistry'), container.get('logger.default'), )); container.set('device.factory.zc95', () => new Zc95DeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), - container.get('settings'), - container.get('device.uniqueNameGenerator'), + container.get('device.knownDeviceRegistry'), container.get('logger.default'), )); container.set('device.factory.estim2b', () => new Estim2bDeviceFactory( container.get('factory.date'), container.get('factory.eventEmitter'), - container.get('settings'), - container.get('device.uniqueNameGenerator'), + container.get('device.knownDeviceRegistry'), container.get('logger.default'), )); @@ -229,7 +233,7 @@ export default class DeviceServiceProvider implements ServiceProvider { + let mockSettings: ReturnType>; + let mockNameGenerator: ReturnType>; + let mockLogger: ReturnType>; + let registry: KnownDeviceRegistry; + + beforeEach(() => { + mockSettings = mock(); + mockNameGenerator = mock(); + mockNameGenerator.generateName.mockReturnValue('Generated Name'); + mockLogger = mock(); + mockLogger.child.mockReturnValue(mockLogger); + + registry = new KnownDeviceRegistry(mockSettings, mockNameGenerator, mockLogger); + }); + + describe('resolve', () => { + it('returns the already known device without persisting anything', () => { + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Existing Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + const result = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); + + expect(result).toBe(existingKnownDevice); + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('builds a new, not-yet-persisted KnownDevice when none exists', () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const deviceId = DeviceId.create('device-1'); + const result = registry.resolve(deviceId, 'testType', 'testProvider'); + + expect(result).toMatchObject({ id: deviceId, type: 'testType', source: 'testProvider' }); + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('uses the provided name over the generated one for a new device', () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const result = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider', 'Explicit Name'); + + expect(result.name).toBe('Explicit Name'); + expect(mockNameGenerator.generateName).not.toHaveBeenCalled(); + }); + + it('falls back to a generated name when none is provided', () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const result = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); + + expect(result.name).toBe('Generated Name'); + }); + + it('builds a new identity when the stored device with the same id has a different type', () => { + const deviceId = DeviceId.create('device-1'); + const existingKnownDevice = new KnownDevice(deviceId, 'Existing Name', 'otherType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + const result = registry.resolve(deviceId, 'testType', 'testProvider'); + + expect(result).not.toBe(existingKnownDevice); + expect(result).toMatchObject({ id: deviceId, type: 'testType', name: 'Generated Name' }); + }); + }); + + describe('persist', () => { + it('delegates to settings.addKnownDevice for a genuinely new identity', () => { + const knownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + registry.persist(knownDevice); + + expect(mockSettings.addKnownDevice).toHaveBeenCalledOnce(); + expect(mockSettings.addKnownDevice).toHaveBeenCalledWith(knownDevice); + }); + + it('does not touch settings when persisting an already-known, unchanged identity', () => { + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + registry.persist(existingKnownDevice); + + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('persists when passed a different KnownDevice instance for an already-known id', () => { + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + const differentInstance = new KnownDevice(DeviceId.create('device-1'), 'Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + registry.persist(differentInstance); + + expect(mockSettings.addKnownDevice).toHaveBeenCalledWith(differentInstance); + }); + }); + + describe('resolve + persist integration', () => { + it('does not write to settings when reconnecting an already-known device', () => { + const existingKnownDevice = new KnownDevice(DeviceId.create('device-1'), 'Existing Name', 'testType', 'testProvider'); + mockSettings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + const knownDevice = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); + registry.persist(knownDevice); + + expect(mockSettings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('writes to settings exactly once when connecting a genuinely new device', () => { + mockSettings.getKnownDeviceById.mockReturnValue(undefined); + + const knownDevice = registry.resolve(DeviceId.create('device-1'), 'testType', 'testProvider'); + registry.persist(knownDevice); + + expect(mockSettings.addKnownDevice).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/tests/unit/device/protocol/zc95/zc95DeviceFactory.spec.ts b/tests/unit/device/protocol/zc95/zc95DeviceFactory.spec.ts index 5bc67f95..343b61b1 100644 --- a/tests/unit/device/protocol/zc95/zc95DeviceFactory.spec.ts +++ b/tests/unit/device/protocol/zc95/zc95DeviceFactory.spec.ts @@ -1,9 +1,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { mock, MockProxy } from 'vitest-mock-extended'; import Zc95DeviceFactory from '../../../../../src/device/protocol/zc95/zc95DeviceFactory.js'; -import Settings from '../../../../../src/settings/settings.js'; +import KnownDeviceRegistry from '../../../../../src/device/knownDeviceRegistry.js'; import KnownDevice from '../../../../../src/settings/knownDevice.js'; -import DeviceNameGenerator from '../../../../../src/device/deviceNameGenerator.js'; import DateFactory from '../../../../../src/factory/dateFactory.js'; import EventEmitterFactory from '../../../../../src/factory/eventEmitterFactory.js'; import Logger from '../../../../../src/logging/Logger.js'; @@ -19,8 +18,7 @@ import { MsgAndResponseIdentifier } from '../../../../../src/device/protocol/zc9 import { DeviceId } from '../../../../../src/device/deviceId.js'; describe('Zc95DeviceFactory', () => { - let settings: MockProxy; - let nameGenerator: MockProxy; + let knownDeviceRegistry: MockProxy; let eventEmitterFactory: EventEmitterFactory; let dateFactory: DateFactory; let logger: MockProxy; @@ -53,12 +51,11 @@ describe('Zc95DeviceFactory', () => { } function createFactory(): Zc95DeviceFactory { - return new Zc95DeviceFactory(dateFactory, eventEmitterFactory, settings, nameGenerator, logger); + return new Zc95DeviceFactory(dateFactory, eventEmitterFactory, knownDeviceRegistry, logger); } beforeEach(() => { - settings = mock(); - nameGenerator = mock(); + knownDeviceRegistry = mock(); eventEmitterFactory = new EventEmitterFactory(); dateFactory = new DateFactory(); logger = mock(); @@ -69,8 +66,9 @@ describe('Zc95DeviceFactory', () => { mockMsgFactory.createGetPatterns.mockReturnValue(fakeGetPatternsMsg); mockMsgHandler.send.mockResolvedValue(patternsResponse); - nameGenerator.generateName.mockReturnValue('Generated Name'); - settings.getKnownDeviceById.mockReturnValue(undefined); + knownDeviceRegistry.resolve.mockImplementation( + (deviceId, type, provider) => new KnownDevice(deviceId, 'Generated Name', type, provider) + ); }); it('uses the transport device id and does not persist a known device when no SerialNo is provided (fw <2.0)', async () => { @@ -88,7 +86,8 @@ describe('Zc95DeviceFactory', () => { expect(device.getDeviceId).toStrictEqual(transportDeviceId); expect(device.getDeviceName).toStrictEqual('Generated Name'); - expect(settings.addKnownDevice).not.toHaveBeenCalled(); + expect(knownDeviceRegistry.resolve).toHaveBeenCalledWith(transportDeviceId, 'zc95', provider); + expect(knownDeviceRegistry.persist).not.toHaveBeenCalled(); }); it('derives a deterministic device id from SerialNo and persists it as a known device when SerialNo is provided', async () => { @@ -108,9 +107,10 @@ describe('Zc95DeviceFactory', () => { expect(device.getDeviceId).toStrictEqual(expectedDeviceId); expect(device.getDeviceId).not.toStrictEqual(transportDeviceId); expect(device.getDeviceName).toStrictEqual('Generated Name'); - expect(settings.addKnownDevice).toHaveBeenCalledTimes(1); + expect(knownDeviceRegistry.resolve).toHaveBeenCalledWith(expectedDeviceId, 'zc95', provider); + expect(knownDeviceRegistry.persist).toHaveBeenCalledTimes(1); - const persisted = settings.addKnownDevice.mock.calls[0][0]; + const persisted = knownDeviceRegistry.persist.mock.calls[0][0]; expect(persisted.id).toStrictEqual(expectedDeviceId); expect(persisted.type).toStrictEqual('zc95'); expect(persisted.source).toStrictEqual(provider); @@ -123,7 +123,7 @@ describe('Zc95DeviceFactory', () => { 'zc95', provider, ); - settings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + knownDeviceRegistry.resolve.mockReturnValue(existingKnownDevice); const factory = createFactory(); @@ -139,8 +139,7 @@ describe('Zc95DeviceFactory', () => { expect(device.getDeviceId).toStrictEqual(existingKnownDevice.id); expect(device.getDeviceName).toStrictEqual('Existing Device Name'); - expect(nameGenerator.generateName).not.toHaveBeenCalled(); - expect(settings.addKnownDevice).toHaveBeenCalledWith(existingKnownDevice); + expect(knownDeviceRegistry.persist).toHaveBeenCalledWith(existingKnownDevice); }); it('throws and does not create a device when retrieving the pattern list fails', async () => { @@ -160,6 +159,6 @@ describe('Zc95DeviceFactory', () => { ), ).rejects.toThrow('timeout'); - expect(settings.addKnownDevice).not.toHaveBeenCalled(); + expect(knownDeviceRegistry.persist).not.toHaveBeenCalled(); }); });