Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve range value read only attribute default precision support #650

Merged
merged 1 commit into from Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/USAGE.md
Expand Up @@ -1794,8 +1794,13 @@ Items that represent components of a device that are characterized by numbers wi
* defaults to no support
* supportedRange=`<range>`
* range formatted as `<minValue>:<maxValue>:<precision>` (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=`<presets>`
* each preset formatted as `<presetValue>=<@assetIdOrName1>:...` (e.g. `presets="1=@Value.Low:Lowest,10=@Value.High:Highest"`)
* limited to a maximum of 150 presets
Expand Down
18 changes: 13 additions & 5 deletions lambda/alexa/smarthome/properties/rangeValue.js
Expand Up @@ -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';
Expand Down Expand Up @@ -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];
}

/**
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 3 additions & 6 deletions lambda/alexa/smarthome/unitOfMeasure.js
Expand Up @@ -11,6 +11,7 @@
* SPDX-License-Identifier: EPL-2.0
*/

import OpenHAB from '#openhab/index.js';
import { Dimension, UnitSymbol, UnitSystem } from '#openhab/constants.js';

/**
Expand Down Expand Up @@ -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)) ||
Expand Down
30 changes: 26 additions & 4 deletions lambda/openhab/index.js
Expand Up @@ -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
Expand Down Expand Up @@ -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*(?:\.(?<precision>\d+))?(?<specifier>[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*(?:\.(?<precision>\d+))?(?<specifier>[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)
);
}
}
14 changes: 8 additions & 6 deletions lambda/test/alexa/cases/discovery/other.test.js
Expand Up @@ -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'
}
}
},
Expand Down Expand Up @@ -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'
}
},
Expand Down Expand Up @@ -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'
}
},
Expand All @@ -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' }
Expand Down
41 changes: 39 additions & 2 deletions lambda/test/openhab.test.js
Expand Up @@ -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
Expand Down Expand Up @@ -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;
});
});
});