diff --git a/docs/USAGE.md b/docs/USAGE.md index 83315022..cf61623a 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1794,8 +1794,13 @@ Items that represent components of a device that are characterized by numbers wi * defaults to no support * supportedRange=`` * range formatted as `::` (e.g. `supportedRange="0:100:1"`) - * precision value used as default increment for adjusted range value request. - * defaults to item state description min, max & step values, if defined, otherwise `"0:100:1"` (Dimmer/Rollershutter); `"0:10:1"` (Number) + * precision value used as: + * default increment for adjusted range value requests + * item state rounding for range value state requests + * defaults to, in order of precedence: + * item state description min, max & step properties + * item state presentation precision for non-controllable Number + * `"0:100:1"` (Dimmer/Rollershutter); `"0:10:1"` (Number); `"0:10:0.01"` (Number Read-Only) * presets=`` * each preset formatted as `=<@assetIdOrName1>:...` (e.g. `presets="1=@Value.Low:Lowest,10=@Value.High:Highest"`) * limited to a maximum of 150 presets diff --git a/lambda/alexa/smarthome/properties/rangeValue.js b/lambda/alexa/smarthome/properties/rangeValue.js index 6ea72098..6d9a1e85 100644 --- a/lambda/alexa/smarthome/properties/rangeValue.js +++ b/lambda/alexa/smarthome/properties/rangeValue.js @@ -11,6 +11,7 @@ * SPDX-License-Identifier: EPL-2.0 */ +import OpenHAB from '#openhab/index.js'; import { ItemType } from '#openhab/constants.js'; import { Parameter, ParameterType } from '../constants.js'; import { AlexaPresetResources } from '../resources.js'; @@ -63,7 +64,9 @@ export default class RangeValue extends Generic { * @return {Array} */ get defaultRange() { - return this.item.type === ItemType.DIMMER || this.item.type === ItemType.ROLLERSHUTTER ? [0, 100, 1] : [0, 10, 1]; + return this.item.type === ItemType.DIMMER || this.item.type === ItemType.ROLLERSHUTTER + ? [0, 100, 1] + : [0, 10, this.isNonControllable ? 0.01 : 1]; } /** @@ -171,12 +174,17 @@ export default class RangeValue extends Generic { // Define supported range as follow: // 1) using parameter values if defined // 2) using item state description minimum, maximum & step values if available - // 3) empty array + // 3) using item state presentation precision for number item type non-controllable property if available const range = parameters[Parameter.SUPPORTED_RANGE] ? parameters[Parameter.SUPPORTED_RANGE] - : item.stateDescription - ? [item.stateDescription.minimum, item.stateDescription.maximum, item.stateDescription.step] - : []; + : [ + item.stateDescription?.minimum ?? this.defaultRange[0], + item.stateDescription?.maximum ?? this.defaultRange[1], + item.stateDescription?.step ?? + (item.type.split(':')[0] === ItemType.NUMBER && this.isNonControllable + ? 1 / 10 ** OpenHAB.getStatePresentationPrecision(item.stateDescription?.pattern) + : undefined) + ]; // Update supported range values if valid (min < max; max - min > prec), otherwise set to undefined parameters[Parameter.SUPPORTED_RANGE] = range[0] < range[1] && range[1] - range[0] > Math.abs(range[2]) ? range : undefined; diff --git a/lambda/alexa/smarthome/unitOfMeasure.js b/lambda/alexa/smarthome/unitOfMeasure.js index 410e95a5..e35bc065 100644 --- a/lambda/alexa/smarthome/unitOfMeasure.js +++ b/lambda/alexa/smarthome/unitOfMeasure.js @@ -11,6 +11,7 @@ * SPDX-License-Identifier: EPL-2.0 */ +import OpenHAB from '#openhab/index.js'; import { Dimension, UnitSymbol, UnitSystem } from '#openhab/constants.js'; /** @@ -491,12 +492,8 @@ class UnitsOfMeasure { * @return {Object} */ static getUnitOfMeasure({ dimension, unitSymbol, statePresentation, system = UnitSystem.METRIC }) { - // Determine symbol using item unit symbol or matching item state presentation with supported list - const symbol = - unitSymbol ?? - Object.values(UnitSymbol).find((symbol) => - new RegExp(`%\\d*(?:\\.\\d+)?[df]\\s*[%]?${symbol}$`).test(statePresentation) - ); + // Determine symbol using item unit symbol or state presentation + const symbol = unitSymbol || OpenHAB.getStatePresentationUnitSymbol(statePresentation); // Return unit of measure using symbol/dimension or fallback to default value using dimension/system return ( this.#UOMS.find((uom) => uom.symbol === symbol && (!dimension || uom.dimension === dimension)) || diff --git a/lambda/openhab/index.js b/lambda/openhab/index.js index f7028b7d..c26ce2c7 100644 --- a/lambda/openhab/index.js +++ b/lambda/openhab/index.js @@ -15,7 +15,7 @@ import fs from 'node:fs'; import axios from 'axios'; import { HttpsAgent } from 'agentkeepalive'; import { validate as uuidValidate } from 'uuid'; -import { ItemType, ItemValue } from './constants.js'; +import { ItemType, ItemValue, UnitSymbol } from './constants.js'; /** * Defines openHAB class @@ -246,13 +246,35 @@ export default class OpenHAB { const type = (item.groupType || item.type).split(':')[0]; if (type === ItemType.DIMMER || type === ItemType.NUMBER || type === ItemType.ROLLERSHUTTER) { - const { precision, specifier } = - item.stateDescription?.pattern?.match(/%\d*(?:\.(?\d+))?(?[df])/)?.groups || {}; + const precision = OpenHAB.getStatePresentationPrecision(item.stateDescription?.pattern); const value = parseFloat(state); - return specifier === 'd' ? value.toFixed() : precision <= 16 ? value.toFixed(precision) : value.toString(); + return isNaN(precision) ? value.toString() : value.toFixed(precision); } return state; } + + /** + * Returns state presentation precision for a given item state description pattern + * + * @param {String} pattern + * @return {Number} + */ + static getStatePresentationPrecision(pattern) { + const { precision, specifier } = pattern?.match(/%\d*(?:\.(?\d+))?(?[df])/)?.groups || {}; + return specifier === 'd' ? 0 : precision <= 16 ? parseInt(precision) : NaN; + } + + /** + * Returns state presentation unit system for a given item state description pattern + * + * @param {String} pattern + * @return {String} + */ + static getStatePresentationUnitSymbol(pattern) { + return Object.values(UnitSymbol).find((symbol) => + new RegExp(`%\\d*(?:\\.\\d+)?[df]\\s*[%]?${symbol}$`).test(pattern) + ); + } } diff --git a/lambda/test/alexa/cases/discovery/other.test.js b/lambda/test/alexa/cases/discovery/other.test.js index 8a9c2893..8bf83d25 100644 --- a/lambda/test/alexa/cases/discovery/other.test.js +++ b/lambda/test/alexa/cases/discovery/other.test.js @@ -182,12 +182,13 @@ export default { type: 'Number:Mass', name: 'range7', label: 'Range Value 7', + stateDescription: { + pattern: '%.1f %unit%', + readOnly: true + }, metadata: { alexa: { - value: 'RangeValue', - config: { - nonControllable: true - } + value: 'RangeValue' } } }, @@ -852,7 +853,7 @@ export default { }, configuration: { 'Alexa.RangeController:range6': { - supportedRange: { minimumValue: 0, maximumValue: 10, precision: 1 }, + supportedRange: { minimumValue: 0, maximumValue: 10, precision: 0.01 }, unitOfMeasure: 'Alexa.Unit.Angle.Degrees' } }, @@ -889,7 +890,7 @@ export default { }, configuration: { 'Alexa.RangeController:range7': { - supportedRange: { minimumValue: 0, maximumValue: 10, precision: 1 }, + supportedRange: { minimumValue: 0, maximumValue: 10, precision: 0.1 }, unitOfMeasure: 'Alexa.Unit.Mass.Kilograms' } }, @@ -901,6 +902,7 @@ export default { parameters: { capabilityNames: ['@Setting.RangeValue'], nonControllable: true, + supportedRange: [0, 10, 0.1], unitOfMeasure: 'Mass.Kilograms' }, item: { name: 'range7', type: 'Number:Mass' } diff --git a/lambda/test/openhab.test.js b/lambda/test/openhab.test.js index d4756a64..5bbf81a9 100644 --- a/lambda/test/openhab.test.js +++ b/lambda/test/openhab.test.js @@ -63,15 +63,16 @@ describe('OpenHAB Tests', () => { it('https client cert', async () => { // set environment const certFile = 'cert.pfx'; + const certData = 'data'; const certPass = 'passphrase'; sinon.stub(fs, 'existsSync').withArgs(certFile).returns(true); - sinon.stub(fs, 'readFileSync').withArgs(certFile).returns('pfx'); + sinon.stub(fs, 'readFileSync').withArgs(certFile).returns(certData); nock(baseURL) .get('/') .reply(200) .on('request', ({ headers, options, socket }) => { expect(headers).to.not.have.property('authorization'); - expect(options).to.nested.include({ 'agent.options.pfx': 'pfx', 'agent.options.passphrase': 'passphrase' }); + expect(options).to.nested.include({ 'agent.options.pfx': certData, 'agent.options.passphrase': certPass }); expect(socket).to.include({ timeout }); }); // run test @@ -415,4 +416,40 @@ describe('OpenHAB Tests', () => { expect(nock.isDone()).to.be.true; }); }); + + describe('get state presentation precision', () => { + it('integer', async () => { + expect(OpenHAB.getStatePresentationPrecision('%d %%')).to.equal(0); + }); + + it('float', async () => { + expect(OpenHAB.getStatePresentationPrecision('%.1f °F')).to.equal(1); + }); + + it('no precision', async () => { + expect(OpenHAB.getStatePresentationPrecision('foo')).to.be.NaN; + }); + + it('undefined', async () => { + expect(OpenHAB.getStatePresentationPrecision(undefined)).to.be.NaN; + }); + }); + + describe('get state presentation unit symbol', () => { + it('percent', async () => { + expect(OpenHAB.getStatePresentationUnitSymbol('%d %%')).to.equal('%'); + }); + + it('temperature', async () => { + expect(OpenHAB.getStatePresentationUnitSymbol('%.1f °F')).to.equal('°F'); + }); + + it('no symbol', async () => { + expect(OpenHAB.getStatePresentationUnitSymbol('%.1f')).to.be.undefined; + }); + + it('undefined', async () => { + expect(OpenHAB.getStatePresentationUnitSymbol(undefined)).to.be.undefined; + }); + }); });