Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/automation/scriptVmFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -222,12 +221,18 @@ export default class ScriptVmFactory
onConsoleLog(msgStr);
}));

await jail.set(VM_REF_GET_ATTRIBUTE, new ivm.Reference(async (deviceId: DeviceId, attrName: string): Promise<string | null> => {
await jail.set(VM_REF_GET_ATTRIBUTE, new ivm.Reference((deviceId: DeviceId, attrName: string): 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() });
const schema = dev.getAttributesSchema();
const propSchema = schema.properties[attrName];
if (!propSchema) return null;
const value = dev.getAttributeValue(attrName) ?? null;
const rawLabel: unknown = propSchema['x-label'];
const label = typeof rawLabel === 'string' ? rawLabel : null;
const modifier = propSchema.readOnly === true ? 'ro' : (propSchema.writeOnly === true ? 'wo' : 'rw');
const type = String(propSchema.type ?? 'unknown');
return JSON.stringify({ value, name: attrName, label, modifier, type });
}));

await jail.set(VM_REF_GET_DEVICE_JSON, new ivm.Reference((deviceId: DeviceId): string | null => {
Expand All @@ -236,7 +241,7 @@ 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<void> => {
await jail.set(VM_REF_SET_ATTRIBUTE, new ivm.Reference(async (deviceId: DeviceId, attrName: string, value: unknown): Promise<void> => {
const dev = this.deviceRepository.getById(deviceId);
if (dev === null) throw new Error(`Device not found: ${deviceId}`);
await dev.setAttribute(attrName, value);
Expand Down
76 changes: 76 additions & 0 deletions src/device/attribute/attributeSchema.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> => ({
'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 } : {}),
});
47 changes: 47 additions & 0 deletions src/device/attribute/attributeSchemaKeywords.ts
Original file line number Diff line number Diff line change
@@ -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,
});
};
63 changes: 35 additions & 28 deletions src/device/device.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,28 @@
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';
import type { TObject } from '@sinclair/typebox';

// An attribute value can be DeviceAttribute or undefined because we want to allow Partial<>
export type DeviceAttributes = Record<string, DeviceAttribute | undefined>;
/** Flat key→value map for device attribute state. */
export type DeviceAttributeValues = Record<string, unknown>;

export type DeviceNotifications = JsonObject;
export type NoDeviceNotifications = Record<never, never>;
type AnyDeviceNotifications = JsonObject;

export type AttributeKeyOf<A extends DeviceAttributes> = keyof A & string;
export type AttributeValueOf<A extends DeviceAttributes, K extends AttributeKeyOf<A>> =
NonNullable<A[K]>['value'];
/** Extract the string keys of an attribute values record. */
export type AttributeKeyOf<A extends DeviceAttributeValues> = keyof A & string;

export type DeviceAttributeOf<T extends DeviceAttributes> = {
[K in AttributeKeyOf<T>]: T[K] & { name: K }
}[AttributeKeyOf<T>];
/** Extract the value type for a given attribute key. */
export type AttributeValueOf<A extends DeviceAttributeValues, K extends AttributeKeyOf<A>> = A[K];

export type DeviceData<T extends DeviceAttributes = DeviceAttributes> = {
/** Flat key→value data payload (e.g. for PATCH requests). */
export type DeviceData<T extends DeviceAttributeValues = DeviceAttributeValues> = {
[K in AttributeKeyOf<T>]: AttributeValueOf<T, K>;
};

Expand Down Expand Up @@ -58,7 +56,7 @@ export type WithUntypedAttributes<D extends AnyDevice> = Omit<D, 'setAttribute'>
// any concrete device's narrower generic-keyed setAttribute. 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<AttributeValue>;
setAttribute(attributeName: string, value: unknown): Promise<unknown>;
};

export type AnyDevice = WithUntypedAttributes<Device>;
Expand All @@ -73,7 +71,7 @@ export type DeviceInfo = {

@Exclude()
export default abstract class Device<
TAttributes extends DeviceAttributes = DeviceAttributes,
TAttributeValues extends DeviceAttributeValues = DeviceAttributeValues,
TNotifications extends DeviceNotifications = NoDeviceNotifications,
TConfig extends AnyDeviceConfig = NoDeviceConfig,
> {
Expand Down Expand Up @@ -104,8 +102,13 @@ export default abstract class Device<
@Expose()
protected lastRefresh: Date | undefined;

/** JSON Schema describing the device's current attributes (shape, validation, metadata). */
@Expose()
protected attributes: TAttributes;
protected attributesSchema: TObject;

/** Flat key→value map of current attribute state. */
@Expose()
protected attributes: TAttributeValues;

@Expose()
protected readonly config: TConfig;
Expand All @@ -118,7 +121,8 @@ export default abstract class Device<

protected constructor(
deviceInfo: DeviceInfo,
attributes: TAttributes,
attributesSchema: TObject,
attributes: TAttributeValues,
config: TConfig,
eventEmitter: EventEmitter,
logger: Logger,
Expand All @@ -128,6 +132,7 @@ export default abstract class Device<
this.provider = deviceInfo.provider;
this.connectedSince = deviceInfo.connectedSince;
this.controllable = deviceInfo.controllable;
this.attributesSchema = attributesSchema;
this.attributes = attributes;
this.config = config;
this.eventEmitter = eventEmitter;
Expand Down Expand Up @@ -172,13 +177,16 @@ export default abstract class Device<
}

/**
* 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
* Get the value of an attribute by key.
* @returns the attribute value, or undefined if the attribute does not exist in the current schema.
*/
public async getAttribute<K extends AttributeKeyOf<TAttributes>>(key: K): Promise<TAttributes[K] | undefined> {
return Promise.resolve(this.attributes[key]);
public getAttributeValue<K extends AttributeKeyOf<TAttributeValues>>(key: K): TAttributeValues[K] | undefined {
return this.attributes[key];
}

/** Returns the full attributes schema (JSON Schema). */
public getAttributesSchema(): TObject {
return this.attributesSchema;
}

public on<K extends DeviceEvent>(event: K, listener: (...args: DeviceEventMap<this, TNotifications>[K]) => void): void
Expand All @@ -198,10 +206,10 @@ export default abstract class Device<
return this.closePromise;
}

public abstract setAttribute<K extends AttributeKeyOf<TAttributes>>(
public abstract setAttribute<K extends AttributeKeyOf<TAttributeValues>>(
attributeName: K,
value: AttributeValueOf<TAttributes, K>
): Promise<AttributeValueOf<TAttributes, K>>;
value: AttributeValueOf<TAttributeValues, K>
): Promise<AttributeValueOf<TAttributeValues, K>>;

// eslint-disable-next-line @typescript-eslint/class-methods-use-this
protected async doRefresh(): Promise<void> {
Expand All @@ -225,10 +233,9 @@ export default abstract class Device<
return this.eventEmitter.emit(eventName, this, ...args);
}

protected isAttributePresent(
attr: TAttributes[keyof TAttributes],
): attr is DeviceAttributeOf<TAttributes> {
return typeof attr === 'object' && 'name' in attr && Object.keys(this.attributes).includes(attr.name);
/** Checks whether an attribute key exists in the current schema. */
protected hasAttribute(attributeName: string): boolean {
return attributeName in this.attributesSchema.properties;
}

private async performClose(): Promise<void>
Expand Down
37 changes: 26 additions & 11 deletions src/device/genericDeviceUpdater.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,52 @@
import AbstractDeviceUpdater from './updater/abstractDeviceUpdater.js';
import type PlainToClassSerializer from '../serialization/plainToClassSerializer.js';
import type { AnyDevice, DeviceData } from './device.js';
import type DeviceUpdaterInterface from './updater/deviceUpdaterInterface.js';
import type JsonSchemaValidatorFactory from '../schemaValidation/JsonSchemaValidatorFactory.js';
import type Logger from '../logging/Logger.js';
import { getTypedKeys } from '../util/objects.js';
import { logError } from '../util/error.js';

export default class GenericDeviceUpdater extends AbstractDeviceUpdater
export default class GenericDeviceUpdater implements DeviceUpdaterInterface
{
private readonly logger: Logger;

private readonly failedMessageCountPerDevice = new Map<string, number>();
private readonly validatorFactory: JsonSchemaValidatorFactory;

public constructor(serializer: PlainToClassSerializer, logger: Logger) {
super(serializer);
private readonly failedMessageCountPerDevice = new Map<string, number>();

public constructor(validatorFactory: JsonSchemaValidatorFactory, logger: Logger) {
this.validatorFactory = validatorFactory;
this.logger = logger.child({ name: GenericDeviceUpdater.name });
}

public async update(device: AnyDevice, rawData: DeviceData): Promise<void> {
let hadFailure = false;
const schema = device.getAttributesSchema();

// Queue update for later to not reject if device is busy
for (const attrKey of getTypedKeys(rawData)) {
if (undefined === await device.getAttribute(attrKey)) {
const propertySchema = schema.properties[attrKey];

if (!propertySchema) {
this.logger.warn(`device: ${device.getDeviceId} -> has no attribute named: ${attrKey}`);
continue;
}

const attrStr = rawData[attrKey];
const deviceLogMsg = `device: ${device.getDeviceId} -> ${attrKey} ${attrStr}`;
if (propertySchema.readOnly === true) {
this.logger.warn(`device: ${device.getDeviceId} -> attribute '${attrKey}' is read-only`);
continue;
}

const value: unknown = rawData[attrKey];
const deviceLogMsg = `device: ${device.getDeviceId} -> ${attrKey} ${JSON.stringify(value)}`;

const validator = this.validatorFactory.create(propertySchema);

if (!validator.validate(value)) {
this.logger.warn(`${deviceLogMsg} -> validation failed: ${validator.getValidationErrorsAsText()}`);
continue;
}

try {
await device.setAttribute(attrKey, attrStr);
await device.setAttribute(attrKey, value);
this.logger.info(`${deviceLogMsg} -> done`);
} catch (e: unknown) {
hadFailure = true;
Expand Down
Loading
Loading