From c1dba479e8799e56ea4d5f960e98a3ed8972bba0 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 9 Aug 2026 18:56:07 +0200 Subject: [PATCH 1/4] chore(device-attribute): split value-kind and presence generics Split DeviceAttribute's single generic T into two: V (concrete value kind, e.g. boolean/string/Int/Float) and T (V | undefined, tracks presence). fromString()/isValidValue() now return/narrow the concrete V instead of the abstract T, which TypeScript can prove sound without "as T" assertions. Removes the "as T" assertions (and their eslint-disable comments) from BoolDeviceAttribute, StrDeviceAttribute, IntDeviceAttribute, FloatDeviceAttribute and IntRangeDeviceAttribute. IntRangeDeviceAttribute now also uses Int.from() in fromString(), fixing a separate bug where a plain unbranded number was laundered into the branded Int type. ListDeviceAttribute's two "as IKey" assertions remain, since its value kind is chosen by the caller per instance rather than fixed per subclass - documented as expected in the issue. Closes #107 --- src/device/attribute/boolDeviceAttribute.ts | 14 ++++++------- src/device/attribute/deviceAttribute.ts | 20 ++++++++++--------- src/device/attribute/floatDeviceAttribute.ts | 12 +++++------ src/device/attribute/intDeviceAttribute.ts | 12 +++++------ .../attribute/intRangeDeviceAttribute.ts | 8 +++----- src/device/attribute/listDeviceAttribute.ts | 20 +++++++++---------- src/device/attribute/numberDeviceAttribute.ts | 11 ++++++---- src/device/attribute/strDeviceAttribute.ts | 14 ++++++------- 8 files changed, 53 insertions(+), 58 deletions(-) diff --git a/src/device/attribute/boolDeviceAttribute.ts b/src/device/attribute/boolDeviceAttribute.ts index aa898ec..c453705 100644 --- a/src/device/attribute/boolDeviceAttribute.ts +++ b/src/device/attribute/boolDeviceAttribute.ts @@ -1,11 +1,11 @@ -import type { DeviceAttributeModifier, NotJustUndefined, NotUndefined } from './deviceAttribute.js'; +import type { DeviceAttributeModifier } from './deviceAttribute.js'; import DeviceAttribute from './deviceAttribute.js'; -type BoolDeviceAttributeValue = NotJustUndefined; +type BoolDeviceAttributeValue = boolean | undefined; export type InitializedBoolDeviceAttribute = BoolDeviceAttribute; -export default class BoolDeviceAttribute extends DeviceAttribute +export default class BoolDeviceAttribute extends DeviceAttribute { public static createInitialized( name: string, @@ -24,13 +24,11 @@ export default class BoolDeviceAttribute { + public override isValidValue(value: unknown): value is boolean { return typeof value === 'boolean'; } diff --git a/src/device/attribute/deviceAttribute.ts b/src/device/attribute/deviceAttribute.ts index 2ad1376..be5dedb 100644 --- a/src/device/attribute/deviceAttribute.ts +++ b/src/device/attribute/deviceAttribute.ts @@ -1,9 +1,8 @@ import { Exclude, Expose } from 'class-transformer'; import type { Float, Int } from '../../util/numbers.js'; -export type NotJustUndefined = [V] extends [undefined] ? never : V; -export type NotUndefined = V extends undefined ? never : V; -export type AttributeValue = NotJustUndefined; +export type BaseAttributeValue = string | Int | Float | boolean | null; +export type AttributeValue = BaseAttributeValue | undefined; export enum DeviceAttributeModifier { @@ -12,13 +11,16 @@ export enum DeviceAttributeModifier writeOnly = 'wo', } -export const isValidAttributeValue = ( - attribute: DeviceAttribute | undefined, +export const isValidAttributeValue = ( + attribute: DeviceAttribute | undefined, value: unknown, -): value is NotUndefined => attribute?.isValidValue(value) ?? false; +): value is V => attribute?.isValidValue(value) ?? false; @Exclude() -export default abstract class DeviceAttribute +export default abstract class DeviceAttribute< + V extends BaseAttributeValue = BaseAttributeValue, + T extends V | undefined = V | undefined, +> { @Expose({ name: 'name' }) private readonly _name: string; @@ -76,7 +78,7 @@ export default abstract class DeviceAttribute; + public abstract isValidValue(value: unknown): value is V; } diff --git a/src/device/attribute/floatDeviceAttribute.ts b/src/device/attribute/floatDeviceAttribute.ts index 619504c..c579767 100644 --- a/src/device/attribute/floatDeviceAttribute.ts +++ b/src/device/attribute/floatDeviceAttribute.ts @@ -1,12 +1,12 @@ -import type { DeviceAttributeModifier, NotJustUndefined } from './deviceAttribute.js'; +import type { DeviceAttributeModifier } from './deviceAttribute.js'; import { Float } from '../../util/numbers.js'; import NumberDeviceAttribute from './numberDeviceAttribute.js'; -type FloatDeviceAttributeValue = NotJustUndefined; +type FloatDeviceAttributeValue = Float | undefined; export type InitializedFloatGenericDeviceAttribute = FloatDeviceAttribute; -export default class FloatDeviceAttribute extends NumberDeviceAttribute +export default class FloatDeviceAttribute extends NumberDeviceAttribute { public constructor( name: string, @@ -37,16 +37,14 @@ export default class FloatDeviceAttribute; +export type IntAttributeValue = Int | undefined; export type InitializedIntGenericDeviceAttribute = IntDeviceAttribute; -export default class IntDeviceAttribute extends NumberDeviceAttribute +export default class IntDeviceAttribute extends NumberDeviceAttribute { public static createInitialized( name: string, @@ -26,16 +26,14 @@ export default class IntDeviceAttribute; -export default class IntRangeDeviceAttribute extends NumberDeviceAttribute +export default class IntRangeDeviceAttribute extends NumberDeviceAttribute { @Expose({ name: 'min' }) private _min: Int; @@ -69,16 +69,14 @@ export default class IntRangeDeviceAttribute = ListDeviceAttributeOption export default class ListDeviceAttribute< IKey extends ListDeviceAttributeItem, IValue extends ListDeviceAttributeItem, - V extends IKey | undefined = IKey | undefined, -> extends DeviceAttribute + T extends IKey | undefined = IKey | undefined, +> extends DeviceAttribute { @Expose({ name: 'values' }) private _values: ListDeviceAttributeOptions; @@ -27,7 +27,7 @@ export default class ListDeviceAttribute< label: string | undefined, modifier: DeviceAttributeModifier, values: ListDeviceAttributeOptions, - initialValue: V, + initialValue: T, ) { super(name, label, modifier, initialValue); @@ -57,18 +57,18 @@ export default class ListDeviceAttribute< ); } - public fromString(value: string): V { + public fromString(value: string): IKey { if (this._values.length === 0 || typeof this._values[0]?.key === 'string') { - // TODO https://github.com/SlvCtrlPlus/slvctrlplus-server/issues/107 + // The value kind (IKey) is chosen by the caller per instance, so TypeScript can't + // prove `value`/the parsed number is an IKey here - see issue #107 for details. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unsafe-type-assertion - return value as V; + return value as IKey; } const parsedInt = parseInt(value, 10); - // TODO https://github.com/SlvCtrlPlus/slvctrlplus-server/issues/107 // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unsafe-type-assertion - return (isNaN(parsedInt) ? value : parsedInt) as V; + return (isNaN(parsedInt) ? value : parsedInt) as IKey; } public get values(): ListDeviceAttributeOptions { @@ -79,7 +79,7 @@ export default class ListDeviceAttribute< this._values = value; } - public isValidValue(value: unknown): value is NotUndefined { + public isValidValue(value: unknown): value is IKey { if (typeof value === 'string' || typeof value === 'number') { return -1 !== this._values.findIndex(entry => entry.key === value); } diff --git a/src/device/attribute/numberDeviceAttribute.ts b/src/device/attribute/numberDeviceAttribute.ts index 8638fad..f1100ae 100644 --- a/src/device/attribute/numberDeviceAttribute.ts +++ b/src/device/attribute/numberDeviceAttribute.ts @@ -1,11 +1,14 @@ -import type { DeviceAttributeModifier, NotJustUndefined, NotUndefined } from './deviceAttribute.js'; +import type { DeviceAttributeModifier } from './deviceAttribute.js'; import DeviceAttribute from './deviceAttribute.js'; import { Expose } from 'class-transformer'; import type { Float, Int } from '../../util/numbers.js'; -export type NumberAttributeValue = NotJustUndefined; +export type NumberAttributeValue = Int | Float; -export default abstract class NumberDeviceAttribute extends DeviceAttribute +export default abstract class NumberDeviceAttribute< + V extends NumberAttributeValue = NumberAttributeValue, + T extends V | undefined = V | undefined, +> extends DeviceAttribute { @Expose({ name: 'uom' }) private readonly _uom: string | undefined; @@ -25,7 +28,7 @@ export default abstract class NumberDeviceAttribute { + public override isValidValue(value: unknown): value is V { return typeof value === 'number'; } } diff --git a/src/device/attribute/strDeviceAttribute.ts b/src/device/attribute/strDeviceAttribute.ts index 33a6e6a..9e20d36 100644 --- a/src/device/attribute/strDeviceAttribute.ts +++ b/src/device/attribute/strDeviceAttribute.ts @@ -1,11 +1,11 @@ -import type { DeviceAttributeModifier, NotJustUndefined, NotUndefined } from './deviceAttribute.js'; +import type { DeviceAttributeModifier } from './deviceAttribute.js'; import DeviceAttribute from './deviceAttribute.js'; -type StrDeviceAttributeValue = NotJustUndefined; +type StrDeviceAttributeValue = string | undefined; export type InitializedStrDeviceAttribute = StrDeviceAttribute; -export default class StrDeviceAttribute extends DeviceAttribute +export default class StrDeviceAttribute extends DeviceAttribute { public static createInitialized( name: string, @@ -25,13 +25,11 @@ export default class StrDeviceAttribute { + public override isValidValue(value: unknown): value is string { return typeof value === 'string'; } From 8223a26d63b22d36e1042e3c98bb1e0ab8bd5dc9 Mon Sep 17 00:00:00 2001 From: HRS Date: Sun, 9 Aug 2026 22:07:34 +0200 Subject: [PATCH 2/4] chore(device-attribute): use presence-flag generic instead of T extends V | undefined Replace the T extends V | undefined split from the previous commit with the stricter presence-flag design also proposed in issue #107: DeviceAttribute now uses a boolean IsSet parameter, and the storage/getter/setter type is computed as `IsSet extends true ? V : V | undefined` (AttributeStorage). This closes the residual gap of the simpler split, where nothing prevented T from degenerating to exactly `undefined`. Every Initialized*DeviceAttribute alias now means <..., true> instead of (e.g. InitializedBoolDeviceAttribute = BoolDeviceAttribute), and DeviceAttribute.hasValue() narrows to `this is { value: V }` instead of `this is { value: T }`. Because TypeScript treats two differently-parameterized instantiations of the same generic class as mutually non-assignable once a conditional type makes a parameter measured-invariant, several consumer type declarations that always construct attributes via createInitialized() had to be updated from the bare (unset-only) attribute type to the Initialized variant to keep compiling; this also makes their "always has a value" contract explicit at the type level: - EStim2bDeviceAttributes (estim2bDevice.ts) - all fields are always constructed with a value in estim2bDeviceFactory.ts - ButtplugIoDeviceAttributes (buttplugIoDevice.ts) - all attributes are always constructed with a value in buttplugIoDeviceFactory.ts - PiperVirtualDeviceAttributes.queuing, TtsVirtualDeviceAttributes.speaking/ queuing/queueLength - always constructed via createInitialized() - Zc95DevicePatternAttributes - always constructed via createInitialized() in getAttributesFromPatternDetails() Zc95DevicePowerChannelAttributes keeps its bare (possibly-unset) type since those attributes are genuinely constructed unset and only get a value once a power status message is received; Zc95Device.allPowerChannelValuesDefined() now narrows via an intersection type (`Attr & { value: Int }`) rather than the Initialized alias, since a type predicate's type must stay assignable to its original parameter type. Updated tests accordingly: - estim2bDevice.spec.ts / buttplugIoDevice.spec.ts test attribute factories now use createInitialized() to match the stricter production types - buttplugIoDevice.spec.ts's "undefined value" test now goes through the untyped AnyDevice interface, since the typed setAttribute() signature no longer accepts undefined for these attributes (the runtime guard in buttplugIoDevice.ts is still reachable via that untyped boundary, e.g. automation scripts) - zc95Device.spec.ts's power channel test helper now constructs unset attributes and assigns .value afterward, matching production - tests/type/*.test-d.ts updated to expect the now-narrower (non-undefined) setAttribute() return/parameter types for initialized attributes --- src/device/attribute/boolDeviceAttribute.ts | 8 ++--- src/device/attribute/deviceAttribute.ts | 20 ++++++++----- src/device/attribute/floatDeviceAttribute.ts | 12 ++++---- src/device/attribute/intDeviceAttribute.ts | 7 ++--- .../attribute/intRangeDeviceAttribute.ts | 11 ++++--- src/device/attribute/listDeviceAttribute.ts | 12 ++++---- src/device/attribute/numberDeviceAttribute.ts | 8 ++--- src/device/attribute/strDeviceAttribute.ts | 10 +++---- .../protocol/buttplugIo/buttplugIoDevice.ts | 11 ++++++- src/device/protocol/estim2b/estim2bDevice.ts | 30 +++++++++++-------- .../virtual/audio/piperVirtualDeviceLogic.ts | 3 +- .../virtual/audio/ttsVirtualDeviceLogic.ts | 8 +++-- src/device/protocol/zc95/zc95Device.ts | 10 +++++-- tests/type/device/buttplugIo.test-d.ts | 9 +++--- tests/type/device/estim2b.test-d.ts | 29 +++++++++--------- tests/type/device/virtual/virtual.test-d.ts | 10 +++---- tests/type/device/zc95/zc95.test-d.ts | 4 +-- .../buttplugIo/buttplugIoDevice.spec.ts | 21 ++++++++----- .../protocol/estim2b/estim2bDevice.spec.ts | 12 ++++---- .../device/protocol/zc95/zc95Device.spec.ts | 15 ++++++---- 20 files changed, 140 insertions(+), 110 deletions(-) diff --git a/src/device/attribute/boolDeviceAttribute.ts b/src/device/attribute/boolDeviceAttribute.ts index c453705..b1912f4 100644 --- a/src/device/attribute/boolDeviceAttribute.ts +++ b/src/device/attribute/boolDeviceAttribute.ts @@ -1,11 +1,9 @@ import type { DeviceAttributeModifier } from './deviceAttribute.js'; import DeviceAttribute from './deviceAttribute.js'; -type BoolDeviceAttributeValue = boolean | undefined; +export type InitializedBoolDeviceAttribute = BoolDeviceAttribute; -export type InitializedBoolDeviceAttribute = BoolDeviceAttribute; - -export default class BoolDeviceAttribute extends DeviceAttribute +export default class BoolDeviceAttribute extends DeviceAttribute { public static createInitialized( name: string, @@ -13,7 +11,7 @@ export default class BoolDeviceAttribute(name, label, modifier, initialValue); + return new BoolDeviceAttribute(name, label, modifier, initialValue); } public static create( diff --git a/src/device/attribute/deviceAttribute.ts b/src/device/attribute/deviceAttribute.ts index be5dedb..12eefa6 100644 --- a/src/device/attribute/deviceAttribute.ts +++ b/src/device/attribute/deviceAttribute.ts @@ -4,6 +4,10 @@ import type { Float, Int } from '../../util/numbers.js'; export type BaseAttributeValue = string | Int | Float | boolean | null; export type AttributeValue = BaseAttributeValue | undefined; +// The storage/getter/setter type for an attribute: the concrete value V once it has been set +// (IsSet = true), or V | undefined beforehand (IsSet = false, the default). +export type AttributeStorage = IsSet extends true ? V : V | undefined; + export enum DeviceAttributeModifier { readOnly = 'ro', @@ -11,15 +15,15 @@ export enum DeviceAttributeModifier writeOnly = 'wo', } -export const isValidAttributeValue = ( - attribute: DeviceAttribute | undefined, +export const isValidAttributeValue = ( + attribute: DeviceAttribute | undefined, value: unknown, ): value is V => attribute?.isValidValue(value) ?? false; @Exclude() export default abstract class DeviceAttribute< V extends BaseAttributeValue = BaseAttributeValue, - T extends V | undefined = V | undefined, + IsSet extends boolean = false, > { @Expose({ name: 'name' }) @@ -32,9 +36,9 @@ export default abstract class DeviceAttribute< private readonly _modifier: DeviceAttributeModifier; @Expose({ name: 'value' }) - private _value: T; + private _value: AttributeStorage; - public constructor(name: string, label: string | undefined, modifier: DeviceAttributeModifier, initialValue: T) { + public constructor(name: string, label: string | undefined, modifier: DeviceAttributeModifier, initialValue: AttributeStorage) { this._name = name; this._label = label; this._modifier = modifier; @@ -60,15 +64,15 @@ export default abstract class DeviceAttribute< /** * @returns the current value or undefined if it has never been set or read from the device */ - public get value(): T { + public get value(): AttributeStorage { return this._value; } - public set value(value: T) { + public set value(value: AttributeStorage) { this._value = value; } - public hasValue(): this is { value: T } { + public hasValue(): this is { value: V } { return this._value !== undefined; } diff --git a/src/device/attribute/floatDeviceAttribute.ts b/src/device/attribute/floatDeviceAttribute.ts index c579767..13b3f24 100644 --- a/src/device/attribute/floatDeviceAttribute.ts +++ b/src/device/attribute/floatDeviceAttribute.ts @@ -1,19 +1,17 @@ -import type { DeviceAttributeModifier } from './deviceAttribute.js'; +import type { AttributeStorage, DeviceAttributeModifier } from './deviceAttribute.js'; import { Float } from '../../util/numbers.js'; import NumberDeviceAttribute from './numberDeviceAttribute.js'; -type FloatDeviceAttributeValue = Float | undefined; +export type InitializedFloatGenericDeviceAttribute = FloatDeviceAttribute; -export type InitializedFloatGenericDeviceAttribute = FloatDeviceAttribute; - -export default class FloatDeviceAttribute extends NumberDeviceAttribute +export default class FloatDeviceAttribute extends NumberDeviceAttribute { public constructor( name: string, label: string | undefined, modifier: DeviceAttributeModifier, uom: string | undefined, - initialValue: T, + initialValue: AttributeStorage, ) { super(name, label, modifier, uom, initialValue); } @@ -25,7 +23,7 @@ export default class FloatDeviceAttribute(name, label, modifier, uom, initialValue); + return new FloatDeviceAttribute(name, label, modifier, uom, initialValue); } public static create( diff --git a/src/device/attribute/intDeviceAttribute.ts b/src/device/attribute/intDeviceAttribute.ts index 426771d..77bdc69 100644 --- a/src/device/attribute/intDeviceAttribute.ts +++ b/src/device/attribute/intDeviceAttribute.ts @@ -2,10 +2,9 @@ import type { DeviceAttributeModifier } from './deviceAttribute.js'; import { Int } from '../../util/numbers.js'; import NumberDeviceAttribute from './numberDeviceAttribute.js'; -export type IntAttributeValue = Int | undefined; -export type InitializedIntGenericDeviceAttribute = IntDeviceAttribute; +export type InitializedIntGenericDeviceAttribute = IntDeviceAttribute; -export default class IntDeviceAttribute extends NumberDeviceAttribute +export default class IntDeviceAttribute extends NumberDeviceAttribute { public static createInitialized( name: string, @@ -14,7 +13,7 @@ export default class IntDeviceAttribute(name, label, modifier, uom, initialValue); + return new IntDeviceAttribute(name, label, modifier, uom, initialValue); } public static create( diff --git a/src/device/attribute/intRangeDeviceAttribute.ts b/src/device/attribute/intRangeDeviceAttribute.ts index b3b8e8d..c006012 100644 --- a/src/device/attribute/intRangeDeviceAttribute.ts +++ b/src/device/attribute/intRangeDeviceAttribute.ts @@ -1,12 +1,11 @@ import { Expose } from 'class-transformer'; -import type { IntAttributeValue } from './intDeviceAttribute.js'; import { Int } from '../../util/numbers.js'; -import type { DeviceAttributeModifier } from './deviceAttribute.js'; +import type { AttributeStorage, DeviceAttributeModifier } from './deviceAttribute.js'; import NumberDeviceAttribute from './numberDeviceAttribute.js'; -export type InitializedIntRangeDeviceAttribute = IntRangeDeviceAttribute; +export type InitializedIntRangeDeviceAttribute = IntRangeDeviceAttribute; -export default class IntRangeDeviceAttribute extends NumberDeviceAttribute +export default class IntRangeDeviceAttribute extends NumberDeviceAttribute { @Expose({ name: 'min' }) private _min: Int; @@ -17,7 +16,7 @@ export default class IntRangeDeviceAttribute) { super(name, label, modifier, uom, initialValue); this._min = min; this._max = max; @@ -34,7 +33,7 @@ export default class IntRangeDeviceAttribute(name, label, modifier, uom, min, max, incrementStep, initialValue); + return new IntRangeDeviceAttribute(name, label, modifier, uom, min, max, incrementStep, initialValue); } public static create( diff --git a/src/device/attribute/listDeviceAttribute.ts b/src/device/attribute/listDeviceAttribute.ts index 3141f66..0018e0d 100644 --- a/src/device/attribute/listDeviceAttribute.ts +++ b/src/device/attribute/listDeviceAttribute.ts @@ -1,5 +1,5 @@ import { Expose } from 'class-transformer'; -import type { DeviceAttributeModifier } from './deviceAttribute.js'; +import type { AttributeStorage, DeviceAttributeModifier } from './deviceAttribute.js'; import DeviceAttribute from './deviceAttribute.js'; import type { Int } from '../../util/numbers.js'; @@ -9,15 +9,15 @@ export type ListDeviceAttributeItem = string | Int; export type InitializedListDeviceAttribute< IKey extends ListDeviceAttributeItem, IValue extends ListDeviceAttributeItem, -> = ListDeviceAttribute; +> = ListDeviceAttribute; export type ListDeviceAttributeOptions = ListDeviceAttributeOption[]; export default class ListDeviceAttribute< IKey extends ListDeviceAttributeItem, IValue extends ListDeviceAttributeItem, - T extends IKey | undefined = IKey | undefined, -> extends DeviceAttribute + IsSet extends boolean = false, +> extends DeviceAttribute { @Expose({ name: 'values' }) private _values: ListDeviceAttributeOptions; @@ -27,7 +27,7 @@ export default class ListDeviceAttribute< label: string | undefined, modifier: DeviceAttributeModifier, values: ListDeviceAttributeOptions, - initialValue: T, + initialValue: AttributeStorage, ) { super(name, label, modifier, initialValue); @@ -41,7 +41,7 @@ export default class ListDeviceAttribute< values: ListDeviceAttributeOptions, initialValue: IKey, ): InitializedListDeviceAttribute { - return new ListDeviceAttribute( + return new ListDeviceAttribute( name, label, modifier, values, initialValue, ); } diff --git a/src/device/attribute/numberDeviceAttribute.ts b/src/device/attribute/numberDeviceAttribute.ts index f1100ae..b93a4da 100644 --- a/src/device/attribute/numberDeviceAttribute.ts +++ b/src/device/attribute/numberDeviceAttribute.ts @@ -1,4 +1,4 @@ -import type { DeviceAttributeModifier } from './deviceAttribute.js'; +import type { AttributeStorage, DeviceAttributeModifier } from './deviceAttribute.js'; import DeviceAttribute from './deviceAttribute.js'; import { Expose } from 'class-transformer'; import type { Float, Int } from '../../util/numbers.js'; @@ -7,8 +7,8 @@ export type NumberAttributeValue = Int | Float; export default abstract class NumberDeviceAttribute< V extends NumberAttributeValue = NumberAttributeValue, - T extends V | undefined = V | undefined, -> extends DeviceAttribute + IsSet extends boolean = false, +> extends DeviceAttribute { @Expose({ name: 'uom' }) private readonly _uom: string | undefined; @@ -18,7 +18,7 @@ export default abstract class NumberDeviceAttribute< label: string | undefined, modifier: DeviceAttributeModifier, uom: string | undefined, - initialValue: T, + initialValue: AttributeStorage, ) { super(name, label, modifier, initialValue); this._uom = uom; diff --git a/src/device/attribute/strDeviceAttribute.ts b/src/device/attribute/strDeviceAttribute.ts index 9e20d36..c95b77e 100644 --- a/src/device/attribute/strDeviceAttribute.ts +++ b/src/device/attribute/strDeviceAttribute.ts @@ -1,11 +1,9 @@ import type { DeviceAttributeModifier } from './deviceAttribute.js'; import DeviceAttribute from './deviceAttribute.js'; -type StrDeviceAttributeValue = string | undefined; +export type InitializedStrDeviceAttribute = StrDeviceAttribute; -export type InitializedStrDeviceAttribute = StrDeviceAttribute; - -export default class StrDeviceAttribute extends DeviceAttribute +export default class StrDeviceAttribute extends DeviceAttribute { public static createInitialized( name: string, @@ -13,14 +11,14 @@ export default class StrDeviceAttribute(name, label, modifier, initialValue); + return new StrDeviceAttribute(name, label, modifier, initialValue); } public static create( name: string, label: string | undefined, modifier: DeviceAttributeModifier, - initialValue?: StrDeviceAttributeValue, + initialValue?: string, ): StrDeviceAttribute { return new StrDeviceAttribute(name, label, modifier, initialValue); } diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index 2590d1d..881d048 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -3,9 +3,12 @@ import type { ButtplugClientDevice, SensorType } from 'buttplug'; import { ActuatorType } from 'buttplug'; import type { AttributeKeyOf, AttributeValueOf, DeviceInfo } from '../../device.js'; import Device from '../../device.js'; +import type { InitializedIntRangeDeviceAttribute } from '../../attribute/intRangeDeviceAttribute.js'; import IntRangeDeviceAttribute from '../../attribute/intRangeDeviceAttribute.js'; +import type { InitializedBoolDeviceAttribute } from '../../attribute/boolDeviceAttribute.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import { Int } from '../../../util/numbers.js'; +import type { InitializedIntGenericDeviceAttribute } from '../../attribute/intDeviceAttribute.js'; import IntDeviceAttribute from '../../attribute/intDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; import type EventEmitter from 'events'; @@ -18,9 +21,11 @@ type ButtplugSensorTypeKey = `${SensorType}-${number}`; export type ButtplugIoDeviceAttributeKey = ButtplugActuatorTypeKey | ButtplugSensorTypeKey; +// All attributes are always constructed with an initial value (see buttplugIoDeviceFactory.ts), +// so they use the Initialized* variants to reflect that at the type level. export type ButtplugIoDeviceAttributes = Record< ButtplugIoDeviceAttributeKey, - IntRangeDeviceAttribute | BoolDeviceAttribute | IntDeviceAttribute | undefined + InitializedIntRangeDeviceAttribute | InitializedBoolDeviceAttribute | InitializedIntGenericDeviceAttribute | undefined >; type AttributeValue = AttributeValueOf; @@ -76,6 +81,10 @@ export default class ButtplugIoDevice extends Device throw new Error(`Attribute with name '${attributeName}' is readonly`); } + // TypeScript now guarantees `value` is defined for a caller bound by the typed setAttribute + // signature above, but this is still reachable via the untyped AnyDevice interface (e.g. + // automation scripts), so the runtime guard stays. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (undefined === value) { throw new Error(`Value to be set for attribute '${attributeName}' cannot be undefined`); } diff --git a/src/device/protocol/estim2b/estim2bDevice.ts b/src/device/protocol/estim2b/estim2bDevice.ts index d4b1acf..93b28f6 100644 --- a/src/device/protocol/estim2b/estim2bDevice.ts +++ b/src/device/protocol/estim2b/estim2bDevice.ts @@ -1,13 +1,14 @@ import type { AttributeKeyOf, AttributeValueOf, DeviceInfo } from '../../device.js'; +import type { InitializedIntRangeDeviceAttribute } from '../../attribute/intRangeDeviceAttribute.js'; import IntRangeDeviceAttribute from '../../attribute/intRangeDeviceAttribute.js'; import type { Estim2bCommand, EStim2bStatus } from './estim2bProtocol.js'; import type EStim2bProtocol from './estim2bProtocol.js'; import { EStim2bMode } from './estim2bProtocol.js'; import { Exclude, Expose } from 'class-transformer'; import { Int } from '../../../util/numbers.js'; -import type BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; -import type StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; -import type ListDeviceAttribute from '../../attribute/listDeviceAttribute.js'; +import type { InitializedBoolDeviceAttribute } from '../../attribute/boolDeviceAttribute.js'; +import type { InitializedStrDeviceAttribute } from '../../attribute/strDeviceAttribute.js'; +import type { InitializedListDeviceAttribute } from '../../attribute/listDeviceAttribute.js'; import { DeviceAttributeModifier, isValidAttributeValue } from '../../attribute/deviceAttribute.js'; import type DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; import PeripheralDevice from '../../peripheralDevice.js'; @@ -15,15 +16,18 @@ import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import type EventEmitter from 'events'; import type Logger from '../../../logging/Logger.js'; +// All attributes below are always constructed with an initial value (see estim2bDeviceFactory.ts +// and setModeBasedAttributes()/updateAttributeValues() in this file), so they use the Initialized* +// variants to reflect that at the type level. export type EStim2bDeviceAttributes = { - mode: ListDeviceAttribute; - channelALevel: IntRangeDeviceAttribute; - channelBLevel: IntRangeDeviceAttribute; - pulseFrequency?: IntRangeDeviceAttribute; - pulsePwm?: IntRangeDeviceAttribute; - channelsJoined: BoolDeviceAttribute; - highPowerMode: BoolDeviceAttribute; - batteryStatus: StrDeviceAttribute; + mode: InitializedListDeviceAttribute; + channelALevel: InitializedIntRangeDeviceAttribute; + channelBLevel: InitializedIntRangeDeviceAttribute; + pulseFrequency?: InitializedIntRangeDeviceAttribute; + pulsePwm?: InitializedIntRangeDeviceAttribute; + channelsJoined: InitializedBoolDeviceAttribute; + highPowerMode: InitializedBoolDeviceAttribute; + batteryStatus: InitializedStrDeviceAttribute; }; export type EStim2bBatteryStatus = 'mains' | 'full' | 'medium' | 'low' | 'critical'; @@ -234,7 +238,7 @@ export default class EStim2bDevice extends PeripheralDevice; type PiperVirtualDeviceAttributes = { text: StrDeviceAttribute; - queuing: BoolDeviceAttribute; + queuing: InitializedBoolDeviceAttribute; }; export default class PiperVirtualDeviceLogic extends VirtualDeviceLogic< diff --git a/src/device/protocol/virtual/audio/ttsVirtualDeviceLogic.ts b/src/device/protocol/virtual/audio/ttsVirtualDeviceLogic.ts index d565b48..7731019 100644 --- a/src/device/protocol/virtual/audio/ttsVirtualDeviceLogic.ts +++ b/src/device/protocol/virtual/audio/ttsVirtualDeviceLogic.ts @@ -3,7 +3,9 @@ import StrDeviceAttribute from '../../../attribute/strDeviceAttribute.js'; import VirtualDeviceLogic from '../virtualDeviceLogic.js'; import say from 'say'; import type VirtualDevice from '../virtualDevice.js'; +import type { InitializedBoolDeviceAttribute } from '../../../attribute/boolDeviceAttribute.js'; import BoolDeviceAttribute from '../../../attribute/boolDeviceAttribute.js'; +import type { InitializedIntGenericDeviceAttribute } from '../../../attribute/intDeviceAttribute.js'; import IntDeviceAttribute from '../../../attribute/intDeviceAttribute.js'; import { Int } from '../../../../util/numbers.js'; import type Logger from '../../../../logging/Logger.js'; @@ -11,9 +13,9 @@ import type { TtsVirtualDeviceConfig } from './ttsVirtualDeviceConfig.js'; type TtsVirtualDeviceAttributes = { text: StrDeviceAttribute; - speaking: BoolDeviceAttribute; - queuing: BoolDeviceAttribute; - queueLength: IntDeviceAttribute; + speaking: InitializedBoolDeviceAttribute; + queuing: InitializedBoolDeviceAttribute; + queueLength: InitializedIntGenericDeviceAttribute; }; export default class TtsVirtualDeviceLogic extends VirtualDeviceLogic< diff --git a/src/device/protocol/zc95/zc95Device.ts b/src/device/protocol/zc95/zc95Device.ts index bcdf403..ca94da8 100644 --- a/src/device/protocol/zc95/zc95Device.ts +++ b/src/device/protocol/zc95/zc95Device.ts @@ -48,7 +48,7 @@ export type Zc95DevicePowerChannelAttributes = Record>>; +type Zc95DevicePatternAttributes = Partial>>; export type Zc95DeviceAttributes = AllOrNone & Zc95DevicePatternAttributes & Required; @@ -161,7 +161,7 @@ export default class Zc95Device extends PeripheralDevice): attrs is { - [K in keyof Zc95DevicePowerChannelAttributes]-?: InitializedIntRangeDeviceAttribute + // Power channel attributes are constructed unset (see getChannelPowerAttribute()) and their + // value is assigned later, so we can't use InitializedIntRangeDeviceAttribute here (a + // different, incompatible instantiation of IntRangeDeviceAttribute) - intersecting with + // `{ value: Int }` instead narrows just the value's definedness. + [K in keyof Zc95DevicePowerChannelAttributes]-?: Zc95DevicePowerChannelAttributes[K] & { value: Int } } { return attrs.powerChannel1?.value !== undefined && attrs.powerChannel2?.value !== undefined diff --git a/tests/type/device/buttplugIo.test-d.ts b/tests/type/device/buttplugIo.test-d.ts index c0f8584..b99b1cc 100644 --- a/tests/type/device/buttplugIo.test-d.ts +++ b/tests/type/device/buttplugIo.test-d.ts @@ -4,10 +4,11 @@ import { Int } from '../../../src/util/numbers.js'; declare const device: ButtplugIoDevice; -// any valid actuator/sensor attribute key: Int | boolean | undefined -expectTypeOf(device.setAttribute('Vibrate-0', Int.from(50))).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('Rotate-1', true)).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('Battery-0', undefined)).toEqualTypeOf>(); +// any valid actuator/sensor attribute key: Int | boolean (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('Vibrate-0', Int.from(50))).toEqualTypeOf>(); +expectTypeOf(device.setAttribute('Rotate-1', true)).toEqualTypeOf>(); +// @ts-expect-error attribute value cannot be undefined +device.setAttribute('Battery-0', undefined); // @ts-expect-error attribute value cannot be a string device.setAttribute('Vibrate-0', 'fast'); diff --git a/tests/type/device/estim2b.test-d.ts b/tests/type/device/estim2b.test-d.ts index c96a6ea..f7d8e62 100644 --- a/tests/type/device/estim2b.test-d.ts +++ b/tests/type/device/estim2b.test-d.ts @@ -4,32 +4,33 @@ import { Int } from '../../../src/util/numbers.js'; declare const device: EStim2bDevice; -// mode: Int (list attribute key) | undefined -expectTypeOf(device.setAttribute('mode', Int.from(0))).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('mode', undefined)).toEqualTypeOf>(); +// mode: Int (initialized list attribute, always has a value) +expectTypeOf(device.setAttribute('mode', Int.from(0))).toEqualTypeOf>(); +// @ts-expect-error mode is always initialized and does not accept undefined +device.setAttribute('mode', undefined); // @ts-expect-error mode does not accept a string value device.setAttribute('mode', 'bounce'); -// channelALevel / channelBLevel: Int | undefined -expectTypeOf(device.setAttribute('channelALevel', Int.from(50))).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('channelBLevel', Int.from(50))).toEqualTypeOf>(); +// channelALevel / channelBLevel: Int (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('channelALevel', Int.from(50))).toEqualTypeOf>(); +expectTypeOf(device.setAttribute('channelBLevel', Int.from(50))).toEqualTypeOf>(); // @ts-expect-error channelALevel does not accept a boolean value device.setAttribute('channelALevel', true); -// pulseFrequency / pulsePwm: Int | undefined -expectTypeOf(device.setAttribute('pulseFrequency', Int.from(10))).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('pulsePwm', Int.from(10))).toEqualTypeOf>(); +// pulseFrequency / pulsePwm: Int (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('pulseFrequency', Int.from(10))).toEqualTypeOf>(); +expectTypeOf(device.setAttribute('pulsePwm', Int.from(10))).toEqualTypeOf>(); // @ts-expect-error pulseFrequency does not accept a string value device.setAttribute('pulseFrequency', '10'); -// channelsJoined / highPowerMode: boolean | undefined -expectTypeOf(device.setAttribute('channelsJoined', true)).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('highPowerMode', false)).toEqualTypeOf>(); +// channelsJoined / highPowerMode: boolean (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('channelsJoined', true)).toEqualTypeOf>(); +expectTypeOf(device.setAttribute('highPowerMode', false)).toEqualTypeOf>(); // @ts-expect-error highPowerMode does not accept an Int value device.setAttribute('highPowerMode', Int.from(1)); -// batteryStatus: string | undefined -expectTypeOf(device.setAttribute('batteryStatus', 'mains')).toEqualTypeOf>(); +// batteryStatus: string (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('batteryStatus', 'mains')).toEqualTypeOf>(); // @ts-expect-error batteryStatus does not accept a boolean value device.setAttribute('batteryStatus', true); diff --git a/tests/type/device/virtual/virtual.test-d.ts b/tests/type/device/virtual/virtual.test-d.ts index 1514e6f..e191faf 100644 --- a/tests/type/device/virtual/virtual.test-d.ts +++ b/tests/type/device/virtual/virtual.test-d.ts @@ -11,14 +11,14 @@ expectTypeOf(device.setAttribute('text', undefined)).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('queuing', false)).toEqualTypeOf>(); +// speaking / queuing: boolean (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('speaking', true)).toEqualTypeOf>(); +expectTypeOf(device.setAttribute('queuing', false)).toEqualTypeOf>(); // @ts-expect-error queuing does not accept a string value device.setAttribute('queuing', 'yes'); -// queueLength: Int | undefined -expectTypeOf(device.setAttribute('queueLength', Int.from(3))).toEqualTypeOf>(); +// queueLength: Int (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('queueLength', Int.from(3))).toEqualTypeOf>(); // @ts-expect-error queueLength does not accept a boolean value device.setAttribute('queueLength', true); diff --git a/tests/type/device/zc95/zc95.test-d.ts b/tests/type/device/zc95/zc95.test-d.ts index a49e234..16c64f9 100644 --- a/tests/type/device/zc95/zc95.test-d.ts +++ b/tests/type/device/zc95/zc95.test-d.ts @@ -20,8 +20,8 @@ expectTypeOf(device.setAttribute('powerChannel4', Int.from(50))).toEqualTypeOf

(dynamic, numeric suffix): Int | undefined -expectTypeOf(device.setAttribute('patternAttribute3', Int.from(10))).toEqualTypeOf>(); +// patternAttribute (dynamic, numeric suffix, initialized attribute, always has a value): Int +expectTypeOf(device.setAttribute('patternAttribute3', Int.from(10))).toEqualTypeOf>(); // @ts-expect-error patternAttribute suffix must be numeric device.setAttribute('patternAttributeFoo', Int.from(10)); diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts index 2ff3117..53a4c1a 100644 --- a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts @@ -7,6 +7,7 @@ import ButtplugIoDevice, { ButtplugIoDeviceAttributes } from "../../../../../src/device/protocol/buttplugIo/buttplugIoDevice.js"; import {DeviceAttributeModifier} from "../../../../../src/device/attribute/deviceAttribute.js"; +import type {AnyDevice} from "../../../../../src/device/device.js"; import {Int} from "../../../../../src/util/numbers.js"; import {describe, it, expect} from "vitest"; import {mock} from "vitest-mock-extended"; @@ -52,7 +53,7 @@ describe('ButtplugIoDevice', () => { const buttplugDeviceMock = mock(); const boolAttrKey: ButtplugIoDeviceAttributeKey = 'Rotate-1'; - const boolAttr = BoolDeviceAttribute.create(boolAttrKey, undefined, DeviceAttributeModifier.readWrite); + const boolAttr = BoolDeviceAttribute.createInitialized(boolAttrKey, undefined, DeviceAttributeModifier.readWrite, true); const device = createDevice( buttplugDeviceMock, @@ -75,7 +76,7 @@ describe('ButtplugIoDevice', () => { const buttplugDeviceMock = mock(); const rangeAttrName: ButtplugIoDeviceAttributeKey = 'Vibrate-2'; - const rangeAttr = IntRangeDeviceAttribute.create( + const rangeAttr = IntRangeDeviceAttribute.createInitialized( rangeAttrName, undefined, DeviceAttributeModifier.readWrite, @@ -83,6 +84,7 @@ describe('ButtplugIoDevice', () => { Int.ZERO, Int.from(20), Int.from(1), + Int.ZERO, ); const device = createDevice( @@ -111,7 +113,7 @@ describe('ButtplugIoDevice', () => { // Arrange const buttplugDeviceMock = mock(); const boolAttrKey: ButtplugIoDeviceAttributeKey = 'Rotate-1'; - const boolAttr = BoolDeviceAttribute.create(boolAttrKey, undefined, DeviceAttributeModifier.readWrite); + const boolAttr = BoolDeviceAttribute.createInitialized(boolAttrKey, undefined, DeviceAttributeModifier.readWrite, false); const device = createDevice(buttplugDeviceMock, {[boolAttrKey]: boolAttr}); // Act @@ -128,7 +130,7 @@ describe('ButtplugIoDevice', () => { // Arrange const buttplugDeviceMock = mock(); const attrKey: ButtplugIoDeviceAttributeKey = 'Vibrate-1'; - const readOnlyAttr = BoolDeviceAttribute.create(attrKey, undefined, DeviceAttributeModifier.readOnly); + const readOnlyAttr = BoolDeviceAttribute.createInitialized(attrKey, undefined, DeviceAttributeModifier.readOnly, false); const device = createDevice(buttplugDeviceMock, {[attrKey]: readOnlyAttr}); // Act @@ -144,7 +146,7 @@ describe('ButtplugIoDevice', () => { // Arrange const buttplugDeviceMock = mock(); const sensorAttrKey: ButtplugIoDeviceAttributeKey = 'Battery-1'; - const attr = BoolDeviceAttribute.create(sensorAttrKey, undefined, DeviceAttributeModifier.readWrite); + const attr = BoolDeviceAttribute.createInitialized(sensorAttrKey, undefined, DeviceAttributeModifier.readWrite, false); const device = createDevice(buttplugDeviceMock, {[sensorAttrKey]: attr}); // Act @@ -160,11 +162,16 @@ describe('ButtplugIoDevice', () => { // Arrange const buttplugDeviceMock = mock(); const attrKey: ButtplugIoDeviceAttributeKey = 'Vibrate-1'; - const attr = BoolDeviceAttribute.create(attrKey, undefined, DeviceAttributeModifier.readWrite); + const attr = BoolDeviceAttribute.createInitialized(attrKey, undefined, DeviceAttributeModifier.readWrite, false); const device = createDevice(buttplugDeviceMock, {[attrKey]: attr}); + // Go through the untyped device interface: this exercises the runtime guard that protects + // against callers (e.g. automation scripts) that aren't bound by the typed setAttribute + // overload, since TypeScript itself now rejects `undefined` here for a typed attribute. + const untypedDevice: AnyDevice = device; + // Act - const result = device.setAttribute(attrKey, undefined); + const result = untypedDevice.setAttribute(attrKey, undefined); // Assert await expect(result).rejects.toThrow(`Value to be set for attribute '${attrKey}' cannot be undefined`); diff --git a/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts b/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts index f8af421..03a6901 100644 --- a/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts +++ b/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts @@ -49,12 +49,12 @@ describe('EStim2bDevice', () => { ]; return { - mode: ListDeviceAttribute.create('mode', 'Mode', DeviceAttributeModifier.readWrite, modeOptions), - channelALevel: IntRangeDeviceAttribute.create('channelALevel', 'Channel A', DeviceAttributeModifier.readWrite, undefined, Int.ZERO, Int.from(99), Int.from(1)), - channelBLevel: IntRangeDeviceAttribute.create('channelBLevel', 'Channel B', DeviceAttributeModifier.readWrite, undefined, Int.ZERO, Int.from(99), Int.from(1)), - channelsJoined: BoolDeviceAttribute.create('channelsJoined', 'Channels Joined', DeviceAttributeModifier.readOnly), - highPowerMode: BoolDeviceAttribute.create('highPowerMode', 'High Power Mode', DeviceAttributeModifier.readWrite), - batteryStatus: StrDeviceAttribute.create('batteryStatus', 'Battery', DeviceAttributeModifier.readOnly), + mode: ListDeviceAttribute.createInitialized('mode', 'Mode', DeviceAttributeModifier.readWrite, modeOptions, Int.from(EStim2bMode.pulse)), + channelALevel: IntRangeDeviceAttribute.createInitialized('channelALevel', 'Channel A', DeviceAttributeModifier.readWrite, undefined, Int.ZERO, Int.from(99), Int.from(1), Int.ZERO), + channelBLevel: IntRangeDeviceAttribute.createInitialized('channelBLevel', 'Channel B', DeviceAttributeModifier.readWrite, undefined, Int.ZERO, Int.from(99), Int.from(1), Int.ZERO), + channelsJoined: BoolDeviceAttribute.createInitialized('channelsJoined', 'Channels Joined', DeviceAttributeModifier.readOnly, false), + highPowerMode: BoolDeviceAttribute.createInitialized('highPowerMode', 'High Power Mode', DeviceAttributeModifier.readWrite, false), + batteryStatus: StrDeviceAttribute.createInitialized('batteryStatus', 'Battery', DeviceAttributeModifier.readOnly, ''), }; } diff --git a/tests/unit/device/protocol/zc95/zc95Device.spec.ts b/tests/unit/device/protocol/zc95/zc95Device.spec.ts index f1a625a..c82ba28 100644 --- a/tests/unit/device/protocol/zc95/zc95Device.spec.ts +++ b/tests/unit/device/protocol/zc95/zc95Device.spec.ts @@ -60,8 +60,11 @@ describe('Zc95Device', () => { } function createPowerChannelAttrs(): Zc95DevicePowerChannelAttributes { - const makeAttr = (ch: number) => - IntRangeDeviceAttribute.createInitialized( + // Power channel attributes are constructed unset in production (see + // Zc95Device.getChannelPowerAttribute()) and only get a value once a power status + // message has been received, so we mirror that here rather than using createInitialized(). + const makeAttr = (ch: number) => { + const attr = IntRangeDeviceAttribute.create( `powerChannel${ch}`, `Channel ${ch}`, DeviceAttributeModifier.readWrite, @@ -69,8 +72,10 @@ describe('Zc95Device', () => { Int.ZERO, Int.from(100), Int.from(1), - Int.from(10), ); + attr.value = Int.from(10); + return attr; + }; return { powerChannel1: makeAttr(1), @@ -693,7 +698,7 @@ describe('Zc95Device', () => { mockProtocol.decode.mockReturnValue({ message: powerStatusMsg }); const overLimitAttrs = createPowerChannelAttrs(); - overLimitAttrs.powerChannel1 = IntRangeDeviceAttribute.createInitialized( + overLimitAttrs.powerChannel1 = IntRangeDeviceAttribute.create( 'powerChannel1', 'Channel 1', DeviceAttributeModifier.readWrite, @@ -701,8 +706,8 @@ describe('Zc95Device', () => { Int.ZERO, Int.from(100), Int.from(1), - Int.from(90), // current value 90 was above the new power limit of 70 ); + overLimitAttrs.powerChannel1.value = Int.from(90); // current value 90 was above the new power limit of 70 const device = createDevice({ activePattern: createActivePatternAttr(), From 9c28faa019acd36b4f23ee7288454116f144bb11 Mon Sep 17 00:00:00 2001 From: HRS Date: Tue, 11 Aug 2026 08:36:44 +0200 Subject: [PATCH 3/4] chore(device-attribute): merge AttributeStorage into AttributeValue, rename IsSet to IsInitialized AttributeValue was a flat, non-generic type (BaseAttributeValue | undefined) used only as the loose/untyped value type at the AnyDevice erasure boundary (device.ts, scriptVmFactory.ts). AttributeStorage was the generic conditional type computing an attribute's concrete storage/getter/setter type. Since AttributeStorage defaults to AllowedAttributeType | undefined when called with no type arguments, it can serve both roles - merged them under the AttributeValue name and dropped AttributeStorage and the now-redundant BaseAttributeValue (renamed to AllowedAttributeType). Also renamed the IsSet type parameter to IsInitialized throughout, matching the existing Initialized*DeviceAttribute naming convention. No behavioral change; bare `AttributeValue` still resolves to `AllowedAttributeType | undefined`, identical to the previous flat type. --- src/device/attribute/boolDeviceAttribute.ts | 2 +- src/device/attribute/deviceAttribute.ts | 23 ++++++++----------- src/device/attribute/floatDeviceAttribute.ts | 6 ++--- src/device/attribute/intDeviceAttribute.ts | 2 +- .../attribute/intRangeDeviceAttribute.ts | 15 +++++++++--- src/device/attribute/listDeviceAttribute.ts | 8 +++---- src/device/attribute/numberDeviceAttribute.ts | 8 +++---- src/device/attribute/strDeviceAttribute.ts | 2 +- 8 files changed, 36 insertions(+), 30 deletions(-) diff --git a/src/device/attribute/boolDeviceAttribute.ts b/src/device/attribute/boolDeviceAttribute.ts index b1912f4..5cf99e4 100644 --- a/src/device/attribute/boolDeviceAttribute.ts +++ b/src/device/attribute/boolDeviceAttribute.ts @@ -3,7 +3,7 @@ import DeviceAttribute from './deviceAttribute.js'; export type InitializedBoolDeviceAttribute = BoolDeviceAttribute; -export default class BoolDeviceAttribute extends DeviceAttribute +export default class BoolDeviceAttribute extends DeviceAttribute { public static createInitialized( name: string, diff --git a/src/device/attribute/deviceAttribute.ts b/src/device/attribute/deviceAttribute.ts index 12eefa6..322089f 100644 --- a/src/device/attribute/deviceAttribute.ts +++ b/src/device/attribute/deviceAttribute.ts @@ -1,12 +1,9 @@ import { Exclude, Expose } from 'class-transformer'; import type { Float, Int } from '../../util/numbers.js'; -export type BaseAttributeValue = string | Int | Float | boolean | null; -export type AttributeValue = BaseAttributeValue | undefined; +export type AllowedAttributeType = string | Int | Float | boolean | null; -// The storage/getter/setter type for an attribute: the concrete value V once it has been set -// (IsSet = true), or V | undefined beforehand (IsSet = false, the default). -export type AttributeStorage = IsSet extends true ? V : V | undefined; +export type AttributeValue = IsInitialized extends true ? V : V | undefined; export enum DeviceAttributeModifier { @@ -15,15 +12,15 @@ export enum DeviceAttributeModifier writeOnly = 'wo', } -export const isValidAttributeValue = ( - attribute: DeviceAttribute | undefined, +export const isValidAttributeValue = ( + attribute: DeviceAttribute | undefined, value: unknown, ): value is V => attribute?.isValidValue(value) ?? false; @Exclude() export default abstract class DeviceAttribute< - V extends BaseAttributeValue = BaseAttributeValue, - IsSet extends boolean = false, + V extends AllowedAttributeType = AllowedAttributeType, + IsInitialized extends boolean = false, > { @Expose({ name: 'name' }) @@ -36,9 +33,9 @@ export default abstract class DeviceAttribute< private readonly _modifier: DeviceAttributeModifier; @Expose({ name: 'value' }) - private _value: AttributeStorage; + private _value: AttributeValue; - public constructor(name: string, label: string | undefined, modifier: DeviceAttributeModifier, initialValue: AttributeStorage) { + public constructor(name: string, label: string | undefined, modifier: DeviceAttributeModifier, initialValue: AttributeValue) { this._name = name; this._label = label; this._modifier = modifier; @@ -64,11 +61,11 @@ export default abstract class DeviceAttribute< /** * @returns the current value or undefined if it has never been set or read from the device */ - public get value(): AttributeStorage { + public get value(): AttributeValue { return this._value; } - public set value(value: AttributeStorage) { + public set value(value: AttributeValue) { this._value = value; } diff --git a/src/device/attribute/floatDeviceAttribute.ts b/src/device/attribute/floatDeviceAttribute.ts index 13b3f24..7a70034 100644 --- a/src/device/attribute/floatDeviceAttribute.ts +++ b/src/device/attribute/floatDeviceAttribute.ts @@ -1,17 +1,17 @@ -import type { AttributeStorage, DeviceAttributeModifier } from './deviceAttribute.js'; +import type { AttributeValue, DeviceAttributeModifier } from './deviceAttribute.js'; import { Float } from '../../util/numbers.js'; import NumberDeviceAttribute from './numberDeviceAttribute.js'; export type InitializedFloatGenericDeviceAttribute = FloatDeviceAttribute; -export default class FloatDeviceAttribute extends NumberDeviceAttribute +export default class FloatDeviceAttribute extends NumberDeviceAttribute { public constructor( name: string, label: string | undefined, modifier: DeviceAttributeModifier, uom: string | undefined, - initialValue: AttributeStorage, + initialValue: AttributeValue, ) { super(name, label, modifier, uom, initialValue); } diff --git a/src/device/attribute/intDeviceAttribute.ts b/src/device/attribute/intDeviceAttribute.ts index 77bdc69..877253b 100644 --- a/src/device/attribute/intDeviceAttribute.ts +++ b/src/device/attribute/intDeviceAttribute.ts @@ -4,7 +4,7 @@ import NumberDeviceAttribute from './numberDeviceAttribute.js'; export type InitializedIntGenericDeviceAttribute = IntDeviceAttribute; -export default class IntDeviceAttribute extends NumberDeviceAttribute +export default class IntDeviceAttribute extends NumberDeviceAttribute { public static createInitialized( name: string, diff --git a/src/device/attribute/intRangeDeviceAttribute.ts b/src/device/attribute/intRangeDeviceAttribute.ts index c006012..a73453e 100644 --- a/src/device/attribute/intRangeDeviceAttribute.ts +++ b/src/device/attribute/intRangeDeviceAttribute.ts @@ -1,11 +1,11 @@ import { Expose } from 'class-transformer'; import { Int } from '../../util/numbers.js'; -import type { AttributeStorage, DeviceAttributeModifier } from './deviceAttribute.js'; +import type { AttributeValue, DeviceAttributeModifier } from './deviceAttribute.js'; import NumberDeviceAttribute from './numberDeviceAttribute.js'; export type InitializedIntRangeDeviceAttribute = IntRangeDeviceAttribute; -export default class IntRangeDeviceAttribute extends NumberDeviceAttribute +export default class IntRangeDeviceAttribute extends NumberDeviceAttribute { @Expose({ name: 'min' }) private _min: Int; @@ -16,7 +16,16 @@ export default class IntRangeDeviceAttribute exte @Expose({ name: 'incrementStep' }) private readonly _incrementStep: Int = Int.from(1); - public constructor(name: string, label: string | undefined, modifier: DeviceAttributeModifier, uom: string | undefined, min: Int, max: Int, incrementStep: Int, initialValue: AttributeStorage) { + public constructor( + name: string, + label: string | undefined, + modifier: DeviceAttributeModifier, + uom: string | undefined, + min: Int, + max: Int, + incrementStep: Int, + initialValue: AttributeValue, + ) { super(name, label, modifier, uom, initialValue); this._min = min; this._max = max; diff --git a/src/device/attribute/listDeviceAttribute.ts b/src/device/attribute/listDeviceAttribute.ts index 0018e0d..488a1b5 100644 --- a/src/device/attribute/listDeviceAttribute.ts +++ b/src/device/attribute/listDeviceAttribute.ts @@ -1,5 +1,5 @@ import { Expose } from 'class-transformer'; -import type { AttributeStorage, DeviceAttributeModifier } from './deviceAttribute.js'; +import type { AttributeValue, DeviceAttributeModifier } from './deviceAttribute.js'; import DeviceAttribute from './deviceAttribute.js'; import type { Int } from '../../util/numbers.js'; @@ -16,8 +16,8 @@ export type ListDeviceAttributeOptions = ListDeviceAttributeOption export default class ListDeviceAttribute< IKey extends ListDeviceAttributeItem, IValue extends ListDeviceAttributeItem, - IsSet extends boolean = false, -> extends DeviceAttribute + IsInitialized extends boolean = false, +> extends DeviceAttribute { @Expose({ name: 'values' }) private _values: ListDeviceAttributeOptions; @@ -27,7 +27,7 @@ export default class ListDeviceAttribute< label: string | undefined, modifier: DeviceAttributeModifier, values: ListDeviceAttributeOptions, - initialValue: AttributeStorage, + initialValue: AttributeValue, ) { super(name, label, modifier, initialValue); diff --git a/src/device/attribute/numberDeviceAttribute.ts b/src/device/attribute/numberDeviceAttribute.ts index b93a4da..402216d 100644 --- a/src/device/attribute/numberDeviceAttribute.ts +++ b/src/device/attribute/numberDeviceAttribute.ts @@ -1,4 +1,4 @@ -import type { AttributeStorage, DeviceAttributeModifier } from './deviceAttribute.js'; +import type { AttributeValue, DeviceAttributeModifier } from './deviceAttribute.js'; import DeviceAttribute from './deviceAttribute.js'; import { Expose } from 'class-transformer'; import type { Float, Int } from '../../util/numbers.js'; @@ -7,8 +7,8 @@ export type NumberAttributeValue = Int | Float; export default abstract class NumberDeviceAttribute< V extends NumberAttributeValue = NumberAttributeValue, - IsSet extends boolean = false, -> extends DeviceAttribute + IsInitialized extends boolean = false, +> extends DeviceAttribute { @Expose({ name: 'uom' }) private readonly _uom: string | undefined; @@ -18,7 +18,7 @@ export default abstract class NumberDeviceAttribute< label: string | undefined, modifier: DeviceAttributeModifier, uom: string | undefined, - initialValue: AttributeStorage, + initialValue: AttributeValue, ) { super(name, label, modifier, initialValue); this._uom = uom; diff --git a/src/device/attribute/strDeviceAttribute.ts b/src/device/attribute/strDeviceAttribute.ts index c95b77e..9c35a94 100644 --- a/src/device/attribute/strDeviceAttribute.ts +++ b/src/device/attribute/strDeviceAttribute.ts @@ -3,7 +3,7 @@ import DeviceAttribute from './deviceAttribute.js'; export type InitializedStrDeviceAttribute = StrDeviceAttribute; -export default class StrDeviceAttribute extends DeviceAttribute +export default class StrDeviceAttribute extends DeviceAttribute { public static createInitialized( name: string, From 673d2dc0e2efbfb17a1146567373bfa5de841a30 Mon Sep 17 00:00:00 2001 From: HRS Date: Tue, 11 Aug 2026 08:55:47 +0200 Subject: [PATCH 4/4] fix(device-attribute): validate concrete numeric invariant in isValidValue NumberDeviceAttribute.isValidValue() only checked typeof value === 'number', so a fractional value passed isValidValue() for Int attributes, and NaN/ Infinity passed for both Int and Float attributes - despite the isValidValue predicate now claiming `value is V` (Int/Float) after the presence-flag refactor. These invalid values can reach hardware protocol commands (e.g. Zc95's createPatternMinMaxChange, EStim2b's power/pulse commands, buttplug.io's scalar writes) via untyped callers of setAttribute() - the HTTP PATCH endpoint and automation scripts. Removed the shared loose check from NumberDeviceAttribute (now abstract again, inherited from DeviceAttribute) and added concrete overrides: - IntDeviceAttribute / IntRangeDeviceAttribute: Number.isInteger(value) - FloatDeviceAttribute: Number.isFinite(value) (matches Float.from()'s own NaN/Infinity rejection) Added unit tests for isValidValue on all three classes covering integers, fractional numbers, NaN, Infinity, and non-number values. Addresses CodeRabbit review comments on PR #110. --- src/device/attribute/floatDeviceAttribute.ts | 4 +++ src/device/attribute/intDeviceAttribute.ts | 4 +++ .../attribute/intRangeDeviceAttribute.ts | 4 +++ src/device/attribute/numberDeviceAttribute.ts | 4 --- .../attribute/floatDeviceAttribute.spec.ts | 21 +++++++++++++ .../attribute/intDeviceAttribute.spec.ts | 22 +++++++++++++ .../attribute/intRangeDeviceAttribute.spec.ts | 31 +++++++++++++++++++ 7 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 tests/unit/device/attribute/floatDeviceAttribute.spec.ts create mode 100644 tests/unit/device/attribute/intDeviceAttribute.spec.ts create mode 100644 tests/unit/device/attribute/intRangeDeviceAttribute.spec.ts diff --git a/src/device/attribute/floatDeviceAttribute.ts b/src/device/attribute/floatDeviceAttribute.ts index 7a70034..d7604fe 100644 --- a/src/device/attribute/floatDeviceAttribute.ts +++ b/src/device/attribute/floatDeviceAttribute.ts @@ -45,6 +45,10 @@ export default class FloatDeviceAttribute return Float.from(num); } + public override isValidValue(value: unknown): value is Float { + return typeof value === 'number' && Number.isFinite(value); + } + public override getType(): string { return 'float'; } diff --git a/src/device/attribute/intDeviceAttribute.ts b/src/device/attribute/intDeviceAttribute.ts index 877253b..84f35f3 100644 --- a/src/device/attribute/intDeviceAttribute.ts +++ b/src/device/attribute/intDeviceAttribute.ts @@ -35,6 +35,10 @@ export default class IntDeviceAttribute e return Int.from(num); } + public override isValidValue(value: unknown): value is Int { + return typeof value === 'number' && Number.isInteger(value); + } + public override getType(): string { return 'int'; } diff --git a/src/device/attribute/intRangeDeviceAttribute.ts b/src/device/attribute/intRangeDeviceAttribute.ts index a73453e..8a40884 100644 --- a/src/device/attribute/intRangeDeviceAttribute.ts +++ b/src/device/attribute/intRangeDeviceAttribute.ts @@ -87,6 +87,10 @@ export default class IntRangeDeviceAttribute { + + const attribute = FloatDeviceAttribute.create('attrName', undefined, DeviceAttributeModifier.readWrite, undefined); + + it.each([ + { value: 5.5, expected: true }, + { value: 0, expected: true }, + { value: -5.5, expected: true }, + { value: NaN, expected: false }, + { value: Infinity, expected: false }, + { value: -Infinity, expected: false }, + { value: '5.5', expected: false }, + { value: true, expected: false }, + ])('returns $expected for isValidValue($value)', ({ value, expected }) => { + expect(attribute.isValidValue(value)).toStrictEqual(expected); + }); +}); diff --git a/tests/unit/device/attribute/intDeviceAttribute.spec.ts b/tests/unit/device/attribute/intDeviceAttribute.spec.ts new file mode 100644 index 0000000..7481da4 --- /dev/null +++ b/tests/unit/device/attribute/intDeviceAttribute.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import IntDeviceAttribute from '../../../../src/device/attribute/intDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../../../src/device/attribute/deviceAttribute.js'; + +describe('IntDeviceAttribute', () => { + + const attribute = IntDeviceAttribute.create('attrName', undefined, DeviceAttributeModifier.readWrite, undefined); + + it.each([ + { value: 5, expected: true }, + { value: 0, expected: true }, + { value: -5, expected: true }, + { value: 1.5, expected: false }, + { value: NaN, expected: false }, + { value: Infinity, expected: false }, + { value: -Infinity, expected: false }, + { value: '5', expected: false }, + { value: true, expected: false }, + ])('returns $expected for isValidValue($value)', ({ value, expected }) => { + expect(attribute.isValidValue(value)).toStrictEqual(expected); + }); +}); diff --git a/tests/unit/device/attribute/intRangeDeviceAttribute.spec.ts b/tests/unit/device/attribute/intRangeDeviceAttribute.spec.ts new file mode 100644 index 0000000..44c9e72 --- /dev/null +++ b/tests/unit/device/attribute/intRangeDeviceAttribute.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import IntRangeDeviceAttribute from '../../../../src/device/attribute/intRangeDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../../../src/device/attribute/deviceAttribute.js'; +import { Int } from '../../../../src/util/numbers.js'; + +describe('IntRangeDeviceAttribute', () => { + + const attribute = IntRangeDeviceAttribute.create( + 'attrName', + undefined, + DeviceAttributeModifier.readWrite, + undefined, + Int.ZERO, + Int.from(100), + Int.from(1), + ); + + it.each([ + { value: 5, expected: true }, + { value: 0, expected: true }, + { value: -5, expected: true }, + { value: 1.5, expected: false }, + { value: NaN, expected: false }, + { value: Infinity, expected: false }, + { value: -Infinity, expected: false }, + { value: '5', expected: false }, + { value: true, expected: false }, + ])('returns $expected for isValidValue($value)', ({ value, expected }) => { + expect(attribute.isValidValue(value)).toStrictEqual(expected); + }); +});