From 699bbb4015df0f8ac3e2dda2b417fe951368d1b0 Mon Sep 17 00:00:00 2001 From: jsetton Date: Sat, 16 Feb 2019 14:56:47 -0500 Subject: [PATCH 1/2] Property Response Improvements * improved property map object with prototype functions along with item type per capability enforcement * updated alexa response property state accuracy logic with ability to associate an item sensor through metadata parameter * added ability to define lock property state mapping when using an item sensor through metadata parameter * updated temperature mode binding mapping to differentiate legacy bindings from latest one * refactored rest functions code to accomodate for future alexa gateway calls * changed the lambda function return method to use handler callback instead of context * updated report state function to use generic alexa response property state logic --- .gitignore | 2 +- USAGE.md | 224 ++++-- alexaCapabilities.js | 28 +- alexaContextProperties.js | 163 ++-- alexaPropertyMap.js | 214 +++++ env_sample | 2 +- index.js | 8 +- ohConnectorV2.js | 2 +- ohConnectorV3.js | 743 +++++++++--------- package-lock.json | 60 +- package.json | 2 +- rest.js | 233 +++--- test/common.js | 13 +- test/test_ohConnectorV2.js | 12 +- test/test_ohConnectorV3.js | 26 +- test/v2/test_discoverThermostat.js | 2 +- test/v3/test_controllerAlexa.js | 45 +- test/v3/test_controllerBrightness.js | 6 +- test/v3/test_controllerChannel.js | 4 +- test/v3/test_controllerColor.js | 2 +- test/v3/test_controllerColorTemperature.js | 17 +- test/v3/test_controllerInput.js | 2 +- test/v3/test_controllerLock.js | 12 +- test/v3/test_controllerPercentage.js | 4 +- test/v3/test_controllerPlayback.js | 2 +- test/v3/test_controllerPower.js | 6 +- test/v3/test_controllerPowerLevel.js | 4 +- test/v3/test_controllerScene.js | 4 +- test/v3/test_controllerSpeaker.js | 6 +- test/v3/test_controllerStepSpeaker.js | 4 +- test/v3/test_controllerThermostatMode.js | 12 +- .../test_controllerThermostatTemperature.js | 57 +- test/v3/test_discoverThermostat.js | 50 +- utils.js | 269 ++++--- 34 files changed, 1304 insertions(+), 936 deletions(-) create mode 100644 alexaPropertyMap.js diff --git a/.gitignore b/.gitignore index ae2b3cf8..2823f51a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +.lambda/ config.js /nbproject/ .vscode @@ -50,4 +51,3 @@ jspm_packages # Yarn Integrity file .yarn-integrity - diff --git a/USAGE.md b/USAGE.md index 8fed3f96..62cbab62 100644 --- a/USAGE.md +++ b/USAGE.md @@ -16,7 +16,7 @@ The skill connects your openHAB setup through the [myopenHAB.org](http://myopenH * NEW Alexa Version 3 API syntax (v3) * Version 3 of the Alex Skill API introduces a more rich and complex set of features that required a change in how items are configured by using the new metadata feature introduced in openaHAB 2.3 * Version 2 tags are still supported and are converted internally to V3 meta data - * See [Label Support](#Label-Support) for using labels in item tags and meta data. + * See [Label Support](#Label-Support) for using labels in item tags and meta data. ### Item Label Recommendation @@ -79,144 +79,194 @@ In openHAB a thermostat is modeled as many different items, typically there are A Stereo is another example of a single endpoint that needs many items to function properly. Power, volume, input, speakers and player controllers are all typical use cases for a stereo that a user may wish to control. ``` -Group Stereo "Stereo" {alexa="Endpoint.Speaker"} -Number Volume "Volume" (Stereo) {alexa="Speaker.volume"} -Switch Mute "Mute" (Stereo) {alexa="Speaker.muted"} -Switch Power "Power" (Stereo) {alexa="PowerController.powerState"} -String Input "Input" (Stereo) {alexa="InputController.input"} -String Channel "Channel" (Stereo) {alexa="ChannelController.channel"} -Player Player "Player" (Stereo) {alexa="PlaybackController.playback"} -``` + Group Stereo "Stereo" {alexa="Endpoint.Speaker"} + Number Volume "Volume" (Stereo) {alexa="Speaker.volume"} + Switch Mute "Mute" (Stereo) {alexa="Speaker.muted"} + Switch Power "Power" (Stereo) {alexa="PowerController.powerState"} + String Input "Input" (Stereo) {alexa="InputController.input"} + String Channel "Channel" (Stereo) {alexa="ChannelController.channel"} + Player Player "Player" (Stereo) {alexa="PlaybackController.playback"} + ``` + #### Supported item mapping metadata * The following are a list of supported metadata. * `PowerController.powerState` * Items that turn on or off such as light switches, power states, etc.. - * ON, OFF + * Supported item type: + * Color + * Dimmer + * Rollershutter + * Switch * Default category: SWITCH * `BrightnessController.brightness` * Items which response to percentage level and brightness commands (dim, brighten, percent), typically lights. - * Numbers + * Supported item type: + * Color + * Dimmer * Default category: LIGHT * `PowerLevelController.powerLevel` * Items which respond to a specific number setting - * Numbers + * Supported item type: + * Dimmer * Default category: SWITCH * `PercentageController.percentage` * Items which respond to percentage commands such as roller shutters. - * Numbers + * Supported item type: + * Dimmer + * Rollershutter * Default category: OTHER * `ThermostatController.targetSetpoint` * Items that represent a target set point for a thermostat, value may be in Celsius or Fahrenheit depending on how the item is configured (default to Celsius). - * Number or Float values + * Supported item type: + * Number(:Temperature) * Default category: THERMOSTAT * supports additional properties: - * scale=Fahrenheit - * scale=Celsius + * scale= + * Celsius (default if omitted) + * Fahrenheit * defaults to scale=Celsius if omitted. * `ThermostatController.upperSetpoint` * Items that represent a upper or HEAT set point for a thermostat, value may be in Celsius or Fahrenheit depending on how the item is configured (default to Celsius). - * Number or Float values + * Supported item type: + * Number(:Temperature) * Default category: THERMOSTAT * supports additional properties: - * scale=... - * Defaults to scale=Celsius if omitted. - * comfort_range=... + * scale= + * Celsius (default if omitted) + * Fahrenheit + * comfort_range= * When dual setpoints (upper,lower) are used this is the amount over the requested temperature when requesting Alexa to set or adjust the current temperature. Defaults to comfort_range=1 if using Fahrenheit and comfort_range=.5 if using Celsius. Ignored if a targetSetpoint is included in the thermostat group. * `ThermostatController.lowerSetpoint` - * Items that represent a lower or COOL set point for a thermostat, value may be in Celsius or Fahrenheit depending on how the item is configured (for example, scale=Fahrenheit, defaults to Celsius if omitted). - * Number or Float values + * Items that represent a lower or COOL set point for a thermostat, value may be in Celsius or Fahrenheit depending on how the item is configured (for example, scale=Fahrenheit, defaults to Celsius if omitted). + * Supported item type: + * Number(:Temperature) * Default category: THERMOSTAT * supports additional properties: - * scale=... - * defaults to scale=Celsius if omitted. - * comfort_range=... + * scale= + * Celsius (default if omitted) + * Fahrenheit + * comfort_range= * When dual setpoints (upper,lower) are used this is the amount under the requested temperature when requesting Alexa to set or adjust the current temperature. Defaults to comfort_range=1 if using Fahrenheit and comfort_range=.5 if using Celsius. Ignored if a targetSetpoint is included in the thermostat group. - * `ThermostatController.thermostatMode` - * Items that represent the mode for a thermostat, default string values are "OFF=off,HEAT=heat,COOL=cool,ECO=eco,AUTO=auto", but these can be mapped to other values in the metadata. The mapping can be, in order of precedence, user-defined (AUTO=3,...) or preset-based related to the thermostat binding used (binding=...). For thermostats that only support a subset of the standards modes, a comma delimited array of the Alexa modes that the thermostat supports can be set using the supportedMode property. - * String or Number + * Items that represent the mode for a thermostat, default string values are "OFF=off,HEAT=heat,COOL=cool,ECO=eco,AUTO=auto", but these can be mapped to other values in the metadata. The mapping can be, in order of precedence, user-defined (AUTO=3,...) or preset-based related to the thermostat binding used (binding=). For thermostats that only support a subset of the standards modes, a comma delimited of the Alexa modes that the thermostat supports can be set using the supportedMode property. + * Supported item type: + * Number + * String * Default category: THERMOSTAT * supports additional optional properties: - * supportedModes= ... defaults to "AUTO,COOL,HEAT,ECO,OFF" if omitted - * OFF=... - * HEAT=... - * COOL=... - * ECO=... - * AUTO=... - * binding=ecobee [OFF=off, HEAT=heat, COOL=cool, AUTO=auto] - * binding=nest [OFF=off, HEAT=heat, COOL=cool, ECO=eco, AUTO=heat-cool] - * binding=zwave [OFF=0, HEAT=1, COOL=2, AUTO=3] - * defaults to binding=default [OFF=off, HEAT=heat, COOL=cool, ECO=eco, AUTO=auto] if omitted - + * OFF= + * HEAT= + * COOL= + * ECO= + * AUTO= + * binding= + * ecobee1 [OFF=off, HEAT=heat, COOL=cool, AUTO=auto] + * nest [OFF=OFF, HEAT=HEAT, COOL=COOL, ECO=ECO, AUTO=HEAT_COOL] + * nest1 [OFF=off, HEAT=heat, COOL=cool, ECO=eco, AUTO=heat-cool] + * zwave1 [OFF=0, HEAT=1, COOL=2, AUTO=3] + * defaults to [OFF=off, HEAT=heat, COOL=cool, ECO=eco, AUTO=auto] if omitted + * supportedModes= + * defaults to "AUTO,COOL,HEAT,ECO,OFF" if omitted * `TemperatureSensor.temperature` * Items that represent the current temperature, value may be in Celsius or Fahrenheit depending on how the item is configured (for example, scale=Fahrenheit, defaults to Celsius if omitted). - * Number or Float values + * Supported item type: + * Number(:Temperature) * Default category: TEMPERATURE_SENSOR * supports additional properties: - * scale=... - * defaults to scale=Celsius if omitted. + * scale= + * Celsius (default if omitted) + * Fahrenheit * `LockController.lockState` - * Items that represent the state of a lock (ON locked, OFF unlocked) - * ON, OFF - * Default category: SMARTLOCK + * Items that represent the state of a lock (ON lock, OFF unlock). When associated to an item sensor, the state of that item will be returned instead of the original actionable item. Additionally, when linking to such item, multiple properties to one state can be mapped (e.g. for a zwave lock: [1=LOCKED,2=UNLOCKED,3=LOCKED,4=UNLOCKED,11=JAMMED]). + * Supported item type: + * Switch + * Supported sensor type: + * Contact [CLOSED=LOCKED, OPEN=UNLOCKED] + * Number [1=LOCKED, 2=UNLOCKED, 3=JAMMED] + * String [locked=LOCKED, unlocked=UNLOCKED, jammed=JAMMED] + * Switch [ON=LOCKED, OFF=UNLOCKED] + * Default category: SMARTLOCK + * supports additional properties: + * =LOCKED + * =UNLOCKED + * =JAMMED + * defaults based on item sensor type if omitted * `ColorController.color` - * Items that represent a color - * H,S,B - * Default category: LIGHT + * Items that represent a color + * Supported item type: + * Color + * Default category: LIGHT * `ColorTemperatureController.colorTemperatureInKelvin` - * Items that represents a color temperature, default increment value may be specified in metadata parameters. For dimmer typed items adjustments, INCREASE/DECREASE commands will be sent instead if increment value not defined, while number typed items will default to 500K increments. - * Two item types supported: - * Dimmer: colder (0%) to warmer (100%) based of Alexa color temperature spectrum [Hue and LIFX support] - * Number: color temperature value in K [custom integration] - * Default category: LIGHT - * supports additional properties: - * increment=N (in % for dimmer/in K for number) + * Items that represents a color temperature, default increment value may be specified in metadata parameters. For dimmer typed items adjustments, INCREASE/DECREASE commands will be sent instead if increment value not defined, while number typed items will default to 500K increments. + * Supported item type: + * Dimmer: colder (0%) to warmer (100%) based of Alexa color temperature spectrum [Hue and LIFX support] + * Number: color temperature value in Kelvin [custom integration] + * Default category: LIGHT + * supports additional properties: + * increment= + * value in % for dimmer item/in Kelvin for number item * defaults to increment=INCREASE/DECREASE (Dimmer) or increment=500 (Number) if omitted * `SceneController.scene` - * Items that represent a scene or an activity depending on defined category and may be set not to support deactivation requests based on metadata parameters. - * String - * Default category: SCENE_TRIGGER - * supports additional properties: - * supportsDeactivation=false - * supportsDeactivation=true - * defaults to supportsDeactivation=true if omitted + * Items that represent a scene or an activity depending on defined category and may be set not to support deactivation requests based on metadata parameters. + * Supported item type: + * Switch + * Default category: SCENE_TRIGGER + * supports additional properties: + * supportsDeactivation= + * true (default if omitted) + * false * `ChannelController.channel` - * Items that represent a channel + * Items that represent a channel + * Supported item type: + * Number * String - * Default category: TV + * Default category: TV * `InputController.input` - * Items that represent a source input (ex, "HDMI 1", or "MUSIC" on a stereo) + * Items that represent a source input (ex, "HDMI 1", or "MUSIC" on a stereo) + * Supported item type: * String - * Default category: TV + * Default category: TV * `Speaker.volume` - * Items that represent a volume level, default increment may be specified in metadata parameters + * Items that represent a volume level, default increment may be specified in metadata parameters + * Supported item type: + * Dimmer * Number - * Default category: SPEAKER - * supports additional properties: - * increment=N + * Default category: SPEAKER + * supports additional properties: + * increment= * defaults to increment=10 (standard value provided by Alexa) if omitted. * `Speaker.muted` - * Items that represent a muted state (ON muted, OFF unmuted) - * ON, OFF - * Default category: SPEAKER + * Items that represent a muted state (ON muted, OFF unmuted) + * Supported item type: + * Switch + * Default category: SPEAKER * `StepSpeaker.volume` - * Items that represent a volume level controlled in steps only (for example IR controlled, ex: +1, -1) - * String - * Default category: SPEAKER + * Items that represent a volume level controlled in steps only (for example IR controlled, ex: +1, -1) + * Supported item type: + * Dimmer + * Number + * Default category: SPEAKER * `StepSpeaker.muted` - * Items that represent a muted state (ON muted, OFF unmuted) - * ON, OFF - * Default category: SPEAKER + * Items that represent a muted state (ON muted, OFF unmuted) + * Supported item type: + * Switch + * Default category: SPEAKER * `PlaybackController.playback` - * Items that represent the playback of a AV device (mostly compatible with Player Items) - * "PLAY", "PAUSE", "NEXT", "PREVIOUS", "REWIND", "FASTFORWARD", "STOP" - * Default category: OTHER + * Items that represent the playback of a AV device (mostly compatible with Player Items) + * Supported item type: + * Player + * Default category: OTHER +* Item Sensor + * When available, use a specific item (called "sensor") for property state reporting over the actionable item state. + * Design to bridge channel status items to provide improved reporting state accuracy. + * Configured by adding the `itemSensor=` metadata parameter. + * Sensor items need to be the same type than their parent item, except for LockController capable items. * Item Categories - * Alexa has certain categories that effect how voice control and their mobile/web UI's display or control endpoints. An example of this is when you create "Smart Device Groups" in the Alex app and associate a specific Echo or Dot to that Group (typically a room). When a user asks to turn the lights ON, Alexa looks for devices in that group that have the category "LIGHT" to send the command to. - * You can override this default value on items by adding it as a parameter to the metadata, ex: - - `Switch LightSwitch "Light Switch" {alexa="PowerController.powerState" [category="OTHER"]}` - * List of Alexa categories currently supported from Alexa Skill API docs: + * Alexa has certain categories that effect how voice control and their mobile/web UI's display or control endpoints. An example of this is when you create "Smart Device Groups" in the Alex app and associate a specific Echo or Dot to that Group (typically a room). When a user asks to turn the lights ON, Alexa looks for devices in that group that have the category "LIGHT" to send the command to. + * You can override this default value on items by adding it as a parameter to the metadata, ex: + + `Switch LightSwitch "Light Switch" {alexa="PowerController.powerState" [category="OTHER"]}` + * List of Alexa categories currently supported from Alexa Skill API docs: Category | Description | Notes ---------|-------------|------- diff --git a/alexaCapabilities.js b/alexaCapabilities.js index 22ceb116..c40b0e29 100644 --- a/alexaCapabilities.js +++ b/alexaCapabilities.js @@ -1,5 +1,5 @@ /** - * Copyright (c) 2014-2016 by the respective copyright holders. + * Copyright (c) 2014-2019 by the respective copyright holders. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 @@ -95,29 +95,33 @@ AlexaCapabilities.prototype.percentageController = function () { }; }; -AlexaCapabilities.prototype.thermostatController = function (targetSetpoint, upperSetpoint, lowerSetpoint, thermostatMode, supportedModes) { +AlexaCapabilities.prototype.thermostatController = function (targetSetpoint, upperSetpoint, lowerSetpoint, thermostatMode) { var supported = []; if (targetSetpoint) { supported.push({ "name": "targetSetpoint" - }) + }); } if (upperSetpoint) { supported.push({ "name": "upperSetpoint" - }) + }); } if (lowerSetpoint) { supported.push({ "name": "lowerSetpoint" - }) + }); } if (thermostatMode) { supported.push({ "name": "thermostatMode" - }) + }); + } + var configuration = {}; + if (typeof thermostatMode.parameters.supportedModes === 'string') { + configuration.supportedModes = thermostatMode.parameters.supportedModes.split(',').map(mode => mode.trim()); } - var controller = { + return { capabilities: { "type": "AlexaInterface", "interface": "Alexa.ThermostatController", @@ -126,19 +130,13 @@ AlexaCapabilities.prototype.thermostatController = function (targetSetpoint, upp "supported": supported, "proactivelyReported": false, "retrievable": true - } + }, + "configuration": configuration }, category: "THERMOSTAT" }; - if(supportedModes){ - controller.capabilities.configuration = { - supportedModes : supportedModes.split(',').map(mode => mode.trim()) - }; - } - return controller; }; - AlexaCapabilities.prototype.temperatureSensor = function () { return { capabilities: { diff --git a/alexaContextProperties.js b/alexaContextProperties.js index 3fe46503..252fe463 100644 --- a/alexaContextProperties.js +++ b/alexaContextProperties.js @@ -1,5 +1,5 @@ /* -* Copyright (c) 2014-2016 by the respective copyright holders. +* Copyright (c) 2014-2019 by the respective copyright holders. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 @@ -8,7 +8,7 @@ */ /** -* Amazon Smart Home Skill Controller Properties for API V3 +* Amazon Smart Home Skill Context Properties for API V3 */ var utils = require('./utils.js'); @@ -95,12 +95,10 @@ AlexaContextProperties.prototype.colorStateProperty = function (state) { } /** - * Returns a property response for color temperature ndpoints + * Returns a property response for color temperature endpoints * @param {integer} state - * @param {string} type */ AlexaContextProperties.prototype.colorTemperatureStateProperty = function (state, type) { - state = utils.normalizeColorTemperature(state, type); return this.generateProperty('Alexa.ColorTemperatureController', 'colorTemperatureInKelvin', parseInt(state)); } @@ -163,8 +161,7 @@ AlexaContextProperties.prototype.temperatureSensorStateProperty = function (stat * @param {string} state */ AlexaContextProperties.prototype.lockStateProperty = function (state) { - var locked = state === "ON" ? "LOCKED" : state === "OFF" ? "UNLOCKED" : "JAMMED"; - return this.generateProperty('Alexa.LockController', 'lockState', locked); + return this.generateProperty('Alexa.LockController', 'lockState', state); } /** @@ -229,135 +226,99 @@ AlexaContextProperties.prototype.generateProperty = function (namespace, name, v } /** - * Given an array of items (name,state only) and a Alexa cookies object, generate a property response for all - * endpoints listed in the propertyMap + * Given an array of capability interfaces and Alexa property map object, generate a property response list * - * @param {array} items + * @param {array} interfaceNames * @param {object} propertyMap */ -AlexaContextProperties.prototype.propertiesResponseForItems = function (items, propertyMap) { +AlexaContextProperties.prototype.propertiesResponseForInterfaces = function (interfaceNames, propertyMap) { var self = this; - var properties = []; + var response = []; - function itemByName(itemName) { - return items.find(item => item.name === itemName); - } - - Object.keys(propertyMap).forEach(function (groupName) { - var item; - var group = propertyMap[groupName]; - switch (groupName) { + interfaceNames.forEach(function (interfaceName) { + var properties = propertyMap[interfaceName]; + switch (interfaceName) { case "PowerController": //Switch, Dimmer [Switchable] - item = itemByName(group.powerState.itemName); - if (item) { - properties.push(self.powerStateProperty(item.state)); - } + var item = properties.powerState.item; + response.push(self.powerStateProperty(item.state)); break; case "PowerLevelController": //Dimmer or Number, Rollershutter [Lighting] - item = itemByName(group.powerLevel.itemName); - if (item) { - properties.push(self.powerLevelStateProperty(item.state)); - } + var item = properties.powerLevel.item; + response.push(self.powerLevelStateProperty(item.state)); break; case "BrightnessController": - item = itemByName(group.brightness.itemName); - if (item) { - properties.push(self.brightnessStateProperty(item.state)); - } + var item = properties.brightness.item; + response.push(self.brightnessStateProperty(item.state)); break; case "PercentageController": - item = itemByName(group.percentage.itemName); - if (item) { - properties.push(self.percentageStateProperty(item.state)); - } + var item = properties.percentage.item; + response.push(self.percentageStateProperty(item.state)); break; case "ColorController": //Color [Lighting] - item = itemByName(group.color.itemName); - if (item) { - properties.push(self.colorStateProperty(item.state)); - } + var item = properties.color.item; + response.push(self.colorStateProperty(item.state)); break; case "ColorTemperatureController": //Dimmer or Number - item = itemByName(group.colorTemperatureInKelvin.itemName); - if(item){ - properties.push(self.colorTemperatureStateProperty(item.state, item.type)); - } + var item = properties.colorTemperatureInKelvin.item; + var state = utils.normalizeColorTemperature(item.state, item.type); + response.push(self.colorTemperatureStateProperty(state)); break; case "ThermostatController": //Group [Thermostat] - if (group.targetSetpoint) { - item = itemByName(group.targetSetpoint.itemName); - if (item) { - var scale = group.targetSetpoint.parameters.scale ? - group.targetSetpoint.parameters.scale.toUpperCase() : "CELSIUS"; - properties.push(self.targetSetpointStateProperty(item.state, scale)); - } + if (properties.targetSetpoint) { + var item = properties.targetSetpoint.item; + var scale = properties.targetSetpoint.parameters.scale ? + properties.targetSetpoint.parameters.scale.toUpperCase() : "CELSIUS"; + response.push(self.targetSetpointStateProperty(item.state, scale)); } - if (group.upperSetpoint) { - item = itemByName(group.upperSetpoint.itemName); - if (item) { - var scale = group.upperSetpoint.parameters.scale ? - group.upperSetpoint.parameters.scale.toUpperCase() : "CELSIUS"; - properties.push(self.upperSetpointStateProperty(item.state, scale)); - } + if (properties.upperSetpoint) { + var item = properties.upperSetpoint.item; + var scale = properties.upperSetpoint.parameters.scale ? + properties.upperSetpoint.parameters.scale.toUpperCase() : "CELSIUS"; + response.push(self.upperSetpointStateProperty(item.state, scale)); } - if (group.lowerSetpoint) { - item = itemByName(group.lowerSetpoint.itemName); - if (item) { - var scale = group.lowerSetpoint.parameters.scale ? - group.lowerSetpoint.parameters.scale.toUpperCase() : "CELSIUS"; - properties.push(self.lowerSetpointStateProperty(item.state, scale)); - } + if (properties.lowerSetpoint) { + var item = properties.lowerSetpoint.item; + var scale = properties.lowerSetpoint.parameters.scale ? + properties.lowerSetpoint.parameters.scale.toUpperCase() : "CELSIUS"; + response.push(self.lowerSetpointStateProperty(item.state, scale)); } - if (group.thermostatMode) { - item = itemByName(group.thermostatMode.itemName); - if (item) { - var state = utils.normalizeThermostatMode(item.state, group.thermostatMode.parameters); - properties.push(self.thermostatModeStateProperty(state)); - } + if (properties.thermostatMode) { + var item = properties.thermostatMode.item; + var state = utils.normalizeThermostatMode(item.state, properties.thermostatMode.parameters); + response.push(self.thermostatModeStateProperty(state)); } break; case "TemperatureSensor": - item = itemByName(group.temperature.itemName); - if (item) { - var scale = group.temperature.parameters.scale ? - group.temperature.parameters.scale.toUpperCase() : "CELSIUS"; - properties.push(self.temperatureSensorStateProperty(item.state, scale)); - } + var item = properties.temperature.item; + var scale = properties.temperature.parameters.scale ? + properties.temperature.parameters.scale.toUpperCase() : "CELSIUS"; + response.push(self.temperatureSensorStateProperty(item.state, scale)); break; case "LockController": //Switch [Lock] - item = itemByName(group.lockState.itemName); - if (item) { - properties.push(self.lockStateProperty(item.state)); - } + var item = properties.lockState.item; + var state = utils.normalizeLockState(item.state, item.type, properties.lockState.parameters); + response.push(self.lockStateProperty(state)); break; case "ChannelController": //Number [Alexa@Channel] - item = itemByName(group.channel.itemName); - if (item){ - properties.push(self.channelStateProperty(item.state)); - } + var item = properties.channel.item; + response.push(self.channelStateProperty(item.state)); break; case "InputController": //String [Alexa@Input] - item = itemByName(group.input.itemName); - if(item){ - properties.push(self.inputStateProperty(item.state)); - } + var item = properties.input.item; + response.push(self.inputStateProperty(item.state)); break; case "PlaybackController": //Player or Group? [Alexa@Player] break; case "SceneController": //Switch ? [Scene] break; case "Speaker": //Group ? (volume dimmer, mute switch) [Alexa@Speaker] - if (group.muted) { - item = itemByName(group.muted.itemName); - if(item){ - properties.push(self.speakerMutedStateProperty(item.state)); - } + if (properties.muted) { + var item = properties.muted.item; + response.push(self.speakerMutedStateProperty(item.state)); } - if (group.volume) { - item = itemByName(group.volume.itemName); - if(item){ - properties.push(self.speakerVolumeStateProperty(item.state)); - } + if (properties.volume) { + var item = properties.volume.item; + response.push(self.speakerVolumeStateProperty(item.state)); } break; case "StepSpeaker": //Group ? (steup string, mute, not really sure) [Alexa@StepSpeaker] @@ -368,8 +329,8 @@ AlexaContextProperties.prototype.propertiesResponseForItems = function (items, p } }); - properties.push(this.endpointHealthProperty()); + response.push(this.endpointHealthProperty()); - return properties; + return response; } module.exports = new AlexaContextProperties(); diff --git a/alexaPropertyMap.js b/alexaPropertyMap.js new file mode 100644 index 00000000..f08783df --- /dev/null +++ b/alexaPropertyMap.js @@ -0,0 +1,214 @@ +/* +* Copyright (c) 2014-2019 by the respective copyright holders. +* +* All rights reserved. This program and the accompanying materials +* are made available under the terms of the Eclipse Public License v1.0 +* which accompanies this distribution, and is available at +* http://www.eclipse.org/legal/epl-v10.html +*/ + + /** +* Amazon Smart Home Skill Property Map for API V3 +*/ +var utils = require('./utils.js'); + + // Define alexa capability namespace format pattern +var CAPABILITY_PATTERN = /^(?:Alexa\.)?(\w+)\.(\w+)$/; +// Define item property metadata parameter format pattern +var ITEM_PARAM_PATTERN = /^item(\w)(\w+)$/; + + /** + * Defines property map object to assoicate items to an endpoint from metadata, per the description below: + * + * openHAB Metadata + * + * Number FooTargetSetPoint "Foo Target SetPoint" {alexa="ThermostatController.targetSetpoint" [scale="Fahrenheit"]} + * Number FooUpperSetPoint "Foo Upper SetPoint" {alexa="ThermostatController.upperSetpoint" [scale="Fahrenheit"]} + * Number FooLowerSetPoint "Foo Lower SetPoint" {alexa="ThermostatController.lowerSetpoint" [scale="Fahrenheit"]} + * String FooMode "Foo Mode" {alexa="ThermostatController.thermostatMode" [OFF=0,HEAT=1,COOL=2,AUTO=3]} + * Switch FooSwitch "FooSwitch" {alexa="PowerController.powerState"} + * + * returns + * + * propertyMap: + * { + * ThermostatController: { + * targetSetpoint: { + * item: { + * name: "FooTargetSetPoint", + * type: "Number", + * }, + * parameters: { + * scale: "Fahrenheit", + * } + * }, + * upperSetpoint: { + * item: { + * name: "FooTargetSetPoint", + * type: "Number", + * }, + * parameters: { + * scale: "Fahrenheit", + * } + * }, + * lowerSetpoint: { + * item: { + * name: "FooTargetSetPoint", + * type: "Number", + * }, + * parameters: { + * scale: "Fahrenheit", + * } + * }, + * thermostatMode: { + * item: { + * name: "FooMode", + * type: "Number", + * }, + * parameters: { + * OFF: 0, + * HEAT: 1, + * COOL: 2, + * AUTO: 3 + * } + * } + * }, + * PowerController: { + * powerState: { + * item: { + * name: "FooSwitch" + * type: "Switch", + * } + * } + * } + * +*/ +var AlexaPropertyMap = function () { +}; + + /** + * Clears property map object + */ +AlexaPropertyMap.prototype.clear = function() { + Object.keys(this).forEach(property => delete this[property]); +} + + /** + * Dumps property map object to string format + * @return {String} + */ +AlexaPropertyMap.prototype.dump = function() { + return JSON.stringify(this); +} + + /** + * Loads json fomated string into property map object + * @param {String} propertyMap + */ +AlexaPropertyMap.prototype.load = function(propertyMap) { + Object.assign(this, JSON.parse(propertyMap)); +} + + /** + * Adds item to property map object + * @param {Object} item + */ +AlexaPropertyMap.prototype.addItem = function(item) { + var propertyMap = this; + var matches; + + item.metadata.alexa.value.split(',').forEach(function (capability) { + if (matches = capability.match(CAPABILITY_PATTERN)) { + var interfaceName = matches[1]; + var propertyName = matches[2]; + var properties = propertyMap[interfaceName] || {}; + var property = { + parameters: item.metadata.alexa.config || {}, + item: { + name: item.name, + type: item.type + } + }; + + // Check if item (group)type supported by capability, skip if not + if (!utils.supportedItemTypeCapability(item.groupType || item.type, interfaceName, propertyName)) { + return; + } + + // Extract item property settings from metadata config. These settings starts with 'item'. + Object.keys(property.parameters).forEach(function(parameter) { + if (matches = parameter.match(ITEM_PARAM_PATTERN)) { + // lowercase the first character (e.g. itemSensor => sensor) + var setting = matches[1].toLowerCase() + matches[2]; + property.item[setting] = property.item[setting] || property.parameters[parameter]; + delete property.parameters[parameter]; + } + }); + + // Add property to map object + propertyMap[interfaceName] = Object.assign(properties, {[propertyName]: property}); + } + }); +} + + /** + * Returns list of categories for a given interface name + * @param {String} interfaceName + * @return {Array} + */ +AlexaPropertyMap.prototype.getCategories = function(interfaceName) { + var properties = this[interfaceName] || {}; + var parameter = 'category'; + + return Object.keys(properties).reduce(function(categories, propertyName) { + var category = (properties[propertyName].parameters[parameter] || '').toUpperCase(); + if (!categories.includes(category) && utils.supportedDisplayCategory(category)) { + categories.push(category); + } + return categories; + }, []); +} + + /** + * Returns list of item objects for a given list of interface names + * + * Items array return format: + * [ + * { + * name: , + * param1: , (item parameter stored in property map) + * ... + * capabilities: [ + * { + * interface: , property: + * }, + * ... + * ] + * }, + * ... + * ] + * + * @param {Array} interfaceNames + * @return {Array} + */ +AlexaPropertyMap.prototype.getItemsByInterfaces = function(interfaceNames) { + var propertyMap = this; + + return interfaceNames.reduce(function(items, interfaceName) { + var properties = propertyMap[interfaceName] || {}; + Object.keys(properties).forEach(function(propertyName) { + var capability = {interface: interfaceName, property: propertyName}; + var item = properties[propertyName].item; + var index = items.findIndex(i => i.name === item.name); + + if (index == -1) { + items.push(Object.assign(item, {capabilities: [capability]})); + } else { + items[index].capabilities.push(capability); + } + }); + return items; + }, []); +} + + module.exports = AlexaPropertyMap; diff --git a/env_sample b/env_sample index b8d49017..088023c8 100644 --- a/env_sample +++ b/env_sample @@ -8,7 +8,7 @@ AWS_FUNCTION_NAME=openhab AWS_HANDLER=index.handler AWS_MEMORY_SIZE=512 AWS_TIMEOUT=15 -AWS_DESCRIPTION=openHAB Alexa Smart Home Skill (development) +AWS_DESCRIPTION=openHAB Alexa Smart Home Skill (development) AWS_RUNTIME=nodejs6.10 AWS_VPC_SUBNETS= AWS_VPC_SECURITY_GROUPS= diff --git a/index.js b/index.js index 0d51bb18..f1bfbd55 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ /** - * Copyright (c) 2014-2016 by the respective copyright holders. + * Copyright (c) 2014-2019 by the respective copyright holders. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 @@ -15,19 +15,19 @@ var ohv3 = require('./ohConnectorV3.js'); * Main entry point. * Incoming events from Alexa Lighting APIs are processed via this method. */ -exports.handler = function (event, context) { +exports.handler = function (event, context, callback) { log.debug('Input: ' + JSON.stringify(event)); var version = parseInt(event.directive ? event.directive.header.payloadVersion : event.header.payloadVersion); switch (version) { case 3: - ohv3.handleRequest(event.directive, context); + ohv3.handleRequest(event.directive, callback); break; case 2: ohv2.handleRequest(event, context); break; default: log.error('No supported payloadVersion: ' + event.header.payloadVersion); - context.fail('No supported payloadVersion.'); + callback('No supported payloadVersion.'); break; } }; diff --git a/ohConnectorV2.js b/ohConnectorV2.js index bbbacb28..99c2cafe 100644 --- a/ohConnectorV2.js +++ b/ohConnectorV2.js @@ -1,5 +1,5 @@ /** - * Copyright (c) 2014-2016 by the respective copyright holders. + * Copyright (c) 2014-2019 by the respective copyright holders. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 diff --git a/ohConnectorV3.js b/ohConnectorV3.js index aa7dc379..c22adfa2 100644 --- a/ohConnectorV3.js +++ b/ohConnectorV3.js @@ -1,5 +1,5 @@ /** - * Copyright (c) 2014-2016 by the respective copyright holders. + * Copyright (c) 2014-2019 by the respective copyright holders. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 @@ -16,24 +16,27 @@ var utils = require('./utils.js'); var rest = require('./rest.js'); var alexaCapabilities = require('./alexaCapabilities.js'); var contextProperties = require('./alexaContextProperties.js'); +var PropertyMap = require('./alexaPropertyMap.js'); +// Define group endpoint format pattern var ENDPOINT_PATTERN = /^(?:Alexa\.)?Endpoint\.(\w+)$/; var directive; -var context; +var callback; var propertyMap; /** * Main entry point for all requests - * @param {*} directive - * @param {*} context + * @param {Object} directive + * @param {Object} callback */ -exports.handleRequest = function (_directive, _context) { +exports.handleRequest = function (_directive, _callback) { directive = _directive; - context = _context; - //if we have a JSON cookie, parse it and set on endpoint + callback = _callback; + propertyMap = new PropertyMap(); + // if we have a JSON cookie, parse it and set on endpoint if (directive.endpoint && directive.endpoint.cookie && directive.endpoint.cookie.propertyMap) { - propertyMap = JSON.parse(directive.endpoint.cookie.propertyMap) + propertyMap.load(directive.endpoint.cookie.propertyMap); } var namespace = directive.header.namespace; //ex: Alexa.BrightnessController @@ -61,7 +64,15 @@ exports.handleRequest = function (_directive, _context) { setColor(); break; case "Alexa.ColorTemperatureController": - adjustColorTemperature(); + switch(name) { + case "SetColorTemperature": + setColorTemperature(); + break; + case "DecreaseColorTemperature": + case "IncreaseColorTemperature": + adjustColorTemperature(); + break; + } break; case "Alexa.ThermostatController": switch (name) { @@ -131,42 +142,19 @@ exports.handleRequest = function (_directive, _context) { * Answers a "ReportState" request. Returns the state(s) of an endpoint */ function reportState() { - rest.getItemStates(directive.endpoint.scope.token, - function (items) { - var properties = contextProperties.propertiesResponseForItems(items, propertyMap); - var result = { - context: { - properties: properties - }, - event: { - header: { - messageId: uuid(), - name: "StateReport", - namespace: "Alexa", - payloadVersion: directive.header.payloadVersion, - correlationToken: directive.header.correlationToken - }, - endpoint: { - scope: directive.endpoint.scope, - endpointId: directive.endpoint.endpointId - }, - payload: {} - } - }; - log.debug('report done with result' + JSON.stringify(result)); - context.succeed(result); - }, function (error) { - log.error("Could not report on item: " + JSON.stringify(error)); - }); + // Generate properties response based on property map received + // and return as state report + getPropertiesResponseAndReturn(); } /** * Turns a Switch ON or OFF */ function setPowerState() { - var state = directive.header.name === 'TurnOn' ? 'ON' : 'OFF'; - var itemName = propertyMap.PowerController.powerState.itemName; - postItemAndReturn(itemName, state); + var postItem = Object.assign(propertyMap.PowerController.powerState.item, { + state: directive.header.name === 'TurnOn' ? 'ON' : 'OFF' + }); + postItemsAndReturn([postItem], 'PowerController'); } /** @@ -211,32 +199,31 @@ function adjustPercentage() { break; } //remove 'Alexa.' from namespace - var namespace = directive.header.namespace.split('Alexa.')[1]; - var itemName = propertyMap[namespace][propertyName].itemName; - log.debug('Turning ' + itemName + ' to ' + payloadValue); + var interfaceName = directive.header.namespace.split('Alexa.')[1]; + var postItem = propertyMap[interfaceName][propertyName].item; + log.debug('Turning ' + postItem.name + ' to ' + payloadValue); //if this is a set command then just post it, otherwise we need to first retrieve the value of the item // so we can adjust it and then post it. if (isSetCommand) { - postItemAndReturn(itemName, payloadValue); + postItem.state = payloadValue; + postItemsAndReturn([postItem], interfaceName); } else { rest.getItem(directive.endpoint.scope.token, - itemName, function (item) { - log.debug('itemGetSuccess: item state ' + + postItem.name, function (item) { + log.debug('adjustPercentage: item state ' + item.state + ' delta ' + payloadValue); //skip this if we don't have a number to start with if (isNaN(item.state)) { - context.done(null, generateGenericErrorResponse()); + returnAlexaResponse(generateGenericErrorResponse()); return; } - var oldState = parseInt(item.state); - var newState = oldState + parseInt(payloadValue); - newState = Math.min(100, newState); - newState = Math.max(0, newState); - postItemAndReturn(itemName, newState); + var state = parseInt(item.state) + parseInt(payloadValue); + postItem.state = state < 0 ? 0 : state < 100 ? state : 100; + postItemsAndReturn([postItem], interfaceName); }, function (error) { - context.done(null, generateGenericErrorResponse()); + returnAlexaResponse(generateGenericErrorResponse()); } ); } @@ -249,139 +236,119 @@ function setColor() { var h = directive.payload.color.hue; var s = directive.payload.color.saturation * 100.0; var b = directive.payload.color.brightness * 100.0; - var state = h + ',' + s + ',' + b; - var itemName = propertyMap.ColorController.color.itemName; - postItemAndReturn(itemName, state); + var postItem = Object.assign(propertyMap.ColorController.color.item, { + state: h + ',' + s + ',' + b + }); + postItemsAndReturn([postItem], 'ColorController'); } /** - * Set the color of a color item + * Set the color temperature + */ +function setColorTemperature() { + var properties = propertyMap.ColorTemperatureController; + var postItem = Object.assign(properties.colorTemperatureInKelvin.item, { + state: utils.normalizeColorTemperature(directive.payload.colorTemperatureInKelvin, + properties.colorTemperatureInKelvin.item.type) + }); + postItemsAndReturn([postItem], 'ColorTemperatureController'); +} + +/** + * Adjust the color temperature */ function adjustColorTemperature() { var properties = propertyMap.ColorTemperatureController; - var itemName = properties.colorTemperatureInKelvin.itemName; + var postItem = properties.colorTemperatureInKelvin.item; rest.getItem(directive.endpoint.scope.token, - itemName, function (item) { - var state; - - if (directive.header.name === 'SetColorTemperature') { - state = utils.normalizeColorTemperature(directive.payload.colorTemperatureInKelvin, item.type); - } else { - // Generate error if in color mode (color controller property defined & empty state) - if (propertyMap.ColorController && !parseInt(item.state)) { - context.done(null, generateControlError({ - type: 'NOT_SUPPORTED_IN_CURRENT_MODE', - message: 'The light is currently set to a color.', - currentDeviceMode: 'COLOR' - })); - return; - } - // Generate error if state not a number - if (isNaN(item.state)) { - log.debug('adjustColorTemperature error: Could not get numeric item state'); - context.done(null, generateGenericErrorResponse()); - return; - } + postItem.name, function (item) { + // Generate error if in color mode (color controller property defined & empty state) + if (propertyMap.ColorController && !parseInt(item.state)) { + returnAlexaResponse(generateControlError({ + type: 'NOT_SUPPORTED_IN_CURRENT_MODE', + message: 'The light is currently set to a color.', + currentDeviceMode: 'COLOR' + })); + return; + } + // Generate error if state not a number + if (isNaN(item.state)) { + log.debug('adjustColorTemperature error: Could not get numeric item state'); + returnAlexaResponse(generateGenericErrorResponse()); + return; + } - var isIncreaseRequest = directive.header.name === 'IncreaseColorTemperature'; - var increment = parseInt(properties.colorTemperatureInKelvin.parameters.increment); + var isIncreaseRequest = directive.header.name === 'IncreaseColorTemperature'; + var increment = parseInt(properties.colorTemperatureInKelvin.parameters.increment); + var state; - switch (item.type) { - case 'Dimmer': - // Send reverse command or value to OH since cold (0%) and warm (100%), depending if increment defined - if (isNaN(increment)) { - state = isIncreaseRequest ? 'DECREASE' : 'INCREASE'; - } else { - state = parseInt(item.state) + (isIncreaseRequest ? -1 : 1) * increment; - state = state < 0 ? 0 : state < 100 ? state : 100; - } - break; - case 'Number': - // Increment current state by defined value as Number item doesn't support IncreaseDecreaseType commands - state = parseInt(item.state) + (isIncreaseRequest ? 1 : -1) * (increment || 500); - state = utils.normalizeColorTemperature(state, item.type); - break; - } + switch (item.type) { + case 'Dimmer': + // Send reverse command/value to OH since cold (0%) and warm (100%), depending if increment defined + if (isNaN(increment)) { + state = isIncreaseRequest ? 'DECREASE' : 'INCREASE'; + } else { + state = parseInt(item.state) + (isIncreaseRequest ? -1 : 1) * increment; + state = state < 0 ? 0 : state < 100 ? state : 100; + } + break; + case 'Number': + // Increment current state by defined value as Number doesn't support IncreaseDecreaseType commands + state = parseInt(item.state) + (isIncreaseRequest ? 1 : -1) * (increment || 500); + state = utils.normalizeColorTemperature(state, item.type); + break; } - log.debug('adjustColorTemperature to value: ' + state); - postItemAndReturn(itemName, state); + postItem.state = state; + log.debug('adjustColorTemperature to value: ' + postItem.state); + postItemsAndReturn([postItem], 'ColorTemperatureController'); } ); } /** * Sets the taget temperature, this can include upper, lower and target setpoints - * in the same request. + * in the same request. */ function setTargetTemperature() { var properties = propertyMap.ThermostatController; - var promises = []; - - /** - * Support Comfort Ranges if only a target setpoint is sent by Alexa, but a user does not define one. - * Only works if the user has no defined targetSetpoint, but does define a upper and lower (dual mode) - */ - if (directive.payload.targetSetpoint && !directive.payload.upperSetpoint && !directive.payload.lowerSetpoint && - !properties.targetSetpoint && properties.upperSetpoint && properties.lowerSetpoint) { - //default range if not set - var upperRange = lowerRange = directive.payload.targetSetpoint.scale == 'FAHRENHEIT' ? 1 : .5; - //use user defined comfort range if set - if (properties.upperSetpoint.parameters && typeof (properties.upperSetpoint.parameters.comfort_range) !== 'undefined') { - upperRange = parseFloat(properties.upperSetpoint.parameters.comfort_range); - } - if (properties.lowerSetpoint.parameters && typeof (properties.lowerSetpoint.parameters.comfort_range) !== 'undefined') { - lowerRange = parseFloat(properties.lowerSetpoint.parameters.comfort_range); - } - //add these to the original alexa request - directive.payload.upperSetpoint = { - value: directive.payload.targetSetpoint.value + upperRange, - scale: directive.payload.targetSetpoint.scale - } - directive.payload.lowerSetpoint = { - value: directive.payload.targetSetpoint.value - lowerRange, - scale: directive.payload.targetSetpoint.scale - } - } - - Object.keys(properties).forEach(function (propertyName) { + // Add requested properties to be updated that are part of the controller properties + var postItems = Object.keys(properties).reduce(function (items, propertyName) { if (directive.payload[propertyName]) { - var state = directive.payload[propertyName].value; - var itemName = properties[propertyName].itemName; - log.debug("Setting " + itemName + " to " + state); - promises.push(new Promise(function (resolve, reject) { - log.debug("PROMISE Setting " + itemName + " to " + state); - rest.postItemCommand(directive.endpoint.scope.token, - itemName, state, function (response) { - log.debug("setTargetTemperature POST response to " + itemName + " : " + response); - resolve({ name: itemName, state: state }); - }, function (error) { - log.debug("setTargetTemperature POST ERROR to " + itemName + " : " + error); - reject(error); - }); + items.push(Object.assign(properties[propertyName].item, { + state: directive.payload[propertyName].value })); } - }); - - Promise.all(promises).then(function (values) { - log.debug("Promise items " + JSON.stringify(values)); - var result = { - context: { - properties: contextProperties.propertiesResponseForItems(values, propertyMap) - }, - event: { - header: generateResponseHeader(directive.header), - payload: {} - } - }; - log.debug('setTargetTemperature done with result' + JSON.stringify(result)); - context.succeed(result); - }).catch(function (err) { - log.debug('setTargetTemperature error ' + err); - context.done(null, generateGenericErrorResponse()); - }); + return items; + }, []); + + // Support Comfort Ranges if only a target setpoint is sent by Alexa, but a user does not define one. + // Only works if the user has no defined targetSetpoint, but does define a upper and lower (dual mode) + if (directive.payload.targetSetpoint && !directive.payload.upperSetpoint && !directive.payload.lowerSetpoint && + !properties.targetSetpoint && properties.upperSetpoint && properties.lowerSetpoint) { + // default range if not set + var upperRange = lowerRange = directive.payload.targetSetpoint.scale == 'FAHRENHEIT' ? 1 : .5; + // use user defined comfort range if set + if (typeof properties.upperSetpoint.parameters.comfort_range !== 'undefined') { + upperRange = parseFloat(properties.upperSetpoint.parameters.comfort_range); + } + if (typeof properties.lowerSetpoint.parameters.comfort_range !== 'undefined') { + lowerRange = parseFloat(properties.lowerSetpoint.parameters.comfort_range); + } + // set dual setpoints + postItems.push( + Object.assign(properties.upperSetpoint.item, { + state: directive.payload.targetSetpoint.value + upperRange + }), + Object.assign(properties.lowerSetpoint.item, { + state: directive.payload.targetSetpoint.value - lowerRange + }) + ) + } + log.debug('setTargetTemperature to values: ', JSON.stringify(postItems)); + postItemsAndReturn(postItems, 'ThermostatController'); } /** @@ -389,74 +356,53 @@ function setTargetTemperature() { */ function adjustTargetTemperature() { var properties = propertyMap.ThermostatController; - /** - * User has a target temperature defined - */ - if (typeof (properties.targetSetpoint) !== 'undefined') { - var itemName = properties.targetSetpoint.itemName; - rest.getItem(directive.endpoint.scope.token, - itemName, function (item) { - var state = parseFloat(item.state) + directive.payload.targetSetpointDelta.value; - postItemAndReturn(itemName, state); - }, function (error) { - context.done(null, generateGenericErrorResponse()); - } - ); - } else if (typeof (properties.upperSetpoint) !== 'undefined' && - typeof (properties.lowerSetpoint) !== 'undefined') { - /** - * user does not have target temerpature defined, but does have upper and lower (dual mode) - */ - var promises = []; - [properties.lowerSetpoint.itemName, properties.upperSetpoint.itemName].forEach(function(itemName) { - promises.push(new Promise(function (resolve, reject) { - rest.getItem(directive.endpoint.scope.token, - itemName, function (item) { - var state = parseFloat(item.state) + directive.payload.targetSetpointDelta.value; - rest.postItemCommand(directive.endpoint.scope.token, - itemName, state, function (response) { - resolve({ name: itemName, state: state }); - }, function (error) { - reject(error); - }); - }); - })); - }); - - Promise.all(promises).then(function (values) { - log.debug(`Promise items ${JSON.stringify(values)}`); - var result = { - context: { - properties: contextProperties.propertiesResponseForItems(values, propertyMap) - }, - event: { - header: generateResponseHeader(directive.header), - payload: {} - } - }; - log.debug('setTargetTemperature done with result' + JSON.stringify(result)); - context.succeed(result); - }).catch(function (err) { - log.debug('setTargetTemperature error ' + err); - context.done(null, generateGenericErrorResponse()); - }); + var propertyNames = []; + var promises = []; + + // adjust target setpoint if defined otherwise upper/lower setpoints if in dual mode + if (properties.targetSetpoint) { + propertyNames.push("targetSetpoint"); + } else if (properties.upperSetpoint && properties.lowerSetpoint) { + propertyNames.push("upperSetpoint", "lowerSetpoint"); } + propertyNames.forEach(function (propertyName) { + promises.push(new Promise(function (resolve, reject) { + rest.getItem(directive.endpoint.scope.token, + properties[propertyName].item.name, function (item) { + resolve(Object.assign(properties[propertyName].item, { + state: parseFloat(item.state) + directive.payload.targetSetpointDelta.value + })); + }, function (error) { + reject(error); + }); + })); + }) + Promise.all(promises).then(function (postItems) { + log.debug('adjustTargetTemperature to values: ', JSON.stringify(postItems)); + postItemsAndReturn(postItems, 'ThermostatController'); + }).catch(function (error) { + log.debug('adjustTargetTemperature failed with error: ' + JSON.stringify(error)); + returnAlexaResponse(generateGenericErrorResponse()); + }); } /** * Sets the mode of the thermostat */ function setThermostatMode() { - var state = utils.normalizeThermostatMode(directive.payload.thermostatMode.value, - propertyMap.ThermostatController.thermostatMode.parameters); - var itemName = propertyMap.ThermostatController.thermostatMode.itemName; + var properties = propertyMap.ThermostatController; + var postItem = Object.assign(properties.thermostatMode.item, { + state: utils.normalizeThermostatMode(directive.payload.thermostatMode.value, + properties.thermostatMode.parameters) + }); - if (typeof (state) !== 'undefined') { - postItemAndReturn(itemName, state); + if (typeof postItem.state !== 'undefined') { + postItemsAndReturn([postItem], 'ThermostatController'); } else { - context.done(null, generateControlError({ + returnAlexaResponse(generateControlError({ type: "UNSUPPORTED_THERMOSTAT_MODE", - message: itemName + " doesn't support thermostat mode [" + directive.payload.thermostatMode.value + "]", + message: postItem.name + " doesn't support thermostat mode [" + + directive.payload.thermostatMode.value + "]", })); } } @@ -465,36 +411,41 @@ function setThermostatMode() { * Locks (ON) or unlocks (OFF) a item */ function setLockState() { - var state = directive.header.name.toUpperCase() === 'LOCK' ? 'ON' : 'OFF'; - var itemName = propertyMap.LockController.lockState.itemName; - postItemAndReturn(itemName, state); + var properties = propertyMap.LockController; + var postItem = Object.assign(properties.lockState.item, { + state: directive.header.name.toUpperCase() === 'LOCK' ? 'ON' : 'OFF' + }); + postItemsAndReturn([postItem], 'LockController'); } /** * Sends the channel value to a string or number item */ function setChannel() { - var itemName = propertyMap.ChannelController.channel.itemName; - var state = directive.payload.channel.number; - postItemAndReturn(itemName, state); + var postItem = Object.assign(propertyMap.ChannelController.channel.item, { + state: directive.payload.channel.number + }); + postItemsAndReturn([postItem], 'ChannelController'); } /** * Adjusts the channel value to a number item */ function adjustChannel() { - var itemName = propertyMap.ChannelController.channel.itemName; + var postItem = propertyMap.ChannelController.channel.item; rest.getItem(directive.endpoint.scope.token, - itemName, function (item) { + postItem.name, function (item) { var state = parseInt(item.state); if (isNaN(state)) { state = Math.abs(directive.payload.channelCount); } else { state += directive.payload.channelCount; } - postItemAndReturn(itemName, state.toString()); + // Value defined as a string in alexa api + postItem.state = state.toString(); + postItemsAndReturn([postItem], 'ChannelController'); }, function (error) { - context.done(null, generateGenericErrorResponse()); + returnAlexaResponse(generateGenericErrorResponse()); } ); } @@ -503,72 +454,66 @@ function adjustChannel() { * Sends the input value (HDMI1, Music, etc..) to a string item */ function setInput() { - var itemName = propertyMap.InputController.input.itemName; - var state = directive.payload.input.replace(/\s/g, '').toUpperCase(); - postItemAndReturn(itemName, state); + var postItem = Object.assign(propertyMap.InputController.input.item, { + state: directive.payload.input.replace(/\s/g, '').toUpperCase() + }); + postItemsAndReturn([postItem], 'InputController'); } + /** * Sends a playback command (PLAY, PASUE, REWIND, etc..) to a string or player item */ function setPlayback() { - var itemName = propertyMap.PlaybackController.playback.itemName; - var state = directive.header.name.toUpperCase(); - postItemAndReturn(itemName, state); + var postItem = Object.assign(propertyMap.PlaybackController.playback.item, { + state: directive.header.name.toUpperCase() + }); + postItemsAndReturn([postItem], 'PlaybackController'); } /** * Sends a send name to a string item */ function setScene() { - var itemName = propertyMap.SceneController.scene.itemName; - var state = directive.header.name === 'Activate' ? "ON" : "OFF"; - rest.postItemCommand(directive.endpoint.scope.token, - itemName, state, function (response) { - var result = { - context: {}, - event: { - header: { - messageId: uuid(), - name: directive.header.name === 'Activate' ? 'ActivationStarted' : 'DeactivationStarted', - namespace: directive.header.namespace, - payloadVersion: directive.header.payloadVersion, - correlationToken: directive.header.correlationToken - }, - payload: { - cause: { - type: 'VOICE_INTERACTION' - }, - timestamp: utils.date() - } - } - }; - log.debug('setScene done with result' + JSON.stringify(result)); - context.succeed(result); - }, function (error) { - context.done(null, generateGenericErrorResponse()); + var postItem = Object.assign(propertyMap.SceneController.scene.item, { + state: directive.header.name === 'Activate' ? 'ON' : 'OFF' + }); + var response = { + context: {}, + event: { + header: generateResponseHeader( + directive.header.name === 'Activate' ? 'ActivationStarted' : 'DeactivationStarted', + 'Alexa.SceneController'), + payload: { + cause: { + type: 'VOICE_INTERACTION' + }, + timestamp: utils.date() + } } - ); + }; + postItemsAndReturn([postItem], 'SceneController', response); } /** * Adjusts a number + or - the volume payload */ function adjustSpeakerVolume() { - var itemName = propertyMap.Speaker.volume.itemName; + var postItem = propertyMap.Speaker.volume.item; var defaultIncrement = parseInt(propertyMap.Speaker.volume.parameters.increment); var volumeIncrement = directive.payload.volumeDefault && defaultIncrement > 0 ? (directive.payload.volume >= 0 ? 1 : -1) * defaultIncrement : directive.payload.volume; rest.getItem(directive.endpoint.scope.token, - itemName, function (item) { + postItem.name, function (item) { var state = parseInt(item.state); if (isNaN(state)) { state = Math.abs(volumeIncrement); } else { state += volumeIncrement; } - postItemAndReturn(itemName, state); + postItem.state = state; + postItemsAndReturn([postItem], 'Speaker'); }, function (error) { - context.done(null, generateGenericErrorResponse()); + returnAlexaResponse(generateGenericErrorResponse()); } ); } @@ -577,36 +522,39 @@ function adjustSpeakerVolume() { * Sets a number item to the volume payload */ function setSpeakerVolume() { - var itemName = propertyMap.Speaker.volume.itemName; - var state = directive.payload.volume; - postItemAndReturn(itemName, state); + var postItem = Object.assign(propertyMap.Speaker.volume.item, { + state: directive.payload.volume + }); + postItemsAndReturn([postItem], 'Speaker'); } /** * Sets a switch item to muted (ON), or unmuted (OFF) */ function setSpeakerMute() { - var itemName = propertyMap.Speaker.muted.itemName; - var state = directive.payload.mute ? "ON" : "OFF"; - postItemAndReturn(itemName, state); + var postItem = Object.assign(propertyMap.Speaker.muted.item, { + state: directive.payload.mute ? 'ON' : 'OFF' + }); + postItemsAndReturn([postItem], 'Speaker'); } /** * Sends a volume step (+1, -1) to a item */ function adjustStepSpeakerVolume() { - var itemName = propertyMap.StepSpeaker.volume.itemName; + var postItem = propertyMap.StepSpeaker.volume.item; rest.getItem(directive.endpoint.scope.token, - itemName, function (item) { + postItem.name, function (item) { var state = parseInt(item.state); if (isNaN(state)) { state = Math.abs(directive.payload.volumeSteps); } else { state += directive.payload.volumeSteps; } - postItemAndReturn(itemName, state); + postItem.state = state; + postItemsAndReturn([postItem], 'StepSpeaker'); }, function (error) { - context.done(null, generateGenericErrorResponse()); + returnAlexaResponse(generateGenericErrorResponse()); } ); } @@ -615,102 +563,159 @@ function adjustStepSpeakerVolume() { * Sets a switch item to muted (ON), or unmuted (OFF) */ function setStepSpeakerMute() { - var itemName = propertyMap.StepSpeaker.muted.itemName; - var state = directive.payload.mute ? "ON" : "OFF"; - postItemAndReturn(itemName, state); + var postItem = Object.assign(propertyMap.StepSpeaker.muted.item, { + state: directive.payload.mute ? "ON" : "OFF" + }); + postItemsAndReturn([postItem], 'StepSpeaker'); } /** + * Generic method to post list of items to OH + * and then return a formatted response to the Alexa request * - * Generic method to get the latest state of an item in OH and then return a formatted result to the Alexa request - * @param {*} itemName + * @param {Array} items + * @param {String} interfaceName + * @param {Object} response Return response object directly if provided (optional) */ -function getItemStateAndReturn(itemName) { - log.debug('getItemStateAndReturn Getting ' + itemName + ' latest state'); - rest.getItem(directive.endpoint.scope.token, - itemName, function (item) { - var result = { - context: { - properties: contextProperties.propertiesResponseForItems( - [{ name: item.name, state: item.state, type: item.type }], propertyMap) - }, - event: { - header: generateResponseHeader(directive.header), - endpoint: { - scope: directive.endpoint.scope, - endpointId: directive.endpoint.endpointId - }, - payload: {} - } - }; - log.debug('getItemStateAndReturn done with result' + JSON.stringify(result)); - context.succeed(result); - }, function (error) { - context.done(null, generateGenericErrorResponse()); +function postItemsAndReturn(items, interfaceName, response) { + var promises = []; + items.forEach(function (item) { + promises.push(new Promise(function (resolve, reject) { + log.debug('postItemsAndReturn Setting ' + item.name + ' to ' + item.state); + rest.postItemCommand(directive.endpoint.scope.token, + item.name, item.state, function (result) { + resolve(result); + }, function (error) { + reject(error); + }); + })); + }); + Promise.all(promises).then(function () { + if (typeof response === 'object') { + log.debug('postItemsAndReturn done with response: ' + JSON.stringify(response)); + returnAlexaResponse(response); + } else { + getPropertiesResponseAndReturn(interfaceName); } - ); + }).catch(function (error) { + log.debug('postItemsAndReturn failed with error: ' + JSON.stringify(error)); + returnAlexaResponse(generateGenericErrorResponse()); + }); } /** + * Generic method to generate properties response + * based of interface-specific properties latest item state from OH + * and then return a formatted response to the Alexa request * - * Generic method to post an item to OH and then return a formatted result to the Alexa request - * @param {*} itemName - * @param {*} state + * @param {String} interfaceName */ -function postItemAndReturn(itemName, state) { - log.debug('postItemAndReturn Setting ' + itemName + ' to ' + state); - rest.postItemCommand(directive.endpoint.scope.token, - itemName, state, function (response) { - getItemStateAndReturn(itemName); - }, function (error) { - context.done(null, generateGenericErrorResponse()); +function getPropertiesResponseAndReturn(interfaceName) { + // Use the property map defined interface names if interfaceName not defined (e.g. reportState) + var interfaceNames = interfaceName ? [interfaceName] : Object.keys(propertyMap); + // Get list of all unique item objects part of interfaces + var interfaceItems = propertyMap.getItemsByInterfaces(interfaceNames); + var promises = []; + + interfaceItems.forEach(function (item) { + promises.push(new Promise(function (resolve, reject) { + // Use item sensor name over standard item name, if defined, to get the accurate current state + var itemName = item.sensor || item.name; + + log.debug('getPropertiesResponseAndReturn Getting ' + itemName + ' latest state'); + rest.getItem(directive.endpoint.scope.token, + itemName, function (result) { + // Update item information in propertyMap object for each item capabilities + item.capabilities.forEach(function (capability) { + propertyMap[capability.interface][capability.property].item = result; + }); + resolve(result); + }, function (error) { + reject(error); + }); + })); + }); + Promise.all(promises).then(function (items) { + // Throw error if one of the state item is set to 'NULL' + if (items.find(item => item.state === 'NULL')) { + throw {message: 'Invalid item state returned by openHAB', items: items}; } - ); + // Generate properties response + var response = { + context: { + properties: contextProperties.propertiesResponseForInterfaces(interfaceNames, propertyMap) + }, + event: { + header: generateResponseHeader(interfaceName ? 'Response' : 'StateReport'), + endpoint: { + scope: directive.endpoint.scope, + endpointId: directive.endpoint.endpointId + }, + payload: {} + } + }; + log.debug('getPropertiesResponseAndReturn done with response: ' + JSON.stringify(response)); + returnAlexaResponse(response); + + }).catch(function (error) { + log.debug('getPropertiesResponseAndReturn failed with error: ' + JSON.stringify(error)); + returnAlexaResponse(generateGenericErrorResponse()); + }); } +/** + * Returns Alexa response + * @param {Object} response + */ +function returnAlexaResponse(response) { + callback(null, response); +}; + /** * V3 response header - * @param {*} header + * @param {String} name + * @param {String} namespace (optional) + * @return {Object} */ -function generateResponseHeader(header) { - return { +function generateResponseHeader(name, namespace) { + var header = { + namespace: namespace || 'Alexa', + name: name, messageId: uuid(), - name: "Response", - namespace: "Alexa", - payloadVersion: header.payloadVersion, - correlationToken: header.correlationToken + payloadVersion: directive.header.payloadVersion }; + // Include correlationToken if provided in directive header request + if (directive.header.correlationToken) { + header.correlationToken = directive.header.correlationToken; + } + return header; } /** * V3 Control Error Response - * @param {*} directive - * @param {*} payload + * @param {Object} payload + * @param {Object} namespace (optional) + * @return {Object} */ -function generateControlError(payload) { - var header = { - namespace: 'Alexa', - name: 'ErrorResponse', - messageId: directive.header.messageId, - correlationToken: directive.header.correlationToken, - payloadVersion: directive.header.payloadVersion - }; - - var result = { +function generateControlError(payload, namespace) { + var response = { event: { - header: header, - endpoint: directive.endpoint, + header: generateResponseHeader('ErrorResponse', namespace), payload: payload } }; + // Include endpoint if provided in directive request + if (directive.endpoint) { + response.event.endpoint = directive.endpoint; + } - log.debug('generateControlError done with result' + JSON.stringify(result)); - return result; + log.debug('generateControlError done with response' + JSON.stringify(response)); + return response; } /** * V3 Generic Error Response - * @param {*} directive + * @return {Object} */ function generateGenericErrorResponse() { return generateControlError({ @@ -721,8 +726,6 @@ function generateGenericErrorResponse() { /** * Device discovery - * @param {*} directive - * @param {*} context */ function discoverDevices() { //request all items with groups @@ -753,7 +756,7 @@ function discoverDevices() { } } - var propertyMap; + var propertyMap = new PropertyMap(); var isEndpointGroup = false; //OH Goups can act as a single Endpoint using its children for capabilities @@ -765,12 +768,9 @@ function discoverDevices() { log.debug("found group " + groupMatch[0] + " for item " + item.name); isEndpointGroup = true; item.members.forEach(function (member) { - //not all items in the group may have Alexa metadata - if (typeof (member.metadata) !== 'undefined') { - log.debug("adding " + member.name + " to group " + item.name); - groupItems.push(member.name); - propertyMap = utils.metadataToPropertyMap(member, propertyMap); - } + log.debug("adding " + member.name + " to group " + item.name); + groupItems.push(member.name); + propertyMap.addItem(member); }); //set display category for group addDisplayCategory(groupMatch[1].toUpperCase()); @@ -780,16 +780,16 @@ function discoverDevices() { } if (!isEndpointGroup) { - propertyMap = utils.metadataToPropertyMap(item); + propertyMap.addItem(item); } - if (propertyMap && Object.keys(propertyMap).length) { - log.debug("Property Map: " + JSON.stringify(propertyMap)); - } else { - //no capability found + //no capability found + if (Object.keys(propertyMap).length == 0) { return; //just returns forEach function } + log.debug("Property Map: " + JSON.stringify(propertyMap)); + capabilities.push(alexaCapabilities.alexa()); Object.keys(propertyMap).forEach(function (interfaceName) { @@ -818,10 +818,8 @@ function discoverDevices() { capability = alexaCapabilities.temperatureSensor(); break; case "ThermostatController": - // Default Alexa supported modes ["AUTO","COOL","HEAT","ECO","OFF"] if null - var supportedModes = properties.thermostatMode && properties.thermostatMode.parameters ? properties.thermostatMode.parameters.supportedModes : null; - capability = alexaCapabilities.thermostatController(properties.targetSetpoint, properties.upperSetpoint, - properties.lowerSetpoint, properties.thermostatMode, supportedModes); + capability = alexaCapabilities.thermostatController(properties.targetSetpoint, + properties.upperSetpoint, properties.lowerSetpoint, properties.thermostatMode); break; case "Speaker": capability = alexaCapabilities.speaker(properties.volume, properties.muted); @@ -856,10 +854,9 @@ function discoverDevices() { capabilities.push(capability.capabilities); // add properties or capability categories if not endpoint group item if (!isEndpointGroup) { - if (properties.categories && properties.categories.length > 0) { - properties.categories.forEach(function (category) { - addDisplayCategory(category); - }); + var categories = propertyMap.getCategories(interfaceName); + if (categories.length) { + categories.forEach(category => addDisplayCategory(category)); } else { addDisplayCategory(capability.category); } @@ -874,7 +871,7 @@ function discoverDevices() { description: item.type + ' ' + item.name + ' via openHAB', displayCategories: displayCategories, cookie: { - propertyMap: JSON.stringify(propertyMap) + propertyMap: propertyMap.dump() }, capabilities: capabilities }; @@ -902,24 +899,24 @@ function discoverDevices() { var payload = { endpoints: discoverdDevices }; - var result = { + var response = { event: { header: header, payload: payload } }; - log.debug('Discovery: ' + JSON.stringify(result)); - context.succeed(result); + log.debug('Discovery: ' + JSON.stringify(response)); + returnAlexaResponse(response); }, function (error) { log.error("discoverDevices failed: " + error.message); - context.done(null, generateGenericErrorResponse()); + returnAlexaResponse(generateGenericErrorResponse()); }); } /** * Convert v2 style label/tag on items to V3 - * @param {*} items + * @param {Object} items */ function convertV2Items(items) { items.forEach(function (item) { @@ -929,8 +926,8 @@ function convertV2Items(items) { /** * Convert v2 style label/tag on a single item to V3 - * @param {*} item - * @param {*} group [group level config parameters] (optional) + * @param {Object} item + * @param {Object} group [group level config parameters] (optional) */ function convertV2Item(item, group = {}) { @@ -1044,7 +1041,13 @@ function convertV2Item(item, group = {}) { } } - // Push all capabilities to metadata values if not already included and merge parameters into metadata config + // Update recursively members of group item with endpoint capability + if (item.type === 'Group' && metadata.values.find(value => value.match(ENDPOINT_PATTERN))) { + item.members.forEach(member => convertV2Item(member, metadata.config)); + } + + // Push all capabilities to metadata values if not already included + // and merge parameters into metadata config capabilities.forEach(function (capability) { if (!metadata.values.find(value => value === capability)) { metadata.values.push(capability); @@ -1069,7 +1072,7 @@ function convertV2Item(item, group = {}) { /** * V2 style tags, given an item, returns an array of action that are supported. - * @param {*} item + * @param {Object} item */ function getV2SwitchableCapabilities(item) { switch (item.type === 'Group' ? item.groupType : item.type) { diff --git a/package-lock.json b/package-lock.json index 80a2727e..1ce76cc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -100,7 +100,7 @@ "assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "integrity": "sha1-5gtrDo8wG9l+U3UhW9pAbIURjAs=", "dev": true }, "ast-types": { @@ -231,12 +231,12 @@ "base64-js": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", - "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" + "integrity": "sha1-yrHmEY8FEJXli1KBrqjBzSK/wOM=" }, "bl": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", - "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", + "integrity": "sha1-oWCRFxcQPAdBDO9j71Gzl8Alr5w=", "optional": true, "requires": { "readable-stream": "^2.3.5", @@ -272,7 +272,7 @@ "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "integrity": "sha1-PH/L9SnYcibz0vUrlm/1Jx60Qd0=", "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -281,7 +281,7 @@ "browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "integrity": "sha1-uqVZ7hTO1zRSIputcyZGfGH6vWA=", "dev": true }, "buffer": { @@ -455,7 +455,7 @@ "continuation-local-storage": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/continuation-local-storage/-/continuation-local-storage-3.2.1.tgz", - "integrity": "sha512-jx44cconVqkCEEyLSKWwkvUXwO561jXMa3LPjTPsm5QR22PA0/mhe33FT4Xb5y74JDvt/Cq+5lm8S8rskLv9ZA==", + "integrity": "sha1-EfYT906RT+mzTJKtLSj+auHbf/s=", "requires": { "async-listener": "^0.6.0", "emitter-listener": "^1.1.1" @@ -535,7 +535,7 @@ "debug": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "integrity": "sha1-W7WgZyYotkFJVmuhaBnmFRjGcmE=", "requires": { "ms": "2.0.0" } @@ -543,7 +543,7 @@ "deep-eql": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", - "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "integrity": "sha1-38lARACtHI/gI+faHfHBR8S0RN8=", "dev": true, "requires": { "type-detect": "^4.0.0" @@ -585,7 +585,7 @@ "diff": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "integrity": "sha1-gAwN0eCov7yVg1wgKtIg/jF+WhI=", "dev": true }, "dotenv": { @@ -613,7 +613,7 @@ "end-of-stream": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", - "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", + "integrity": "sha1-7SljTRm6ukY7bOa4CjchPqtx7EM=", "optional": true, "requires": { "once": "^1.4.0" @@ -710,13 +710,13 @@ "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "integrity": "sha1-VTp7hEb/b2hDWcRF8eN6BdrMM90=", "optional": true }, "fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "integrity": "sha1-a+Dem+mYzhavivwkSXue6bfM2a0=", "optional": true }, "fs-extra": { @@ -813,7 +813,7 @@ "glob": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "integrity": "sha1-wZyd+aAocC1nhhI4SmVSQExjbRU=", "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -831,7 +831,7 @@ "growl": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.3.tgz", - "integrity": "sha512-hKlsbA5Vu3xsh1Cg3J7jSmX/WaW6A5oBeqzM88oNbCRQFz+zUaXm6yxS4RVytp1scBoJzSYl4YAEOQIt6O8V1Q==", + "integrity": "sha1-GSa6kM8+3+KttJJ/WIC8IsZseQ8=", "dev": true }, "has-flag": { @@ -861,7 +861,7 @@ "http-proxy-agent": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz", - "integrity": "sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==", + "integrity": "sha1-5IIb7vWyFCogJr1zkm/lN2McVAU=", "requires": { "agent-base": "4", "debug": "3.1.0" @@ -870,7 +870,7 @@ "https-proxy-agent": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz", - "integrity": "sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ==", + "integrity": "sha1-UVUpcPoE1yPgTFbQQXjD+SWSu8A=", "requires": { "agent-base": "^4.1.0", "debug": "^3.1.0" @@ -1042,7 +1042,7 @@ "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "integrity": "sha1-UWbihkV/AzBgZL5Ul+jbsMPTIIM=", "requires": { "brace-expansion": "^1.1.7" } @@ -1084,7 +1084,7 @@ "commander": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", - "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "integrity": "sha1-FXFS/R56bI2YpbcVzzdt+SgARWM=", "dev": true } } @@ -1103,7 +1103,7 @@ "node-lambda": { "version": "0.11.7", "resolved": "https://registry.npmjs.org/node-lambda/-/node-lambda-0.11.7.tgz", - "integrity": "sha512-1qvfYWJyLLVB3PxDSlEHVoTT4JsBdSkrFmCdpvbH0ghxd+EwssPkxATIO2hVvhAzDCSnaCxg0lSxZd8oG6xSVw==", + "integrity": "sha1-Lz4bxnM7sdphFzXBRsa6t5wroec=", "optional": true, "requires": { "archiver": "^2.1.1", @@ -1155,7 +1155,7 @@ "pac-proxy-agent": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-2.0.2.tgz", - "integrity": "sha512-cDNAN1Ehjbf5EHkNY5qnRhGPUCp6SnpyVof5fRzN800QV1Y2OkzbH9rmjZkbBRa8igof903yOnjIl6z0SlAhxA==", + "integrity": "sha1-kNn2cwqw9NJgfc3NTT1kGqJsOJY=", "optional": true, "requires": { "agent-base": "^4.2.0", @@ -1171,7 +1171,7 @@ "pac-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-3.0.0.tgz", - "integrity": "sha512-tcc38bsjuE3XZ5+4vP96OfhOugrX+JcnpUbhfuc4LuXBLQhoTthOstZeoQJBDnQUDYzYmdImKsbz0xSl1/9qeA==", + "integrity": "sha1-auoweH2wqJFwTet4AKcip2FabyY=", "optional": true, "requires": { "co": "^4.6.0", @@ -1206,12 +1206,12 @@ "process-nextick-args": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", - "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" + "integrity": "sha1-o31zL0JxtKsa0HDTVQjoKQeI/6o=" }, "proxy-agent": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-2.3.1.tgz", - "integrity": "sha512-CNKuhC1jVtm8KJYFTS2ZRO71VCBx3QSA92So/e6NrY6GoJonkx3Irnk4047EsCcswczwqAekRj3s8qLRGahSKg==", + "integrity": "sha1-PUnYY9Rs9fN8qDlISDRuoCNz6sY=", "optional": true, "requires": { "agent-base": "^4.2.0", @@ -1302,7 +1302,7 @@ "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "integrity": "sha1-mR7GnSluAxN0fVm9/St0XDX4go0=" }, "safer-buffer": { "version": "2.1.2", @@ -1356,7 +1356,7 @@ "socks-proxy-agent": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-3.0.1.tgz", - "integrity": "sha512-ZwEDymm204mTzvdqyUqOdovVr2YRd2NYskrYrF2LXyZ9qDiMAoFESGK8CRphiO7rtbo2Y757k2Nia3x2hGtalA==", + "integrity": "sha1-Lq58+OKoLTRWV2FTmn+XGMVhdlk=", "requires": { "agent-base": "^4.1.0", "socks": "^1.1.10" @@ -1365,7 +1365,7 @@ "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "integrity": "sha1-dHIq8y6WFOnCh6jQu95IteLxomM=", "optional": true }, "stack-trace": { @@ -1390,7 +1390,7 @@ "supports-color": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.4.0.tgz", - "integrity": "sha512-rKC3+DyXWgK0ZLKwmRsrkyHVZAjNkfzeehuFWdGGcqGDTZFH73+RH6S/RDAAxl9GusSjZSUWYLmT9N5pzXFOXQ==", + "integrity": "sha1-iD992rwWUUKyphQn8zUt7RldGj4=", "dev": true, "requires": { "has-flag": "^2.0.0" @@ -1451,7 +1451,7 @@ "to-buffer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", - "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", + "integrity": "sha1-STvUj2LXxD/N7TE6A9ytsuEhOoA=", "optional": true }, "triple-beam": { @@ -1470,7 +1470,7 @@ "type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "integrity": "sha1-dkb7XxiHHPu3dJ5pvTmmOI63RQw=", "dev": true }, "unpipe": { @@ -1497,7 +1497,7 @@ "uuid": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.2.1.tgz", - "integrity": "sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==" + "integrity": "sha1-EsUou51Y0LkmXZovbw/ovhf/HxQ=" }, "winston": { "version": "3.2.1", diff --git a/package.json b/package.json index 0463dd76..23fc16db 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "url": "https://github.com/openhab/openhab-alexa.git" }, "author": "openHAB", - "license": "EPLv1", + "license": "EPL-1.0", "bugs": { "url": "https://github.com/openhab/openhab-alexa/issues" }, diff --git a/rest.js b/rest.js index 8506771a..3443e324 100644 --- a/rest.js +++ b/rest.js @@ -1,5 +1,5 @@ /** - * Copyright (c) 2014-2016 by the respective copyright holders. + * Copyright (c) 2014-2019 by the respective copyright holders. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 @@ -7,147 +7,182 @@ * http://www.eclipse.org/legal/epl-v10.html */ +var fs = require('fs'); +var http = require('http'); +var https = require('https'); +var qs = require('querystring'); + var log = require('./log.js'); +var utils = require('./utils.js'); var config = getConfig(); -var http = require(config.proto === 'http' ? 'http' : 'https'); /** * Get config */ function getConfig() { - var config ; - try { - config = require('./config.js'); - } catch (e) { - // default config - log.info('getConfig failed to load config.js file, loading default config instead...'); - config = { - user: process.env.OPENHAB_USERNAME || null, - pass: process.env.OPENHAB_PASSWORD || null, - host: process.env.OPENHAB_HOSTNAME || 'localhost', - port: process.env.OPENHAB_PORT || 8443, - path: process.env.OPENHAB_PATH || '/rest/items/', - proto: process.env.OPENHAB_PROTOCOL || 'https' - }; - } - // merge username & password if specified - if (config.user && config.pass) { - config.userpass = config.user + ":" + config.pass; + // Default configuration + var defaults = { + openhab: { + host: process.env.OPENHAB_HOST || 'localhost', + port: process.env.OPENHAB_PORT || 8443, + path: process.env.OPENHAB_PATH || '/rest', + user: process.env.OPENHAB_USERNAME || null, + pass: process.env.OPENHAB_PASSWORD || null, + proto: process.env.OPENHAB_PROTOCOL || 'https' } - return config; + }; + // Merge file config settings, if exists, with default ones + var config = Object.assign(defaults, + fs.existsSync('./config.js') ? require('./config.js') : {} + ); + // Merge username & password if specified + if (config.openhab.user && config.openhab.pass) { + config.openhab.userpass = config.openhab.user + ":" + config.openhab.pass; + } + return config; } /** - * Returns all items + * Returns openHAB authorization header value + * @param {String} token */ -function getItems(token, success, failure) { - return getItemOrItems(token, null, null, success, failure); +function ohAuthorizationHeader(token) { + if (config.openhab.userpass) { + // Basic Authentication + return 'Basic ' + new Buffer(config.openhab.userpass).toString('base64'); + } else { + // OAuth2 Authentication + return 'Bearer ' + token; + } } /** - * Returns all items as just Name and State + * Returns a single item + * @param {String} token + * @param {String} itemName + * @param {Function} success + * @param {Function} failure */ -function getItemStates(token, success, failure) { - return getItemOrItems(token, null, 'fields=name,state,editable', success, failure); +function getItem(token, itemName, success, failure) { + getItemOrItems(token, itemName, null, success, failure); } /** - * Returns all items + * Returns all items (v2) + * @param {String} token + * @param {Function} success + * @param {Function} failure */ -function getItemsRecursively(token, success, failure) { - return getItemOrItems(token, null, 'metadata=alexa&recursive=true', success, failure); +function getItems(token, success, failure) { + getItemOrItems(token, null, null, success, failure); } /** - * Returns a single item + * Returns all items recursively with alexa metadata (v3) + * @param {String} token + * @param {Function} success + * @param {Function} failure */ -function getItem(token, itemName, success, failure) { - return getItemOrItems(token, itemName, 'metadata=alexa', success, failure); +function getItemsRecursively(token, success, failure) { + getItemOrItems(token, null, {'metadata': 'alexa', 'recursive': true}, success, failure); } /** - * Returns a single item + * Returns get item(s) result + * @param {String} token + * @param {String} itemName + * @param {Object} parameters + * @param {Function} success + * @param {Function} failure */ function getItemOrItems(token, itemName, parameters, success, failure) { - var options = httpItemOptions(token, itemName, 'GET', parameters); - http.get(options, function (response) { - var body = ''; - - response.on('data', function (data) { - body += data.toString('utf-8'); - }); - - response.on('end', function () { - if (response.statusCode != 200) { - failure({ - message: 'Error response ' + response.statusCode - }); - log.info('getItem failed for path: ' + options.path + - ' code: ' + response.statusCode + ' body: ' + body); - return; - } - var resp = JSON.parse(body); - success(resp); - }); - - response.on('error', function (e) { - failure(e); - }); - }) - .end(); + var options = { + hostname: config.openhab.host, + port: config.openhab.port, + path: config.openhab.path + '/items' + (itemName ? '/' + itemName : '') + + (parameters ? '?' + qs.stringify(parameters) : ''), + method: 'GET', + headers: { + 'Authorization': ohAuthorizationHeader(token), + 'Content-Type': 'text/plain' + } + }; + + httpRequest(options, null, config.openhab.proto, success, failure); } /** * POST a command to a item + * @param {String} token + * @param {String} itemName + * @param {String} value + * @param {Function} success + * @param {Function} failure **/ function postItemCommand(token, itemName, value, success, failure) { - var data = value.toString(); - var options = httpItemOptions(token, itemName, 'POST', null, data.length); - var req = http.request(options, function (response) { - var body = ''; - if (response.statusCode == 200 || response.statusCode == 201) { - success(response); - } else { - failure({ - message: 'Error response ' + response.statusCode - }); - } - response.on('error', function (e) { - failure(e); - }); - }); + var data = value.toString(); + var options = { + hostname: config.openhab.host, + port: config.openhab.port, + path: config.openhab.path + '/items/' + itemName, + method: 'POST', + headers: { + 'Authorization': ohAuthorizationHeader(token), + 'Content-Type': 'text/plain', + 'Content-Length': data.length + } + }; - req.write(data); - req.end(); + if (itemName) { + httpRequest(options, data, config.openhab.proto, success, failure); + } else { + failure({ + message: 'No item name provided' + }); + } } /** - * Returns a http option object sutiable for item commands + * Handles HTTP request + * @param {Object} options + * @param {String} data + * @param {String} protocol + * @param {Function} success + * @param {Function} failure */ -function httpItemOptions(token, itemName, method, parameters, length) { - var options = { - hostname: config.host, - port: config.port, - path: config.path + (itemName || '') + (parameters ? '?' + parameters : ''), - method: method || 'GET', - headers: {} - }; - - if (config.userpass) { - options.auth = config.userpass; - } else { - options.headers['Authorization'] = 'Bearer ' + token; - } +function httpRequest(options, data, protocol, success, failure) { + // log.debug('http request: ' + JSON.stringify({options: options, data: data, protocol: protocol})); + var proto = protocol === 'http' ? http : https; + var req = proto.request(options, function(response) { + var body = ''; + + response.on('data', function(chunk) { + body += chunk.toString('utf-8'); + }); - if (method === 'POST' || method === 'PUT') { - options.headers['Content-Type'] = 'text/plain'; - options.headers['Content-Length'] = length; - } - return options; + response.on('end', function() { + var successStatusCodes = [200, 201, 202]; + var result = utils.parseJSON(body); + if (successStatusCodes.includes(response.statusCode)) { + success(result); + } else { + failure({ + message: 'Failed http request: ' + response.statusMessage + ' (' + response.statusCode + ')', + result: result + }); + } + }); + + response.on('error', function(error) { + failure(error); + }); + }); + + req.write(data || ''); + req.end(); } -module.exports.getItems = getItems; module.exports.getItem = getItem; +module.exports.getItems = getItems; module.exports.getItemsRecursively = getItemsRecursively; -module.exports.getItemStates = getItemStates; module.exports.postItemCommand = postItemCommand; diff --git a/test/common.js b/test/common.js index b583dc6e..e6d4556b 100644 --- a/test/common.js +++ b/test/common.js @@ -1,10 +1,13 @@ +/** + * Copyright (c) 2014-2019 by the respective copyright holders. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ var assert = require('chai').assert; -// set log level to error in test environment -if (process.env.NODE_ENV === 'test') { - process.env.LOG_LEVEL = 'ERROR'; -} - /** * Generate directive request based of default template * @param {*} request diff --git a/test/test_ohConnectorV2.js b/test/test_ohConnectorV2.js index c5012de6..1b820fce 100644 --- a/test/test_ohConnectorV2.js +++ b/test/test_ohConnectorV2.js @@ -1,3 +1,11 @@ +/** +* Copyright (c) 2014-2019 by the respective copyright holders. +* +* All rights reserved. This program and the accompanying materials +* are made available under the terms of the Eclipse Public License v1.0 +* which accompanies this distribution, and is available at +* http://www.eclipse.org/legal/epl-v10.html +*/ var common = require('./common.js'); var ohv2 = require('../ohConnectorV2.js'); var rest = require('../rest.js'); @@ -22,7 +30,7 @@ describe('ohConnectorV2 Tests', function () { success({"statusCode": 200}); }; - // mock aws lamnda context calls + // mock aws lambda context calls context = { "succeed": function(result) { capture.result = result; }, "done": function(error, result) { capture.result = result; } @@ -32,7 +40,7 @@ describe('ohConnectorV2 Tests', function () { beforeEach(function () { // reset mock variables response = {}; - capture = {"calls": [], "result" : null}; + capture = {"calls": [], "result": null}; }); // Discovery Tests diff --git a/test/test_ohConnectorV3.js b/test/test_ohConnectorV3.js index dc45107c..88786722 100644 --- a/test/test_ohConnectorV3.js +++ b/test/test_ohConnectorV3.js @@ -1,3 +1,11 @@ +/** +* Copyright (c) 2014-2019 by the respective copyright holders. +* +* All rights reserved. This program and the accompanying materials +* are made available under the terms of the Eclipse Public License v1.0 +* which accompanies this distribution, and is available at +* http://www.eclipse.org/legal/epl-v10.html +*/ var common = require('./common.js'); var ohv3 = require('../ohConnectorV3.js'); var rest = require('../rest.js'); @@ -7,7 +15,7 @@ var utils = common.utils; describe('ohConnectorV3 Tests', function () { - var capture, context, response; + var callback, capture, response; before(function () { // mock rest external calls @@ -17,25 +25,21 @@ describe('ohConnectorV3 Tests', function () { rest.getItemsRecursively = function(token, success, failure) { success(Array.isArray(response.openhab) && response.staged ? response.openhab.shift() : response.openhab); }; - rest.getItemStates = function(token, success, failure) { - success(Array.isArray(response.openhab) && response.staged ? response.openhab.shift() : response.openhab); - }; rest.postItemCommand = function(token, itemName, value, success) { capture.calls.push({"name": itemName, "value": value}); success({"statusCode": 200}); }; - // mock aws lamnda context calls - context = { - "succeed": function(result) { capture.result = result; }, - "done": function(error, result) { capture.result = result; } + // mock aws lambda callback calls + callback = function(error, result) { + capture.result = capture.result ? [].concat(capture.result, result) : result; }; }); beforeEach(function () { // reset mock variables response = {}; - capture = {"calls": [], "result" : null}; + capture = {"calls": [], "result": null}; }); // Discovery Tests @@ -53,7 +57,7 @@ describe('ohConnectorV3 Tests', function () { it(test.description, function () { response = {"openhab": test.mocked}; - ohv3.handleRequest(directive, context); + ohv3.handleRequest(directive, callback); // console.log("Endpoints: " + JSON.stringify(capture.result.event.payload.endpoints, null, 2)); assert.discoveredEndpoints(capture.result.event.payload.endpoints, test.expected); }); @@ -70,7 +74,7 @@ describe('ohConnectorV3 Tests', function () { tests.forEach(function(test) { it(test.description, function(done) { response = test.mocked; - ohv3.handleRequest(utils.generateDirectiveRequest(test.directive), context); + ohv3.handleRequest(utils.generateDirectiveRequest(test.directive), callback); // Wait for async functions setTimeout(function() { // console.log("Capture: " + JSON.stringify(capture, null, 2)); diff --git a/test/v2/test_discoverThermostat.js b/test/v2/test_discoverThermostat.js index 35a39e72..42632480 100644 --- a/test/v2/test_discoverThermostat.js +++ b/test/v2/test_discoverThermostat.js @@ -34,7 +34,7 @@ module.exports = { }, { "link": "https://myopenhab.org/rest/items/temperature", - "type": "Group", + "type": "Number", "name": "temperature", "label": "Temperature", "tags": ["CurrentTemperature", "Fahrenheit"], diff --git a/test/v3/test_controllerAlexa.js b/test/v3/test_controllerAlexa.js index bca67ee3..27fb0135 100644 --- a/test/v3/test_controllerAlexa.js +++ b/test/v3/test_controllerAlexa.js @@ -10,17 +10,15 @@ module.exports = [ "endpointId": "light1", "cookie": { "propertyMap": JSON.stringify({ - "PowerController": {"powerState": {"parameters": {}, "itemName": "light1"}}, - "BrightnessController": {"brightness": {"parameters": {}, "itemName": "light1"}}, - "ColorController": {"color": {"parameters": {}, "itemName": "light1"}} + "PowerController": {"powerState": {"parameters": {}, "item": {"name": "light1"}}}, + "BrightnessController": {"brightness": {"parameters": {}, "item": {"name": "light1"}}}, + "ColorController": {"color": {"parameters": {}, "item": {"name": "light1"}}} }) } } }, mocked: { - openhab: [ - {"name": "light1", "state": "0,0,42", "type": "Color"} - ] + openhab: {"name": "light1", "state": "0,0,42", "type": "Color"} }, expected: { alexa: { @@ -56,5 +54,40 @@ module.exports = [ }, openhab: [] } + }, + { + description: "report state unreachable error", + directive: { + "header": { + "namespace": "Alexa", + "name": "ReportState" + }, + "endpoint": { + "endpointId": "switch1", + "cookie": { + "propertyMap": JSON.stringify({ + "PowerController": {"powerState": {"parameters": {}, "item": {"name": "switch1"}}}, + }) + } + } + }, + mocked: { + openhab: {"name": "switch1", "state": "NULL", "type": "Switch"} + }, + expected: { + alexa: { + "event": { + "header": { + "namespace": "Alexa", + "name": "ErrorResponse" + }, + "payload": { + type: "ENDPOINT_UNREACHABLE", + message: "Unable to reach device" + } + } + }, + openhab: [] + } } ]; diff --git a/test/v3/test_controllerBrightness.js b/test/v3/test_controllerBrightness.js index 5257db72..04084cc9 100644 --- a/test/v3/test_controllerBrightness.js +++ b/test/v3/test_controllerBrightness.js @@ -10,7 +10,7 @@ module.exports = [ "endpointId": "light1", "cookie": { "propertyMap": JSON.stringify({ - "BrightnessController": {"brightness": {"parameters": {}, "itemName": "light1"}} + "BrightnessController": {"brightness": {"parameters": {}, "item": {"name": "light1"}}} }) } }, @@ -53,7 +53,7 @@ module.exports = [ "endpointId": "light1", "cookie": { "propertyMap": JSON.stringify({ - "BrightnessController": {"brightness": {"parameters": {}, "itemName": "light1"}} + "BrightnessController": {"brightness": {"parameters": {}, "item": {"name": "light1"}}} }) } }, @@ -96,7 +96,7 @@ module.exports = [ "endpointId": "light1", "cookie": { "propertyMap": JSON.stringify({ - "BrightnessController": {"brightness": {"parameters": {}, "itemName": "light1"}} + "BrightnessController": {"brightness": {"parameters": {}, "item": {"name": "light1"}}} }) } }, diff --git a/test/v3/test_controllerChannel.js b/test/v3/test_controllerChannel.js index 9f49e0f4..7c005413 100644 --- a/test/v3/test_controllerChannel.js +++ b/test/v3/test_controllerChannel.js @@ -10,7 +10,7 @@ module.exports = [ "endpointId": "gTelevision", "cookie": { "propertyMap": JSON.stringify({ - "ChannelController": {"channel": {"parameters": {}, "itemName": "gTelevision"}} + "ChannelController": {"channel": {"parameters": {}, "item": {"name": "gTelevision"}}} }) } }, @@ -64,7 +64,7 @@ module.exports = [ "endpointId": "gTelevision", "cookie": { "propertyMap": JSON.stringify({ - "ChannelController": {"channel": {"parameters": {}, "itemName": "gTelevision"}} + "ChannelController": {"channel": {"parameters": {}, "item": {"name": "gTelevision"}}} }) } }, diff --git a/test/v3/test_controllerColor.js b/test/v3/test_controllerColor.js index e95f42eb..52ac1830 100644 --- a/test/v3/test_controllerColor.js +++ b/test/v3/test_controllerColor.js @@ -10,7 +10,7 @@ module.exports = [ "endpointId": "light1", "cookie": { "propertyMap": JSON.stringify({ - "ColorController": {"color": {"parameters": {}, "itemName": "light1"}} + "ColorController": {"color": {"parameters": {}, "item": {"name": "light1"}}} }) } }, diff --git a/test/v3/test_controllerColorTemperature.js b/test/v3/test_controllerColorTemperature.js index 6e096edf..8f613835 100644 --- a/test/v3/test_controllerColorTemperature.js +++ b/test/v3/test_controllerColorTemperature.js @@ -10,7 +10,8 @@ module.exports = [ "endpointId": "gColorLight", "cookie": { "propertyMap": JSON.stringify({ - "ColorTemperatureController": {"colorTemperatureInKelvin": {"parameters": {}, "itemName": "colorTemperature"}} + "ColorTemperatureController": { + "colorTemperatureInKelvin": {"parameters": {}, "item": {"name": "colorTemperature", "type": "Dimmer"}}} }) } }, @@ -53,7 +54,8 @@ module.exports = [ "endpointId": "gColorLight", "cookie": { "propertyMap": JSON.stringify({ - "ColorTemperatureController": {"colorTemperatureInKelvin": {"parameters": {}, "itemName": "colorTemperature"}} + "ColorTemperatureController": { + "colorTemperatureInKelvin": {"parameters": {}, "item": {"name": "colorTemperature", "type": "Dimmer"}}} }) } } @@ -97,7 +99,8 @@ module.exports = [ "endpointId": "gColorLight", "cookie": { "propertyMap": JSON.stringify({ - "ColorTemperatureController": {"colorTemperatureInKelvin": {"parameters": {"increment": 10}, "itemName": "colorTemperature"}} + "ColorTemperatureController": { + "colorTemperatureInKelvin": {"parameters": {"increment": 10}, "item": {"name": "colorTemperature", "type": "Dimmer"}}} }) } } @@ -141,7 +144,8 @@ module.exports = [ "endpointId": "gColorLight", "cookie": { "propertyMap": JSON.stringify({ - "ColorTemperatureController": {"colorTemperatureInKelvin": {"parameters": {"increment": 900}, "itemName": "colorTemperature"}} + "ColorTemperatureController": { + "colorTemperatureInKelvin": {"parameters": {"increment": 900}, "item": {"name": "colorTemperature", "type": "Number"}}} }) } } @@ -185,8 +189,9 @@ module.exports = [ "endpointId": "gColorLight", "cookie": { "propertyMap": JSON.stringify({ - "ColorController": {"color": {"parameters": {}, "itemName": "colorLight"}}, - "ColorTemperatureController": {"colorTemperatureInKelvin": {"parameters": {"increment": 900}, "itemName": "colorTemperature"}} + "ColorController": {"color": {"parameters": {}, "item": {"name": "colorLight"}}}, + "ColorTemperatureController": { + "colorTemperatureInKelvin": {"parameters": {"increment": 900}, "item": {"name": "colorTemperature", "type": "Number"}}} }) } } diff --git a/test/v3/test_controllerInput.js b/test/v3/test_controllerInput.js index 8e954272..62b923fa 100644 --- a/test/v3/test_controllerInput.js +++ b/test/v3/test_controllerInput.js @@ -10,7 +10,7 @@ module.exports = [ "endpointId": "tvSource", "cookie": { "propertyMap": JSON.stringify({ - "InputController": {"input": {"parameters": {}, "itemName": "tvSource"}} + "InputController": {"input": {"parameters": {}, "item": {"name": "tvSource"}}} }) } }, diff --git a/test/v3/test_controllerLock.js b/test/v3/test_controllerLock.js index b22ac329..a075060e 100644 --- a/test/v3/test_controllerLock.js +++ b/test/v3/test_controllerLock.js @@ -10,7 +10,7 @@ module.exports = [ "endpointId": "doorLock", "cookie": { "propertyMap": JSON.stringify({ - "LockController": {"lockState": {"parameters": {}, "itemName": "doorLock"}} + "LockController": {"lockState": {"parameters": {}, "item": {"name": "doorLock", "type": "Switch"}}} }) } } @@ -50,7 +50,7 @@ module.exports = [ "endpointId": "doorLock", "cookie": { "propertyMap": JSON.stringify({ - "LockController": {"lockState": {"parameters": {}, "itemName": "doorLock"}} + "LockController": {"lockState": {"parameters": {}, "item": {"name": "doorLock", "type": "Switch"}}} }) } } @@ -80,7 +80,7 @@ module.exports = [ } }, { - description: "lock jammed state", + description: "lock jammed sensor state map parameters", directive: { "header": { "namespace": "Alexa.LockController", @@ -90,13 +90,15 @@ module.exports = [ "endpointId": "doorLock", "cookie": { "propertyMap": JSON.stringify({ - "LockController": {"lockState": {"parameters": {}, "itemName": "doorLock"}} + "LockController": {"lockState": { + "parameters": {1: "LOCKED", 2: "UNLOCKED", 42: "JAMMED"}, + "item": {"name": "doorLock", "sensor": "doorLockSensor", "type": "Number"}}} }) } } }, mocked: { - openhab: {"name": "doorLock", "state": "NULL", "type": "Switch"} + openhab: {"name": "doorLockSensor", "state": "42", "type": "Number"} }, expected: { alexa: { diff --git a/test/v3/test_controllerPercentage.js b/test/v3/test_controllerPercentage.js index a887cd13..0371caed 100644 --- a/test/v3/test_controllerPercentage.js +++ b/test/v3/test_controllerPercentage.js @@ -10,7 +10,7 @@ module.exports = [ "endpointId": "device1", "cookie": { "propertyMap": JSON.stringify({ - "PercentageController": {"percentage": {"parameters": {}, "itemName": "device1"}} + "PercentageController": {"percentage": {"parameters": {}, "item": {"name": "device1"}}} }) } }, @@ -53,7 +53,7 @@ module.exports = [ "endpointId": "device1", "cookie": { "propertyMap": JSON.stringify({ - "PercentageController": {"percentage": {"parameters": {}, "itemName": "device1"}} + "PercentageController": {"percentage": {"parameters": {}, "item": {"name": "device1"}}} }) } }, diff --git a/test/v3/test_controllerPlayback.js b/test/v3/test_controllerPlayback.js index 94355827..5769d6d8 100644 --- a/test/v3/test_controllerPlayback.js +++ b/test/v3/test_controllerPlayback.js @@ -10,7 +10,7 @@ module.exports = [ "endpointId": "gSpeaker", "cookie": { "propertyMap": JSON.stringify({ - "PlaybackController": {"playback": {"parameters": {}, "itemName": "speakerPlayer"}} + "PlaybackController": {"playback": {"parameters": {}, "item": {"name": "speakerPlayer"}}} }) } } diff --git a/test/v3/test_controllerPower.js b/test/v3/test_controllerPower.js index e86f0c55..398353ad 100644 --- a/test/v3/test_controllerPower.js +++ b/test/v3/test_controllerPower.js @@ -10,7 +10,7 @@ module.exports = [ "endpointId": "light1", "cookie": { "propertyMap": JSON.stringify({ - "PowerController": {"powerState": {"parameters": {}, "itemName": "light1"}} + "PowerController": {"powerState": {"parameters": {}, "item": {"name": "light1"}}} }) } }, @@ -50,7 +50,7 @@ module.exports = [ "endpointId": "light1", "cookie": { "propertyMap": JSON.stringify({ - "PowerController": {"powerState": {"parameters": {}, "itemName": "light1"}} + "PowerController": {"powerState": {"parameters": {}, "item": {"name": "light1"}}} }) } }, @@ -90,7 +90,7 @@ module.exports = [ "endpointId": "light1", "cookie": { "propertyMap": JSON.stringify({ - "PowerController": {"powerState": {"parameters": {}, "itemName": "light1"}} + "PowerController": {"powerState": {"parameters": {}, "item": {"name": "light1"}}} }) } }, diff --git a/test/v3/test_controllerPowerLevel.js b/test/v3/test_controllerPowerLevel.js index cf0882ad..a9136eab 100644 --- a/test/v3/test_controllerPowerLevel.js +++ b/test/v3/test_controllerPowerLevel.js @@ -10,7 +10,7 @@ module.exports = [ "endpointId": "device1", "cookie": { "propertyMap": JSON.stringify({ - "PowerLevelController": {"powerLevel": {"parameters": {}, "itemName": "device1"}} + "PowerLevelController": {"powerLevel": {"parameters": {}, "item": {"name": "device1"}}} }) } }, @@ -53,7 +53,7 @@ module.exports = [ "endpointId": "device1", "cookie": { "propertyMap": JSON.stringify({ - "PowerLevelController": {"powerLevel": {"parameters": {}, "itemName": "device1"}} + "PowerLevelController": {"powerLevel": {"parameters": {}, "item": {"name": "device1"}}} }) } }, diff --git a/test/v3/test_controllerScene.js b/test/v3/test_controllerScene.js index f71c4a0d..5efd1fce 100644 --- a/test/v3/test_controllerScene.js +++ b/test/v3/test_controllerScene.js @@ -10,7 +10,7 @@ module.exports = [ "endpointId": "scene1", "cookie": { "propertyMap": JSON.stringify({ - "SceneController": {"scene": {"parameters": {}, "itemName": "scene1"}} + "SceneController": {"scene": {"parameters": {}, "item": {"name": "scene1"}}} }) } } @@ -46,7 +46,7 @@ module.exports = [ "endpointId": "scene1", "cookie": { "propertyMap": JSON.stringify({ - "SceneController": {"scene": {"parameters": {}, "itemName": "scene1"}} + "SceneController": {"scene": {"parameters": {}, "item": {"name": "scene1"}}} }) } } diff --git a/test/v3/test_controllerSpeaker.js b/test/v3/test_controllerSpeaker.js index af2abe67..6fc27b55 100644 --- a/test/v3/test_controllerSpeaker.js +++ b/test/v3/test_controllerSpeaker.js @@ -10,7 +10,7 @@ module.exports = [ "endpointId": "gSpeaker", "cookie": { "propertyMap": JSON.stringify({ - "Speaker": {"volume": {"parameters": {}, "itemName": "speakerVolume"}} + "Speaker": {"volume": {"parameters": {}, "item": {"name": "speakerVolume"}}} }) } }, @@ -53,7 +53,7 @@ module.exports = [ "endpointId": "gSpeaker", "cookie": { "propertyMap": JSON.stringify({ - "Speaker": {"volume": {"parameters": {"increment": 5}, "itemName": "speakerVolume"}} + "Speaker": {"volume": {"parameters": {"increment": 5}, "item": {"name": "speakerVolume"}}} }) } }, @@ -101,7 +101,7 @@ module.exports = [ "endpointId": "gSpeaker", "cookie": { "propertyMap": JSON.stringify({ - "Speaker": {"muted": {"parameters": {}, "itemName": "speakerMute"}} + "Speaker": {"muted": {"parameters": {}, "item": {"name": "speakerMute"}}} }) } }, diff --git a/test/v3/test_controllerStepSpeaker.js b/test/v3/test_controllerStepSpeaker.js index 7cefc842..c7a31398 100644 --- a/test/v3/test_controllerStepSpeaker.js +++ b/test/v3/test_controllerStepSpeaker.js @@ -10,7 +10,7 @@ module.exports = [ "endpointId": "gStepSpeaker", "cookie": { "propertyMap": JSON.stringify({ - "StepSpeaker": {"volume": {"parameters": {}, "itemName": "stepSpeakerVolume"}} + "StepSpeaker": {"volume": {"parameters": {}, "item": {"name": "stepSpeakerVolume"}}} }) } }, @@ -53,7 +53,7 @@ module.exports = [ "endpointId": "gStepSpeaker", "cookie": { "propertyMap": JSON.stringify({ - "StepSpeaker": {"muted": {"parameters": {}, "itemName": "stepSpeakerMute"}} + "StepSpeaker": {"muted": {"parameters": {}, "item": {"name": "stepSpeakerMute"}}} }) } }, diff --git a/test/v3/test_controllerThermostatMode.js b/test/v3/test_controllerThermostatMode.js index 808f7e26..c05734a7 100644 --- a/test/v3/test_controllerThermostatMode.js +++ b/test/v3/test_controllerThermostatMode.js @@ -12,7 +12,7 @@ module.exports = [ "propertyMap": JSON.stringify({ "ThermostatController": { "thermostatMode": { - "parameters": {"OFF": "0", "HEAT": "1", "COOL": "2","AUTO":"3"}, "itemName": "thermostatMode" + "parameters": {"OFF": "0", "HEAT": "1", "COOL": "2", "AUTO":"3"}, "item": {"name": "thermostatMode"} } } }) @@ -61,7 +61,7 @@ module.exports = [ "propertyMap": JSON.stringify({ "ThermostatController": { "thermostatMode": { - "parameters": {"OFF": 0, "HEAT": 1, "COOL": 2, "AUTO":3}, "itemName": "thermostatMode" + "parameters": {"OFF": 0, "HEAT": 1, "COOL": 2, "AUTO": 3}, "item": {"name": "thermostatMode"} } } }) @@ -109,7 +109,7 @@ module.exports = [ "cookie": { "propertyMap": JSON.stringify({ "ThermostatController": { - "thermostatMode": {"parameters": {"binding": "nest"}, "itemName": "thermostatMode"} + "thermostatMode": {"parameters": {"binding": "nest"}, "item": {"name": "thermostatMode"}} } }) } @@ -121,7 +121,7 @@ module.exports = [ } }, mocked: { - openhab: {"name": "thermostatMode", "state": "heat-cool", "type": "String"} + openhab: {"name": "thermostatMode", "state": "HEAT_COOL", "type": "String"} }, expected: { alexa: { @@ -140,7 +140,7 @@ module.exports = [ } }, openhab: [ - {"name": "thermostatMode", "value": "heat-cool"} + {"name": "thermostatMode", "value": "HEAT_COOL"} ] } }, @@ -157,7 +157,7 @@ module.exports = [ "propertyMap": JSON.stringify({ "ThermostatController": { "thermostatMode": { - "parameters": {"OFF": "0", "HEAT": "1", "COOL": "2","AUTO":"3"}, "itemName": "thermostatMode" + "parameters": {"OFF": "0", "HEAT": "1", "COOL": "2", "AUTO":"3"}, "item": {"name": "thermostatMode"} } } }) diff --git a/test/v3/test_controllerThermostatTemperature.js b/test/v3/test_controllerThermostatTemperature.js index e7f692e3..8208be42 100644 --- a/test/v3/test_controllerThermostatTemperature.js +++ b/test/v3/test_controllerThermostatTemperature.js @@ -11,9 +11,9 @@ module.exports = [ "cookie": { "propertyMap": JSON.stringify({ "ThermostatController": { - "targetSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "itemName": "targetTemperature"}, - "upperSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "itemName": "highTargetTemperature"}, - "lowerSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "itemName": "lowTargetTemperature"} + "targetSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "item": {"name": "targetTemperature"}}, + "upperSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "item": {"name": "highTargetTemperature"}}, + "lowerSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "item": {"name": "lowTargetTemperature"}} } }) } @@ -33,7 +33,14 @@ module.exports = [ } } }, - mocked: {}, + mocked: { + openhab: [ + {"name": "targetTemperature", "state": "73", "type": "Number"}, + {"name": "highTargetTemperature", "state": "78", "type": "Number"}, + {"name": "lowTargetTemperature", "state": "68", "type": "Number"} + ], + staged: true + }, expected: { alexa: { "context": { @@ -90,8 +97,8 @@ module.exports = [ "cookie": { "propertyMap": JSON.stringify({ "ThermostatController": { - "upperSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "itemName": "highTargetTemperature"}, - "lowerSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "itemName": "lowTargetTemperature"} + "upperSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "item": {"name": "highTargetTemperature"}}, + "lowerSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "item": {"name": "lowTargetTemperature"}} } }) } @@ -103,7 +110,13 @@ module.exports = [ } } }, - mocked: {}, + mocked: { + openhab: [ + {"name": "highTargetTemperature", "state": "74", "type": "Number"}, + {"name": "lowTargetTemperature", "state": "72", "type": "Number"} + ], + staged: true + }, expected: { alexa: { "context": { @@ -151,9 +164,8 @@ module.exports = [ "cookie": { "propertyMap": JSON.stringify({ "ThermostatController": { - "upperSetpoint": {"parameters": {"scale": "FAHRENHEIT", "comfort_range": 5}, "itemName": "highTargetTemperature"}, - "lowerSetpoint": {"parameters": {"scale": "FAHRENHEIT", "comfort_range": 5}, "itemName": "lowTargetTemperature"}, - "COMFORT_MODE" : 5 + "upperSetpoint": {"parameters": {"scale": "FAHRENHEIT", "comfort_range": 5}, "item": {"name": "highTargetTemperature"}}, + "lowerSetpoint": {"parameters": {"scale": "FAHRENHEIT", "comfort_range": 5}, "item": {"name": "lowTargetTemperature"}}, } }) } @@ -165,7 +177,13 @@ module.exports = [ } } }, - mocked: {}, + mocked: { + openhab: [ + {"name": "highTargetTemperature", "state": "78", "type": "Number"}, + {"name": "lowTargetTemperature", "state": "68", "type": "Number"} + ], + staged: true + }, expected: { alexa: { "context": { @@ -213,7 +231,7 @@ module.exports = [ "cookie": { "propertyMap": JSON.stringify({ "ThermostatController": { - "targetSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "itemName": "targetTemperature"} + "targetSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "item": {"name": "targetTemperature"}} } }) } @@ -270,8 +288,8 @@ module.exports = [ "cookie": { "propertyMap": JSON.stringify({ "ThermostatController": { - "upperSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "itemName": "highTargetTemperature"}, - "lowerSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "itemName": "lowTargetTemperature"} + "upperSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "item": {"name": "highTargetTemperature"}}, + "lowerSetpoint": {"parameters": {"scale": "FAHRENHEIT"}, "item": {"name": "lowTargetTemperature"}} } }) } @@ -285,8 +303,11 @@ module.exports = [ }, mocked: { openhab: [ + {"name": "highTargetTemperature", "state": "75", "type": "Number"}, {"name": "lowTargetTemperature", "state": "73", "type": "Number"}, - {"name": "highTargetTemperature", "state": "75", "type": "Number"} + {"name": "highTargetTemperature", "state": "77", "type": "Number"}, + {"name": "lowTargetTemperature", "state": "75", "type": "Number"} + ], staged: true }, @@ -318,7 +339,11 @@ module.exports = [ "name": "Response" } } - } + }, + openhab: [ + {"name": "highTargetTemperature", "value": 77}, + {"name": "lowTargetTemperature", "value": 75} + ] } } ]; diff --git a/test/v3/test_discoverThermostat.js b/test/v3/test_discoverThermostat.js index 909202a7..4649389d 100644 --- a/test/v3/test_discoverThermostat.js +++ b/test/v3/test_discoverThermostat.js @@ -229,7 +229,7 @@ module.exports = { }, { "link": "https://myopenhab.org/rest/items/temperature1", - "type": "Group", + "type": "Number", "name": "temperature1", "label": "Temperature 1", "tags": [], @@ -258,13 +258,18 @@ module.exports = { "friendlyName": "Thermostat 1", "propertyMap": { "TemperatureSensor": { - "temperature": {"parameters": {"scale": "Fahrenheit"}, "itemName": "currentTemperature1"} + "temperature": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "currentTemperature1", "type": "Number"}} }, "ThermostatController": { - "targetSetpoint": {"parameters": {"scale": "Fahrenheit"}, "itemName": "targetTemperature1"}, - "upperSetpoint": {"parameters": {"scale": "Fahrenheit"}, "itemName": "highTargetTemperature1"}, - "lowerSetpoint": {"parameters": {"scale": "Fahrenheit"}, "itemName": "lowTargetTemperature1"}, - "thermostatMode": { "parameters": {"binding": "default"}, "itemName": "thermostatMode1" } + "targetSetpoint": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "targetTemperature1", "type": "Number"}}, + "upperSetpoint": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "highTargetTemperature1", "type": "Number"}}, + "lowerSetpoint": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "lowTargetTemperature1", "type": "Number"}}, + "thermostatMode": { + "parameters": {"binding": "default"}, "item": {"name": "thermostatMode1", "type": "String"}} } } }, @@ -281,13 +286,18 @@ module.exports = { "friendlyName": "Thermostat 2", "propertyMap": { "TemperatureSensor": { - "temperature": {"parameters": {"scale": "Fahrenheit"}, "itemName": "currentTemperature2"} + "temperature": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "currentTemperature2", "type": "Number"}} }, "ThermostatController": { - "targetSetpoint": {"parameters": {"scale": "Fahrenheit"}, "itemName": "targetTemperature2"}, - "upperSetpoint": {"parameters": {"scale": "Fahrenheit"}, "itemName": "highTargetTemperature2"}, - "lowerSetpoint": {"parameters": {"scale": "Fahrenheit"}, "itemName": "lowTargetTemperature2"}, - "thermostatMode": { "parameters": {"binding": "foobar"}, "itemName": "thermostatMode2" } + "targetSetpoint": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "targetTemperature2", "type": "Number"}}, + "upperSetpoint": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "highTargetTemperature2", "type": "Number"}}, + "lowerSetpoint": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "lowTargetTemperature2", "type": "Number"}}, + "thermostatMode": { + "parameters": {"binding": "foobar"}, "item": {"name": "thermostatMode2", "type": "String"}} } } }, @@ -304,13 +314,18 @@ module.exports = { "friendlyName": "Thermostat 3", "propertyMap": { "TemperatureSensor": { - "temperature": {"parameters": {"scale": "Fahrenheit"}, "itemName": "currentTemperature3"} + "temperature": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "currentTemperature3", "type": "Number"}} }, "ThermostatController": { - "targetSetpoint": {"parameters": {"scale": "Fahrenheit"}, "itemName": "targetTemperature3"}, - "upperSetpoint": {"parameters": {"scale": "Fahrenheit"}, "itemName": "highTargetTemperature3"}, - "lowerSetpoint": {"parameters": {"scale": "Fahrenheit"}, "itemName": "lowTargetTemperature3"}, - "thermostatMode": { "parameters": {"binding": "foobar", "supportedModes": "AUTO,OFF"}, "itemName": "thermostatMode3" } + "targetSetpoint": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "targetTemperature3", "type": "Number"}}, + "upperSetpoint": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "highTargetTemperature3", "type": "Number"}}, + "lowerSetpoint": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "lowTargetTemperature3", "type": "Number"}}, + "thermostatMode": { + "parameters": {"binding": "foobar", "supportedModes": "AUTO,OFF"}, "item": {"name": "thermostatMode3", "type": "String"}} } } }, @@ -323,7 +338,8 @@ module.exports = { "friendlyName": "Temperature 1", "propertyMap": { "TemperatureSensor": { - "temperature": {"parameters": {"scale": "Fahrenheit"}, "itemName": "temperature1"} + "temperature": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "temperature1", "type": "Number"}} } } } diff --git a/utils.js b/utils.js index a1c48e96..1d5c8558 100644 --- a/utils.js +++ b/utils.js @@ -1,5 +1,5 @@ /** - * Copyright (c) 2014-2016 by the respective copyright holders. + * Copyright (c) 2014-2019 by the respective copyright holders. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 @@ -7,40 +7,63 @@ * http://www.eclipse.org/legal/epl-v10.html */ - /** - * Define alexa capability namespace format pattern - **/ - var CAPABILITY_PATTERN = /^(?:Alexa\.)?(\w+)\.(\w+)$/; - - /** +/** * Define alexa supported display categories - **/ - var DISPLAY_CATEGORIES = [ - 'ACTIVITY_TRIGGER', 'CAMERA', 'DOOR', 'LIGHT', 'MICROWAVE', 'OTHER', 'SCENE_TRIGGER', - 'SMARTLOCK', 'SMARTPLUG', 'SPEAKER', 'SWITCH', 'TEMPERATURE_SENSOR', 'THERMOSTAT', 'TV' - ]; + */ +var DISPLAY_CATEGORIES = [ + 'ACTIVITY_TRIGGER', 'CAMERA', 'DOOR', 'LIGHT', 'MICROWAVE', 'OTHER', 'SCENE_TRIGGER', + 'SMARTLOCK', 'SMARTPLUG', 'SPEAKER', 'SWITCH', 'TEMPERATURE_SENSOR', 'THERMOSTAT', 'TV' +]; + +/** + * Define openHAB item type supported capabilities + */ +var ITEM_TYPE_CAPABILITIES = { + 'BrightnessController': {'brightness': ['Color', 'Dimmer']}, + 'ChannelController': {'channel': ['Number', 'String']}, + 'ColorController': {'color': ['Color']}, + 'ColorTemperatureController': {'colorTemperatureInKelvin': ['Dimmer', 'Number']}, + 'InputController': {'input': ['Number', 'String']}, + 'LockController': {'lockState': ['Switch']}, + 'PercentageController': {'percentage': ['Dimmer', 'Rollershutter']}, + 'PlaybackController': {'playback': ['Player']}, + 'PowerController': {'powerState': ['Color', 'Dimmer', 'Rollershutter', 'Switch']}, + 'PowerLevelController': {'powerLevel': ['Dimmer']}, + 'SceneController': {'scene': ['Switch']}, + 'Speaker': {'volume': ['Dimmer', 'Number'], 'muted': ['Switch']}, + 'StepSpeaker': {'volume': ['Dimmer', 'Number'], 'muted': ['Switch']}, + 'TemperatureSensor': {'temperature': ['Number', 'Number:Temperature']}, + 'ThermostatController': {'targetSetpoint': ['Number', 'Number:Temperature'], + 'lowerSetpoint': ['Number', 'Number:Temperature'], + 'upperSetpoint': ['Number', 'Number:Temperature'], + 'thermostatMode': ['Number', 'String']}, +}; /** -* Define alexa thermostat mode mapping based on binding used in OH -**/ + * Define alexa thermostat mode mapping based on binding used in OH + */ var THERMOSTAT_MODE_MAPPING = { - ecobee: {AUTO: 'auto', COOL: 'cool', HEAT: 'heat', OFF: 'off'}, - nest: {AUTO: 'heat-cool', COOL: 'cool', HEAT: 'heat', ECO: 'eco', OFF: 'off'}, - zwave: {AUTO: '3', COOL: '2', HEAT: '1', OFF: '0'}, + ecobee1: {AUTO: 'auto', COOL: 'cool', HEAT: 'heat', OFF: 'off'}, + nest: {AUTO: 'HEAT_COOL', COOL: 'COOL', HEAT: 'HEAT', ECO: 'ECO', OFF: 'OFF'}, + nest1: {AUTO: 'heat-cool', COOL: 'cool', HEAT: 'heat', ECO: 'eco', OFF: 'off'}, + zwave1: {AUTO: '3', COOL: '2', HEAT: '1', OFF: '0'}, default: {AUTO: 'auto', COOL: 'cool', HEAT: 'heat', ECO: 'eco', OFF: 'off'} }; /** -* Normilizes thermostat modes based on binding name -* Alexa: AUTO, COOL, HEAT, ECO, OFF -* OH: depending on thermostat binding or user mappings defined -**/ + * Normilizes thermostat modes based on binding name + * Alexa: AUTO, COOL, HEAT, ECO, OFF + * OH: depending on thermostat binding or user mappings defined + * + * @param {String} mode + * @param {Object} parameters + * @return {String} + */ function normalizeThermostatMode(mode, parameters = {}) { var alexaModes = Object.keys(THERMOSTAT_MODE_MAPPING.default); var bindingName = parameters.binding ? parameters.binding.toLowerCase() : 'default'; var userMap = Object.keys(parameters).reduce(function(map, param) { - if (alexaModes.includes(param)) map[param] = parameters[param]; - return map; + return Object.assign(map, alexaModes.includes(param) ? {[param]: parameters[param]} : {}); }, {}); var thermostatModeMap = Object.keys(userMap).length > 0 ? userMap : THERMOSTAT_MODE_MAPPING[bindingName]; @@ -51,20 +74,59 @@ function normalizeThermostatMode(mode, parameters = {}) { // Convert OH to Alexa else { return Object.keys(thermostatModeMap).reduce(function(result, alexaMode) { - if (typeof(thermostatModeMap[alexaMode]) !== 'undefined' && thermostatModeMap[alexaMode].toString() === mode.toString()) result = alexaMode; + if (typeof(thermostatModeMap[alexaMode]) !== 'undefined' && + thermostatModeMap[alexaMode].toString() === mode.toString()) result = alexaMode; return result; }, mode); } } /** -* Normilizes color temperature value based on item type -* Alexa colorTemperature api property spectrum from 1000K (warmer) to 10000K (colder) -* -* Two item types: -* - Dimmer: colder (0%) to warmer (100%) based of Alexa color temperature spectrum [hue and lifx support] -* - Number: color temperature value in K [custom integration] -**/ + * Normilizes lock property state when using an item sensor (Contact, Number, Switch or String Item) + * User mapping e.g. [1=LOCKED,2=UNLOCKED,3=LOCKED,4=UNLOCKED,11=JAMMED] (Zwave) + * + * @param {String} state + * @param {String} type + * @param {Object} parameters + * @return {String} + */ +function normalizeLockState(state, type, parameters = {}) { + var alexaStates = ['LOCKED', 'UNLOCKED', 'JAMMED']; + var userMap = Object.keys(parameters).reduce(function(map, param) { + return Object.assign(map, alexaStates.includes(parameters[param]) ? {[param]: parameters[param]} : {}); + }, {}); + + // Convert OH to Alexa using user map, if defined, otherwise fallback to item type default + if (userMap[state]) { + return userMap[state]; + } else { + switch(type) { + case 'Contact': + return state === 'CLOSED' ? 'LOCKED' : state === 'OPEN' ? 'UNLOCKED' : undefined; + case 'Number': + return parseInt(state) === 1 ? 'LOCKED' : parseInt(state) === 2 ? 'UNLOCKED' : + parseInt(state) === 3 ? 'JAMMED' : undefined; + case 'String': + return state.toLowerCase() === 'locked' ? 'LOCKED' : state.toLowerCase() === 'unlocked' ? 'UNLOCKED' : + state.toLowerCase() === 'jammed' ? 'JAMMED' : undefined; + case 'Switch': + return state === 'ON' ? 'LOCKED' : state === 'OFF' ? 'UNLOCKED' : undefined; + } + } +} + +/** + * Normilizes color temperature value based on item type + * Alexa colorTemperature api property spectrum from 1000K (warmer) to 10000K (colder) + * + * Two item types: + * - Dimmer: colder (0%) to warmer (100%) based of Alexa color temperature spectrum [hue and lifx support] + * - Number: color temperature value in K [custom integration] + * + * @param {String} value + * @param {String} type + * @return {Integer} + */ function normalizeColorTemperature(value, type) { // Return if value not numeric if (isNaN(value)) { @@ -87,115 +149,64 @@ function normalizeColorTemperature(value, type) { } /** -* Returns date in iso string format -*/ + * Determines if display category is supported by alexa api + * @param {String} category + * @return {Boolean} + */ +function supportedDisplayCategory(category) { + return DISPLAY_CATEGORIES.includes(category.toUpperCase()); +} + +/** + * Determines if openHAB item type is supported by alexa capability + * @param {String} type + * @param {String} controller + * @param {String} property + * @return {Boolean} + */ +function supportedItemTypeCapability(type, controller, property) { + if (ITEM_TYPE_CAPABILITIES[controller] && ITEM_TYPE_CAPABILITIES[controller][property]) { + return ITEM_TYPE_CAPABILITIES[controller][property].includes(type); + } +} + +/** + * Returns date in iso string format + * @return {String} + */ function date() { - var d = new Date(); - return d.toISOString(); + var date = new Date(); + return date.toISOString(); } /** -* Determines if display category is supported by alexa api -*/ -function supportedDisplayCategory(category) { - return DISPLAY_CATEGORIES.includes(category.toUpperCase()); + * Returns time epoch seconds + * @return {Integer} + */ +function timeInSeconds() { + var time = new Date().getTime(); + return Math.round(time / 1000); } /** - * Creates/Modifies a map structure to assoicate items to an endpoint from metadata, will return a new map - * if propertyMap is omitted or null, otherwise will modify the existing map (and return it as well) - * eg: - * - * OH Metadata - * - * Number FooTargetSetPoint "Foo Target SetPoint" {alexa="ThermostatController.targetSetpoint" [scale="Fahrenheit"]} - * Number FooUpperSetPoint "Foo Upper SetPoint" {alexa="ThermostatController.upperSetpoint" [scale="Fahrenheit"]} - * Number FooLowerSetPoint "Foo Lower SetPoint" {alexa="ThermostatController.lowerSetpoint" [scale="Fahrenheit"]} - * String FooMode "Foo Mode" {alexa="ThermostatController.thermostatMode" [OFF=0,HEAT=1,COOL=2,AUTO=3]} - * Switch FooSwitch "FooSwitch" {alexa="PowerController.powerState"} - * - * returns - * - * propertyMap: - * { - * ThermostatController: { - * targetSetpoint: { - * itemName: "FooTargetSetPoint", - * parameters: { - * scale: "Fahrenheit", - * } - * }, - * upperSetpoint: { - * itemName: "FooTargetSetPoint", - * parameters: { - * scale: "Fahrenheit", - * } - * }, - * lowerSetpoint: { - * itemName: "FooTargetSetPoint", - * parameters: { - * scale: "Fahrenheit", - * } - * }, - * thermostatMode: { - * itemName: "FooMode", - * parameters: { - * OFF: 0, - * HEAT: 1, - * COOL: 2, - * AUTO: 3 - * } - * } - * }, - * PowerController: { - * powerState: { - * itemName: "FooSwitch" - * } - * } - * @param {object} item - * @param {object} propertyMap + * Returns JSON object if parseable otherwise text + * @param {String} text + * @return {Object} */ -function metadataToPropertyMap(item, propertyMap = {}) { - item.metadata.alexa.value.split(',').forEach(function(capability) { - var matches; - if (matches = capability.match(CAPABILITY_PATTERN)) { - var interfaceName = matches[1]; - var propertyName = matches[2]; - var properties = propertyMap[interfaceName] || {}; - var config = item.metadata.alexa.config || {}; - var categories = properties.categories || []; - - // Extract category from metadata config and store remaining parameters - var parameters = Object.keys(config).reduce(function(parameters, key) { - if (key === 'category') { - var category = config.category.toUpperCase(); - if (!categories.includes(category) && supportedDisplayCategory(category)) { - categories.push(category); - } - } else { - parameters[key] = config[key]; - } - return parameters; - }, {}); - - // Add property to map object - propertyMap[interfaceName] = Object.assign(properties, { - [propertyName]: { - parameters: parameters, - itemName: item.name - } - }); - // Update interface categories if not empty - if (categories.length) { - propertyMap[interfaceName].categories = categories; - } - } - }); - return propertyMap; +function parseJSON(text) { + try { + return JSON.parse(text); + } catch (e) { + return text; + } } module.exports.date = date; -module.exports.metadataToPropertyMap = metadataToPropertyMap; +module.exports.parseJSON = parseJSON; +module.exports.timeInSeconds = timeInSeconds; + module.exports.normalizeColorTemperature = normalizeColorTemperature; +module.exports.normalizeLockState = normalizeLockState; module.exports.normalizeThermostatMode = normalizeThermostatMode; module.exports.supportedDisplayCategory = supportedDisplayCategory; +module.exports.supportedItemTypeCapability = supportedItemTypeCapability; From 83478faeabf7032b64227758333abab118b9707c Mon Sep 17 00:00:00 2001 From: jsetton Date: Sat, 16 Feb 2019 19:27:37 -0500 Subject: [PATCH 2/2] Thermostat mode property not defined capabilities prototype bug fix --- alexaCapabilities.js | 8 +- test/v3/test_discoverThermostat.js | 117 +++++++++++------------------ 2 files changed, 48 insertions(+), 77 deletions(-) diff --git a/alexaCapabilities.js b/alexaCapabilities.js index c40b0e29..80d35edc 100644 --- a/alexaCapabilities.js +++ b/alexaCapabilities.js @@ -96,6 +96,7 @@ AlexaCapabilities.prototype.percentageController = function () { }; AlexaCapabilities.prototype.thermostatController = function (targetSetpoint, upperSetpoint, lowerSetpoint, thermostatMode) { + var configuration = {}; var supported = []; if (targetSetpoint) { supported.push({ @@ -113,14 +114,13 @@ AlexaCapabilities.prototype.thermostatController = function (targetSetpoint, upp }); } if (thermostatMode) { + if (typeof thermostatMode.parameters.supportedModes === 'string') { + configuration.supportedModes = thermostatMode.parameters.supportedModes.split(',').map(mode => mode.trim()); + } supported.push({ "name": "thermostatMode" }); } - var configuration = {}; - if (typeof thermostatMode.parameters.supportedModes === 'string') { - configuration.supportedModes = thermostatMode.parameters.supportedModes.split(',').map(mode => mode.trim()); - } return { capabilities: { "type": "AlexaInterface", diff --git a/test/v3/test_discoverThermostat.js b/test/v3/test_discoverThermostat.js index 4649389d..275681e1 100644 --- a/test/v3/test_discoverThermostat.js +++ b/test/v3/test_discoverThermostat.js @@ -139,24 +139,40 @@ module.exports = { { "members": [ { - "link": "https://myopenhab.org/rest/items/currentTemperature3", - "type": "Number", - "name": "currentTemperature3", + "link": "https://myopenhab.org/rest/items/thermostatMode3", + "type": "String", + "name": "thermostatMode3", "tags": [], "metadata": { "alexa": { - "value": "TemperatureSensor.temperature", + "value": "ThermostatController.thermostatMode", "config": { - "scale": "Fahrenheit" + "binding": "foobar", + "supportedModes" : "AUTO,OFF" } } }, "groupNames": ["gThermostat3"] - }, + } + ], + "link": "https://myopenhab.org/rest/items/gThermostat3", + "type": "Group", + "name": "gThermostat3", + "label": "Thermostat 3", + "tags": [], + "metadata": { + "alexa": { + "value": "Endpoint.Thermostat" + } + }, + "groupNames": [] + }, + { + "members": [ { - "link": "https://myopenhab.org/rest/items/targetTemperature3", + "link": "https://myopenhab.org/rest/items/targetTemperature4", "type": "Number", - "name": "targetTemperature3", + "name": "targetTemperature4", "tags": [], "metadata": { "alexa": { @@ -166,59 +182,13 @@ module.exports = { } } }, - "groupNames": ["gThermostat3"] - }, - { - "link": "https://myopenhab.org/rest/items/highTargetTemperature3", - "type": "Number", - "name": "highTargetTemperature3", - "tags": [], - "metadata": { - "alexa": { - "value": "ThermostatController.upperSetpoint", - "config": { - "scale": "Fahrenheit" - } - } - }, - "groupNames": ["gThermostat3"] - }, - { - "link": "https://myopenhab.org/rest/items/lowTargetTemperature3", - "type": "Number", - "name": "lowTargetTemperature3", - "tags": [], - "metadata": { - "alexa": { - "value": "ThermostatController.lowerSetpoint", - "config": { - "scale": "Fahrenheit" - } - } - }, - "groupNames": ["gThermostat3"] + "groupNames": ["gThermostat4"] }, - { - "link": "https://myopenhab.org/rest/items/thermostatMode3", - "type": "String", - "name": "thermostatMode3", - "tags": [], - "metadata": { - "alexa": { - "value": "ThermostatController.thermostatMode", - "config": { - "binding": "foobar", - "supportedModes" : "AUTO,OFF" - } - } - }, - "groupNames": ["gThermostat3"] - } ], - "link": "https://myopenhab.org/rest/items/gThermostat3", + "link": "https://myopenhab.org/rest/items/gThermostat4", "type": "Group", - "name": "gThermostat3", - "label": "Thermostat 3", + "name": "gThermostat4", + "label": "Thermostat 4", "tags": [], "metadata": { "alexa": { @@ -304,28 +274,29 @@ module.exports = { "gThermostat3": { "capabilities": [ "Alexa", - "Alexa.TemperatureSensor.temperature", - "Alexa.ThermostatController.targetSetpoint", - "Alexa.ThermostatController.upperSetpoint", - "Alexa.ThermostatController.lowerSetpoint", "Alexa.ThermostatController.thermostatMode" ], "displayCategories": ["THERMOSTAT"], "friendlyName": "Thermostat 3", "propertyMap": { - "TemperatureSensor": { - "temperature": { - "parameters": {"scale": "Fahrenheit"}, "item": {"name": "currentTemperature3", "type": "Number"}} - }, "ThermostatController": { - "targetSetpoint": { - "parameters": {"scale": "Fahrenheit"}, "item": {"name": "targetTemperature3", "type": "Number"}}, - "upperSetpoint": { - "parameters": {"scale": "Fahrenheit"}, "item": {"name": "highTargetTemperature3", "type": "Number"}}, - "lowerSetpoint": { - "parameters": {"scale": "Fahrenheit"}, "item": {"name": "lowTargetTemperature3", "type": "Number"}}, "thermostatMode": { - "parameters": {"binding": "foobar", "supportedModes": "AUTO,OFF"}, "item": {"name": "thermostatMode3", "type": "String"}} + "parameters": {"binding": "foobar", "supportedModes": "AUTO,OFF"}, + "item": {"name": "thermostatMode3", "type": "String"}} + } + } + }, + "gThermostat4": { + "capabilities": [ + "Alexa", + "Alexa.ThermostatController.targetSetpoint" + ], + "displayCategories": ["THERMOSTAT"], + "friendlyName": "Thermostat 4", + "propertyMap": { + "ThermostatController": { + "targetSetpoint": { + "parameters": {"scale": "Fahrenheit"}, "item": {"name": "targetTemperature4", "type": "Number"}} } } },