diff --git a/src/device/protocol/zc95/zc95DeviceFactory.ts b/src/device/protocol/zc95/zc95DeviceFactory.ts index 3b02f57..4d13e84 100644 --- a/src/device/protocol/zc95/zc95DeviceFactory.ts +++ b/src/device/protocol/zc95/zc95DeviceFactory.ts @@ -14,6 +14,7 @@ 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 { @@ -60,12 +61,15 @@ export default class Zc95DeviceFactory availablePatterns.map((pattern) => ({ key: Int.from(pattern.Id), value: pattern.Name })) ); - // Not relevant until https://github.com/CrashOverride85/zc95/issues/151 is resolved - // this.settings.addKnownDevice(knownDevice); + // We only receive serial no. info for ZC95 devices with fw >=2.0 + const knownDevice = this.createKnownDevice( + versionDetails.SerialNo !== undefined ? DeviceId.create(versionDetails.SerialNo) : deviceId, + provider, + ); - return new Zc95Device( - deviceId, - this.nameGenerator.generateName(), + const device = new Zc95Device( + knownDevice.id, + knownDevice.name, provider, this.dateFactory.now(), versionDetails.ZC95, @@ -79,6 +83,13 @@ export default class Zc95DeviceFactory this.eventEmitterFactory.create(), this.logger, ); + + // 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); + } + + return device; } catch (e) { logError(this.logger, 'Could not retrieve pattern list', e); throw e; @@ -99,4 +110,22 @@ 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/device/protocol/zc95/zc95MessageFactory.ts b/src/device/protocol/zc95/zc95MessageFactory.ts index c26fee7..fb74047 100644 --- a/src/device/protocol/zc95/zc95MessageFactory.ts +++ b/src/device/protocol/zc95/zc95MessageFactory.ts @@ -95,6 +95,7 @@ export interface VersionMsgResponse extends MsgResponse ZC95: string; WsMajor: number; WsMinor: number; + SerialNo?: string; } interface PatternDetail diff --git a/tests/unit/device/protocol/zc95/zc95DeviceFactory.spec.ts b/tests/unit/device/protocol/zc95/zc95DeviceFactory.spec.ts new file mode 100644 index 0000000..5bc67f9 --- /dev/null +++ b/tests/unit/device/protocol/zc95/zc95DeviceFactory.spec.ts @@ -0,0 +1,165 @@ +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 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'; +import Zc95Protocol from '../../../../../src/device/protocol/zc95/zc95Protocol.js'; +import DeviceBidirectionalTransport from '../../../../../src/device/transport/deviceBidirectionalTransport.js'; +import MessageResponseHandler from '../../../../../src/device/protocol/messageResponseHandler.js'; +import Zc95MessageFactory, { + GetPatternsMsg, + PatternsMsgResponse, + VersionMsgResponse, +} from '../../../../../src/device/protocol/zc95/zc95MessageFactory.js'; +import { MsgAndResponseIdentifier } from '../../../../../src/device/protocol/zc95/zc95Protocol.js'; +import { DeviceId } from '../../../../../src/device/deviceId.js'; + +describe('Zc95DeviceFactory', () => { + let settings: MockProxy; + let nameGenerator: MockProxy; + let eventEmitterFactory: EventEmitterFactory; + let dateFactory: DateFactory; + let logger: MockProxy; + let mockProtocol: MockProxy; + let mockTransport: MockProxy; + let mockMsgFactory: MockProxy; + let mockMsgHandler: MockProxy>; + + const fakeGetPatternsMsg = {} as MsgAndResponseIdentifier; + const patternsResponse: PatternsMsgResponse = { + Type: 'PatternList', + MsgId: 1, + Result: 'OK', + Patterns: [{ Type: 'PatternDetail', Id: 0, Name: 'Pattern A' }], + }; + + const transportDeviceId = DeviceId.create('transport-device-id'); + const provider = 'usb'; + + function baseVersionDetails(overrides: Partial = {}): VersionMsgResponse { + return { + Type: 'VersionDetails', + MsgId: 1, + Result: 'OK', + ZC95: '2.0.0', + WsMajor: 1, + WsMinor: 0, + ...overrides, + }; + } + + function createFactory(): Zc95DeviceFactory { + return new Zc95DeviceFactory(dateFactory, eventEmitterFactory, settings, nameGenerator, logger); + } + + beforeEach(() => { + settings = mock(); + nameGenerator = mock(); + eventEmitterFactory = new EventEmitterFactory(); + dateFactory = new DateFactory(); + logger = mock(); + mockProtocol = mock(); + mockTransport = mock(); + mockMsgFactory = mock(); + mockMsgHandler = mock>(); + + mockMsgFactory.createGetPatterns.mockReturnValue(fakeGetPatternsMsg); + mockMsgHandler.send.mockResolvedValue(patternsResponse); + nameGenerator.generateName.mockReturnValue('Generated Name'); + settings.getKnownDeviceById.mockReturnValue(undefined); + }); + + it('uses the transport device id and does not persist a known device when no SerialNo is provided (fw <2.0)', async () => { + const factory = createFactory(); + + const device = await factory.create( + transportDeviceId, + baseVersionDetails({ SerialNo: undefined }), + mockProtocol, + mockTransport, + mockMsgFactory, + mockMsgHandler, + provider, + ); + + expect(device.getDeviceId).toStrictEqual(transportDeviceId); + expect(device.getDeviceName).toStrictEqual('Generated Name'); + expect(settings.addKnownDevice).not.toHaveBeenCalled(); + }); + + it('derives a deterministic device id from SerialNo and persists it as a known device when SerialNo is provided', async () => { + const factory = createFactory(); + const expectedDeviceId = DeviceId.create('ZC95-SERIAL-123'); + + const device = await factory.create( + transportDeviceId, + baseVersionDetails({ SerialNo: 'ZC95-SERIAL-123' }), + mockProtocol, + mockTransport, + mockMsgFactory, + mockMsgHandler, + provider, + ); + + expect(device.getDeviceId).toStrictEqual(expectedDeviceId); + expect(device.getDeviceId).not.toStrictEqual(transportDeviceId); + expect(device.getDeviceName).toStrictEqual('Generated Name'); + expect(settings.addKnownDevice).toHaveBeenCalledTimes(1); + + const persisted = settings.addKnownDevice.mock.calls[0][0]; + expect(persisted.id).toStrictEqual(expectedDeviceId); + expect(persisted.type).toStrictEqual('zc95'); + expect(persisted.source).toStrictEqual(provider); + }); + + it('reuses an already known device (id and name) when the derived serial-based id is already known', async () => { + const existingKnownDevice = new KnownDevice( + DeviceId.create('ZC95-SERIAL-123'), + 'Existing Device Name', + 'zc95', + provider, + ); + settings.getKnownDeviceById.mockReturnValue(existingKnownDevice); + + const factory = createFactory(); + + const device = await factory.create( + transportDeviceId, + baseVersionDetails({ SerialNo: 'ZC95-SERIAL-123' }), + mockProtocol, + mockTransport, + mockMsgFactory, + mockMsgHandler, + provider, + ); + + expect(device.getDeviceId).toStrictEqual(existingKnownDevice.id); + expect(device.getDeviceName).toStrictEqual('Existing Device Name'); + expect(nameGenerator.generateName).not.toHaveBeenCalled(); + expect(settings.addKnownDevice).toHaveBeenCalledWith(existingKnownDevice); + }); + + it('throws and does not create a device when retrieving the pattern list fails', async () => { + mockMsgHandler.send.mockRejectedValue(new Error('timeout')); + + const factory = createFactory(); + + await expect( + factory.create( + transportDeviceId, + baseVersionDetails({ SerialNo: undefined }), + mockProtocol, + mockTransport, + mockMsgFactory, + mockMsgHandler, + provider, + ), + ).rejects.toThrow('timeout'); + + expect(settings.addKnownDevice).not.toHaveBeenCalled(); + }); +});