diff --git a/src/app.ts b/src/app.ts index e6d4454c..976544db 100644 --- a/src/app.ts +++ b/src/app.ts @@ -109,8 +109,8 @@ const configureWebsocket = (io: WebsocketServer, container: Container { - void deviceUpdateHandler.handle(data); + socket.on(WebSocketEvent.deviceUpdateReceived, (data, ack) => { + void deviceUpdateHandler.handle(data, ack); }); }); diff --git a/src/automation/scriptVmFactory.ts b/src/automation/scriptVmFactory.ts index 13d7adae..6dc3353e 100644 --- a/src/automation/scriptVmFactory.ts +++ b/src/automation/scriptVmFactory.ts @@ -2,7 +2,6 @@ import ivm from 'isolated-vm'; import { EventEmitter } from 'events'; import { transform } from 'sucrase'; import type DeviceRepositoryInterface from '../repository/deviceRepositoryInterface.js'; -import type { AttributeValue } from '../device/attribute/deviceAttribute.js'; import type { AnyDevice } from '../device/device.js'; import type Logger from '../logging/Logger.js'; import type { ScriptVmSignalEvents } from './scriptVm.js'; @@ -23,8 +22,8 @@ export const deviceToBridgeJson = (device: AnyDevice): string => { }; const VM_REF_LOG = '__log'; -const VM_REF_GET_ATTRIBUTE = '__getAttribute'; -const VM_REF_SET_ATTRIBUTE = '__setAttribute'; +const VM_REF_GET_DEVICE_DATA = '__getDeviceData'; +const VM_REF_UPDATE_DEVICE_DATA = '__updateDeviceData'; const VM_REF_GET_DEVICE_JSON = '__getDeviceJson'; const VM_REF_GET_DEVICES_JSON = '__getDevicesJson'; const VM_REF_DISPATCH_EVENT = '__dispatchEvent'; @@ -80,10 +79,10 @@ var console = { trace: (...args) => ${VM_REF_LOG}.applySync(undefined, ['trace', __formatLogArgs(args)], { arguments: { copy: true } }), }; -async function __resolveAttr(deviceId, attributeName) { - const json = await ${VM_REF_GET_ATTRIBUTE}.apply( +async function __resolveDeviceData(deviceId) { + const json = await ${VM_REF_GET_DEVICE_DATA}.apply( undefined, - [deviceId, attributeName], + [deviceId], { arguments: { copy: true }, result: { copy: true, promise: true } } ); return json !== null ? JSON.parse(json) : null; @@ -93,14 +92,13 @@ function __createDeviceProxy(d) { return Object.freeze({ get getDeviceId() { return d.id; }, get getDeviceName() { return d.name; }, - async getAttribute(attributeName) { - const attr = await __resolveAttr(d.id, attributeName); - return attr ?? undefined; + async getDeviceData() { + return await __resolveDeviceData(d.id); }, - async setAttribute(attributeName, value) { - await ${VM_REF_SET_ATTRIBUTE}.apply( + async updateDeviceData(update) { + await ${VM_REF_UPDATE_DEVICE_DATA}.apply( undefined, - [d.id, attributeName, value], + [d.id, update], { arguments: { copy: true }, result: { promise: true } } ); } @@ -222,12 +220,10 @@ export default class ScriptVmFactory onConsoleLog(msgStr); })); - await jail.set(VM_REF_GET_ATTRIBUTE, new ivm.Reference(async (deviceId: DeviceId, attrName: string): Promise => { + await jail.set(VM_REF_GET_DEVICE_DATA, new ivm.Reference((deviceId: DeviceId): string | null => { const dev = this.deviceRepository.getById(deviceId); if (dev === null) return null; - const attr = await dev.getAttribute(attrName); - if (attr === undefined) return null; - return JSON.stringify({ value: attr.value ?? null, name: attr.name, label: attr.label ?? null, modifier: attr.modifier, type: attr.getType() }); + return JSON.stringify(dev.getDeviceData()); })); await jail.set(VM_REF_GET_DEVICE_JSON, new ivm.Reference((deviceId: DeviceId): string | null => { @@ -236,10 +232,11 @@ export default class ScriptVmFactory return deviceToBridgeJson(dev); })); - await jail.set(VM_REF_SET_ATTRIBUTE, new ivm.Reference(async (deviceId: DeviceId, attrName: string, value: AttributeValue): Promise => { + await jail.set(VM_REF_UPDATE_DEVICE_DATA, new ivm.Reference(async (deviceId: DeviceId, update: Record): Promise => { const dev = this.deviceRepository.getById(deviceId); if (dev === null) throw new Error(`Device not found: ${deviceId}`); - await dev.setAttribute(attrName, value); + const result = await dev.updateDeviceData(update); + return JSON.stringify(result); })); await jail.set(VM_REF_GET_DEVICES_JSON, new ivm.Reference((): string => { diff --git a/src/controller/patchDeviceController.ts b/src/controller/patchDeviceController.ts index 0dbc1313..bf60061d 100644 --- a/src/controller/patchDeviceController.ts +++ b/src/controller/patchDeviceController.ts @@ -6,6 +6,7 @@ import type DeviceUpdaterInterface from '../device/updater/deviceUpdaterInterfac import type { DeviceData } from '../device/device.js'; import type { DeviceId } from '../device/deviceId.js'; import { StatusCodes } from 'http-status-codes'; +import DeviceDataValidationError from '../device/deviceDataValidationError.js'; type PatchDeviceRequest = Request<{ deviceId: DeviceId }, unknown, DeviceData>; @@ -32,13 +33,16 @@ export default class PatchDeviceController implements ControllerInterface } try { - await this.deviceUpdater.update(device, req.body); + const result = await this.deviceUpdater.update(device, req.body); + res.status(StatusCodes.OK).json(result); } catch (e: unknown) { + if (e instanceof DeviceDataValidationError) { + res.status(StatusCodes.BAD_REQUEST).json({ validationErrors: e.validationErrors }); + return; + } + const error = BaseError.normalize(e); res.status(StatusCodes.INTERNAL_SERVER_ERROR).send(error.message); - return; } - - res.sendStatus(StatusCodes.ACCEPTED); } } diff --git a/src/device/attribute/attributeSchema.ts b/src/device/attribute/attributeSchema.ts new file mode 100644 index 00000000..9d68a819 --- /dev/null +++ b/src/device/attribute/attributeSchema.ts @@ -0,0 +1,76 @@ +import { Type } from '@sinclair/typebox'; +import type { TInteger, TBoolean, TString } from '@sinclair/typebox'; + +/** + * A single labeled member of an integer "list" attribute, e.g. `{ value: 0, label: 'Sine' }`. + * Rendered on the wire as a JSON Schema `oneOf` entry: `{ const: 0, 'x-label': 'Sine' }`. + */ +export type IntChoice = { value: number, label: string }; + +type CommonAttributeOptions = { + label: string; + group?: number; + readOnly?: boolean; + writeOnly?: boolean; +}; + +const modifierKeywords = (options: CommonAttributeOptions): { readOnly?: true, writeOnly?: true } => ({ + ...(options.readOnly === true ? { readOnly: true } : {}), + ...(options.writeOnly === true ? { writeOnly: true } : {}), +}); + +const commonKeywords = (options: CommonAttributeOptions): Record => ({ + 'x-label': options.label, + ...(options.group !== undefined ? { 'x-group': options.group } : {}), + ...modifierKeywords(options), +}); + +/** A bounded, steppable integer attribute (e.g. a range slider). */ +export const rangeIntProperty = (options: CommonAttributeOptions & { + min: number; + max: number; + incrementStep: number; + uom?: string; + default?: number; +}): TInteger => Type.Integer({ + ...commonKeywords(options), + ...(options.uom !== undefined ? { 'x-uom': options.uom } : {}), + 'minimum': options.min, + 'maximum': options.max, + 'x-increment-step': options.incrementStep, + ...(options.default !== undefined ? { default: options.default } : {}), +}); + +/** A plain, unbounded integer attribute. */ +export const intProperty = (options: CommonAttributeOptions & { + uom?: string; + default?: number; +}): TInteger => Type.Integer({ + ...commonKeywords(options), + ...(options.uom !== undefined ? { 'x-uom': options.uom } : {}), + ...(options.default !== undefined ? { default: options.default } : {}), +}); + +/** An integer attribute whose value must be one of a fixed, labeled set of choices. */ +export const listIntProperty = (options: CommonAttributeOptions & { + choices: IntChoice[]; + default?: number; +}): TInteger => Type.Integer({ + ...commonKeywords(options), + ...(options.default !== undefined ? { default: options.default } : {}), + oneOf: options.choices.map((choice): object => ({ 'const': choice.value, 'x-label': choice.label })), +}); + +export const boolProperty = (options: CommonAttributeOptions & { + default?: boolean; +}): TBoolean => Type.Boolean({ + ...commonKeywords(options), + ...(options.default !== undefined ? { default: options.default } : {}), +}); + +export const strProperty = (options: CommonAttributeOptions & { + default?: string; +}): TString => Type.String({ + ...commonKeywords(options), + ...(options.default !== undefined ? { default: options.default } : {}), +}); diff --git a/src/device/attribute/attributeSchemaKeywords.ts b/src/device/attribute/attributeSchemaKeywords.ts new file mode 100644 index 00000000..3387cf16 --- /dev/null +++ b/src/device/attribute/attributeSchemaKeywords.ts @@ -0,0 +1,47 @@ +import type { Ajv, AnySchemaObject } from 'ajv'; + +/** + * Pure annotation keywords used to describe device attributes within a JSON Schema document. + * These carry no validation semantics (ajv treats them as always-valid), they exist purely to + * enrich the schema with information a client needs to render a form (label, unit, grouping). + */ +const attributeAnnotationKeywords = ['x-label', 'x-uom', 'x-group'] as const; + +const floatingPointTolerance = 1e-9; + +/** + * Validates that a numeric value lies on the step grid anchored at the property's `minimum` + * (or at 0 if no `minimum` is declared), e.g. for `minimum: 12, x-increment-step: 3` the values + * 12, 15, 18, ... are valid, but 13, 14, 20 are not. `maximum`/`minimum` themselves are still + * enforced by their own standard keywords. + */ +const validateIncrementStep = (step: number, value: number, parentSchema?: AnySchemaObject): boolean => { + const anchor: unknown = parentSchema?.minimum; + const start = 'number' === typeof anchor ? anchor : 0; + + if (0 === step) { + return value === start; + } + + const steps = (value - start) / step; + + return Math.abs(steps - Math.round(steps)) < floatingPointTolerance; +}; + +/** + * Registers the `x-*` attribute annotation keywords, plus the validating `x-increment-step` + * keyword, on the given ajv instance so schemas using them validate under `strict: true` + * (ajv otherwise throws on unknown keywords in strict mode). + */ +export const registerAttributeSchemaKeywords = (ajv: Ajv): void => { + for (const keyword of attributeAnnotationKeywords) { + ajv.addKeyword({ keyword, validate: (): boolean => true }); + } + + ajv.addKeyword({ + keyword: 'x-increment-step', + type: 'number', + schemaType: 'number', + validate: validateIncrementStep, + }); +}; diff --git a/src/device/attribute/rangeDeviceAttribute.ts b/src/device/attribute/rangeDeviceAttribute.ts new file mode 100644 index 00000000..10b673cb --- /dev/null +++ b/src/device/attribute/rangeDeviceAttribute.ts @@ -0,0 +1,116 @@ +import { Expose } from 'class-transformer'; +import { TypeGuard, type Static, type TSchema } from '@sinclair/typebox'; +import type { RangeBounds } from './deviceAttributeSchema.js'; +import { nullable, rangeIntSchema, uninitialized } from './deviceAttributeSchema.js'; +import type { AttributeOptions, RequiresValue, SchemaOf } from './deviceAttribute.js'; +import { DeviceAttributeModifier } from './deviceAttribute.js'; +import type DeviceAttribute from './deviceAttribute.js'; +import type { NumberAttributeOptions, NumberMemberSchema } from './numberDeviceAttribute.js'; +import NumberDeviceAttribute from './numberDeviceAttribute.js'; +import { Int } from '../../util/numbers.js'; + +export type ValueOf = Exclude, null | undefined>; + +export default class RangeDeviceAttribute extends NumberDeviceAttribute +{ + @Expose({ name: 'min' }) + private readonly _min: ValueOf; + + @Expose({ name: 'max' }) + private readonly _max: ValueOf; + + @Expose({ name: 'incrementStep' }) + private readonly _incrementStep: ValueOf; + + public constructor( + name: string, + label: string | undefined, + modifier: DeviceAttributeModifier, + uom: string | undefined, + schema: RequiresValue>, + initialValue: Static, + ) { + super(name, label, modifier, uom, schema, initialValue); + + const bounds = this.extractBounds(schema); + this._min = bounds.minimum; + this._max = bounds.maximum; + this._incrementStep = bounds['x-increment-step']; + } + + public static override create(options: NumberAttributeOptions): RangeDeviceAttribute; + public static override create(options: AttributeOptions): DeviceAttribute; + public static override create(options: NumberAttributeOptions): RangeDeviceAttribute { + return new RangeDeviceAttribute( + options.name, options.label, options.modifier, options.uom, options.schema, options.initialValue, + ); + } + + public get min(): ValueOf { + return this._min; + } + + public get max(): ValueOf { + return this._max; + } + + public get incrementStep(): ValueOf { + return this._incrementStep; + } + + public override getType(): string { + return 'range'; + } + + private extractBounds(schema: RequiresValue>): RangeBounds> { + const results: RangeBounds>[] = []; + + const walk = (node: TSchema): void => { + if (this.hasRangeBounds(node)) { + results.push({ + 'minimum': node.minimum, + 'maximum': node.maximum, + 'x-increment-step': node['x-increment-step'], + }); + } + if (TypeGuard.IsUnion(node)) { + for (const member of node.anyOf) { + walk(member); + } + } + }; + + walk(schema); + + if (results.length > 1) { + throw new Error('Schema contains multiple range bounds — ambiguous'); + } + + const bounds = results[0]; + + if (bounds === undefined) { + throw new Error('Schema does not contain range bounds (minimum/maximum/x-increment-step)'); + } + + return bounds; + } + + // eslint-disable-next-line @typescript-eslint/class-methods-use-this + private hasRangeBounds(schema: TSchema): schema is RequiresValue> & RangeBounds> { + return 'minimum' in schema && 'maximum' in schema && 'x-increment-step' in schema; + } +} + +// eslint-disable-next-line @typescript-eslint/no-magic-numbers +const foo = nullable(uninitialized(rangeIntSchema({ min: Int.from(0), max: Int.from(100), incrementStep: Int.from(1) }))); + +const attr = RangeDeviceAttribute.create({ + name: 'test', + label: 'Test', + modifier: DeviceAttributeModifier.readWrite, + uom: 'units', + schema: foo, + initialValue: undefined, +}); + +type Foo = Static; diff --git a/src/device/device.ts b/src/device/device.ts index 32d6ed80..4fd21a6c 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -1,33 +1,43 @@ import { Exclude, Expose } from 'class-transformer'; import DeviceState from './deviceState.js'; -import type { AttributeValue } from './attribute/deviceAttribute.js'; -import type DeviceAttribute from './attribute/deviceAttribute.js'; import type { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import type { EventEmitter } from 'events'; import type { DeviceId } from './deviceId.js'; import type { JsonObject } from '../types.js'; import type { DropFirst } from '../types.js'; import type Logger from '../logging/Logger.js'; - -// An attribute value can be DeviceAttribute or undefined because we want to allow Partial<> -export type DeviceAttributes = Record; +import type { TSchema } from '@sinclair/typebox'; +import type JsonSchemaValidatorFactory from '../schemaValidation/JsonSchemaValidatorFactory.js'; +import type JsonSchemaValidator from '../schemaValidation/JsonSchemaValidator.js'; +import DeviceDataValidationError from './deviceDataValidationError.js'; + +/** Flat key→value map for device attribute state. */ +export type DeviceAttributeValues = Record; + +/** + * Recursive deep-partial type for attribute updates. + * Distributive over unions (handles discriminated union schemas). + * Scalars and arrays pass through unchanged; only object keys become optional. + */ +export type DeviceDataUpdate = A extends unknown + ? A extends Record + ? { [K in keyof A]?: DeviceDataUpdate } + : A + : never; + +export type DeviceData = DeviceDataUpdate; + +export type DeviceDataApplyError = { path: string, message: string }; + +export type DeviceDataUpdateResult = { + deviceData: T; + errors: DeviceDataApplyError[]; +}; export type DeviceNotifications = JsonObject; export type NoDeviceNotifications = Record; type AnyDeviceNotifications = JsonObject; -export type AttributeKeyOf = keyof A & string; -export type AttributeValueOf> = - NonNullable['value']; - -export type DeviceAttributeOf = { - [K in AttributeKeyOf]: T[K] & { name: K } -}[AttributeKeyOf]; - -export type DeviceData = { - [K in AttributeKeyOf]: AttributeValueOf; -}; - export type DeviceError = { reason: string; occurredAt: Date; @@ -53,12 +63,12 @@ export type DeviceEventMap< [DeviceEvent.deviceNotification]: [device: TDevice, notification: DeviceNotification]; }; -export type WithUntypedAttributes = Omit & { +export type WithUntypedAttributes = Omit & { // Method syntax: this is the AnyDevice type-erasure boundary, and needs to structurally accept - // any concrete device's narrower generic-keyed setAttribute. Property syntax would check + // any concrete device's narrower updateDeviceData. Property syntax would check // parameters contravariantly and break that (see AiroticDevice/Zc95Device/etc. assignability). // eslint-disable-next-line @typescript-eslint/method-signature-style - setAttribute(attributeName: string, value: AttributeValue): Promise; + updateDeviceData(update: DeviceData): Promise; }; export type AnyDevice = WithUntypedAttributes; @@ -73,7 +83,7 @@ export type DeviceInfo = { @Exclude() export default abstract class Device< - TAttributes extends DeviceAttributes = DeviceAttributes, + TDeviceData extends DeviceAttributeValues = DeviceAttributeValues, TNotifications extends DeviceNotifications = NoDeviceNotifications, TConfig extends AnyDeviceConfig = NoDeviceConfig, > { @@ -104,21 +114,32 @@ export default abstract class Device< @Expose() protected lastRefresh: Date | undefined; + /** JSON Schema describing the device's current attributes (shape, validation, metadata). */ + @Expose() + protected dataSchema: TSchema; + + /** Flat key→value map of current attribute state. */ @Expose() - protected attributes: TAttributes; + protected data: TDeviceData; @Expose() protected readonly config: TConfig; protected readonly logger: Logger; + private readonly validatorFactory: JsonSchemaValidatorFactory; + + private validator: JsonSchemaValidator; + private closePromise?: Promise; private readonly eventEmitter: EventEmitter; protected constructor( deviceInfo: DeviceInfo, - attributes: TAttributes, + attributesSchema: TSchema, + attributes: TDeviceData, + validatorFactory: JsonSchemaValidatorFactory, config: TConfig, eventEmitter: EventEmitter, logger: Logger, @@ -128,7 +149,10 @@ export default abstract class Device< this.provider = deviceInfo.provider; this.connectedSince = deviceInfo.connectedSince; this.controllable = deviceInfo.controllable; - this.attributes = attributes; + this.dataSchema = attributesSchema; + this.data = attributes; + this.validatorFactory = validatorFactory; + this.validator = validatorFactory.create(attributesSchema); this.config = config; this.eventEmitter = eventEmitter; this.logger = logger.child({ name: `${new.target.name}.${deviceInfo.deviceId}` }); @@ -171,14 +195,8 @@ export default abstract class Device< this.updateLastRefresh(); } - /** - * Get attribute by key - * @param key The attribute key - * @returns attribute value or undefined if attribute is not found. And attribute potentially cannot be found - * if the generic attribute type of this class happens to be a/wrapped in a Partial - */ - public async getAttribute>(key: K): Promise { - return Promise.resolve(this.attributes[key]); + public getDeviceData(): TDeviceData { + return structuredClone(this.data); } public on(event: K, listener: (...args: DeviceEventMap[K]) => void): void @@ -198,10 +216,61 @@ export default abstract class Device< return this.closePromise; } - public abstract setAttribute>( - attributeName: K, - value: AttributeValueOf - ): Promise>; + /** + * Apply a partial attribute update (template method). + * + * 1. Build a merged candidate via `buildCandidate` (overridable for transition-aware merges) + * 2. Validate the candidate against the current schema (ajv, x-keywords active) + * 3. If invalid → throw `DeviceDataValidationError` (zero device messages sent) + * 4. If valid → delegate to `applyDeviceData` for ordered side-effects + * 5. Return result envelope + * @throws DeviceDataValidationError if the merged candidate fails schema validation. + */ + public async updateDeviceData(update: DeviceDataUpdate): Promise> { + const candidate = this.buildCandidate(update); + + if (!this.validator.validate(candidate)) { + const validationErrors = this.validator.getValidationErrors().map( + (err): DeviceDataApplyError => ({ + path: err.instancePath || '/', + message: err.message ?? 'validation failed', + }), + ); + + throw new DeviceDataValidationError( + `Attribute update failed validation: ${this.validator.getValidationErrors().map(e => e.message).join(', ')}`, + validationErrors, + ); + } + + const errors = await this.applyDeviceData(update); + + this.updateLastRefresh(); + + return { + deviceData: this.getDeviceData(), + errors, + }; + } + + /** + * Build a merged candidate from the current state + incoming update for pre-validation. + * Deep-merges `candidateBase(update)` with the update (objects merged, scalars/arrays replaced). + */ + protected buildCandidate(update: DeviceDataUpdate): unknown { + return Device.deepMerge(this.candidateBase(update), update); + } + + /** + * The base state the update is merged onto. + * Default: clone of current attributes. + * Override to reshape the base for state transitions (e.g. add/remove groups when a + * discriminant flips in a union schema). + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected candidateBase(update: DeviceDataUpdate): TDeviceData { + return structuredClone(this.data); + } // eslint-disable-next-line @typescript-eslint/class-methods-use-this protected async doRefresh(): Promise { @@ -214,23 +283,33 @@ export default abstract class Device< // no-op } - protected updateLastRefresh(): void - { - this.lastRefresh = new Date(); - this.emit(DeviceEvent.deviceRefreshed); - } - protected emit(eventName: K, ...args: DropFirst[K]>): boolean { return this.eventEmitter.emit(eventName, this, ...args); } - protected isAttributePresent( - attr: TAttributes[keyof TAttributes], - ): attr is DeviceAttributeOf { - return typeof attr === 'object' && 'name' in attr && Object.keys(this.attributes).includes(attr.name); + /** + * Update the attributes schema and recompile the validator. + * Devices should call this instead of setting `attributesSchema` directly. + */ + protected updateAttributesSchema(schema: TSchema): void { + this.dataSchema = schema; + this.validator = this.validatorFactory.create(schema); } + protected updateLastRefresh(): void + { + this.lastRefresh = new Date(); + this.emit(DeviceEvent.deviceRefreshed); + } + + /** + * Apply the validated update to the device (ordered side-effects, protocol messages). + * Called only after the merged candidate has passed schema validation. + * Implementations should collect per-group errors rather than throwing. + */ + protected abstract applyDeviceData(update: DeviceDataUpdate): Promise; + private async performClose(): Promise { try { @@ -240,4 +319,27 @@ export default abstract class Device< this.emit(DeviceEvent.deviceDisconnected); } } + + /** + * Generic recursive deep-merge: object keys are merged recursively, + * scalars and arrays are replaced by the incoming value. + */ + private static deepMerge(target: unknown, source: unknown): unknown { + if ( + typeof target !== 'object' || target === null || Array.isArray(target) + || typeof source !== 'object' || source === null || Array.isArray(source) + ) { + return source; + } + + const result: Record = { ...target }; + + for (const [key, sourceVal] of Object.entries(source)) { + if (sourceVal !== undefined) { + result[key] = Device.deepMerge(result[key], sourceVal); + } + } + + return result; + } } diff --git a/src/device/deviceDataValidationError.ts b/src/device/deviceDataValidationError.ts new file mode 100644 index 00000000..cf0dbb6f --- /dev/null +++ b/src/device/deviceDataValidationError.ts @@ -0,0 +1,16 @@ +import type { DeviceDataApplyError } from './device.js'; + +/** + * Thrown when a merged attribute candidate fails schema validation before any + * device messages are sent. Controllers should map this to a 400 response. + */ +export default class DeviceDataValidationError extends Error +{ + public readonly validationErrors: DeviceDataApplyError[]; + + public constructor(message: string, validationErrors: DeviceDataApplyError[]) { + super(message); + this.name = 'DeviceDataValidationError'; + this.validationErrors = validationErrors; + } +} diff --git a/src/device/genericDeviceUpdater.ts b/src/device/genericDeviceUpdater.ts index 5b583aa0..337e56c0 100644 --- a/src/device/genericDeviceUpdater.ts +++ b/src/device/genericDeviceUpdater.ts @@ -1,54 +1,53 @@ -import AbstractDeviceUpdater from './updater/abstractDeviceUpdater.js'; -import type PlainToClassSerializer from '../serialization/plainToClassSerializer.js'; -import type { AnyDevice, DeviceData } from './device.js'; +import type { AnyDevice, DeviceData, DeviceDataUpdateResult } from './device.js'; +import type DeviceUpdaterInterface from './updater/deviceUpdaterInterface.js'; import type Logger from '../logging/Logger.js'; -import { getTypedKeys } from '../util/objects.js'; import { logError } from '../util/error.js'; +import DeviceDataValidationError from './deviceDataValidationError.js'; -export default class GenericDeviceUpdater extends AbstractDeviceUpdater +export default class GenericDeviceUpdater implements DeviceUpdaterInterface { private readonly logger: Logger; private readonly failedMessageCountPerDevice = new Map(); - public constructor(serializer: PlainToClassSerializer, logger: Logger) { - super(serializer); - + public constructor(logger: Logger) { this.logger = logger.child({ name: GenericDeviceUpdater.name }); } - public async update(device: AnyDevice, rawData: DeviceData): Promise { - let hadFailure = false; - - // Queue update for later to not reject if device is busy - for (const attrKey of getTypedKeys(rawData)) { - if (undefined === await device.getAttribute(attrKey)) { - this.logger.warn(`device: ${device.getDeviceId} -> has no attribute named: ${attrKey}`); - continue; - } + public async update(device: AnyDevice, data: DeviceData): Promise { + const deviceLogMsg = `device: ${device.getDeviceId} -> ${JSON.stringify(data)}`; - const attrStr = rawData[attrKey]; - const deviceLogMsg = `device: ${device.getDeviceId} -> ${attrKey} ${attrStr}`; + try { + const result = await device.updateDeviceData(data); - try { - await device.setAttribute(attrKey, attrStr); + if (result.errors.length > 0) { + this.logger.warn(`${deviceLogMsg} -> completed with ${result.errors.length} error(s)`); + for (const error of result.errors) { + this.logger.warn(` ${error.path}: ${error.message}`); + } + } else { this.logger.info(`${deviceLogMsg} -> done`); - } catch (e: unknown) { - hadFailure = true; + } + + this.failedMessageCountPerDevice.delete(device.getDeviceId); - logError(this.logger, `${deviceLogMsg} -> failed`, e); + return result; + } catch (e: unknown) { + // Validation errors propagate to callers (controller → 400) + if (e instanceof DeviceDataValidationError) { + throw e; } - } - if (hadFailure) { + logError(this.logger, `${deviceLogMsg} -> failed`, e); + const failedMessageCount = (this.failedMessageCountPerDevice.get(device.getDeviceId) ?? 0) + 1; this.failedMessageCountPerDevice.set(device.getDeviceId, failedMessageCount); if (failedMessageCount % 10 === 0) { this.logger.warn(`Device ${device.getDeviceId} has ${failedMessageCount} failed update attempts`); } - } else { - this.failedMessageCountPerDevice.delete(device.getDeviceId); + + throw e; } } } diff --git a/src/device/peripheralDevice.ts b/src/device/peripheralDevice.ts index 01c10863..4c1b4bd9 100644 --- a/src/device/peripheralDevice.ts +++ b/src/device/peripheralDevice.ts @@ -1,4 +1,4 @@ -import type { DeviceAttributes, DeviceInfo, DeviceNotifications, NoDeviceNotifications, WithUntypedAttributes } from './device.js'; +import type { DeviceAttributeValues, DeviceInfo, DeviceNotifications, NoDeviceNotifications, WithUntypedAttributes } from './device.js'; import Device from './device.js'; import type BidirectionalDeviceTransport from './transport/deviceBidirectionalTransport.js'; import type { AnyDeviceProtocol, AnyMessageWithResponse } from './protocol/deviceProtocol.js'; @@ -7,15 +7,17 @@ import type { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js'; import type EventEmitter from 'events'; import type Logger from '../logging/Logger.js'; import { logError } from '../util/error.js'; +import type { TSchema } from '@sinclair/typebox'; +import type JsonSchemaValidatorFactory from '../schemaValidation/JsonSchemaValidatorFactory.js'; export type AnyPeripheralDevice = WithUntypedAttributes>; export default abstract class PeripheralDevice< TProtocol extends DeviceProtocol, - TAttributes extends DeviceAttributes = DeviceAttributes, + TDeviceData extends DeviceAttributeValues = DeviceAttributeValues, TNotifications extends DeviceNotifications = NoDeviceNotifications, TConfig extends AnyDeviceConfig = NoDeviceConfig, -> extends Device +> extends Device { protected readonly transport: BidirectionalDeviceTransport; @@ -25,12 +27,14 @@ export default abstract class PeripheralDevice< deviceInfo: DeviceInfo, protocol: TProtocol, transport: BidirectionalDeviceTransport, - attributes: TAttributes, + attributesSchema: TSchema, + attributes: TDeviceData, + validatorFactory: JsonSchemaValidatorFactory, config: TConfig, eventEmitter: EventEmitter, logger: Logger, ) { - super(deviceInfo, attributes, config, eventEmitter, logger); + super(deviceInfo, attributesSchema, attributes, validatorFactory, config, eventEmitter, logger); this.protocol = protocol; this.transport = transport; diff --git a/src/device/protocol/airotic/airoticDevice.ts b/src/device/protocol/airotic/airoticDevice.ts index 6f121b98..3bcd4511 100644 --- a/src/device/protocol/airotic/airoticDevice.ts +++ b/src/device/protocol/airotic/airoticDevice.ts @@ -85,7 +85,7 @@ export default class AiroticDevice extends BleDevice { this.breathTimestamps.length = 0; - this.attributes.breathsPerMin.value = undefined; - this.attributes.bpmTrend.value = undefined; + this.data.breathsPerMin.value = undefined; + this.data.bpmTrend.value = undefined; this.updateLastRefresh(); this.breathTimeoutHandle = null; }, BREATH_TIMEOUT_MS); @@ -205,8 +205,8 @@ export default class AiroticDevice extends BleDevice public async setAttribute< K extends AttributeKeyOf, >(attributeName: K, value: AttributeValue): Promise> { - const attribute = this.attributes[attributeName]; + const attribute = this.data[attributeName]; if (undefined === attribute) { throw new Error(`Attribute with name '${attributeName}' does not exist for this device`); @@ -100,7 +100,7 @@ export default class ButtplugIoDevice extends Device await this.send(actuatorType, parseInt(index, 10), valueToSend); - const attr = this.attributes[attributeName]; + const attr = this.data[attributeName]; if (undefined !== attr) { attr.value = value; @@ -123,7 +123,7 @@ export default class ButtplugIoDevice extends Device protected override async doRefresh(): Promise { for (const sensor of this.buttplugClientDevice.messageAttributes.SensorReadCmd ?? []) { const value = await this.buttplugClientDevice.sensorRead(sensor.Index, sensor.SensorType); - const attr = this.attributes[`${sensor.SensorType}-${sensor.Index}`]; + const attr = this.data[`${sensor.SensorType}-${sensor.Index}`]; if (undefined !== attr) { if (undefined === value[0] || isNaN(value[0])) { this.logger.warn(`Received invalid sensor value for sensor type '${sensor.SensorType}' and index '${sensor.Index}': ${JSON.stringify(value)}. Supposed to be a number. Ignoring this value.`); diff --git a/src/device/protocol/estim2b/estim2bDevice.ts b/src/device/protocol/estim2b/estim2bDevice.ts index d4b1acf3..651fd15a 100644 --- a/src/device/protocol/estim2b/estim2bDevice.ts +++ b/src/device/protocol/estim2b/estim2bDevice.ts @@ -58,7 +58,7 @@ export default class EStim2bDevice extends PeripheralDevice, >(attributeName: K, value: AttributeValue): Promise> { - const attribute = this.attributes[attributeName]; + const attribute = this.data[attributeName]; if (undefined === attribute) { throw new Error(`Attribute '${attributeName}' does not exist`); @@ -84,18 +84,18 @@ export default class EStim2bDevice extends PeripheralDevice { const status = await this.send(this.protocol.createGetStatusCommand()); - this.attributes = this.setModeBasedAttributes(status); + this.data = this.setModeBasedAttributes(status); this.updateAttributeValues(status); } @@ -224,13 +224,13 @@ export default class EStim2bDevice extends PeripheralDevice, >(attributeName: K, value: AttributeValue): Promise> { - const attr = this.attributes[attributeName]; + const attr = this.data[attributeName]; if (undefined === attr) { throw new Error(`Attribute with name '${attributeName}' does not exist for this device`); @@ -97,11 +97,11 @@ export default class GenericSlvCtrlPlusDevice extends SlvCtrlPlusDevice const response = await this.send({ command: 'status', args: [] }); for (const attrKey in response.data) { - if (!(attrKey in this.attributes)) { + if (!(attrKey in this.data)) { continue; } - const attribute = this.attributes[attrKey]; + const attribute = this.data[attrKey]; // Ignore attributes that were not announced by the device during handshake if (undefined === attribute) { diff --git a/src/device/protocol/virtual/virtualDevice.ts b/src/device/protocol/virtual/virtualDevice.ts index 18f2137e..9d15bb65 100644 --- a/src/device/protocol/virtual/virtualDevice.ts +++ b/src/device/protocol/virtual/virtualDevice.ts @@ -56,7 +56,7 @@ export default class VirtualDevice< return new Promise>((resolve, reject) => { this.state = DeviceState.busy; - const attribute = this.attributes[attributeName]; + const attribute = this.data[attributeName]; if (undefined === attribute) { reject(new Error( diff --git a/src/device/protocol/zc95/zc95AttributesSchema.ts b/src/device/protocol/zc95/zc95AttributesSchema.ts new file mode 100644 index 00000000..b61ee0c4 --- /dev/null +++ b/src/device/protocol/zc95/zc95AttributesSchema.ts @@ -0,0 +1,113 @@ +import { Type } from '@sinclair/typebox'; +import type { Static, TInteger } from '@sinclair/typebox'; +import { listIntProperty, rangeIntProperty } from '../../attribute/attributeSchema.js'; +import type { IntChoice } from '../../attribute/attributeSchema.js'; +import type { MenuItem, MinMaxMenuItem, MultiChoiceMenuItem, PatternsMsgResponse } from './zc95MessageFactory.js'; +import { Zc95DevicePowerChannelIndex } from './zc95Device.js'; + +type PatternDetail = PatternsMsgResponse['Patterns'][number]; + +const isMinMaxMenuItem = (menuItem: MenuItem): menuItem is MinMaxMenuItem => 'MIN_MAX' === menuItem.Type; +const isMultiChoiceMenuItem = (menuItem: MenuItem): menuItem is MultiChoiceMenuItem => 'MULTI_CHOICE' === menuItem.Type; + +/** Builds the schema properties for pattern-specific menu items, keyed by bare menu-item id. */ +const patternAttributeProperties = (menuItems: (MinMaxMenuItem | MultiChoiceMenuItem)[]): Record => { + const properties: Record = {}; + + for (const menuItem of menuItems) { + if (isMinMaxMenuItem(menuItem)) { + properties[String(menuItem.Id)] = rangeIntProperty({ + label: menuItem.Title, + group: menuItem.Group, + uom: 'us' === menuItem.UoM ? 'µs' : menuItem.UoM, + min: menuItem.Min, + max: menuItem.Max, + incrementStep: menuItem.IncrementStep, + default: menuItem.Default, + }); + } else if (isMultiChoiceMenuItem(menuItem)) { + const choices: IntChoice[] = menuItem.Choices.map((choice): IntChoice => ({ value: choice.Id, label: choice.Name })); + + properties[String(menuItem.Id)] = listIntProperty({ + label: menuItem.Title, + group: menuItem.Group, + choices, + default: menuItem.Default, + }); + } + } + + return properties; +}; + +export type Zc95SchemaInput = { + patterns: PatternDetail[]; + activePatternMenuItems: (MinMaxMenuItem | MultiChoiceMenuItem)[]; + powerChannels: { channel: Zc95DevicePowerChannelIndex, maxOutputPower: number }[]; +}; + +/** + * Builds a discriminated-union JSON Schema describing a zc95 device's current attributes. + * + * Three possible branches (discriminated on `patternStarted`): + * - **stopped**: `{ activePattern, patternStarted: false }` + * - **started (loading)**: `{ activePattern, patternStarted: true, patternAttributes }` — no powerChannels yet + * - **started (full)**: `{ activePattern, patternStarted: true, powerChannels, patternAttributes }` + * + * `Static<>` on the return type gives a proper TS discriminated union — no hand-written types needed. + */ +const findMaxPower = (channels: Zc95SchemaInput['powerChannels'], index: Zc95DevicePowerChannelIndex): number => + channels.find(c => c.channel === index)?.maxOutputPower ?? 0; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types -- return type must be inferred so Static> derives the full discriminated union +export const zc95AttributesSchema = (input: Zc95SchemaInput) => { + const patternChoices: IntChoice[] = input.patterns.map((pattern): IntChoice => ({ value: pattern.Id, label: pattern.Name })); + const activePatternProp = listIntProperty({ label: 'Pattern', choices: patternChoices, default: 0 }); + + const stoppedSchema = Type.Object({ + activePattern: activePatternProp, + patternStarted: Type.Literal(false, { 'x-label': 'Pattern Started' }), + }, { additionalProperties: false }); + + const { One, Two, Three, Four } = Zc95DevicePowerChannelIndex; + const hasPowerChannels = input.powerChannels.length > 0; + + const powerChannelsSchema = Type.Object({ + [One]: rangeIntProperty({ label: 'Channel 1', min: 0, max: findMaxPower(input.powerChannels, One), incrementStep: 1, default: 0 }), + [Two]: rangeIntProperty({ label: 'Channel 2', min: 0, max: findMaxPower(input.powerChannels, Two), incrementStep: 1, default: 0 }), + [Three]: rangeIntProperty({ label: 'Channel 3', min: 0, max: findMaxPower(input.powerChannels, Three), incrementStep: 1, default: 0 }), + [Four]: rangeIntProperty({ label: 'Channel 4', min: 0, max: findMaxPower(input.powerChannels, Four), incrementStep: 1, default: 0 }), + }); + + const patternAttributesSchema = Type.Object( + patternAttributeProperties(input.activePatternMenuItems), + { additionalProperties: false }, + ); + + const startedBase = { + activePattern: activePatternProp, + patternStarted: Type.Literal(true, { 'x-label': 'Pattern Started' }), + patternAttributes: patternAttributesSchema, + }; + + const startedSchemas = hasPowerChannels + ? [Type.Object({ ...startedBase, powerChannels: powerChannelsSchema }, { additionalProperties: false })] + : [ + Type.Object(startedBase, { additionalProperties: false }), + Type.Object({ ...startedBase, powerChannels: powerChannelsSchema }, { additionalProperties: false }), + ]; + + return Type.Union([stoppedSchema, ...startedSchemas]); +}; + +/** Derived value type from the schema — discriminated union on `patternStarted`. */ +export type Zc95AttributeValues = Static>; + +/** The started branch(es) of the discriminated union — may or may not have powerChannels. */ +export type Zc95StartedAttributes = Extract; + +/** Started with powerChannels present (after first PowerStatus message). */ +export type Zc95StartedWithPowerAttributes = Extract }>; + +/** The stopped branch of the discriminated union — available after narrowing. */ +export type Zc95StoppedAttributes = Extract; diff --git a/src/device/protocol/zc95/zc95Device.ts b/src/device/protocol/zc95/zc95Device.ts index bcdf4039..00810f8e 100644 --- a/src/device/protocol/zc95/zc95Device.ts +++ b/src/device/protocol/zc95/zc95Device.ts @@ -1,37 +1,23 @@ import { Exclude, Expose } from 'class-transformer'; -import type { AttributeKeyOf, AttributeValueOf, DeviceAttributeOf, DeviceInfo } from '../../device.js'; +import type { DeviceDataUpdate, DeviceDataApplyError, DeviceDataUpdateResult, DeviceInfo } from '../../device.js'; import type { - MenuItem, MinMaxMenuItem, MultiChoiceMenuItem, + PatternsMsgResponse, PowerStatusMsgResponse, } from './zc95MessageFactory.js'; import type Zc95MessageFactory from './zc95MessageFactory.js'; -import type { - InitializedIntRangeDeviceAttribute, -} from '../../attribute/intRangeDeviceAttribute.js'; -import IntRangeDeviceAttribute from '../../attribute/intRangeDeviceAttribute.js'; -import type { InitializedListDeviceAttribute } from '../../attribute/listDeviceAttribute.js'; -import ListDeviceAttribute from '../../attribute/listDeviceAttribute.js'; -import type { InitializedBoolDeviceAttribute } from '../../attribute/boolDeviceAttribute.js'; -import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; -import { Int } from '../../../util/numbers.js'; -import { getTypedKeys } from '../../../util/objects.js'; -import type { AllOrNone } from '../../../types.js'; import type { NoDeviceConfig } from '../../deviceConfig.js'; import PeripheralDevice from '../../peripheralDevice.js'; import type { MsgResponse } from './zc95Protocol.js'; import type Zc95Protocol from './zc95Protocol.js'; -import typeDetect from 'type-detect'; import type BidirectionalDeviceTransport from '../../transport/deviceBidirectionalTransport.js'; import type MessageResponseHandler from '../messageResponseHandler.js'; import type Logger from '../../../logging/Logger.js'; import type EventEmitter from 'events'; - -type RequiredZc95DeviceAttributes = { - activePattern: InitializedListDeviceAttribute; - patternStarted: InitializedBoolDeviceAttribute; -}; +import { zc95AttributesSchema } from './zc95AttributesSchema.js'; +import type { Zc95AttributeValues, Zc95StartedAttributes, Zc95StartedWithPowerAttributes } from './zc95AttributesSchema.js'; +import type JsonSchemaValidatorFactory from '../../../schemaValidation/JsonSchemaValidatorFactory.js'; export enum Zc95DevicePowerChannelIndex { One = 1, @@ -40,30 +26,25 @@ export enum Zc95DevicePowerChannelIndex { Four = 4, } -type Zc95DevicePowerChannelAttributesKeyPrefix = `powerChannel`; -type Zc95DevicePowerChannelAttributesKey = `${Zc95DevicePowerChannelAttributesKeyPrefix}${Zc95DevicePowerChannelIndex}`; +export type { Zc95AttributeValues } from './zc95AttributesSchema.js'; -export type Zc95DevicePowerChannelAttributes = Record; +export type PatternDetail = PatternsMsgResponse['Patterns'][number]; -type Zc95DevicePatternAttributesKeyPrefix = `patternAttribute`; -type Zc95DevicePatternAttributesKey = `${Zc95DevicePatternAttributesKeyPrefix}${number}`; +export type PowerChannelState = { channel: Zc95DevicePowerChannelIndex, maxOutputPower: number }; -type Zc95DevicePatternAttributes = Partial>>; +/** Power channel keys as they appear in the nested `powerChannels` object. */ +type PowerChannelKey = '1' | '2' | '3' | '4'; -export type Zc95DeviceAttributes = AllOrNone & Zc95DevicePatternAttributes - & Required; - -type AnyZc95DeviceAttribute = DeviceAttributeOf; - -type AttributeValue = AttributeValueOf; +/** Narrowed API surface available after `isPatternStarted()` returns true. */ +export type Zc95StartedDeviceApi = { + updateDeviceData: (update: DeviceDataUpdate) => Promise>; + getDeviceData: () => Zc95StartedAttributes; +}; @Exclude() -export default class Zc95Device extends PeripheralDevice +export default class Zc95Device extends PeripheralDevice { private static readonly powerScaleFactor = 10; - private static readonly patternAttributePrefix = 'patternAttribute'; - - private static readonly powerChannelAttributePrefix = 'powerChannel'; @Expose() // eslint-disable-next-line @typescript-eslint/no-unused-private-class-members @@ -73,187 +54,319 @@ export default class Zc95Device extends PeripheralDevice; + /** Stored pattern list so the schema can be rebuilt without re-fetching. */ + private readonly patterns: PatternDetail[]; + + /** The menu items of the currently active pattern (empty when no pattern is started). */ + private activePatternMenuItems: (MinMaxMenuItem | MultiChoiceMenuItem)[] = []; + + /** Power channel state for schema rebuilding. */ + private powerChannelState: PowerChannelState[] = []; + + // eslint-disable-next-line @typescript-eslint/max-params public constructor( deviceInfo: DeviceInfo, fwVersion: string, protocol: Zc95Protocol, transport: BidirectionalDeviceTransport, - attributes: Zc95DeviceAttributes, + attributesSchema: ReturnType, + attributes: Zc95AttributeValues, + patterns: PatternDetail[], config: NoDeviceConfig, msgFactory: Zc95MessageFactory, messageResponseHandler: MessageResponseHandler, + validatorFactory: JsonSchemaValidatorFactory, eventEmitter: EventEmitter, logger: Logger, + activePatternMenuItems: (MinMaxMenuItem | MultiChoiceMenuItem)[] = [], + powerChannelState: PowerChannelState[] = [], ) { - super(deviceInfo, protocol, transport, attributes, config, eventEmitter, logger); + super(deviceInfo, protocol, transport, attributesSchema, attributes, validatorFactory, config, eventEmitter, logger); this.fwVersion = fwVersion; + this.patterns = patterns; + this.activePatternMenuItems = activePatternMenuItems; + this.powerChannelState = powerChannelState; this.msgFactory = msgFactory; this.transport.onReceive(data => this.onReceivedMessage(data)); this.messageResponseHandler = messageResponseHandler; } - public async setAttribute< - K extends AttributeKeyOf, - >(attributeName: K, value: AttributeValue): Promise> { - const attribute = this.attributes[attributeName]; - - if (!this.isAttributePresent(attribute)) { - throw new Error(`Attribute with name '${attributeName}' does not exist for this device`); - } + /** Type guard: narrows this device to expose power-channel and pattern-attribute keys with precise types. */ + public isPatternStarted(): this is Zc95Device & Zc95StartedDeviceApi { + return this.data.patternStarted; + } - if (Zc95Device.isActivePatternAttribute(attribute) && attribute.isValidValue(value)) { - await this.setAttributeActivePattern(value); - this.updateLastRefresh(); - return attribute.value; + /** + * Ordered apply: activePattern → patternStarted → powerChannels → patternAttributes. + * Called by the base class after the merged candidate has passed schema validation. + */ + protected override async applyDeviceData( + update: DeviceDataUpdate, + ): Promise { + const errors: DeviceDataApplyError[] = []; + + if ('activePattern' in update && typeof update.activePattern === 'number') { + try { + await this.setAttributeActivePattern(update.activePattern); + } catch (e: unknown) { + errors.push({ path: '/activePattern', message: e instanceof Error ? e.message : String(e) }); + } } - if (Zc95Device.isPatternStartedAttribute(attribute) && attribute.isValidValue(value)) { - await this.setAttributePatternStarted(value); - this.updateLastRefresh(); - return attribute.value; + if ('patternStarted' in update && typeof update.patternStarted === 'boolean') { + try { + await this.setAttributePatternStarted(update.patternStarted); + } catch (e: unknown) { + errors.push({ path: '/patternStarted', message: e instanceof Error ? e.message : String(e) }); + } } - if (Zc95Device.isPowerChannelAttribute(attribute) && attribute.isValidValue(value)) { - await this.setAttributePowerChannel(attribute, value); - this.updateLastRefresh(); - return attribute.value; + if ('powerChannels' in update && update.powerChannels !== undefined) { + try { + await this.setAttributePowerChannels(update.powerChannels); + } catch (e: unknown) { + errors.push({ path: '/powerChannels', message: e instanceof Error ? e.message : String(e) }); + } } - if (Zc95Device.isPatternDetailAttribute(attribute) && attribute.isValidValue(value)) { - await this.setAttributePatternDetail(attribute, value); - this.updateLastRefresh(); - return attribute.value; + if ('patternAttributes' in update && update.patternAttributes !== undefined) { + try { + await this.setAttributePatternAttributes(update.patternAttributes); + } catch (e: unknown) { + errors.push({ path: '/patternAttributes', message: e instanceof Error ? e.message : String(e) }); + } } - throw new Error( - `Could not set value ${JSON.stringify(value)} (type: ${typeof value}) for attribute '${attributeName}'`, - ); + return errors; } - private async setAttributePatternDetail(patternDetailAttr: DeviceAttributeOf, value: number): Promise { - const menuItemId = parseInt(patternDetailAttr.name.slice(Zc95Device.patternAttributePrefix.length), 10); + /** + * Transition-aware base state: + * - stopping: strip nested groups so the stopped schema branch validates + * - starting: seed mandatory `patternAttributes` (populated during apply) + * - otherwise: current state as-is + */ + protected override candidateBase(update: DeviceDataUpdate): Zc95AttributeValues { + const current = super.candidateBase(update); + + if ('patternStarted' in update && false === update.patternStarted) { + return { activePattern: current.activePattern, patternStarted: false }; + } - if (isNaN(menuItemId)) { - throw new Error(`Attribute name '${patternDetailAttr.name}' does not contain a valid menu item id`); + if ('patternStarted' in update && true === update.patternStarted && !current.patternStarted) { + return { + activePattern: current.activePattern, + patternStarted: true, + patternAttributes: {}, + }; } - if (IntRangeDeviceAttribute.isInstance(patternDetailAttr) && patternDetailAttr.isValidValue(value)) { - const message = this.msgFactory.createPatternMinMaxChange(menuItemId, value); - Zc95Device.assertOkResponse(await this.messageResponseHandler.send(message)); - patternDetailAttr.value = value; - } else if (ListDeviceAttribute.isInstance(patternDetailAttr) && patternDetailAttr.isValidValue(value)) { - const message = this.msgFactory.createPatternMultiChoiceChange(menuItemId, value); - Zc95Device.assertOkResponse(await this.messageResponseHandler.send(message)); - patternDetailAttr.value = value; - } else { - throw new Error( - `Unknown type for pattern detail attribute ${patternDetailAttr.name} (type: ${typeDetect(patternDetailAttr)}, value: ${value})`, - ); + return current; + } + + /** Narrows attributes to started state. Throws if pattern not running. */ + private getStartedAttributes(): Zc95StartedAttributes { + if (!this.data.patternStarted) { + throw new Error('Pattern is not started'); } + + return this.data; } - private async setAttributePowerChannel( - attribute: DeviceAttributeOf, - value: number, + private async setAttributePatternAttributes( + incoming: Record, ): Promise { - if (!Zc95Device.allPowerChannelValuesDefined(this.attributes)) { - throw new Error('Cannot set channel power before all channel values have been initialized'); + const attrs = this.getStartedAttributes(); + + for (const [key, value] of Object.entries(incoming)) { + if (typeof value !== 'number') { + throw new Error(`Expected number value for pattern attribute '${key}', got ${typeof value}`); + } + + const menuItemId = parseInt(key, 10); + + if (isNaN(menuItemId)) { + throw new Error(`Pattern attribute key '${key}' is not a valid menu item id`); + } + + const menuItem = this.activePatternMenuItems.find(item => item.Id === menuItemId); + + if (!menuItem) { + throw new Error(`No menu item found for pattern attribute '${key}'`); + } + + if ('MIN_MAX' === menuItem.Type) { + const message = this.msgFactory.createPatternMinMaxChange(menuItemId, value); + Zc95Device.assertOkResponse(await this.messageResponseHandler.send(message)); + } else { + const message = this.msgFactory.createPatternMultiChoiceChange(menuItemId, value); + Zc95Device.assertOkResponse(await this.messageResponseHandler.send(message)); + } + + attrs.patternAttributes[key] = value; + } + } + + private async setAttributePowerChannels(incoming: Record): Promise { + const attrs = this.getStartedAttributes(); + + if (!Zc95Device.hasPowerChannels(attrs)) { + throw new Error('Power channels are not yet available (waiting for first PowerStatus message)'); } - const tmpData: { [K in keyof Zc95DevicePowerChannelAttributes]-?: InitializedIntRangeDeviceAttribute['value'] } = { - powerChannel1: this.attributes.powerChannel1.value, - powerChannel2: this.attributes.powerChannel2.value, - powerChannel3: this.attributes.powerChannel3.value, - powerChannel4: this.attributes.powerChannel4.value, - }; + // Merge incoming values with current state, validating each channel key + const merged = { ...attrs.powerChannels }; - tmpData[attribute.name] = Int.from(value); + for (const [key, val] of Object.entries(incoming)) { + if (!Zc95Device.isPowerChannelKey(key)) { + throw new Error(`Invalid power channel key '${key}'`); + } + + if (typeof val !== 'number') { + throw new Error(`Expected number value for power channel '${key}', got ${typeof val}`); + } + + merged[key] = val; + } const message = this.msgFactory.createSetPower( - tmpData.powerChannel1 * Zc95Device.powerScaleFactor, - tmpData.powerChannel2 * Zc95Device.powerScaleFactor, - tmpData.powerChannel3 * Zc95Device.powerScaleFactor, - tmpData.powerChannel4 * Zc95Device.powerScaleFactor, + merged[1] * Zc95Device.powerScaleFactor, + merged[2] * Zc95Device.powerScaleFactor, + merged[3] * Zc95Device.powerScaleFactor, + merged[4] * Zc95Device.powerScaleFactor, ); Zc95Device.assertOkResponse(await this.messageResponseHandler.send(message)); - this.attributes[attribute.name].value = Int.from(value); + attrs.powerChannels = merged; } private async setAttributeActivePattern(value: number): Promise { - if (this.attributes.activePattern.value === value) { + if (this.data.activePattern === value) { return; } - if (this.attributes.patternStarted.value) { + if (this.data.patternStarted) { await this.setAttributePatternStarted(false); } - this.attributes.activePattern.value = Int.from(value); + this.data.activePattern = value; } private async setAttributePatternStarted(value: boolean): Promise { - if (this.attributes.patternStarted.value === value) { + if (this.data.patternStarted === value) { return; } if (value) { const patternDetailsMessage = this.msgFactory.createGetPatternDetails( - this.attributes.activePattern.value, + this.data.activePattern, ); const patternDetails = await this.messageResponseHandler.send(patternDetailsMessage); - const patternAttributes = Zc95Device.getAttributesFromPatternDetails(patternDetails.MenuItems); - Object.assign(this.attributes, Zc95Device.getChannelPowerAttributes(), patternAttributes); + this.activePatternMenuItems = patternDetails.MenuItems; + + // Build started state — no powerChannels yet (seeded on first PowerStatus) + const patternAttributes: Record = {}; + for (const menuItem of patternDetails.MenuItems) { + patternAttributes[String(menuItem.Id)] = menuItem.Default; + } + + this.data = { + activePattern: this.data.activePattern, + patternStarted: true, + patternAttributes, + }; + + this.powerChannelState = []; + this.rebuildSchema(); const patternStartMessage = this.msgFactory.createPatternStart( - this.attributes.activePattern.value, + this.data.activePattern, ); Zc95Device.assertOkResponse(await this.messageResponseHandler.send(patternStartMessage)); } else { const patternStopMessage = this.msgFactory.createPatternStop(); Zc95Device.assertOkResponse(await this.messageResponseHandler.send(patternStopMessage)); - this.removePatternAttributesAndData(); - } - this.attributes.patternStarted.value = value; - } + // Transition to stopped state + this.data = { + activePattern: this.data.activePattern, + patternStarted: false, + }; - private removePatternAttributesAndData(): void { - getTypedKeys(this.attributes).forEach(key => { - if (key.startsWith(Zc95Device.patternAttributePrefix) - || key.startsWith(Zc95Device.powerChannelAttributePrefix) - ) { - Reflect.deleteProperty(this.attributes, key); - } - }); + this.activePatternMenuItems = []; + this.powerChannelState = []; + this.rebuildSchema(); + } } private processPowerStatusMessage(msg: PowerStatusMsgResponse): void { + if (!this.data.patternStarted) { + return; + } + + const attrs = this.data; + let schemaChanged = false; + const needsSeed = !Zc95Device.hasPowerChannels(attrs); + + // If powerChannels don't exist yet, seed them and re-narrow + if (needsSeed) { + this.data = { ...attrs, powerChannels: { 1: 0, 2: 0, 3: 0, 4: 0 } }; + } + + // Re-read after potential seed; narrow to started-with-power + const currentAttrs = this.data; + if (!Zc95Device.hasPowerChannels(currentAttrs)) { + return; + } + for (const channel of msg.Channels) { - const channelAttrName: Zc95DevicePowerChannelAttributesKey = `${Zc95Device.powerChannelAttributePrefix}${channel.Channel}`; - const channelAttr = this.attributes[channelAttrName]; - const percentagePowerLimit = Int.from(Math.floor(channel.PowerLimit / Zc95Device.powerScaleFactor)); + const channelKeyStr = String(channel.Channel); - if (!channelAttr) { + if (!Zc95Device.isPowerChannelKey(channelKeyStr)) { continue; } - if (undefined !== this.attributes[channelAttrName]?.value - && this.attributes[channelAttrName].value > percentagePowerLimit - ) { - this.attributes[channelAttrName].value = percentagePowerLimit; + const channelKey = channelKeyStr; + const percentagePowerLimit = Math.floor(channel.PowerLimit / Zc95Device.powerScaleFactor); + + if (currentAttrs.powerChannels[channelKey] > percentagePowerLimit) { + currentAttrs.powerChannels[channelKey] = percentagePowerLimit; } - channelAttr.value = Int.from(Math.floor(channel.MaxOutputPower / Zc95Device.powerScaleFactor)); // or channel.OutputPower? - channelAttr.max = percentagePowerLimit; + currentAttrs.powerChannels[channelKey] = Math.floor(channel.MaxOutputPower / Zc95Device.powerScaleFactor); + + const channelState = this.powerChannelState.find(c => c.channel === channel.Channel); + + if (channelState) { + if (channelState.maxOutputPower !== percentagePowerLimit) { + channelState.maxOutputPower = percentagePowerLimit; + schemaChanged = true; + } + } else { + this.powerChannelState.push({ channel: channel.Channel, maxOutputPower: percentagePowerLimit }); + schemaChanged = true; + } + } + + if (schemaChanged || needsSeed) { + this.rebuildSchema(); } this.updateLastRefresh(); } + /** Rebuilds the attributes schema from current device state and recompiles the validator. */ + private rebuildSchema(): void { + this.updateAttributesSchema(zc95AttributesSchema({ + patterns: this.patterns, + activePatternMenuItems: this.activePatternMenuItems, + powerChannels: this.powerChannelState, + })); + } + private onReceivedMessage(data: Buffer): void { const decodedMessage = this.protocol.decode(data); @@ -268,102 +381,19 @@ export default class Zc95Device extends PeripheralDevice( - attrName, - menuItem.Title, - DeviceAttributeModifier.readWrite, - menuItem.Choices.map(choice => ({ key: Int.from(choice.Id), value: choice.Name })), - Int.from(menuItem.Default), - ); - } - } - - return patternAttributes; - } - - private static getChannelPowerAttribute(channelIndex: Zc95DevicePowerChannelIndex): IntRangeDeviceAttribute { - return IntRangeDeviceAttribute.create( - `${Zc95Device.powerChannelAttributePrefix}${channelIndex}`, - `Channel ${channelIndex}`, - DeviceAttributeModifier.readWrite, - undefined, - Int.ZERO, - Int.ZERO, - Int.from(1), - ); - } - - private static getChannelPowerAttributes(): Zc95DevicePowerChannelAttributes { - return { - powerChannel1: Zc95Device.getChannelPowerAttribute(Zc95DevicePowerChannelIndex.One), - powerChannel2: Zc95Device.getChannelPowerAttribute(Zc95DevicePowerChannelIndex.Two), - powerChannel3: Zc95Device.getChannelPowerAttribute(Zc95DevicePowerChannelIndex.Three), - powerChannel4: Zc95Device.getChannelPowerAttribute(Zc95DevicePowerChannelIndex.Four), - }; - } - - private static allPowerChannelValuesDefined(attrs: Partial): attrs is { - [K in keyof Zc95DevicePowerChannelAttributes]-?: InitializedIntRangeDeviceAttribute - } { - return attrs.powerChannel1?.value !== undefined - && attrs.powerChannel2?.value !== undefined - && attrs.powerChannel3?.value !== undefined - && attrs.powerChannel4?.value !== undefined - ; - } - - private static isPowerChannelAttribute( - attribute: AnyZc95DeviceAttribute, - ): attribute is DeviceAttributeOf { - return attribute.name.startsWith(Zc95Device.powerChannelAttributePrefix) - && ['1', '2', '3', '4'].includes(attribute.name.slice(Zc95Device.powerChannelAttributePrefix.length)); + /** Narrows started attributes to the branch with powerChannels. */ + private static hasPowerChannels(attrs: Zc95StartedAttributes): attrs is Zc95StartedWithPowerAttributes { + return 'powerChannels' in attrs; } - private static isPatternStartedAttribute(attribute: AnyZc95DeviceAttribute): attribute is Zc95DeviceAttributes['patternStarted'] & { name: 'patternStarted' } { - return attribute.name === 'patternStarted'; - } - - private static isActivePatternAttribute(attribute: AnyZc95DeviceAttribute): attribute is Zc95DeviceAttributes['activePattern'] & { name: 'activePattern' } { - return attribute.name === 'activePattern'; - } - - private static isPatternDetailAttribute(attribute: AnyZc95DeviceAttribute): attribute is DeviceAttributeOf { - return attribute.name.startsWith(Zc95Device.patternAttributePrefix) - && !isNaN(parseInt(attribute.name.slice(Zc95Device.patternAttributePrefix.length), 10)); + private static isPowerChannelKey(key: string): key is PowerChannelKey { + return ['1', '2', '3', '4'].includes(key); } private static isPowerStatusMessage(msg: MsgResponse): msg is PowerStatusMsgResponse { return msg.MsgId === -1 && msg.Type === 'PowerStatus'; } - private static isMinMaxMenuItem(menuItem: MenuItem): menuItem is MinMaxMenuItem { - return menuItem.Type === 'MIN_MAX'; - } - - private static isMultiChoiceMenuItem(menuItem: MenuItem): menuItem is MultiChoiceMenuItem { - return menuItem.Type === 'MULTI_CHOICE'; - } - private static assertOkResponse(response: MsgResponse): void { if (response.Result !== 'OK') { diff --git a/src/device/protocol/zc95/zc95DeviceFactory.ts b/src/device/protocol/zc95/zc95DeviceFactory.ts index 95105ea3..ef67430c 100644 --- a/src/device/protocol/zc95/zc95DeviceFactory.ts +++ b/src/device/protocol/zc95/zc95DeviceFactory.ts @@ -1,15 +1,10 @@ import type KnownDeviceRegistry from '../../knownDeviceRegistry.js'; import type DateFactory from '../../../factory/dateFactory.js'; import type Logger from '../../../logging/Logger.js'; -import type { Zc95DeviceAttributes } from './zc95Device.js'; +import type { Zc95AttributeValues } from './zc95Device.js'; import Zc95Device from './zc95Device.js'; -import type { VersionMsgResponse } from './zc95MessageFactory.js'; +import type { PatternsMsgResponse, VersionMsgResponse } from './zc95MessageFactory.js'; import type Zc95MessageFactory from './zc95MessageFactory.js'; -import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; -import type { ListDeviceAttributeOptions } from '../../attribute/listDeviceAttribute.js'; -import ListDeviceAttribute from '../../attribute/listDeviceAttribute.js'; -import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; -import { Int } from '../../../util/numbers.js'; import type Zc95Protocol from './zc95Protocol.js'; import type DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; import type MessageResponseHandler from '../messageResponseHandler.js'; @@ -17,6 +12,8 @@ import type EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; import { logError } from '../../../util/error.js'; import type { DetectionId } from '../../deviceId.js'; import { DeviceId } from '../../deviceId.js'; +import { zc95AttributesSchema } from './zc95AttributesSchema.js'; +import type JsonSchemaValidatorFactory from '../../../schemaValidation/JsonSchemaValidatorFactory.js'; export default class Zc95DeviceFactory { @@ -28,17 +25,21 @@ export default class Zc95DeviceFactory private readonly knownDeviceRegistry: KnownDeviceRegistry; + private readonly validatorFactory: JsonSchemaValidatorFactory; + private readonly logger: Logger; public constructor( dateFactory: DateFactory, eventEmitterFactory: EventEmitterFactory, knownDeviceRegistry: KnownDeviceRegistry, + validatorFactory: JsonSchemaValidatorFactory, logger: Logger, ) { this.dateFactory = dateFactory; this.eventEmitterFactory = eventEmitterFactory; this.knownDeviceRegistry = knownDeviceRegistry; + this.validatorFactory = validatorFactory; this.logger = logger; } @@ -57,9 +58,7 @@ export default class Zc95DeviceFactory Zc95DeviceFactory.PATTERN_LIST_RESPONSE_TIMEOUT_MS, )).Patterns; - const attributes = Zc95DeviceFactory.getAttributes( - availablePatterns.map(pattern => ({ key: Int.from(pattern.Id), value: pattern.Name })), - ); + const { schema, attributes } = Zc95DeviceFactory.buildInitialAttributes(availablePatterns); // We only receive serial no. info for ZC95 devices with fw >=2.0 const knownDevice = this.knownDeviceRegistry.resolve( @@ -81,10 +80,13 @@ export default class Zc95DeviceFactory versionDetails.ZC95, protocol, transport, + schema, attributes, + availablePatterns, {}, messageFactory, messageResponseHandler, + this.validatorFactory, this.eventEmitterFactory.create(), this.logger, ); @@ -101,18 +103,20 @@ export default class Zc95DeviceFactory } } - private static getAttributes(patterns: ListDeviceAttributeOptions): Zc95DeviceAttributes { - const activePatternAttr = ListDeviceAttribute.createInitialized( - 'activePattern', 'Pattern', DeviceAttributeModifier.readWrite, patterns, Int.ZERO, - ); + private static buildInitialAttributes( + patterns: PatternsMsgResponse['Patterns'], + ): { schema: ReturnType, attributes: Zc95AttributeValues } { + const schema = zc95AttributesSchema({ + patterns, + activePatternMenuItems: [], + powerChannels: [], + }); - const patternStartedAttr = BoolDeviceAttribute.createInitialized( - 'patternStarted', 'Pattern Started', DeviceAttributeModifier.readWrite, false, - ); - - return { - activePattern: activePatternAttr, - patternStarted: patternStartedAttr, + const attributes: Zc95AttributeValues = { + activePattern: 0, + patternStarted: false, }; + + return { schema, attributes }; } } diff --git a/src/device/serializedTypes.ts b/src/device/serializedTypes.ts index a14ae492..bb0fc2dd 100644 --- a/src/device/serializedTypes.ts +++ b/src/device/serializedTypes.ts @@ -1,58 +1,6 @@ import type DeviceState from './deviceState.js'; -import type { DeviceAttributeModifier } from './attribute/deviceAttribute.js'; import type { DeviceId } from './deviceId.js'; - -type SerializedDeviceAttributeBase = { - name: string; - label: string | undefined; - modifier: DeviceAttributeModifier; - type: string; -}; - -type SerializedIntRangeDeviceAttribute = SerializedDeviceAttributeBase & { - type: 'range'; - value: number | undefined; - min: number; - max: number; - incrementStep: number; - uom: string | undefined; -}; - -type SerializedIntDeviceAttribute = SerializedDeviceAttributeBase & { - type: 'int'; - value: number | undefined; - uom: string | undefined; -}; - -type SerializedFloatDeviceAttribute = SerializedDeviceAttributeBase & { - type: 'float'; - value: number | undefined; - uom: string | undefined; -}; - -type SerializedBoolDeviceAttribute = SerializedDeviceAttributeBase & { - type: 'bool'; - value: boolean | undefined; -}; - -type SerializedStrDeviceAttribute = SerializedDeviceAttributeBase & { - type: 'str'; - value: string | undefined; -}; - -type SerializedListDeviceAttribute = SerializedDeviceAttributeBase & { - type: 'list'; - value: string | number | undefined; - values: { key: string | number, value: string | number }[]; -}; - -type SerializedDeviceAttribute = - | SerializedIntRangeDeviceAttribute - | SerializedIntDeviceAttribute - | SerializedFloatDeviceAttribute - | SerializedBoolDeviceAttribute - | SerializedStrDeviceAttribute - | SerializedListDeviceAttribute; +import type { JsonObject } from '../types.js'; type SerializedDeviceBase = { connectedSince: Date; @@ -63,7 +11,10 @@ type SerializedDeviceBase = { errorInfo: { reason: string, occurredAt: Date } | undefined; controllable: boolean; lastRefresh: Date | undefined; - attributes: Record; + /** JSON Schema describing the device's attribute shape, validation rules, and metadata. */ + attributesSchema: JsonObject; + /** Flat key→value map of current attribute state. */ + attributes: Record; config: Record; }; diff --git a/src/device/updater/bufferedDeviceUpdater.ts b/src/device/updater/bufferedDeviceUpdater.ts index 030e748a..43f29dbc 100644 --- a/src/device/updater/bufferedDeviceUpdater.ts +++ b/src/device/updater/bufferedDeviceUpdater.ts @@ -1,4 +1,4 @@ -import type { AnyDevice, DeviceData } from '../device.js'; +import type { AnyDevice, DeviceData, DeviceDataUpdateResult } from '../device.js'; import type DeviceUpdaterInterface from './deviceUpdaterInterface.js'; import { SequentialTaskQueue } from '@timesplinter/sequential-task-queue'; @@ -8,8 +8,8 @@ export default class BufferedDeviceUpdater implements DeviceUpdaterInterface deviceUpdater: DeviceUpdaterInterface, device: AnyDevice, deviceData: DeviceData, - ): Promise => { - await deviceUpdater.update(device, deviceData); + ): Promise => { + return deviceUpdater.update(device, deviceData); }; private readonly decoratedDeviceUpdater: DeviceUpdaterInterface; @@ -21,7 +21,7 @@ export default class BufferedDeviceUpdater implements DeviceUpdaterInterface this.queue = new SequentialTaskQueue(); } - public async update(device: AnyDevice, deviceData: DeviceData): Promise { - await this.queue.push(BufferedDeviceUpdater.handleUpdate, { args: [this.decoratedDeviceUpdater, device, deviceData] }); + public async update(device: AnyDevice, deviceData: DeviceData): Promise { + return this.queue.push(BufferedDeviceUpdater.handleUpdate, { args: [this.decoratedDeviceUpdater, device, deviceData] }); } } diff --git a/src/device/updater/deviceUpdaterInterface.ts b/src/device/updater/deviceUpdaterInterface.ts index 7d001c8f..1a21fe0a 100644 --- a/src/device/updater/deviceUpdaterInterface.ts +++ b/src/device/updater/deviceUpdaterInterface.ts @@ -1,7 +1,7 @@ -import type { AnyDevice, DeviceData } from '../device.js'; +import type { AnyDevice, DeviceData, DeviceDataUpdateResult } from '../device.js'; type DeviceUpdaterInterface = { - update: (device: AnyDevice, rawData: DeviceData) => Promise; + update: (device: AnyDevice, data: DeviceData) => Promise; }; export default DeviceUpdaterInterface; diff --git a/src/serviceProvider/deviceServiceProvider.ts b/src/serviceProvider/deviceServiceProvider.ts index fa70075f..fd9fb10d 100644 --- a/src/serviceProvider/deviceServiceProvider.ts +++ b/src/serviceProvider/deviceServiceProvider.ts @@ -113,6 +113,7 @@ export default class DeviceServiceProvider implements ServiceProvider { - const plainToClass = container.get('serializer.plainToClass'); const logger = container.get('logger.default'); - const deviceUpdater = new GenericDeviceUpdater(plainToClass, logger); + const deviceUpdater = new GenericDeviceUpdater(logger); return new BufferedDeviceUpdater(deviceUpdater); }); diff --git a/src/serviceProvider/schemaValidationServiceProvider.ts b/src/serviceProvider/schemaValidationServiceProvider.ts index ae6dd2f1..93f5498d 100644 --- a/src/serviceProvider/schemaValidationServiceProvider.ts +++ b/src/serviceProvider/schemaValidationServiceProvider.ts @@ -4,6 +4,7 @@ import type { Ajv } from 'ajv'; import { Ajv2020 } from 'ajv/dist/2020.js'; import ajvFormatsPlugin from 'ajv-formats'; import JsonSchemaValidatorFactory from '../schemaValidation/JsonSchemaValidatorFactory.js'; +import { registerAttributeSchemaKeywords } from '../device/attribute/attributeSchemaKeywords.js'; export default class SchemaValidationServiceProvider implements ServiceProvider { @@ -11,6 +12,7 @@ export default class SchemaValidationServiceProvider implements ServiceProvider< container.set('ajv', (): Ajv => { const ajv = new Ajv2020({ allErrors: true, strict: true }); ajvFormatsPlugin.default(ajv); + registerAttributeSchemaKeywords(ajv); return ajv; }); diff --git a/src/socket/deviceUpdateHandler.ts b/src/socket/deviceUpdateHandler.ts index 40f17470..938a8891 100644 --- a/src/socket/deviceUpdateHandler.ts +++ b/src/socket/deviceUpdateHandler.ts @@ -1,8 +1,9 @@ import type ConnectedDeviceRepository from '../repository/connectedDeviceRepository.js'; import type DeviceUpdaterInterface from '../device/updater/deviceUpdaterInterface.js'; -import type { DeviceUpdateData } from './types.js'; +import type { DeviceUpdateAck, DeviceUpdateData } from './types.js'; import type Logger from '../logging/Logger.js'; import { logError } from '../util/error.js'; +import DeviceDataValidationError from '../device/deviceDataValidationError.js'; export default class DeviceUpdateHandler { @@ -22,7 +23,7 @@ export default class DeviceUpdateHandler this.logger = logger; } - public async handle(data: DeviceUpdateData): Promise { + public async handle(data: DeviceUpdateData, ack?: (response: DeviceUpdateAck) => void): Promise { const deviceId = data.deviceId; const device = this.connectedDeviceRepository.getById(deviceId); @@ -31,8 +32,14 @@ export default class DeviceUpdateHandler } try { - await this.deviceUpdater.update(device, data.data); + const result = await this.deviceUpdater.update(device, data.data); + ack?.(result); } catch (err: unknown) { + if (err instanceof DeviceDataValidationError) { + ack?.({ validationErrors: err.validationErrors }); + return; + } + logError(this.logger, `Error while updating device with id ${deviceId}`, err); } } diff --git a/src/socket/types.ts b/src/socket/types.ts index fd9ce80b..c0a90efa 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -1,5 +1,5 @@ import type { Server } from 'socket.io'; -import type { DeviceData, AnyDeviceNotification } from '../device/device.js'; +import type { DeviceData, DeviceDataUpdateResult, DeviceDataApplyError, AnyDeviceNotification } from '../device/device.js'; import type WebSocketEvent from '../device/webSocketEvent.js'; import type SettingsEventType from '../settings/settingsEventType.js'; import type AutomationEventType from '../automation/automationEventType.js'; @@ -10,8 +10,10 @@ import type { DeviceId } from '../device/deviceId.js'; export type DeviceUpdateData = { deviceId: DeviceId, data: DeviceData }; +export type DeviceUpdateAck = DeviceDataUpdateResult | { validationErrors: DeviceDataApplyError[] }; + export type ClientToServerEvents = { - [WebSocketEvent.deviceUpdateReceived]: (data: DeviceUpdateData) => void; + [WebSocketEvent.deviceUpdateReceived]: (data: DeviceUpdateData, ack?: (response: DeviceUpdateAck) => void) => void; }; export type ServerToClientEvents = { diff --git a/tests/type/device/zc95/zc95.test-d.ts b/tests/type/device/zc95/zc95.test-d.ts index a49e234c..b9d38ba7 100644 --- a/tests/type/device/zc95/zc95.test-d.ts +++ b/tests/type/device/zc95/zc95.test-d.ts @@ -1,29 +1,31 @@ -import { expectTypeOf } from 'vitest'; +import { assertType, expectTypeOf } from 'vitest'; import type Zc95Device from '../../../../src/device/protocol/zc95/zc95Device.js'; -import { Int } from '../../../../src/util/numbers.js'; +import type { Zc95StartedDeviceApi } from '../../../../src/device/protocol/zc95/zc95Device.js'; +import type { Zc95AttributeValues, Zc95StartedAttributes } from '../../../../src/device/protocol/zc95/zc95AttributesSchema.js'; +import type { DeviceDataUpdateResult } from '../../../../src/device/device.js'; declare const device: Zc95Device; -// activePattern: Int (initialized list attribute, always has a value) -expectTypeOf(device.setAttribute('activePattern', Int.from(1))).toEqualTypeOf>(); -// @ts-expect-error activePattern does not accept a string value -device.setAttribute('activePattern', 'pattern-1'); +// --- getDeviceData returns the full union --- +expectTypeOf(device.getDeviceData()).toEqualTypeOf(); -// patternStarted: boolean (initialized bool attribute, always has a value) -expectTypeOf(device.setAttribute('patternStarted', true)).toEqualTypeOf>(); -// @ts-expect-error patternStarted does not accept an Int value -device.setAttribute('patternStarted', Int.from(1)); +// --- getAttributesSchema is available --- +expectTypeOf(device.getAttributesSchema).toBeFunction(); -// powerChannel1-4 (dynamic, fixed suffix 1|2|3|4): Int | undefined -expectTypeOf(device.setAttribute('powerChannel1', Int.from(50))).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('powerChannel4', Int.from(50))).toEqualTypeOf>(); -// @ts-expect-error powerChannel5 is not a valid power channel index -device.setAttribute('powerChannel5', Int.from(50)); +// --- After narrowing with isPatternStarted(): started-state data is available --- -// patternAttribute (dynamic, numeric suffix): Int | undefined -expectTypeOf(device.setAttribute('patternAttribute3', Int.from(10))).toEqualTypeOf>(); -// @ts-expect-error patternAttribute suffix must be numeric -device.setAttribute('patternAttributeFoo', Int.from(10)); +if (device.isPatternStarted()) { + // After narrowing, getDeviceData still returns the union (structuredClone breaks narrowing), + // but the discriminant can be used to narrow the result + const data = device.getDeviceData(); + if (data.patternStarted) { + expectTypeOf(data.patternAttributes).toEqualTypeOf>(); + } -// @ts-expect-error unknown attribute name is rejected -device.setAttribute('doesNotExist', Int.from(1)); + // updateDeviceData accepts started-state updates + expectTypeOf(device.updateDeviceData({ powerChannels: { 1: 50 } })) + .toEqualTypeOf>>(); +} + +// --- isPatternStarted() returns a proper type predicate --- +assertType<(this: Zc95Device) => this is Zc95Device & Zc95StartedDeviceApi>(device.isPatternStarted); diff --git a/tests/unit/automation/scriptRuntime.spec.ts b/tests/unit/automation/scriptRuntime.spec.ts index 4491ce5e..42c4ff80 100644 --- a/tests/unit/automation/scriptRuntime.spec.ts +++ b/tests/unit/automation/scriptRuntime.spec.ts @@ -269,13 +269,13 @@ describe('ScriptRuntime (isolated-vm)', () => { }); // ----------------------------------------------------------------------- - // device.getAttribute + // device.getDeviceData // ----------------------------------------------------------------------- - it('device.getAttribute returns { value } for an existing attribute', async () => { + it('device.getDeviceData returns { value } for an existing attribute', async () => { await runtime.load(` onEvent('deviceConnected', async (device) => { - const attr = await device.getAttribute('label'); + const attr = await device.getDeviceData('label'); console.log(attr !== undefined ? attr.value : 'undefined'); console.log('${TEST_END_MARKER}'); }); @@ -285,10 +285,10 @@ describe('ScriptRuntime (isolated-vm)', () => { expect(logs).toContain('hello'); }); - it('device.getAttribute returns undefined for a missing attribute', async () => { + it('device.getDeviceData returns undefined for a missing attribute', async () => { await runtime.load(` onEvent('deviceConnected', async (device) => { - const attr = await device.getAttribute('nonexistent'); + const attr = await device.getDeviceData('nonexistent'); console.log(String(attr)); console.log('${TEST_END_MARKER}'); }); @@ -378,14 +378,14 @@ describe('ScriptRuntime (isolated-vm)', () => { }); // ----------------------------------------------------------------------- - // devices.getById with getAttribute + // devices.getById with getDeviceData // ----------------------------------------------------------------------- - it('devices.getById getAttribute returns the attribute value', async () => { + it('devices.getById getDeviceData returns the attribute value', async () => { await runtime.load(` onEvent('deviceConnected', async (device) => { const d = devices.getById('${deviceA.getDeviceId}'); - const attr = await d.getAttribute('label'); + const attr = await d.getDeviceData('label'); console.log(String(attr !== undefined ? attr.value : null)); console.log('${TEST_END_MARKER}'); }); @@ -395,11 +395,11 @@ describe('ScriptRuntime (isolated-vm)', () => { expect(logs).toContain('hello'); }); - it('devices.getById getAttribute returns null for an unknown device', async () => { + it('devices.getById getDeviceData returns null for an unknown device', async () => { await runtime.load(` onEvent('deviceConnected', async (device) => { const d = devices.getById('ghost'); - const attr = d !== null ? await d.getAttribute('label') : undefined; + const attr = d !== null ? await d.getDeviceData('label') : undefined; console.log(String(attr !== undefined ? attr.value : null)); console.log('${TEST_END_MARKER}'); }); diff --git a/tests/unit/device/attribute/attributeSchemaKeywords.spec.ts b/tests/unit/device/attribute/attributeSchemaKeywords.spec.ts new file mode 100644 index 00000000..2cbb8c1a --- /dev/null +++ b/tests/unit/device/attribute/attributeSchemaKeywords.spec.ts @@ -0,0 +1,68 @@ +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { describe, expect, it } from 'vitest'; +import { registerAttributeSchemaKeywords } from '../../../../src/device/attribute/attributeSchemaKeywords.js'; + +describe('registerAttributeSchemaKeywords', () => { + const buildValidator = (): ((data: unknown) => boolean) => { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + registerAttributeSchemaKeywords(ajv); + + return ajv.compile({ + 'type': 'integer', + 'minimum': 12, + 'maximum': 20, + 'x-increment-step': 3, + }); + }; + + it.each([12, 15, 18])('accepts %i as it lies on the step grid anchored at minimum', value => { + expect(buildValidator()(value)).toBe(true); + }); + + it.each([13, 14, 16, 17, 19, 20])('rejects %i as it does not lie on the step grid', value => { + expect(buildValidator()(value)).toBe(false); + }); + + it('rejects a value below minimum regardless of step alignment', () => { + expect(buildValidator()(9)).toBe(false); + }); + + it('rejects a value above maximum even if step-aligned', () => { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + registerAttributeSchemaKeywords(ajv); + + const validate = ajv.compile({ + 'type': 'integer', + 'minimum': 12, + 'maximum': 20, + 'x-increment-step': 3, + }); + + // 21 is step-aligned (12, 15, 18, 21, ...) but exceeds maximum + expect(validate(21)).toBe(false); + }); + + it('anchors the step grid at 0 when no minimum is declared', () => { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + registerAttributeSchemaKeywords(ajv); + + const validate = ajv.compile({ 'type': 'integer', 'x-increment-step': 5 }); + + expect(validate(10)).toBe(true); + expect(validate(12)).toBe(false); + }); + + it('treats x-label, x-uom and x-group as pure annotations that never fail validation', () => { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + registerAttributeSchemaKeywords(ajv); + + const validate = ajv.compile({ + 'type': 'integer', + 'x-label': 'Channel 1', + 'x-uom': '%', + 'x-group': 0, + }); + + expect(validate(42)).toBe(true); + }); +}); diff --git a/tests/unit/device/protocol/zc95/zc95AttributesSchema.spec.ts b/tests/unit/device/protocol/zc95/zc95AttributesSchema.spec.ts new file mode 100644 index 00000000..50f1a542 --- /dev/null +++ b/tests/unit/device/protocol/zc95/zc95AttributesSchema.spec.ts @@ -0,0 +1,188 @@ +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { describe, expect, it } from 'vitest'; +import { zc95AttributesSchema } from '../../../../../src/device/protocol/zc95/zc95AttributesSchema.js'; +import { Zc95DevicePowerChannelIndex } from '../../../../../src/device/protocol/zc95/zc95Device.js'; +import { registerAttributeSchemaKeywords } from '../../../../../src/device/attribute/attributeSchemaKeywords.js'; +import JsonSchemaValidatorFactory from '../../../../../src/schemaValidation/JsonSchemaValidatorFactory.js'; + +describe('zc95AttributesSchema', () => { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + registerAttributeSchemaKeywords(ajv); + + const jsonSchemaValidatorFactory = new JsonSchemaValidatorFactory(ajv); + + const minMaxMenuItem = { + Id: 1, + Title: 'Pulse Width', + Group: 0, + Type: 'MIN_MAX' as const, + Default: 150, + Min: 20, + Max: 2000, + IncrementStep: 10, + UoM: 'us', + }; + + const multiChoiceMenuItem = { + Id: 2, + Title: 'Waveform', + Group: 1, + Type: 'MULTI_CHOICE' as const, + Default: 0, + Choices: [ + { Id: 0, Name: 'Sine' }, + { Id: 1, Name: 'Square' }, + ], + }; + + const buildSchema = () => zc95AttributesSchema({ + patterns: [ + { Type: 'PatternDetail', Id: 0, Name: 'Waves' }, + { Type: 'PatternDetail', Id: 1, Name: 'Pulse' }, + ], + activePatternMenuItems: [minMaxMenuItem, multiChoiceMenuItem], + powerChannels: [ + { channel: Zc95DevicePowerChannelIndex.One, maxOutputPower: 85 }, + { channel: Zc95DevicePowerChannelIndex.Two, maxOutputPower: 85 }, + { channel: Zc95DevicePowerChannelIndex.Three, maxOutputPower: 85 }, + { channel: Zc95DevicePowerChannelIndex.Four, maxOutputPower: 85 }, + ], + }); + + // Schema is Type.Union([stopped, started]) — stopped at index 0, started at index 1. + const getStoppedBranch = (schema: ReturnType) => schema.anyOf[0]; + const getStartedBranch = (schema: ReturnType) => schema.anyOf[1]; + + it('produces a union schema with stopped and started branches', () => { + const schema = buildSchema(); + + expect(schema.anyOf).toHaveLength(2); + expect(getStoppedBranch(schema)).toBeDefined(); + expect(getStartedBranch(schema)).toBeDefined(); + }); + + it('maps a MIN_MAX menu item to a range-shaped property nested in patternAttributes', () => { + const schema = buildSchema(); + const started = getStartedBranch(schema); + + expect(started.properties.patternAttributes.properties[1]).toMatchObject({ + type: 'integer', + 'x-label': 'Pulse Width', + 'x-group': 0, + 'x-uom': 'µs', + minimum: 20, + maximum: 2000, + 'x-increment-step': 10, + default: 150, + }); + }); + + it('maps a MULTI_CHOICE menu item to a labeled-enum shaped property nested in patternAttributes', () => { + const schema = buildSchema(); + const started = getStartedBranch(schema); + + expect(started.properties.patternAttributes.properties[2]).toMatchObject({ + type: 'integer', + 'x-label': 'Waveform', + 'x-group': 1, + default: 0, + oneOf: [ + { const: 0, 'x-label': 'Sine' }, + { const: 1, 'x-label': 'Square' }, + ], + }); + }); + + it('maps the pattern list to the activePattern choices on both branches', () => { + const schema = buildSchema(); + const stopped = getStoppedBranch(schema); + const started = getStartedBranch(schema); + + const expectedChoices = { + oneOf: [ + { const: 0, 'x-label': 'Waves' }, + { const: 1, 'x-label': 'Pulse' }, + ], + }; + + expect(stopped.properties.activePattern).toMatchObject(expectedChoices); + expect(started.properties.activePattern).toMatchObject(expectedChoices); + }); + + it('maps power channels to bounded range properties nested in powerChannels', () => { + const schema = buildSchema(); + const started = getStartedBranch(schema); + + expect(started.properties.powerChannels.properties[1]).toMatchObject({ type: 'integer', minimum: 0, maximum: 85 }); + expect(started.properties.powerChannels.properties[4]).toMatchObject({ type: 'integer', minimum: 0, maximum: 85 }); + }); + + it('validates a started value object that satisfies all bounds and choices', () => { + const validator = jsonSchemaValidatorFactory.create(buildSchema()); + + const isValid = validator.validate({ + activePattern: 1, + patternStarted: true, + powerChannels: { 1: 40, 2: 0, 3: 0, 4: 0 }, + patternAttributes: { 1: 150, 2: 1 }, + }); + + expect(isValid).toBe(true); + }); + + it('validates a stopped value object', () => { + const validator = jsonSchemaValidatorFactory.create(buildSchema()); + + const isValid = validator.validate({ + activePattern: 0, + patternStarted: false, + }); + + expect(isValid).toBe(true); + }); + + it('rejects a value exceeding the range maximum', () => { + const validator = jsonSchemaValidatorFactory.create(buildSchema()); + + const isValid = validator.validate({ + activePattern: 0, + patternStarted: true, + powerChannels: { 1: 999, 2: 0, 3: 0, 4: 0 }, + patternAttributes: { 1: 150, 2: 0 }, + }); + + expect(isValid).toBe(false); + }); + + it('rejects a value that is not one of the labeled choices', () => { + const validator = jsonSchemaValidatorFactory.create(buildSchema()); + + const isValid = validator.validate({ + activePattern: 0, + patternStarted: true, + powerChannels: { 1: 0, 2: 0, 3: 0, 4: 0 }, + patternAttributes: { 1: 150, 2: 7 }, + }); + + expect(isValid).toBe(false); + }); + + it('rejects unknown properties in the top-level object', () => { + const validator = jsonSchemaValidatorFactory.create(buildSchema()); + + const isValid = validator.validate({ + activePattern: 0, + patternStarted: false, + somethingUnexpected: true, + }); + + expect(isValid).toBe(false); + }); + + it('throws under ajv strict mode if the x-* annotation keywords are not registered', () => { + const strictAjv = new Ajv2020({ allErrors: true, strict: true }); + const strictFactory = new JsonSchemaValidatorFactory(strictAjv); + + expect(() => strictFactory.create(buildSchema())).toThrow(); + }); +}); diff --git a/tests/unit/device/protocol/zc95/zc95Device.spec.ts b/tests/unit/device/protocol/zc95/zc95Device.spec.ts index f1a625ab..51c5b87c 100644 --- a/tests/unit/device/protocol/zc95/zc95Device.spec.ts +++ b/tests/unit/device/protocol/zc95/zc95Device.spec.ts @@ -2,9 +2,10 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { mock, MockProxy } from 'vitest-mock-extended'; import { EventEmitter } from 'events'; import Zc95Device, { - Zc95DeviceAttributes, - Zc95DevicePowerChannelAttributes, + Zc95DevicePowerChannelIndex, } from '../../../../../src/device/protocol/zc95/zc95Device.js'; +import type { Zc95AttributeValues, PowerChannelState } from '../../../../../src/device/protocol/zc95/zc95Device.js'; +import type { MinMaxMenuItem, MultiChoiceMenuItem } from '../../../../../src/device/protocol/zc95/zc95MessageFactory.js'; import Zc95Protocol, { MsgAndResponseIdentifier, MsgResponse } from '../../../../../src/device/protocol/zc95/zc95Protocol.js'; import DeviceBidirectionalTransport from '../../../../../src/device/transport/deviceBidirectionalTransport.js'; import MessageResponseHandler from '../../../../../src/device/protocol/messageResponseHandler.js'; @@ -13,16 +14,14 @@ import Zc95MessageFactory, { PatternDetailsMsgResponse, PowerStatusMsgResponse, } from '../../../../../src/device/protocol/zc95/zc95MessageFactory.js'; -import IntRangeDeviceAttribute from '../../../../../src/device/attribute/intRangeDeviceAttribute.js'; -import ListDeviceAttribute, { - InitializedListDeviceAttribute, -} from '../../../../../src/device/attribute/listDeviceAttribute.js'; -import BoolDeviceAttribute from '../../../../../src/device/attribute/boolDeviceAttribute.js'; -import { DeviceAttributeModifier } from '../../../../../src/device/attribute/deviceAttribute.js'; -import { Int } from '../../../../../src/util/numbers.js'; import { DeviceId } from '../../../../../src/device/deviceId.js'; import Logger from '../../../../../src/logging/Logger.js'; import assert from 'assert'; +import { zc95AttributesSchema } from '../../../../../src/device/protocol/zc95/zc95AttributesSchema.js'; +import JsonSchemaValidatorFactory from '../../../../../src/schemaValidation/JsonSchemaValidatorFactory.js'; +import DeviceDataValidationError from '../../../../../src/device/deviceDataValidationError.js'; +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { registerAttributeSchemaKeywords } from '../../../../../src/device/attribute/attributeSchemaKeywords.js'; describe('Zc95Device', () => { let mockProtocol: MockProxy; @@ -30,57 +29,44 @@ describe('Zc95Device', () => { let mockMsgHandler: MockProxy>; let mockMsgFactory: MockProxy; let mockLogger: MockProxy; + let validatorFactory: JsonSchemaValidatorFactory; const fakeMsgId = {} as MsgAndResponseIdentifier; const okResponse: AckMsgResponse = { Type: 'Ack', MsgId: 1, Result: 'OK' }; const errorResponse: AckMsgResponse = { Type: 'Ack', MsgId: 1, Result: 'ERROR', Error: 'something went wrong' }; - function createActivePatternAttr( - currentValue: Int = Int.from(0) - ): InitializedListDeviceAttribute { - return ListDeviceAttribute.createInitialized( - 'activePattern', - 'Active Pattern', - DeviceAttributeModifier.readWrite, - [ - { key: Int.from(0), value: 'Pattern A' }, - { key: Int.from(1), value: 'Pattern B' }, - ], - currentValue, - ); - } - - function createPatternStartedAttr(initialValue: boolean = false) { - return BoolDeviceAttribute.createInitialized( - 'patternStarted', - 'Pattern Started', - DeviceAttributeModifier.readWrite, - initialValue, - ); - } - - function createPowerChannelAttrs(): Zc95DevicePowerChannelAttributes { - const makeAttr = (ch: number) => - IntRangeDeviceAttribute.createInitialized( - `powerChannel${ch}`, - `Channel ${ch}`, - DeviceAttributeModifier.readWrite, - undefined, - Int.ZERO, - Int.from(100), - Int.from(1), - Int.from(10), - ); - - return { - powerChannel1: makeAttr(1), - powerChannel2: makeAttr(2), - powerChannel3: makeAttr(3), - powerChannel4: makeAttr(4), + const defaultPatterns = [ + { Type: 'PatternDetail' as const, Id: 0, Name: 'Pattern A' }, + { Type: 'PatternDetail' as const, Id: 1, Name: 'Pattern B' }, + ]; + + const defaultPowerChannels: PowerChannelState[] = [ + { channel: Zc95DevicePowerChannelIndex.One, maxOutputPower: 100 }, + { channel: Zc95DevicePowerChannelIndex.Two, maxOutputPower: 100 }, + { channel: Zc95DevicePowerChannelIndex.Three, maxOutputPower: 100 }, + { channel: Zc95DevicePowerChannelIndex.Four, maxOutputPower: 100 }, + ]; + + type CreateDeviceOverrides = { + attributes?: Zc95AttributeValues; + attributesSchema?: ReturnType; + patterns?: typeof defaultPatterns; + activePatternMenuItems?: (MinMaxMenuItem | MultiChoiceMenuItem)[]; + powerChannelState?: PowerChannelState[]; + }; + + function createDevice(overrides: CreateDeviceOverrides = {}): Zc95Device { + const patterns = overrides.patterns ?? defaultPatterns; + const attributes: Zc95AttributeValues = overrides.attributes ?? { + activePattern: 0, + patternStarted: false, }; - } + const attributesSchema = overrides.attributesSchema ?? zc95AttributesSchema({ + patterns, + activePatternMenuItems: overrides.activePatternMenuItems ?? [], + powerChannels: overrides.powerChannelState ?? [], + }); - function createDevice(attrs: Zc95DeviceAttributes): Zc95Device { return new Zc95Device( { deviceId: DeviceId.create('device-id'), @@ -92,21 +78,45 @@ describe('Zc95Device', () => { '1.0.0', mockProtocol, mockTransport, - attrs, + attributesSchema, + attributes, + patterns, {}, mockMsgFactory, mockMsgHandler, + validatorFactory, new EventEmitter(), mockLogger, + overrides.activePatternMenuItems, + overrides.powerChannelState, ); } + function createStartedDevice(): Zc95Device { + return createDevice({ + attributes: { + activePattern: 0, + patternStarted: true, + powerChannels: { 1: 10, 2: 10, 3: 10, 4: 10 }, + patternAttributes: {}, + }, + powerChannelState: defaultPowerChannels, + }); + } + + /** Helper to read powerChannels from a started device with power channels. */ + function getPowerChannels(device: Zc95Device): Record { + assert(device.isPatternStarted()); + const data = device.getDeviceData(); + assert('powerChannels' in data, 'Expected powerChannels to be present'); + return data.powerChannels; + } + function getOnReceiveCallback(): (data: Buffer) => void { const call = mockTransport.onReceive.mock.calls[0]; assert(call !== undefined, 'Expected onReceive to have been registered'); return call[0]; - } beforeEach(() => { @@ -118,42 +128,70 @@ describe('Zc95Device', () => { mockTransport.getDeviceIdentifier.mockReturnValue('test-device'); mockLogger.child.mockReturnValue(mockLogger); + + // Real ajv with x-* keywords so validation works end-to-end + const ajv = new Ajv2020({ allErrors: true, strict: true }); + registerAttributeSchemaKeywords(ajv); + validatorFactory = new JsonSchemaValidatorFactory(ajv); }); - describe('setAttribute', () => { - it('throws an error when setting a non-existing attribute', async () => { - const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(), + describe('updateDeviceData', () => { + describe('result envelope', () => { + it('returns deviceData and empty errors on success', async () => { + const device = createDevice({ + attributes: { activePattern: 0, patternStarted: false }, + }); + + const result = await device.updateDeviceData({ activePattern: 1 }); + + expect(result.deviceData.activePattern).toStrictEqual(1); + expect(result.errors).toStrictEqual([]); + }); + }); + + describe('validation', () => { + it('throws DeviceDataValidationError when update violates schema', async () => { + const device = createDevice({ + attributes: { activePattern: 0, patternStarted: false }, + }); + + // activePattern 999 doesn't match any oneOf const value + await expect( + device.updateDeviceData({ activePattern: 999 }), + ).rejects.toThrow(DeviceDataValidationError); }); - await expect( - device.setAttribute('powerChannel1', Int.from(5)) - ).rejects.toThrow("Attribute with name 'powerChannel1' does not exist for this device"); + it('includes validation error details in the thrown error', async () => { + const device = createDevice({ + attributes: { activePattern: 0, patternStarted: false }, + }); + + const error = await device.updateDeviceData({ activePattern: 999 }).catch((e: unknown) => e); + + assert(error instanceof DeviceDataValidationError); + expect(error.validationErrors.length).toBeGreaterThan(0); + }); }); describe('activePattern', () => { it('does not send any messages when the pattern is already active', async () => { const device = createDevice({ - activePattern: createActivePatternAttr(Int.from(0)), - patternStarted: createPatternStartedAttr(false), + attributes: { activePattern: 0, patternStarted: false }, }); - await device.setAttribute('activePattern', Int.from(0)); + await device.updateDeviceData({ activePattern: 0 }); expect(mockMsgHandler.send).not.toHaveBeenCalled(); }); it('switches to the new pattern when a different pattern is selected', async () => { const device = createDevice({ - activePattern: createActivePatternAttr(Int.from(0)), - patternStarted: createPatternStartedAttr(false), + attributes: { activePattern: 0, patternStarted: false }, }); - await device.setAttribute('activePattern', Int.from(1)); + await device.updateDeviceData({ activePattern: 1 }); - const activePattern = await device.getAttribute('activePattern'); - expect(activePattern?.value).toStrictEqual(Int.from(1)); + expect(device.getDeviceData().activePattern).toStrictEqual(1); expect(mockMsgHandler.send).not.toHaveBeenCalled(); }); @@ -161,28 +199,22 @@ describe('Zc95Device', () => { mockMsgFactory.createPatternStop.mockReturnValue(fakeMsgId); mockMsgHandler.send.mockResolvedValue(okResponse); - const device = createDevice({ - activePattern: createActivePatternAttr(Int.from(0)), - patternStarted: createPatternStartedAttr(true), - ...createPowerChannelAttrs(), - }); + const device = createStartedDevice(); - await device.setAttribute('activePattern', Int.from(1)); + await device.updateDeviceData({ activePattern: 1 }); expect(mockMsgFactory.createPatternStop).toHaveBeenCalledTimes(1); - const activePattern = await device.getAttribute('activePattern'); - expect(activePattern?.value).toStrictEqual(Int.from(1)); + expect(device.getDeviceData().activePattern).toStrictEqual(1); }); }); describe('patternStarted', () => { it('does not send any messages when the pattern is already in the requested state', async () => { const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(false), + attributes: { activePattern: 0, patternStarted: false }, }); - await device.setAttribute('patternStarted', false); + await device.updateDeviceData({ patternStarted: false }); expect(mockMsgHandler.send).not.toHaveBeenCalled(); }); @@ -205,21 +237,19 @@ describe('Zc95Device', () => { .mockResolvedValueOnce(okResponse); const device = createDevice({ - activePattern: createActivePatternAttr(Int.from(0)), - patternStarted: createPatternStartedAttr(false), + attributes: { activePattern: 0, patternStarted: false }, }); - await device.setAttribute('patternStarted', true); + await device.updateDeviceData({ patternStarted: true }); - expect(mockMsgFactory.createGetPatternDetails).toHaveBeenCalledWith(Int.from(0)); - expect(mockMsgFactory.createPatternStart).toHaveBeenCalledWith(Int.from(0)); + expect(mockMsgFactory.createGetPatternDetails).toHaveBeenCalledWith(0); + expect(mockMsgFactory.createPatternStart).toHaveBeenCalledWith(0); expect(mockMsgHandler.send).toHaveBeenCalledTimes(2); - const patternStarted = await device.getAttribute('patternStarted'); - expect(patternStarted?.value).toStrictEqual(true); + expect(device.getDeviceData().patternStarted).toStrictEqual(true); }); - it('adds power channel attributes when starting the pattern', async () => { + it('does NOT seed powerChannels on pattern start (waits for PowerStatus)', async () => { const patternDetailsResponse: PatternDetailsMsgResponse = { Type: 'PatternDetail', MsgId: 1, @@ -237,16 +267,14 @@ describe('Zc95Device', () => { .mockResolvedValueOnce(okResponse); const device = createDevice({ - activePattern: createActivePatternAttr(Int.from(0)), - patternStarted: createPatternStartedAttr(false), + attributes: { activePattern: 0, patternStarted: false }, }); - await device.setAttribute('patternStarted', true); + await device.updateDeviceData({ patternStarted: true }); - expect(await device.getAttribute('powerChannel1')).toBeDefined(); - expect(await device.getAttribute('powerChannel2')).toBeDefined(); - expect(await device.getAttribute('powerChannel3')).toBeDefined(); - expect(await device.getAttribute('powerChannel4')).toBeDefined(); + assert(device.isPatternStarted()); + // powerChannels should NOT be present yet + expect('powerChannels' in device.getDeviceData()).toBe(false); }); it('creates MinMax pattern attributes from pattern details when starting', async () => { @@ -279,133 +307,31 @@ describe('Zc95Device', () => { .mockResolvedValueOnce(okResponse); const device = createDevice({ - activePattern: createActivePatternAttr(Int.from(0)), - patternStarted: createPatternStartedAttr(false), - }); - - await device.setAttribute('patternStarted', true); - - const patternAttr = await device.getAttribute('patternAttribute5'); - expect(patternAttr).toBeDefined(); - expect(patternAttr).toBeInstanceOf(IntRangeDeviceAttribute); - expect(patternAttr?.value).toStrictEqual(Int.from(50)); - }); - - it('converts UoM "us" to "µs" when creating pattern attributes', async () => { - const patternDetailsResponse: PatternDetailsMsgResponse = { - Type: 'PatternDetail', - MsgId: 1, - Result: 'OK', - Name: 'Pattern A', - Id: 0, - ButtonA: '', - MenuItems: [ - { - Id: 3, - Title: 'Pulse Width', - Group: 0, - Type: 'MIN_MAX', - Default: 100, - Min: 0, - Max: 1000, - IncrementStep: 10, - UoM: 'us', - }, - ], - }; - - mockMsgFactory.createGetPatternDetails.mockReturnValue(fakeMsgId); - mockMsgFactory.createPatternStart.mockReturnValue(fakeMsgId); - mockMsgHandler.send - .mockResolvedValueOnce(patternDetailsResponse) - .mockResolvedValueOnce(okResponse); - - const device = createDevice({ - activePattern: createActivePatternAttr(Int.from(0)), - patternStarted: createPatternStartedAttr(false), - }); - - await device.setAttribute('patternStarted', true); - - const patternAttr = await device.getAttribute('patternAttribute3'); - expect(patternAttr).toBeInstanceOf(IntRangeDeviceAttribute); - if (!(patternAttr instanceof IntRangeDeviceAttribute)) return; - expect(patternAttr.uom).toStrictEqual('µs'); - }); - - it('creates MultiChoice pattern attributes from pattern details when starting', async () => { - const patternDetailsResponse: PatternDetailsMsgResponse = { - Type: 'PatternDetail', - MsgId: 1, - Result: 'OK', - Name: 'Pattern A', - Id: 0, - ButtonA: '', - MenuItems: [ - { - Id: 7, - Title: 'Mode', - Group: 0, - Type: 'MULTI_CHOICE', - Default: 0, - Choices: [ - { Id: 0, Name: 'Sine' }, - { Id: 1, Name: 'Square' }, - ], - }, - ], - }; - - mockMsgFactory.createGetPatternDetails.mockReturnValue(fakeMsgId); - mockMsgFactory.createPatternStart.mockReturnValue(fakeMsgId); - mockMsgHandler.send - .mockResolvedValueOnce(patternDetailsResponse) - .mockResolvedValueOnce(okResponse); - - const device = createDevice({ - activePattern: createActivePatternAttr(Int.from(0)), - patternStarted: createPatternStartedAttr(false), + attributes: { activePattern: 0, patternStarted: false }, }); - await device.setAttribute('patternStarted', true); + await device.updateDeviceData({ patternStarted: true }); - const patternAttr = await device.getAttribute('patternAttribute7'); - expect(patternAttr).toBeDefined(); - expect(patternAttr).toBeInstanceOf(ListDeviceAttribute); - expect(patternAttr?.value).toStrictEqual(Int.from(0)); + assert(device.isPatternStarted()); + const data = device.getDeviceData(); + assert(data.patternStarted); + expect(data.patternAttributes).toStrictEqual({ '5': 50 }); }); - it('sends PatternStop and removes power/pattern attributes when stopping', async () => { + it('sends PatternStop and removes powerChannels/patternAttributes when stopping', async () => { mockMsgFactory.createPatternStop.mockReturnValue(fakeMsgId); mockMsgHandler.send.mockResolvedValue(okResponse); - const device = createDevice({ - activePattern: createActivePatternAttr(Int.from(0)), - patternStarted: createPatternStartedAttr(true), - ...createPowerChannelAttrs(), - patternAttribute1: IntRangeDeviceAttribute.createInitialized( - 'patternAttribute1', - 'Intensity', - DeviceAttributeModifier.readWrite, - undefined, - Int.ZERO, - Int.from(100), - Int.from(1), - Int.from(50), - ), - }); + const device = createStartedDevice(); - await device.setAttribute('patternStarted', false); + await device.updateDeviceData({ patternStarted: false }); expect(mockMsgFactory.createPatternStop).toHaveBeenCalledTimes(1); - - const patternStarted = await device.getAttribute('patternStarted'); - expect(patternStarted?.value).toStrictEqual(false); - expect(await device.getAttribute('powerChannel1')).toBeUndefined(); - expect(await device.getAttribute('patternAttribute1')).toBeUndefined(); + expect(device.getDeviceData().patternStarted).toStrictEqual(false); + expect(device.isPatternStarted()).toBe(false); }); - it('throws when the PatternStart response is not OK', async () => { + it('collects error when the PatternStart response is not OK', async () => { const patternDetailsResponse: PatternDetailsMsgResponse = { Type: 'PatternDetail', MsgId: 1, @@ -423,43 +349,39 @@ describe('Zc95Device', () => { .mockResolvedValueOnce(errorResponse); const device = createDevice({ - activePattern: createActivePatternAttr(Int.from(0)), - patternStarted: createPatternStartedAttr(false), + attributes: { activePattern: 0, patternStarted: false }, }); - await expect(device.setAttribute('patternStarted', true)).rejects.toThrow( - 'Device response is not OK, but ERROR: something went wrong' - ); + const result = await device.updateDeviceData({ patternStarted: true }); + + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]?.path).toBe('/patternStarted'); + expect(result.errors[0]?.message).toContain('Device response is not OK'); }); - it('throws when the PatternStop response is not OK', async () => { + it('collects error when the PatternStop response is not OK', async () => { mockMsgFactory.createPatternStop.mockReturnValue(fakeMsgId); mockMsgHandler.send.mockResolvedValue(errorResponse); - const device = createDevice({ - activePattern: createActivePatternAttr(Int.from(0)), - patternStarted: createPatternStartedAttr(true), - ...createPowerChannelAttrs(), - }); + const device = createStartedDevice(); - await expect(device.setAttribute('patternStarted', false)).rejects.toThrow( - 'Device response is not OK, but ERROR: something went wrong' - ); + const result = await device.updateDeviceData({ patternStarted: false }); + + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]?.path).toBe('/patternStarted'); + expect(result.errors[0]?.message).toContain('Device response is not OK'); }); }); - describe('powerChannel', () => { + describe('powerChannels', () => { it('sends SetPower with all channel values multiplied by 10', async () => { mockMsgFactory.createSetPower.mockReturnValue(fakeMsgId); mockMsgHandler.send.mockResolvedValue(okResponse); - const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(true), - ...createPowerChannelAttrs(), - }); + const device = createStartedDevice(); - await device.setAttribute('powerChannel1', Int.from(20)); + assert(device.isPatternStarted()); + await device.updateDeviceData({ powerChannels: { 1: 20, 2: 10, 3: 10, 4: 10 } }); expect(mockMsgFactory.createSetPower).toHaveBeenCalledWith(200, 100, 100, 100); }); @@ -468,182 +390,120 @@ describe('Zc95Device', () => { mockMsgFactory.createSetPower.mockReturnValue(fakeMsgId); mockMsgHandler.send.mockResolvedValue(okResponse); - const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(true), - ...createPowerChannelAttrs(), - }); + const device = createStartedDevice(); - await device.setAttribute('powerChannel3', Int.from(42)); + assert(device.isPatternStarted()); + await device.updateDeviceData({ powerChannels: { 3: 42 } }); - const attr = await device.getAttribute('powerChannel3'); - expect(attr?.value).toStrictEqual(Int.from(42)); + const pc = getPowerChannels(device); + expect(pc['3']).toStrictEqual(42); }); - it('throws when the SetPower response is not OK', async () => { + it('collects error when the SetPower response is not OK', async () => { mockMsgFactory.createSetPower.mockReturnValue(fakeMsgId); mockMsgHandler.send.mockResolvedValue(errorResponse); - const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(true), - ...createPowerChannelAttrs(), - }); + const device = createStartedDevice(); + + assert(device.isPatternStarted()); + const result = await device.updateDeviceData({ powerChannels: { 1: 5 } }); - await expect(device.setAttribute('powerChannel2', Int.from(5))).rejects.toThrow( - 'Device response is not OK, but ERROR: something went wrong' - ); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]?.path).toBe('/powerChannels'); + expect(result.errors[0]?.message).toContain('Device response is not OK'); }); - it('throws when not all power channel values are initialized', async () => { + it('throws validation error when power channels update violates schema bounds', async () => { + // Started device without power channel state — schema has max=0 for all channels const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(true), - powerChannel1: IntRangeDeviceAttribute.create( - 'powerChannel1', - 'Channel 1', - DeviceAttributeModifier.readWrite, - undefined, - Int.ZERO, - Int.from(100), - Int.from(1), - ), - powerChannel2: IntRangeDeviceAttribute.create( - 'powerChannel2', - 'Channel 2', - DeviceAttributeModifier.readWrite, - undefined, - Int.ZERO, - Int.from(100), - Int.from(1), - ), - powerChannel3: IntRangeDeviceAttribute.create( - 'powerChannel3', - 'Channel 3', - DeviceAttributeModifier.readWrite, - undefined, - Int.ZERO, - Int.from(100), - Int.from(1), - ), - powerChannel4: IntRangeDeviceAttribute.create( - 'powerChannel4', - 'Channel 4', - DeviceAttributeModifier.readWrite, - undefined, - Int.ZERO, - Int.from(100), - Int.from(1), - ), + attributes: { + activePattern: 0, + patternStarted: true, + patternAttributes: {}, + }, }); - await expect(device.setAttribute('powerChannel1', Int.from(5))).rejects.toThrow( - 'Cannot set channel power before all channel values have been initialized' - ); + assert(device.isPatternStarted()); + // Value 5 exceeds max=0 in the schema → validation failure + await expect( + device.updateDeviceData({ powerChannels: { 1: 5 } }), + ).rejects.toThrow(DeviceDataValidationError); }); }); - describe('patternAttribute (MinMax)', () => { + describe('patternAttributes (MinMax)', () => { it('sends PatternMinMaxChange and updates the attribute value', async () => { mockMsgFactory.createPatternMinMaxChange.mockReturnValue(fakeMsgId); mockMsgHandler.send.mockResolvedValue(okResponse); - const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(true), - patternAttribute5: IntRangeDeviceAttribute.createInitialized( - 'patternAttribute5', - 'Intensity', - DeviceAttributeModifier.readWrite, - undefined, - Int.ZERO, - Int.from(100), - Int.from(1), - Int.from(50), - ), - }); - - await device.setAttribute('patternAttribute5', Int.from(75)); - - expect(mockMsgFactory.createPatternMinMaxChange).toHaveBeenCalledWith(5, Int.from(75)); - const attr = await device.getAttribute('patternAttribute5'); - expect(attr?.value).toStrictEqual(Int.from(75)); - }); - - it('throws when the PatternMinMaxChange response is not OK', async () => { - mockMsgFactory.createPatternMinMaxChange.mockReturnValue(fakeMsgId); - mockMsgHandler.send.mockResolvedValue(errorResponse); + const menuItem: MinMaxMenuItem = { + Id: 5, + Title: 'Intensity', + Group: 0, + Type: 'MIN_MAX', + Default: 50, + Min: 0, + Max: 100, + IncrementStep: 1, + UoM: '%', + }; const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(true), - patternAttribute5: IntRangeDeviceAttribute.createInitialized( - 'patternAttribute5', - 'Intensity', - DeviceAttributeModifier.readWrite, - undefined, - Int.ZERO, - Int.from(100), - Int.from(1), - Int.from(50), - ), + attributes: { + activePattern: 0, + patternStarted: true, + powerChannels: { 1: 0, 2: 0, 3: 0, 4: 0 }, + patternAttributes: { '5': 50 }, + }, + activePatternMenuItems: [menuItem], + powerChannelState: defaultPowerChannels, }); - await expect( - device.setAttribute('patternAttribute5', Int.from(75)) - ).rejects.toThrow('Device response is not OK, but ERROR: something went wrong'); + assert(device.isPatternStarted()); + await device.updateDeviceData({ patternAttributes: { '5': 75 } }); + + expect(mockMsgFactory.createPatternMinMaxChange).toHaveBeenCalledWith(5, 75); + const data = device.getDeviceData(); + assert(data.patternStarted); + expect(data.patternAttributes['5']).toStrictEqual(75); }); }); - describe('patternAttribute (MultiChoice)', () => { + describe('patternAttributes (MultiChoice)', () => { it('sends PatternMultiChoiceChange and updates the attribute value', async () => { mockMsgFactory.createPatternMultiChoiceChange.mockReturnValue(fakeMsgId); mockMsgHandler.send.mockResolvedValue(okResponse); - const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(true), - patternAttribute7: ListDeviceAttribute.createInitialized( - 'patternAttribute7', - 'Mode', - DeviceAttributeModifier.readWrite, - [ - { key: Int.from(0), value: 'Sine' }, - { key: Int.from(1), value: 'Square' }, - ], - Int.from(0), - ), - }); - - await device.setAttribute('patternAttribute7', Int.from(1)); - - expect(mockMsgFactory.createPatternMultiChoiceChange).toHaveBeenCalledWith(7, Int.from(1)); - const attr = await device.getAttribute('patternAttribute7'); - expect(attr?.value).toStrictEqual(Int.from(1)); - }); - - it('throws when the PatternMultiChoiceChange response is not OK', async () => { - mockMsgFactory.createPatternMultiChoiceChange.mockReturnValue(fakeMsgId); - mockMsgHandler.send.mockResolvedValue(errorResponse); + const menuItem: MultiChoiceMenuItem = { + Id: 7, + Title: 'Mode', + Group: 0, + Type: 'MULTI_CHOICE', + Default: 0, + Choices: [ + { Id: 0, Name: 'Sine' }, + { Id: 1, Name: 'Square' }, + ], + }; const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(true), - patternAttribute7: ListDeviceAttribute.createInitialized( - 'patternAttribute7', - 'Mode', - DeviceAttributeModifier.readWrite, - [ - { key: Int.from(0), value: 'Sine' }, - { key: Int.from(1), value: 'Square' }, - ], - Int.from(0), - ), + attributes: { + activePattern: 0, + patternStarted: true, + powerChannels: { 1: 0, 2: 0, 3: 0, 4: 0 }, + patternAttributes: { '7': 0 }, + }, + activePatternMenuItems: [menuItem], + powerChannelState: defaultPowerChannels, }); - await expect( - device.setAttribute('patternAttribute7', Int.from(1)) - ).rejects.toThrow('Device response is not OK, but ERROR: something went wrong'); + assert(device.isPatternStarted()); + await device.updateDeviceData({ patternAttributes: { '7': 1 } }); + + expect(mockMsgFactory.createPatternMultiChoiceChange).toHaveBeenCalledWith(7, 1); + const data = device.getDeviceData(); + assert(data.patternStarted); + expect(data.patternAttributes['7']).toStrictEqual(1); }); }); }); @@ -665,70 +525,78 @@ describe('Zc95Device', () => { ], }; - it('updates the channel max and value from power status', async () => { + it('seeds powerChannels on first PowerStatus message', () => { mockProtocol.decode.mockReturnValue({ message: powerStatusMsg }); + // Started device without powerChannels (loading state) const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(true), - ...createPowerChannelAttrs(), + attributes: { + activePattern: 0, + patternStarted: true, + patternAttributes: {}, + }, }); const onReceive = getOnReceiveCallback(); onReceive(buildPowerStatusBuffer(powerStatusMsg)); - // value = floor(MaxOutputPower * 0.1) = floor(500 * 0.1) = 50 - // max = floor(PowerLimit * 0.1) = floor(700 * 0.1) = 70 - const ch1 = await device.getAttribute('powerChannel1'); - expect(ch1?.value).toStrictEqual(Int.from(50)); - expect(ch1?.max).toStrictEqual(Int.from(70)); + const pc = getPowerChannels(device); - // value = floor(300 * 0.1) = 30, max = floor(1000 * 0.1) = 100 - const ch2 = await device.getAttribute('powerChannel2'); - expect(ch2?.value).toStrictEqual(Int.from(30)); - expect(ch2?.max).toStrictEqual(Int.from(100)); + // value = floor(MaxOutputPower / 10) + expect(pc['1']).toStrictEqual(50); + expect(pc['2']).toStrictEqual(30); }); - it('sets value to MaxOutputPower percentage even when current value exceeded the power limit', async () => { + it('updates the channel value from power status', () => { mockProtocol.decode.mockReturnValue({ message: powerStatusMsg }); - const overLimitAttrs = createPowerChannelAttrs(); - overLimitAttrs.powerChannel1 = IntRangeDeviceAttribute.createInitialized( - 'powerChannel1', - 'Channel 1', - DeviceAttributeModifier.readWrite, - undefined, - Int.ZERO, - Int.from(100), - Int.from(1), - Int.from(90), // current value 90 was above the new power limit of 70 - ); - - const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(true), - ...overLimitAttrs, - }); + const device = createStartedDevice(); const onReceive = getOnReceiveCallback(); onReceive(buildPowerStatusBuffer(powerStatusMsg)); - // Final value = floor(MaxOutputPower * 0.1) = floor(500 * 0.1) = 50 - // max = floor(PowerLimit * 0.1) = floor(700 * 0.1) = 70 - const ch1 = await device.getAttribute('powerChannel1'); - expect(ch1?.value).toStrictEqual(Int.from(50)); - expect(ch1?.max).toStrictEqual(Int.from(70)); + const pc = getPowerChannels(device); + + // value = floor(MaxOutputPower / 10) + expect(pc['1']).toStrictEqual(50); + expect(pc['2']).toStrictEqual(30); }); - it('ignores channels that are not in the attributes', () => { + it('rejects power channel values above the power limit', async () => { mockProtocol.decode.mockReturnValue({ message: powerStatusMsg }); - // No power channel attributes registered - const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(false), + const device = createStartedDevice(); + + const onReceive = getOnReceiveCallback(); + onReceive(buildPowerStatusBuffer(powerStatusMsg)); + + // PowerLimit for channel 1 = 700, max = floor(700 / 10) = 70 + // PowerLimit for channel 2 = 1000, max = floor(1000 / 10) = 100 + await expect(device.updateDeviceData({ + powerChannels: { 1: 71 }, + })).rejects.toThrow(DeviceDataValidationError); + + await expect(device.updateDeviceData({ + powerChannels: { 2: 101 }, + })).rejects.toThrow(DeviceDataValidationError); + + // Values at exactly the limit should pass + mockMsgHandler.send.mockResolvedValue(okResponse); + const result = await device.updateDeviceData({ + powerChannels: { 1: 70, 2: 100 }, }); + const pc = getPowerChannels(device); + expect(pc['1']).toStrictEqual(70); + expect(pc['2']).toStrictEqual(100); + expect(result.errors).toHaveLength(0); + }); + + it('ignores power status when pattern is not started', () => { + mockProtocol.decode.mockReturnValue({ message: powerStatusMsg }); + + const device = createDevice(); + const onReceive = getOnReceiveCallback(); expect(() => onReceive(buildPowerStatusBuffer(powerStatusMsg))).not.toThrow(); }); @@ -738,10 +606,7 @@ describe('Zc95Device', () => { error: { type: 'invalid_frame', reason: 'bad JSON' }, }); - const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(false), - }); + const device = createDevice(); const onReceive = getOnReceiveCallback(); expect(() => onReceive(Buffer.from('not json'))).not.toThrow(); @@ -756,10 +621,7 @@ describe('Zc95Device', () => { }; mockProtocol.decode.mockReturnValue({ message: nonPowerStatusMsg }); - const device = createDevice({ - activePattern: createActivePatternAttr(), - patternStarted: createPatternStartedAttr(false), - }); + const device = createDevice(); const onReceive = getOnReceiveCallback(); expect(() => onReceive(Buffer.from('{}'))).not.toThrow(); diff --git a/tests/unit/device/protocol/zc95/zc95DeviceFactory.spec.ts b/tests/unit/device/protocol/zc95/zc95DeviceFactory.spec.ts index 93b5369a..064c42fe 100644 --- a/tests/unit/device/protocol/zc95/zc95DeviceFactory.spec.ts +++ b/tests/unit/device/protocol/zc95/zc95DeviceFactory.spec.ts @@ -17,6 +17,9 @@ import Zc95MessageFactory, { import { MsgAndResponseIdentifier } from '../../../../../src/device/protocol/zc95/zc95Protocol.js'; import { DeviceId, DetectionId } from '../../../../../src/device/deviceId.js'; import assert from 'assert'; +import JsonSchemaValidatorFactory from '../../../../../src/schemaValidation/JsonSchemaValidatorFactory.js'; +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { registerAttributeSchemaKeywords } from '../../../../../src/device/attribute/attributeSchemaKeywords.js'; describe('Zc95DeviceFactory', () => { let knownDeviceRegistry: MockProxy; @@ -53,7 +56,11 @@ describe('Zc95DeviceFactory', () => { } function createFactory(): Zc95DeviceFactory { - return new Zc95DeviceFactory(dateFactory, eventEmitterFactory, knownDeviceRegistry, logger); + const ajv = new Ajv2020({ allErrors: true, strict: true }); + registerAttributeSchemaKeywords(ajv); + const validatorFactory = new JsonSchemaValidatorFactory(ajv); + + return new Zc95DeviceFactory(dateFactory, eventEmitterFactory, knownDeviceRegistry, validatorFactory, logger); } beforeEach(() => {