diff --git a/packages/cc/src/cc/MeterCC.ts b/packages/cc/src/cc/MeterCC.ts index be9dab695e1..ad13eb83af9 100644 --- a/packages/cc/src/cc/MeterCC.ts +++ b/packages/cc/src/cc/MeterCC.ts @@ -1,8 +1,8 @@ -import { type ConfigManager } from "@zwave-js/config"; import { + type FloatParameters, type IZWaveEndpoint, type MaybeUnknown, - type MeterScale, + encodeBitMask, encodeFloatWithScale, getFloatParameters, getMeter, @@ -21,15 +21,17 @@ import { type SupervisionResult, UNKNOWN_STATE, ValueMetadata, - ZWaveError, - ZWaveErrorCodes, - getMinIntegerSize, parseBitMask, parseFloatWithScale, validatePayload, } from "@zwave-js/core/safe"; import type { ZWaveApplicationHost, ZWaveHost } from "@zwave-js/host/safe"; -import { getEnumMemberName, num2hex, pick } from "@zwave-js/shared/safe"; +import { + type AllOrNone, + getEnumMemberName, + num2hex, + pick, +} from "@zwave-js/shared/safe"; import { validateArgs } from "@zwave-js/transformers"; import { CCAPI, @@ -37,7 +39,9 @@ import { PhysicalCCAPI, type PollValueImplementation, SET_VALUE, + SET_VALUE_HOOKS, type SetValueImplementation, + type SetValueImplementationHooksFactory, throwMissingPropertyKey, throwUnsupportedProperty, throwUnsupportedPropertyKey, @@ -89,18 +93,28 @@ export const MeterCCValues = Object.freeze({ ...V.dynamicPropertyAndKeyWithName( "resetSingle", "reset", - (meterType: number) => meterType, + toPropertyKey, ({ property, propertyKey }) => property === "reset" && typeof propertyKey === "number", - (meterType: number) => ({ + (meterType: number, rateType: RateType, scale: number) => ({ ...ValueMetadata.WriteOnlyBoolean, // This is only a placeholder label. A config manager is needed to // determine the actual label. - label: `Reset (${num2hex(meterType)})`, + label: `Reset (${ + rateType === RateType.Consumed + ? "Consumption, " + : rateType === RateType.Produced + ? "Production, " + : "" + }${num2hex(scale)})`, states: { true: "Reset", }, - ccSpecific: { meterType }, + ccSpecific: { + meterType, + rateType, + scale, + }, } as const), ), @@ -144,22 +158,23 @@ function splitPropertyKey(key: number): { } function getValueLabel( - configManager: ConfigManager, meterType: number, - scale: MeterScale, + scale: number, rateType: RateType, suffix?: string, ): string { let ret = getMeterName(meterType); + const scaleLabel = + (getMeterScale(meterType, scale) ?? getUnknownMeterScale(scale)).label; switch (rateType) { case RateType.Consumed: - ret += ` Consumption [${scale.label}]`; + ret += ` Consumption [${scaleLabel}]`; break; case RateType.Produced: - ret += ` Production [${scale.label}]`; + ret += ` Production [${scaleLabel}]`; break; default: - ret += ` [${scale.label}]`; + ret += ` [${scaleLabel}]`; } if (suffix) { ret += ` (${suffix})`; @@ -167,13 +182,124 @@ function getValueLabel( return ret; } +function parseMeterValueAndInfo(data: Buffer, offset: number): { + type: number; + rateType: RateType; + scale1: number; + value: number; + bytesRead: number; +} { + validatePayload(data.length >= offset + 1); + + const type = data[offset] & 0b0_00_11111; + const rateType = (data[offset] & 0b0_11_00000) >>> 5; + const scale1Bit2 = (data[offset] & 0b1_00_00000) >>> 7; + + const { + scale: scale1Bits10, + value, + bytesRead, + } = parseFloatWithScale(data.subarray(offset + 1)); + + return { + type, + rateType, + // The scale is composed of two fields + scale1: (scale1Bit2 << 2) | scale1Bits10, + value, + // We've read one byte more than the float contains + bytesRead: bytesRead + 1, + }; +} + +function encodeMeterValueAndInfo( + type: number, + rateType: RateType, + scale: number, + value: number, +): { data: Buffer; floatParams: FloatParameters; scale2: number | undefined } { + // We need at least 2 bytes + + const scale1 = scale >= 7 ? 7 : scale & 0b111; + const scale1Bits10 = scale1 & 0b11; + const scale1Bit2 = scale1 >>> 2; + const scale2 = scale >= 7 ? scale - 7 : undefined; + + const typeByte = (type & 0b0_00_11111) + | ((rateType & 0b11) << 5) + | (scale1Bit2 << 7); + + const floatParams = getFloatParameters(value); + const valueBytes = encodeFloatWithScale( + value, + scale1Bits10, + floatParams, + ); + + return { + data: Buffer.concat([Buffer.from([typeByte]), valueBytes]), + floatParams: pick(floatParams, ["precision", "size"]), + scale2, + }; +} + +function parseScale( + scale1: number, + data: Buffer, + scale2Offset: number, +): number { + if (scale1 === 7) { + validatePayload(data.length >= scale2Offset + 1); + const scale2 = data[scale2Offset]; + return scale1 + scale2; + } else { + return scale1; + } +} + +export function isAccumulatedValue( + meterType: number, + scale: number, +): boolean { + // FIXME: We should probably move the meter definitions into code + switch (meterType) { + case 0x01: // Electric + return ( + scale === 0x00 // kWh + || scale === 0x01 // kVAh + || scale === 0x03 // Pulse count + || scale === 0x08 // kVarh + ); + case 0x02: // Gas + return ( + scale === 0x00 // m³ + || scale === 0x01 // ft³ + || scale === 0x03 // Pulse count + ); + case 0x03: // Water + return ( + scale === 0x00 // m³ + || scale === 0x01 // ft³ + || scale === 0x02 // US gallons + || scale === 0x03 // Pulse count + ); + case 0x04: // Heating + return scale === 0x00; // kWh + case 0x05: // Cooling + return scale === 0x00; // kWh + } + return false; +} + @API(CommandClasses.Meter) export class MeterCCAPI extends PhysicalCCAPI { public supportsCommand(cmd: MeterCommand): MaybeNotKnown { switch (cmd) { case MeterCommand.Get: + case MeterCommand.Report: return true; // This is mandatory case MeterCommand.SupportedGet: + case MeterCommand.SupportedReport: return this.version >= 2; case MeterCommand.Reset: { const ret = this.tryGetValueDB()?.getValue({ @@ -246,11 +372,29 @@ export class MeterCCAPI extends PhysicalCCAPI { } } + @validateArgs() + public async sendReport( + options: MeterCCReportOptions, + ): Promise { + this.assertSupportsCommand(MeterCommand, MeterCommand.Report); + + const cc = new MeterCCReport(this.applHost, { + nodeId: this.endpoint.nodeId, + endpoint: this.endpoint.index, + ...options, + }); + return this.applHost.sendCommand(cc, this.commandOptions); + } + + @validateArgs() // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - public async getAll() { + public async getAll(accumulatedOnly: boolean = false) { const valueDB = this.tryGetValueDB(); if (this.version >= 2) { + const meterType = valueDB?.getValue( + MeterCCValues.type.endpoint(this.endpoint.index), + ); const supportedScales = valueDB?.getValue( MeterCCValues.supportedScales.endpoint(this.endpoint.index), ) ?? []; @@ -267,6 +411,15 @@ export class MeterCCAPI extends PhysicalCCAPI { const ret = []; for (const rateType of rateTypes) { for (const scale of supportedScales) { + // Skip non-accumulated values if requested + if ( + accumulatedOnly + && meterType != undefined + && !isAccumulatedValue(meterType, scale) + ) { + continue; + } + const response = await this.get({ scale, rateType, @@ -305,6 +458,20 @@ export class MeterCCAPI extends PhysicalCCAPI { } } + @validateArgs() + public async sendSupportedReport( + options: MeterCCSupportedReportOptions, + ): Promise { + this.assertSupportsCommand(MeterCommand, MeterCommand.SupportedReport); + + const cc = new MeterCCSupportedReport(this.applHost, { + nodeId: this.endpoint.nodeId, + endpoint: this.endpoint.index, + ...options, + }); + await this.applHost.sendCommand(cc, this.commandOptions); + } + @validateArgs() public async reset( options?: MeterCCResetOptions, @@ -341,20 +508,112 @@ export class MeterCCAPI extends PhysicalCCAPI { ); } - const resetOptions: MeterCCResetOptions = propertyKey != undefined - ? { - type: propertyKey, + if (typeof propertyKey === "number") { + const { meterType, scale, rateType } = splitPropertyKey( + propertyKey, + ); + return this.reset({ + type: meterType, + scale, + rateType, targetValue: 0, - } - : {}; - await this.reset(resetOptions); - - // Refresh values - await this.getAll(); + }); + } else { + return this.reset(); + } return undefined; }; } + + protected [SET_VALUE_HOOKS]: SetValueImplementationHooksFactory = ( + { property, propertyKey }, + _value, + _options, + ) => { + if (property !== "reset") return; + + if (typeof propertyKey === "number") { + // Reset single + const { meterType, rateType, scale } = splitPropertyKey( + propertyKey, + ); + const readingValueId = MeterCCValues.value( + meterType, + rateType, + scale, + ).endpoint(this.endpoint.index); + + return { + optimisticallyUpdateRelatedValues: ( + supervisedAndSuccessful, + ) => { + if (!supervisedAndSuccessful) return; + + // After resetting a single reading with supervision, store zero + // in the corresponding value + const valueDB = this.tryGetValueDB(); + if (!valueDB) return; + + if (isAccumulatedValue(meterType, scale)) { + valueDB.setValue({ + commandClass: this.ccId, + endpoint: this.endpoint.index, + property, + propertyKey, + }, 0); + } + }, + + verifyChanges: () => { + this.schedulePoll( + readingValueId, + 0, + { transition: "fast" }, + ); + }, + }; + } else { + // Reset all + const valueDB = this.tryGetValueDB(); + if (!valueDB) return; + + const accumulatedValues = valueDB.findValues((vid) => + vid.commandClass === this.ccId + && vid.endpoint === this.endpoint.index + && MeterCCValues.value.is(vid) + ).filter(({ propertyKey }) => { + if (typeof propertyKey !== "number") return false; + const { meterType, scale } = splitPropertyKey(propertyKey); + return isAccumulatedValue(meterType, scale); + }); + + return { + optimisticallyUpdateRelatedValues: ( + supervisedAndSuccessful, + ) => { + if (!supervisedAndSuccessful) return; + + // After setting the reset all value with supervision, + // reset all accumulated values, since we know they are now zero. + for (const value of accumulatedValues) { + valueDB.setValue(value, 0); + } + }, + + verifyChanges: () => { + // Poll all accumulated values, unless they were updated by the device + for (const valueID of accumulatedValues) { + this.schedulePoll( + valueID, + 0, + { transition: "fast" }, + ); + } + }, + }; + } + }; } @commandClass(CommandClasses.Meter) @@ -595,7 +854,7 @@ supports reset: ${suppResp.supportsReset}`; } // @publicAPI -export interface MeterCCReportOptions extends CCCommandOptions { +export interface MeterCCReportOptions { type: number; scale: number; value: number; @@ -608,29 +867,22 @@ export interface MeterCCReportOptions extends CCCommandOptions { export class MeterCCReport extends MeterCC { public constructor( host: ZWaveHost, - options: CommandClassDeserializationOptions | MeterCCReportOptions, + options: + | CommandClassDeserializationOptions + | (MeterCCReportOptions & CCCommandOptions), ) { super(host, options); if (gotDeserializationOptions(options)) { - validatePayload(this.payload.length >= 2); - this.type = this.payload[0] & 0b0_00_11111; - - this.rateType = (this.payload[0] & 0b0_11_00000) >>> 5; - const scale1Bit2 = (this.payload[0] & 0b1_00_00000) >>> 7; - - const { - scale: scale1Bits10, - value, - bytesRead, - } = parseFloatWithScale(this.payload.subarray(1)); - let offset = 2 + (bytesRead - 1); - // The scale is composed of two fields (see SDS13781) - const scale1 = (scale1Bit2 << 2) | scale1Bits10; - let scale2 = 0; + const { type, rateType, scale1, value, bytesRead } = + parseMeterValueAndInfo(this.payload, 0); + this.type = type; + this.rateType = rateType; this.value = value; + let offset = bytesRead; + const floatSize = bytesRead - 2; - if (this.version >= 2 && this.payload.length >= offset + 2) { + if (this.payload.length >= offset + 2) { this.deltaTime = this.payload.readUInt16BE(offset); offset += 2; if (this.deltaTime === 0xffff) { @@ -638,9 +890,9 @@ export class MeterCCReport extends MeterCC { } if ( - // 0 means that no previous value is included + // Previous value is included only if delta time is not 0 this.deltaTime !== 0 - && this.payload.length >= offset + (bytesRead - 1) + && this.payload.length >= offset + floatSize ) { const { value: prevValue } = parseFloatWithScale( // This float is split in the payload @@ -649,21 +901,14 @@ export class MeterCCReport extends MeterCC { this.payload.subarray(offset), ]), ); - offset += bytesRead - 1; + offset += floatSize; this.previousValue = prevValue; } - if ( - this.version >= 4 - && scale1 === 7 - && this.payload.length >= offset + 1 - ) { - scale2 = this.payload[offset]; - } } else { // 0 means that no previous value is included this.deltaTime = 0; } - this.scale = scale1 === 7 ? scale1 + scale2 : scale1; + this.scale = parseScale(scale1, this.payload, offset); } else { this.type = options.type; this.scale = options.scale; @@ -740,12 +985,7 @@ export class MeterCCReport extends MeterCC { ); this.setMetadata(applHost, valueValue, { ...valueValue.meta, - label: getValueLabel( - applHost.configManager, - this.type, - scale, - this.rateType, - ), + label: getValueLabel(this.type, this.scale, this.rateType), unit: scale.label, ccSpecific: { meterType: this.type, @@ -766,41 +1006,44 @@ export class MeterCCReport extends MeterCC { public deltaTime: MaybeUnknown; public serialize(): Buffer { - const scale1 = this.scale >= 7 ? 7 : this.scale & 0b111; - const scale1Bits10 = scale1 & 0b11; - const scale1Bit2 = scale1 >>> 2; - const scale2 = this.scale >= 7 ? this.scale - 7 : 0; - - const typeByte = (this.type & 0b0_00_11111) - | ((this.rateType & 0b11) << 5) - | (scale1Bit2 << 7); - - const floatParams = getFloatParameters(this.value); - const valueBytes = encodeFloatWithScale( - this.value, - scale1Bits10, - floatParams, - ); - const prevValueBytes = this.previousValue != undefined - ? encodeFloatWithScale( - this.previousValue, - scale1Bits10, - floatParams, - ) - : Buffer.from([]); + const { data: typeAndValue, floatParams, scale2 } = + encodeMeterValueAndInfo( + this.type, + this.rateType, + this.scale, + this.value, + ); const deltaTime = this.deltaTime ?? 0xffff; const deltaTimeBytes = Buffer.allocUnsafe(2); deltaTimeBytes.writeUInt16BE(deltaTime, 0); this.payload = Buffer.concat([ - Buffer.from([typeByte]), - valueBytes, + typeAndValue, deltaTimeBytes, - prevValueBytes, - Buffer.from([scale2]), ]); + if (this.deltaTime !== 0 && this.previousValue != undefined) { + // Encode the float, but only keep the value bytes + const prevValueBytes = encodeFloatWithScale( + this.previousValue, + 0, // we discard the scale anyways + floatParams, + ).subarray(1); + + this.payload = Buffer.concat([ + this.payload, + prevValueBytes, + ]); + } + + if (scale2 != undefined) { + this.payload = Buffer.concat([ + this.payload, + Buffer.from([scale2]), + ]); + } + return super.serialize(); } @@ -853,11 +1096,14 @@ export class MeterCCGet extends MeterCC { ) { super(host, options); if (gotDeserializationOptions(options)) { - // TODO: Deserialize payload - throw new ZWaveError( - `${this.constructor.name}: deserialization not implemented`, - ZWaveErrorCodes.Deserialization_NotImplemented, - ); + if (this.payload.length >= 1) { + this.rateType = (this.payload[0] & 0b11_000_000) >>> 6; + this.scale = (this.payload[0] & 0b00_111_000) >>> 3; + if (this.scale === 7) { + validatePayload(this.payload.length >= 2); + this.scale += this.payload[1]; + } + } } else { this.rateType = options.rateType; this.scale = options.scale; @@ -921,43 +1167,61 @@ export class MeterCCGet extends MeterCC { } } +// @publicAPI +export interface MeterCCSupportedReportOptions { + type: number; + supportsReset: boolean; + supportedScales: readonly number[]; + supportedRateTypes: readonly RateType[]; +} + @CCCommand(MeterCommand.SupportedReport) export class MeterCCSupportedReport extends MeterCC { public constructor( host: ZWaveHost, - options: CommandClassDeserializationOptions, + options: + | CommandClassDeserializationOptions + | (MeterCCSupportedReportOptions & CCCommandOptions), ) { super(host, options); - validatePayload(this.payload.length >= 2); - this.type = this.payload[0] & 0b0_00_11111; - this.supportsReset = !!(this.payload[0] & 0b1_00_00000); - const hasMoreScales = !!(this.payload[1] & 0b1_0000000); - if (hasMoreScales) { - // The bitmask is spread out - validatePayload(this.payload.length >= 3); - const extraBytes = this.payload[2]; - validatePayload(this.payload.length >= 3 + extraBytes); - // The bitmask is the original payload byte plus all following bytes - // Since the first byte only has 7 bits, we need to reduce all following bits by 1 - this.supportedScales = parseBitMask( - Buffer.concat([ - Buffer.from([this.payload[1] & 0b0_1111111]), - this.payload.subarray(3, 3 + extraBytes), - ]), - 0, - ).map((scale) => (scale >= 8 ? scale - 1 : scale)); - } else { - // only 7 bits in the bitmask. Bit 7 is 0, so no need to mask it out - this.supportedScales = parseBitMask( - Buffer.from([this.payload[1]]), - 0, + + if (gotDeserializationOptions(options)) { + validatePayload(this.payload.length >= 2); + this.type = this.payload[0] & 0b0_00_11111; + this.supportsReset = !!(this.payload[0] & 0b1_00_00000); + const hasMoreScales = !!(this.payload[1] & 0b1_0000000); + if (hasMoreScales) { + // The bitmask is spread out + validatePayload(this.payload.length >= 3); + const extraBytes = this.payload[2]; + validatePayload(this.payload.length >= 3 + extraBytes); + // The bitmask is the original payload byte plus all following bytes + // Since the first byte only has 7 bits, we need to reduce all following bits by 1 + this.supportedScales = parseBitMask( + Buffer.concat([ + Buffer.from([this.payload[1] & 0b0_1111111]), + this.payload.subarray(3, 3 + extraBytes), + ]), + 0, + ).map((scale) => (scale >= 8 ? scale - 1 : scale)); + } else { + // only 7 bits in the bitmask. Bit 7 is 0, so no need to mask it out + this.supportedScales = parseBitMask( + Buffer.from([this.payload[1]]), + 0, + ); + } + // This is only present in V4+ + this.supportedRateTypes = parseBitMask( + Buffer.from([(this.payload[0] & 0b0_11_00000) >>> 5]), + 1, ); + } else { + this.type = options.type; + this.supportsReset = options.supportsReset; + this.supportedScales = options.supportedScales; + this.supportedRateTypes = options.supportedRateTypes; } - // This is only present in V4+ - this.supportedRateTypes = parseBitMask( - Buffer.from([(this.payload[0] & 0b0_11_00000) >>> 5]), - 1, - ); } @ccValue(MeterCCValues.type) @@ -980,15 +1244,67 @@ export class MeterCCSupportedReport extends MeterCC { if (this.version < 6) { this.ensureMetadata(applHost, MeterCCValues.resetAll); } else { - const resetSingleValue = MeterCCValues.resetSingle(this.type); - this.ensureMetadata(applHost, resetSingleValue, { - ...resetSingleValue.meta, - label: `Reset (${getMeterName(this.type)})`, - }); + for (const scale of this.supportedScales) { + // Only accumulated values can be reset + if (!isAccumulatedValue(this.type, scale)) continue; + + for (const rateType of this.supportedRateTypes) { + const resetSingleValue = MeterCCValues.resetSingle( + this.type, + rateType, + scale, + ); + this.ensureMetadata(applHost, resetSingleValue, { + ...resetSingleValue.meta, + label: `Reset ${ + getValueLabel( + this.type, + scale, + rateType, + ) + }`, + }); + } + } } return true; } + public serialize(): Buffer { + const typeByte = (this.type & 0b0_00_11111) + | (this.supportedRateTypes.includes(RateType.Consumed) + ? 0b0_01_00000 + : 0) + | (this.supportedRateTypes.includes(RateType.Produced) + ? 0b0_10_00000 + : 0) + | (this.supportsReset ? 0b1_00_00000 : 0); + const supportedScales = encodeBitMask( + this.supportedScales, + undefined, + // The first byte only has 7 bits for the bitmask, + // so we add a fake bit for the value -1 and later shift + // the first byte one to the right + -1, + ); + const scalesByte1 = (supportedScales[0] >>> 1) + | (supportedScales.length > 1 ? 0b1000_0000 : 0); + + this.payload = Buffer.from([ + typeByte, + scalesByte1, + ]); + if (supportedScales.length > 1) { + this.payload = Buffer.concat([ + this.payload, + Buffer.from([supportedScales.length - 1]), + Buffer.from(supportedScales.subarray(1)), + ]); + } + + return super.serialize(); + } + public toLogEntry(applHost: ZWaveApplicationHost): MessageOrCCLogEntry { const message: MessageRecord = { "meter type": getMeterName(this.type), @@ -1017,15 +1333,12 @@ export class MeterCCSupportedReport extends MeterCC { export class MeterCCSupportedGet extends MeterCC {} // @publicAPI -export type MeterCCResetOptions = - | { - type?: undefined; - targetValue?: undefined; - } - | { - type: number; - targetValue: number; - }; +export type MeterCCResetOptions = AllOrNone<{ + type: number; + scale: number; + rateType: RateType; + targetValue: number; +}>; @CCCommand(MeterCommand.Reset) @useSupervision() @@ -1038,39 +1351,53 @@ export class MeterCCReset extends MeterCC { ) { super(host, options); if (gotDeserializationOptions(options)) { - // TODO: Deserialize payload - throw new ZWaveError( - `${this.constructor.name}: deserialization not implemented`, - ZWaveErrorCodes.Deserialization_NotImplemented, - ); + if (this.payload.length > 0) { + const { + type, + rateType, + scale1, + value, + bytesRead: scale2Offset, + } = parseMeterValueAndInfo(this.payload, 0); + this.type = type; + this.rateType = rateType; + this.targetValue = value; + this.scale = parseScale(scale1, this.payload, scale2Offset); + } } else { this.type = options.type; + this.scale = options.scale; + this.rateType = options.rateType; this.targetValue = options.targetValue; - // Test if this is a valid target value - if ( - this.targetValue != undefined - && !getMinIntegerSize(this.targetValue, true) - ) { - throw new ZWaveError( - `${this.targetValue} is not a valid target value!`, - ZWaveErrorCodes.Argument_Invalid, - ); - } } } public type: number | undefined; + public scale: number | undefined; + public rateType: RateType | undefined; public targetValue: number | undefined; public serialize(): Buffer { - if (this.version >= 6 && this.targetValue != undefined && this.type) { - const size = (this.targetValue - && getMinIntegerSize(this.targetValue, true)) - || 0; - if (size > 0) { - this.payload = Buffer.allocUnsafe(1 + size); - this.payload[0] = (size << 5) | (this.type & 0b11111); - this.payload.writeIntBE(this.targetValue, 1, size); + if ( + this.type != undefined + && this.scale != undefined + && this.rateType != undefined + && this.targetValue != undefined + ) { + const { data: typeAndValue, scale2 } = encodeMeterValueAndInfo( + this.type, + this.rateType, + this.scale, + this.targetValue, + ); + + this.payload = typeAndValue; + + if (scale2 != undefined) { + this.payload = Buffer.concat([ + this.payload, + Buffer.from([scale2]), + ]); } } return super.serialize(); @@ -1081,6 +1408,13 @@ export class MeterCCReset extends MeterCC { if (this.type != undefined) { message.type = getMeterName(this.type); } + if (this.rateType != undefined) { + message["rate type"] = getEnumMemberName(RateType, this.rateType); + } + if (this.type != undefined && this.scale != undefined) { + message.scale = (getMeterScale(this.type, this.scale) + ?? getUnknownMeterScale(this.scale)).label; + } if (this.targetValue != undefined) { message["target value"] = this.targetValue; } diff --git a/packages/cc/src/cc/index.ts b/packages/cc/src/cc/index.ts index 4ca2320d059..7c07f667590 100644 --- a/packages/cc/src/cc/index.ts +++ b/packages/cc/src/cc/index.ts @@ -388,6 +388,7 @@ export type { MeterCCGetOptions, MeterCCReportOptions, MeterCCResetOptions, + MeterCCSupportedReportOptions, } from "./MeterCC"; export { MeterCC, diff --git a/packages/core/src/values/Primitive.ts b/packages/core/src/values/Primitive.ts index 91fdc10358e..52297675592 100644 --- a/packages/core/src/values/Primitive.ts +++ b/packages/core/src/values/Primitive.ts @@ -182,11 +182,16 @@ export function getIntegerLimits( return (IntegerLimits as any)[`${signed ? "" : "U"}Int${size * 8}`]; } -export function getFloatParameters(value: number): { +export interface FloatParameters { precision: number; size: number; +} + +export interface FloatParametersWithValue extends FloatParameters { roundedValue: number; -} { +} + +export function getFloatParameters(value: number): FloatParametersWithValue { const precision = Math.min(getPrecision(value), 7); value = Math.round(value * Math.pow(10, precision)); const size: number | undefined = getMinIntegerSize(value, true); diff --git a/packages/testing/src/CCSpecificCapabilities.ts b/packages/testing/src/CCSpecificCapabilities.ts index 789891a2692..0234632eedc 100644 --- a/packages/testing/src/CCSpecificCapabilities.ts +++ b/packages/testing/src/CCSpecificCapabilities.ts @@ -28,6 +28,28 @@ export interface NotificationCCCapabilities { notificationTypesAndEvents: Record; } +export interface MeterCCCapabilities { + meterType: number; + supportedScales: number[]; + supportedRateTypes: number[]; + supportsReset: boolean; + getValue?: ( + scale: number, + rateType: number, + ) => number | { + value: number; + deltaTime: number; + prevValue?: number; + } | undefined; + onReset?: ( + options?: { + scale: number; + rateType: number; + targetValue: number; + }, + ) => void; +} + export interface MultilevelSensorCCCapabilities { sensors: Record = diff --git a/packages/zwave-js/src/lib/node/MockNodeBehaviors.ts b/packages/zwave-js/src/lib/node/MockNodeBehaviors.ts index 4efa04e44e9..2d00a2098e2 100644 --- a/packages/zwave-js/src/lib/node/MockNodeBehaviors.ts +++ b/packages/zwave-js/src/lib/node/MockNodeBehaviors.ts @@ -31,6 +31,7 @@ import { BasicCCBehaviors } from "./mockCCBehaviors/Basic"; import { ConfigurationCCBehaviors } from "./mockCCBehaviors/Configuration"; import { EnergyProductionCCBehaviors } from "./mockCCBehaviors/EnergyProduction"; import { ManufacturerSpecificCCBehaviors } from "./mockCCBehaviors/ManufacturerSpecific"; +import { MeterCCBehaviors } from "./mockCCBehaviors/Meter"; import { MultilevelSensorCCBehaviors } from "./mockCCBehaviors/MultilevelSensor"; import { NotificationCCBehaviors } from "./mockCCBehaviors/Notification"; import { ScheduleEntryLockCCBehaviors } from "./mockCCBehaviors/ScheduleEntryLock"; @@ -294,6 +295,7 @@ export function createDefaultBehaviors(): MockNodeBehavior[] { ...ConfigurationCCBehaviors, ...EnergyProductionCCBehaviors, ...ManufacturerSpecificCCBehaviors, + ...MeterCCBehaviors, ...MultilevelSensorCCBehaviors, ...NotificationCCBehaviors, ...ScheduleEntryLockCCBehaviors, diff --git a/packages/zwave-js/src/lib/node/mockCCBehaviors/Meter.ts b/packages/zwave-js/src/lib/node/mockCCBehaviors/Meter.ts new file mode 100644 index 00000000000..0528c811e40 --- /dev/null +++ b/packages/zwave-js/src/lib/node/mockCCBehaviors/Meter.ts @@ -0,0 +1,143 @@ +import { + MeterCCGet, + MeterCCReport, + MeterCCReset, + MeterCCSupportedGet, + MeterCCSupportedReport, + RateType, +} from "@zwave-js/cc"; +import { CommandClasses } from "@zwave-js/core"; +import { + type MeterCCCapabilities, + type MockNodeBehavior, + MockZWaveFrameType, + createMockZWaveRequestFrame, +} from "@zwave-js/testing"; + +export const defaultCapabilities: MeterCCCapabilities = { + meterType: 0x01, // Electric + supportedScales: [0x00], // kWh + supportedRateTypes: [RateType.Consumed], + supportsReset: true, +}; + +const respondToMeterSupportedGet: MockNodeBehavior = { + async onControllerFrame(controller, self, frame) { + if ( + frame.type === MockZWaveFrameType.Request + && frame.payload instanceof MeterCCSupportedGet + ) { + const capabilities = { + ...defaultCapabilities, + ...self.getCCCapabilities( + CommandClasses.Meter, + frame.payload.endpointIndex, + ), + }; + const cc = new MeterCCSupportedReport(self.host, { + nodeId: controller.host.ownNodeId, + type: capabilities.meterType, + supportedScales: capabilities.supportedScales, + supportedRateTypes: capabilities.supportedRateTypes, + supportsReset: capabilities.supportsReset, + }); + await self.sendToController( + createMockZWaveRequestFrame(cc, { + ackRequested: false, + }), + ); + return true; + } + return false; + }, +}; + +const respondToMeterGet: MockNodeBehavior = { + async onControllerFrame(controller, self, frame) { + if ( + frame.type === MockZWaveFrameType.Request + && frame.payload instanceof MeterCCGet + ) { + const capabilities = { + ...defaultCapabilities, + ...self.getCCCapabilities( + CommandClasses.Meter, + frame.payload.endpointIndex, + ), + }; + const scale = frame.payload.scale + ?? capabilities.supportedScales[0]; + const rateType = frame.payload.rateType + ?? capabilities.supportedRateTypes[0] + ?? RateType.Consumed; + + const value = capabilities.getValue?.(scale, rateType) ?? { + value: 0, + deltaTime: 0, + }; + const normalizedValue = typeof value === "number" + ? { + value, + deltaTime: 0, + } + : value; + + const cc = new MeterCCReport(self.host, { + nodeId: controller.host.ownNodeId, + type: capabilities.meterType, + scale, + rateType, + ...normalizedValue, + }); + await self.sendToController( + createMockZWaveRequestFrame(cc, { + ackRequested: false, + }), + ); + return true; + } + return false; + }, +}; + +const respondToMeterReset: MockNodeBehavior = { + onControllerFrame(controller, self, frame) { + if ( + frame.type === MockZWaveFrameType.Request + && frame.payload instanceof MeterCCReset + ) { + const capabilities = { + ...defaultCapabilities, + ...self.getCCCapabilities( + CommandClasses.Meter, + frame.payload.endpointIndex, + ), + }; + + const cc = frame.payload; + if ( + cc.type != undefined + && cc.scale != undefined + && cc.rateType != undefined + && cc.targetValue != undefined + ) { + capabilities.onReset?.({ + scale: cc.scale, + rateType: cc.rateType, + targetValue: cc.targetValue, + }); + } else { + capabilities.onReset?.(); + } + + return true; + } + return false; + }, +}; + +export const MeterCCBehaviors = [ + respondToMeterSupportedGet, + respondToMeterGet, + respondToMeterReset, +]; diff --git a/packages/zwave-js/src/lib/test/cc/MeterCC.test.ts b/packages/zwave-js/src/lib/test/cc/MeterCC.test.ts index 1f58d5b9611..2587d4b190a 100644 --- a/packages/zwave-js/src/lib/test/cc/MeterCC.test.ts +++ b/packages/zwave-js/src/lib/test/cc/MeterCC.test.ts @@ -98,16 +98,16 @@ test("the Reset command (V6) should serialize correctly", (t) => { const cc = new MeterCCReset(host, { nodeId: 1, type: 7, - targetValue: 0x010203, + scale: 3, + rateType: RateType.Unspecified, + targetValue: 12.3, }); const expected = buildCCBuffer( Buffer.from([ MeterCommand.Reset, // CC Command - 0b100_00111, // Size, Type - 0, - 1, - 2, - 3, + 0b0_00_00111, // scale (2), rate type, type + 0b001_11_001, // precision, scale, size + 123, // 12.3 ]), ); t.deepEqual(cc.serialize(), expected);