diff --git a/front/package-lock.json b/front/package-lock.json
index e20278781b..87affcee8f 100644
--- a/front/package-lock.json
+++ b/front/package-lock.json
@@ -2995,9 +2995,9 @@
"dev": true
},
"@gladysassistant/gladys-gateway-js": {
- "version": "3.9.0",
- "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-3.9.0.tgz",
- "integrity": "sha512-zAIrc48rAS32Yqf1jkaVGZaXWy5EpUzDP/dPRusoOyIYJ9j2Wm1nwlotqq/m9nf4uINqzfLhiYJ5PH3cTPF3kg==",
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.2.0.tgz",
+ "integrity": "sha512-ATkDhUnFzNpE39o/CqTvR8TzfxSn52AxiJAXXCvDJS6cp1DBQ8G1wqCjKxrV2Ef+rnYMV4Ir+YIohimnH46oJg==",
"requires": {
"@ctrlpanel/pbkdf2": "^1.0.0",
"array-buffer-to-hex": "^1.0.0",
@@ -4338,7 +4338,7 @@
"after": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz",
- "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8="
+ "integrity": "sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA=="
},
"aggregate-error": {
"version": "3.1.0",
@@ -5902,7 +5902,7 @@
"backo2": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
- "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc="
+ "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA=="
},
"balanced-match": {
"version": "1.0.0",
@@ -5974,7 +5974,7 @@
"base64-arraybuffer": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz",
- "integrity": "sha1-mBjHngWbE1X5fgQooBfIOOkLqBI="
+ "integrity": "sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg=="
},
"base64-js": {
"version": "1.3.1",
diff --git a/front/package.json b/front/package.json
index f1a04161a1..1c4c3a12cf 100644
--- a/front/package.json
+++ b/front/package.json
@@ -43,7 +43,7 @@
"prettier": "^1.17.1"
},
"dependencies": {
- "@gladysassistant/gladys-gateway-js": "^3.9.0",
+ "@gladysassistant/gladys-gateway-js": "^4.2.0",
"@gladysassistant/theme-optimized": "^1.0.3",
"@jaames/iro": "^5.5.2",
"@yaireo/tagify": "^4.5.0",
diff --git a/front/src/components/app.jsx b/front/src/components/app.jsx
index 90b46113a3..0ab4944604 100644
--- a/front/src/components/app.jsx
+++ b/front/src/components/app.jsx
@@ -27,6 +27,7 @@ import GatewayForgotPassword from '../routes/gateway-forgot-password';
import GatewayResetPassword from '../routes/gateway-reset-password';
import GatewayConfirmEmail from '../routes/gateway-confirm-email';
import GoogleHomeGateway from '../routes/integration/all/google-home-gateway';
+import AlexaGateway from '../routes/integration/all/alexa-gateway';
import SignupWelcomePage from '../routes/signup/1-welcome';
import SignupCreateAccountLocal from '../routes/signup/2-create-account-local';
@@ -234,6 +235,7 @@ const AppRouter = connect(
+
diff --git a/front/src/components/header/index.jsx b/front/src/components/header/index.jsx
index 9426c17525..1a6d438f5d 100644
--- a/front/src/components/header/index.jsx
+++ b/front/src/components/header/index.jsx
@@ -19,7 +19,8 @@ const PAGES_WITHOUT_HEADER = [
'/subscribe-gateway',
'/gateway-configure-two-factor',
'/confirm-email',
- '/dashboard/integration/device/google-home/authorize'
+ '/dashboard/integration/device/google-home/authorize',
+ '/dashboard/integration/device/alexa/authorize'
];
const Header = ({ ...props }) => {
diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json
index e68729a140..93755029b8 100644
--- a/front/src/config/i18n/en.json
+++ b/front/src/config/i18n/en.json
@@ -542,6 +542,20 @@
"cancelButton": "Cancel",
"connectButton": "Link"
},
+ "alexa": {
+ "title": "Gladys Assistant",
+ "cardTitle": "Do you want to connect Gladys Assistant to Amazon Alexa?",
+ "description": "By signin in, you are authorizing Alexa to access your devices.",
+ "error": "An error occured. Please retry !",
+ "connectedAs": "Connected as",
+ "googleWillBeAble": "Alexa will be able:",
+ "seeDevices": "List all your devices",
+ "controlDevices": "Control all your devices",
+ "getNewDeviceValues": "Periodically refresh your device states",
+ "privacyPolicy": "Read Alexa's privacy policy.",
+ "cancelButton": "Cancel",
+ "connectButton": "Link"
+ },
"zwave": {
"title": "Z-Wave",
"description": "Control your Z-Wave devices.",
diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json
index 0044a21e78..230b664075 100644
--- a/front/src/config/i18n/fr.json
+++ b/front/src/config/i18n/fr.json
@@ -669,6 +669,20 @@
"cancelButton": "Annuler",
"connectButton": "Lier"
},
+ "alexa": {
+ "title": "Gladys Assistant",
+ "cardTitle": "Voulez-vous connecter Gladys Assistant à Amazon Alexa ?",
+ "description": "En vous connectant, vous authorizez Alexa à accéder à vos appareils.",
+ "error": "Une erreur est survenue. Merci de réessayer.",
+ "connectedAs": "Connecté en tant que",
+ "googleWillBeAble": "Amazon Alexa pourra :",
+ "seeDevices": "Lister les appareils présents chez vous",
+ "controlDevices": "Contrôler vos appareils",
+ "getNewDeviceValues": "Récupérer périodiquement les états de vos appareils",
+ "privacyPolicy": "Lire la politique de confidentialité d'Alexa.",
+ "cancelButton": "Annuler",
+ "connectButton": "Lier"
+ },
"zwave": {
"title": "Z-Wave",
"description": "Contrôlez vos appareils Z-Wave.",
diff --git a/front/src/routes/integration/all/alexa-gateway/Layout.jsx b/front/src/routes/integration/all/alexa-gateway/Layout.jsx
new file mode 100644
index 0000000000..844823c0d3
--- /dev/null
+++ b/front/src/routes/integration/all/alexa-gateway/Layout.jsx
@@ -0,0 +1,15 @@
+const Layout = ({ children }) => (
+
+);
+
+export default Layout;
diff --git a/front/src/routes/integration/all/alexa-gateway/index.js b/front/src/routes/integration/all/alexa-gateway/index.js
new file mode 100644
index 0000000000..7ecf81d5f7
--- /dev/null
+++ b/front/src/routes/integration/all/alexa-gateway/index.js
@@ -0,0 +1,135 @@
+import { Component } from 'preact';
+import { connect } from 'unistore/preact';
+import cx from 'classnames';
+import { Text, Localizer, MarkupText } from 'preact-i18n';
+import Layout from './Layout';
+import style from './style.css';
+
+class AlexaGateway extends Component {
+ cancel = async e => {
+ e.preventDefault();
+ await this.setState({ loading: true });
+ if (this.props.redirect_uri && this.props.state) {
+ const redirectUrl = `${this.props.redirect_uri}?state=${this.props.state}&error=cancelled`;
+ window.location.replace(redirectUrl);
+ } else {
+ this.setState({ loading: false, error: true });
+ }
+ };
+ link = async e => {
+ e.preventDefault();
+ try {
+ await this.setState({ loading: true, error: false });
+ const responseAuthorize = await this.props.session.gatewayClient.alexaAuthorize({
+ client_id: this.props.client_id,
+ redirect_uri: this.props.redirect_uri,
+ state: this.props.state
+ });
+ window.location.replace(responseAuthorize.redirectUrl);
+ } catch (e) {
+ await this.setState({ loading: false, error: true });
+ console.error(e);
+ if (this.props.redirect_uri && this.props.state) {
+ const redirectUrl = `${this.props.redirect_uri}?state=${this.props.state}&error=errored`;
+ window.location.replace(redirectUrl);
+ }
+ }
+ };
+
+ render(props, { loading, error }) {
+ return (
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default connect('user,session', {})(AlexaGateway);
diff --git a/front/src/routes/integration/all/alexa-gateway/style.css b/front/src/routes/integration/all/alexa-gateway/style.css
new file mode 100644
index 0000000000..2f2a9b6eec
--- /dev/null
+++ b/front/src/routes/integration/all/alexa-gateway/style.css
@@ -0,0 +1,3 @@
+.colWidth {
+ max-width: 35rem;
+}
diff --git a/server/lib/gateway/gateway.forwardDeviceStateToAlexa.js b/server/lib/gateway/gateway.forwardDeviceStateToAlexa.js
new file mode 100644
index 0000000000..3b37a9d965
--- /dev/null
+++ b/server/lib/gateway/gateway.forwardDeviceStateToAlexa.js
@@ -0,0 +1,117 @@
+const get = require('get-value');
+const uuid = require('uuid');
+
+const logger = require('../../utils/logger');
+const { EVENTS } = require('../../utils/constants');
+const { mappings, readValues } = require('../../services/alexa/lib/deviceMappings');
+const { syncDeviceConverter } = require('../../services/alexa/lib/syncDeviceConverter');
+
+// eslint-disable-next-line jsdoc/require-returns
+/**
+ * @description send a current state to google
+ * @param {Object} stateManager - The state manager.
+ * @param {Object} gladysGatewayClient - The gladysGatewayClient.
+ * @param {string} deviceFeatureSelector - The selector of the device feature to send.
+ * @example
+ * sendCurrentState(stateManager, 'light');
+ */
+async function sendCurrentState(stateManager, gladysGatewayClient, deviceFeatureSelector) {
+ logger.debug(`Gladys Gateway: Forwarding state to Alexa: ${deviceFeatureSelector}`);
+ try {
+ // if the event is a DEVICE.NEW_STATE event
+ const gladysFeature = stateManager.get('deviceFeature', deviceFeatureSelector);
+ const gladysDevice = stateManager.get('deviceById', gladysFeature.device_id);
+
+ const device = syncDeviceConverter(gladysDevice);
+
+ if (!device) {
+ logger.debug(`Gladys Gateway: Not forwarding state, device feature doesnt seems handled.`);
+ return;
+ }
+
+ const func = get(readValues, `${gladysFeature.category}.${gladysFeature.type}`);
+ const mapping = get(mappings, `${gladysFeature.category}.capabilities.${gladysFeature.type}`);
+
+ if (!func || !mapping) {
+ logger.debug(`Gladys Gateway: Not forwarding state, device feature doesnt seems handled.`);
+ return;
+ }
+
+ const now = new Date().toISOString();
+
+ const properties = [
+ {
+ namespace: mapping.interface,
+ name: get(mapping, 'properties.supported.0.name'),
+ value: func(gladysFeature.last_value),
+ timeOfSample: now,
+ uncertaintyInMilliseconds: 0,
+ },
+ ];
+
+ const payload = {
+ event: {
+ header: {
+ namespace: 'Alexa',
+ name: 'ChangeReport',
+ messageId: uuid.v4(),
+ payloadVersion: '3',
+ },
+ endpoint: {
+ endpointId: gladysDevice.selector,
+ },
+ payload: {
+ change: {
+ cause: {
+ type: 'PHYSICAL_INTERACTION',
+ },
+ properties,
+ },
+ },
+ },
+ context: {
+ properties,
+ },
+ };
+
+ await gladysGatewayClient.alexaReportState(payload);
+ } catch (e) {
+ logger.warn(`Gladys Gateway: Unable to forward alexa reportState`);
+ logger.warn(e);
+ }
+}
+
+/**
+ * @description Forward websocket message to Gateway.
+ * @param {Object} event - Websocket event.
+ * @returns {Promise} - Resolve when finished.
+ * @example
+ * forwardWebsockets({
+ * type: ''
+ * payload: {}
+ * });
+ */
+async function forwardDeviceStateToAlexa(event) {
+ if (!this.connected) {
+ logger.debug('Gateway: not connected. Prevent forwarding device new state.');
+ return null;
+ }
+ if (!this.alexaConnected) {
+ logger.debug('Gateway: Alexa not connected. Prevent forwarding device new state.');
+ return null;
+ }
+ if (event.type === EVENTS.DEVICE.NEW_STATE && event.device_feature) {
+ if (this.forwardStateToAlexaTimeouts.has(event.device_feature)) {
+ clearTimeout(this.forwardStateToAlexaTimeouts.get(event.device_feature));
+ }
+ const newTimeout = setTimeout(() => {
+ sendCurrentState(this.stateManager, this.gladysGatewayClient, event.device_feature);
+ }, this.alexaForwardStateTimeout);
+ this.forwardStateToAlexaTimeouts.set(event.device_feature, newTimeout);
+ }
+ return null;
+}
+
+module.exports = {
+ forwardDeviceStateToAlexa,
+};
diff --git a/server/lib/gateway/gateway.handleAlexaMessage.js b/server/lib/gateway/gateway.handleAlexaMessage.js
new file mode 100644
index 0000000000..922b85aee3
--- /dev/null
+++ b/server/lib/gateway/gateway.handleAlexaMessage.js
@@ -0,0 +1,59 @@
+const get = require('get-value');
+const logger = require('../../utils/logger');
+const { SYSTEM_VARIABLE_NAMES } = require('../../utils/constants');
+
+/**
+ * @description Handle a new Gladys Alexa Gateway message.
+ * @param {Object} data - Gateway message.
+ * @param {Object} rawMessage - Message with metadata.
+ * @param {Function} cb - Callback.
+ * @returns {Promise} Resolve when finished.
+ * @example
+ * handleNewMessage({
+ * type: 'gladys-api-call',
+ * });
+ */
+async function handleAlexaMessage(data, rawMessage, cb) {
+ try {
+ const service = this.serviceManager.getService('alexa');
+ const body = {
+ ...data.data,
+ user: {
+ id: rawMessage.sender_id,
+ local_user_id: rawMessage.local_user_id,
+ },
+ };
+ // save that the user is connected to gateway alexa
+ if (!this.alexaConnected) {
+ await this.variable.setValue(
+ SYSTEM_VARIABLE_NAMES.GLADYS_GATEWAY_ALEXA_USER_IS_CONNECTED_WITH_GATEWAY,
+ rawMessage.sender_id,
+ );
+ this.alexaConnected = true;
+ }
+ const directiveNamespace = get(body, 'directive.header.namespace');
+ const directiveName = get(body, 'directive.header.name');
+ logger.debug(`gateway.handleAlexaMessage: New message : ${directiveNamespace}`);
+ let response;
+ if (directiveNamespace === 'Alexa.Discovery') {
+ response = service.alexaHandler.onDiscovery();
+ } else if (directiveNamespace === 'Alexa.PowerController') {
+ response = service.alexaHandler.onExecute(body);
+ } else if (directiveNamespace === 'Alexa' && directiveName === 'ReportState') {
+ response = service.alexaHandler.onReportState(body);
+ } else {
+ response = {
+ status: 400,
+ };
+ }
+
+ cb(response);
+ } catch (e) {
+ logger.error(e);
+ cb({ status: 400 });
+ }
+}
+
+module.exports = {
+ handleAlexaMessage,
+};
diff --git a/server/lib/gateway/gateway.handleNewMessage.js b/server/lib/gateway/gateway.handleNewMessage.js
index 99c75a370a..d10b12c75c 100644
--- a/server/lib/gateway/gateway.handleNewMessage.js
+++ b/server/lib/gateway/gateway.handleNewMessage.js
@@ -84,6 +84,11 @@ async function handleNewMessage(data, rawMessage, cb) {
if (data.type === 'gladys-open-api' && data.action === 'google-home-request') {
this.handleGoogleHomeMessage(data, rawMessage, cb);
}
+
+ // if the message is a Alexa request
+ if (data.type === 'gladys-open-api' && data.action === 'alexa-request') {
+ this.handleAlexaMessage(data, rawMessage, cb);
+ }
}
module.exports = {
diff --git a/server/lib/gateway/index.js b/server/lib/gateway/index.js
index 4d67b54624..5023816d39 100644
--- a/server/lib/gateway/index.js
+++ b/server/lib/gateway/index.js
@@ -11,9 +11,11 @@ const serverUrl = getConfig().gladysGatewayServerUrl;
const cryptoLib = new WebCrypto();
const { backup } = require('./gateway.backup');
+const { forwardDeviceStateToAlexa } = require('./gateway.forwardDeviceStateToAlexa');
const { forwardDeviceStateToGoogleHome } = require('./gateway.forwardDeviceStateToGoogleHome');
const { checkIfBackupNeeded } = require('./gateway.checkIfBackupNeeded');
const { handleGoogleHomeMessage } = require('./gateway.handleGoogleHomeMessage');
+const { handleAlexaMessage } = require('./gateway.handleAlexaMessage');
const { handleNewMessage } = require('./gateway.handleNewMessage');
const { login } = require('./gateway.login');
const { loginTwoFactor } = require('./gateway.loginTwoFactor');
@@ -46,8 +48,11 @@ const Gateway = function Gateway(variable, event, system, sequelize, config, use
this.restoreInProgress = false;
this.usersKeys = [];
this.googleHomeConnected = false;
+ this.alexaConnected = false;
this.forwardStateToGoogleHomeTimeouts = new Map();
+ this.forwardStateToAlexaTimeouts = new Map();
this.googleHomeForwardStateTimeout = 5 * 1000;
+ this.alexaForwardStateTimeout = 5 * 1000;
this.backupRandomInterval = 2 * 60 * 60 * 1000; // 2 hours
this.GladysGatewayClient = GladysGatewayClient;
this.gladysGatewayClient = new GladysGatewayClient({ cryptoLib, serverUrl, logger });
@@ -61,11 +66,14 @@ const Gateway = function Gateway(variable, event, system, sequelize, config, use
this.event.on(EVENTS.WEBSOCKET.SEND, eventFunctionWrapper(this.forwardWebsockets.bind(this)));
this.event.on(EVENTS.GATEWAY.USER_KEYS_CHANGED, eventFunctionWrapper(this.refreshUserKeys.bind(this)));
this.event.on(EVENTS.TRIGGERS.CHECK, eventFunctionWrapper(this.forwardDeviceStateToGoogleHome.bind(this)));
+ this.event.on(EVENTS.TRIGGERS.CHECK, eventFunctionWrapper(this.forwardDeviceStateToAlexa.bind(this)));
};
Gateway.prototype.backup = backup;
Gateway.prototype.checkIfBackupNeeded = checkIfBackupNeeded;
Gateway.prototype.handleGoogleHomeMessage = handleGoogleHomeMessage;
+Gateway.prototype.handleAlexaMessage = handleAlexaMessage;
+Gateway.prototype.forwardDeviceStateToAlexa = forwardDeviceStateToAlexa;
Gateway.prototype.forwardDeviceStateToGoogleHome = forwardDeviceStateToGoogleHome;
Gateway.prototype.handleNewMessage = handleNewMessage;
Gateway.prototype.login = login;
diff --git a/server/package-lock.json b/server/package-lock.json
index fd90475f8a..6ad5739493 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -870,9 +870,9 @@
}
},
"@gladysassistant/gladys-gateway-js": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.1.1.tgz",
- "integrity": "sha512-9cwkdp0/G/3vTpw/huTeCIdo5IxL6mslqT8jV82s/opJxGPFWcRx3Dt+GAUXpmHEsVYqIXCWCY8372ZaR32LCw==",
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.2.0.tgz",
+ "integrity": "sha512-ATkDhUnFzNpE39o/CqTvR8TzfxSn52AxiJAXXCvDJS6cp1DBQ8G1wqCjKxrV2Ef+rnYMV4Ir+YIohimnH46oJg==",
"requires": {
"@ctrlpanel/pbkdf2": "^1.0.0",
"array-buffer-to-hex": "^1.0.0",
@@ -2123,7 +2123,7 @@
"after": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz",
- "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8="
+ "integrity": "sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA=="
},
"agent-base": {
"version": "6.0.2",
@@ -2919,7 +2919,7 @@
"backo2": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
- "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc="
+ "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA=="
},
"balanced-match": {
"version": "1.0.0",
@@ -2929,7 +2929,7 @@
"base64-arraybuffer": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz",
- "integrity": "sha1-mBjHngWbE1X5fgQooBfIOOkLqBI="
+ "integrity": "sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg=="
},
"base64-js": {
"version": "1.5.1",
@@ -3758,9 +3758,9 @@
"dev": true
},
"csstype": {
- "version": "3.0.11",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz",
- "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw=="
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz",
+ "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA=="
},
"d": {
"version": "1.0.1",
diff --git a/server/package.json b/server/package.json
index af6de2dd2b..a4874dfc54 100644
--- a/server/package.json
+++ b/server/package.json
@@ -73,7 +73,7 @@
"supertest": "^3.4.2"
},
"dependencies": {
- "@gladysassistant/gladys-gateway-js": "^4.1.1",
+ "@gladysassistant/gladys-gateway-js": "^4.2.0",
"@hapi/joi": "^17.1.0",
"@hapi/joi-date": "^2.0.1",
"async-retry": "^1.3.3",
diff --git a/server/services/alexa/index.js b/server/services/alexa/index.js
new file mode 100644
index 0000000000..9ce854b332
--- /dev/null
+++ b/server/services/alexa/index.js
@@ -0,0 +1,32 @@
+const logger = require('../../utils/logger');
+const AlexaHandler = require('./lib');
+
+module.exports = function AlexaService(gladys, serviceId) {
+ const alexaHandler = new AlexaHandler(gladys, serviceId);
+
+ /**
+ * @public
+ * @description This function starts service
+ * @example
+ * gladys.services['alexa'].start();
+ */
+ async function start() {
+ logger.info('starting Alexa service');
+ }
+
+ /**
+ * @public
+ * @description This function stops the service
+ * @example
+ * gladys.services['alexa'].stop();
+ */
+ async function stop() {
+ logger.info('stopping Alexa service');
+ }
+
+ return Object.freeze({
+ start,
+ stop,
+ alexaHandler,
+ });
+};
diff --git a/server/services/alexa/lib/alexa.onDiscovery.js b/server/services/alexa/lib/alexa.onDiscovery.js
new file mode 100644
index 0000000000..5df60e9793
--- /dev/null
+++ b/server/services/alexa/lib/alexa.onDiscovery.js
@@ -0,0 +1,34 @@
+const uuid = require('uuid');
+const { syncDeviceConverter } = require('./syncDeviceConverter');
+
+/**
+ * @public
+ * @description Returns devices formatted the Amazon Alexa way.
+ * @example
+ * onDiscovery();
+ */
+function onDiscovery() {
+ const gladysDevices = Object.values(this.gladys.stateManager.state.device).map((store) => store.get());
+ // Convert it to Alexa devices
+ const endpoints = gladysDevices.map((d) => syncDeviceConverter(d)).filter((d) => d !== null);
+
+ const response = {
+ event: {
+ header: {
+ namespace: 'Alexa.Discovery',
+ name: 'Discover.Response',
+ payloadVersion: '3',
+ messageId: uuid.v4(),
+ },
+ payload: {
+ endpoints,
+ },
+ },
+ };
+
+ return response;
+}
+
+module.exports = {
+ onDiscovery,
+};
diff --git a/server/services/alexa/lib/alexa.onExecute.js b/server/services/alexa/lib/alexa.onExecute.js
new file mode 100644
index 0000000000..b581df8bb5
--- /dev/null
+++ b/server/services/alexa/lib/alexa.onExecute.js
@@ -0,0 +1,74 @@
+const uuid = require('uuid');
+const get = require('get-value');
+const { EVENTS, ACTIONS, ACTIONS_STATUS, DEVICE_FEATURE_CATEGORIES } = require('../../../utils/constants');
+const { BadParameters, NotFoundError } = require('../../../utils/coreErrors');
+const { writeValues, readValues } = require('./deviceMappings');
+
+/**
+ * @public
+ * @param {Object} body - The body of the request.
+ * @description Returns response.
+ * @example
+ * onExecute();
+ */
+function onExecute(body) {
+ const directiveNamespace = get(body, 'directive.header.namespace');
+ const directiveName = get(body, 'directive.header.name');
+ const endpointId = get(body, 'directive.endpoint.endpointId');
+ const correlationToken = get(body, 'directive.header.correlationToken');
+ let value;
+ const deviceInMemory = this.gladys.stateManager.get('device', endpointId);
+ if (!deviceInMemory) {
+ throw new NotFoundError(`Device "${endpointId}" not found`);
+ }
+ let deviceFeature;
+ switch (directiveNamespace) {
+ case 'Alexa.PowerController':
+ deviceFeature = deviceInMemory.features.find(
+ (f) => f.category === DEVICE_FEATURE_CATEGORIES.SWITCH || f.category === DEVICE_FEATURE_CATEGORIES.LIGHT,
+ );
+ value = writeValues['Alexa.PowerController'](directiveName);
+ break;
+ default:
+ throw new BadParameters(`Unkown directive ${directiveNamespace}`);
+ }
+
+ const action = {
+ type: ACTIONS.DEVICE.SET_VALUE,
+ status: ACTIONS_STATUS.PENDING,
+ value,
+ device: endpointId,
+ feature_category: deviceFeature.category,
+ feature_type: deviceFeature.type,
+ };
+ this.gladys.event.emit(EVENTS.ACTION.TRIGGERED, action);
+ const response = {
+ event: {
+ header: {
+ namespace: 'Alexa',
+ name: 'Response',
+ payloadVersion: '3',
+ messageId: uuid.v4(),
+ correlationToken,
+ },
+ endpoint: body.directive.endpoint,
+ payload: {},
+ },
+ context: {
+ properties: [
+ {
+ namespace: 'Alexa.PowerController',
+ name: 'powerState',
+ value: readValues[deviceFeature.category][deviceFeature.type](value),
+ timeOfSample: new Date().toISOString(),
+ uncertaintyInMilliseconds: 500,
+ },
+ ],
+ },
+ };
+ return response;
+}
+
+module.exports = {
+ onExecute,
+};
diff --git a/server/services/alexa/lib/alexa.onReportState.js b/server/services/alexa/lib/alexa.onReportState.js
new file mode 100644
index 0000000000..a090ec69db
--- /dev/null
+++ b/server/services/alexa/lib/alexa.onReportState.js
@@ -0,0 +1,46 @@
+const uuid = require('uuid');
+const get = require('get-value');
+
+const { mappings, readValues } = require('./deviceMappings');
+
+/**
+ * @public
+ * @param {Object} body - The body of the request.
+ * @description Returns response.
+ * @example
+ * onReportState();
+ */
+function onReportState(body) {
+ const deviceSelector = get(body, 'directive.endpoint.endpointId');
+ const device = this.gladys.stateManager.get('device', deviceSelector);
+ const properties = [];
+ const now = new Date().toISOString();
+ device.features.forEach((feature) => {
+ const func = get(readValues, `${feature.category}.${feature.type}`);
+ const mapping = get(mappings, `${feature.category}.capabilities.${feature.type}`);
+ if (func && mapping) {
+ properties.push({
+ namespace: mapping.interface,
+ name: get(mapping, 'properties.supported.0.name'),
+ value: func(feature.last_value),
+ timeOfSample: now,
+ uncertaintyInMilliseconds: 0,
+ });
+ }
+ });
+ const response = {
+ event: {
+ header: { ...body.directive.header, name: 'StateReport', messageId: uuid.v4() },
+ endpoint: body.directive.endpoint,
+ payload: {},
+ },
+ context: {
+ properties,
+ },
+ };
+ return response;
+}
+
+module.exports = {
+ onReportState,
+};
diff --git a/server/services/alexa/lib/deviceMappings.js b/server/services/alexa/lib/deviceMappings.js
new file mode 100644
index 0000000000..190c1b57a7
--- /dev/null
+++ b/server/services/alexa/lib/deviceMappings.js
@@ -0,0 +1,63 @@
+const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../utils/constants');
+
+const mappings = {
+ [DEVICE_FEATURE_CATEGORIES.LIGHT]: {
+ category: 'LIGHT',
+ capabilities: {
+ [DEVICE_FEATURE_TYPES.LIGHT.BINARY]: {
+ type: 'AlexaInterface',
+ interface: 'Alexa.PowerController',
+ version: '3',
+ properties: {
+ supported: [
+ {
+ name: 'powerState',
+ },
+ ],
+ proactivelyReported: true,
+ retrievable: true,
+ },
+ },
+ },
+ },
+ [DEVICE_FEATURE_CATEGORIES.SWITCH]: {
+ category: 'SMARTPLUG',
+ capabilities: {
+ [DEVICE_FEATURE_TYPES.SWITCH.BINARY]: {
+ type: 'AlexaInterface',
+ interface: 'Alexa.PowerController',
+ version: '3',
+ properties: {
+ supported: [
+ {
+ name: 'powerState',
+ },
+ ],
+ proactivelyReported: true,
+ retrievable: true,
+ },
+ },
+ },
+ },
+};
+
+const readValues = {
+ [DEVICE_FEATURE_CATEGORIES.LIGHT]: {
+ [DEVICE_FEATURE_TYPES.LIGHT.BINARY]: (value) => {
+ return value === 1 ? 'ON' : 'OFF';
+ },
+ },
+ [DEVICE_FEATURE_CATEGORIES.SWITCH]: {
+ [DEVICE_FEATURE_TYPES.SWITCH.BINARY]: (value) => {
+ return value === 1 ? 'ON' : 'OFF';
+ },
+ },
+};
+
+const writeValues = {
+ 'Alexa.PowerController': (directiveName) => {
+ return directiveName === 'TurnOn' ? 1 : 0;
+ },
+};
+
+module.exports = { mappings, readValues, writeValues };
diff --git a/server/services/alexa/lib/index.js b/server/services/alexa/lib/index.js
new file mode 100644
index 0000000000..f966d6824c
--- /dev/null
+++ b/server/services/alexa/lib/index.js
@@ -0,0 +1,21 @@
+const { onDiscovery } = require('./alexa.onDiscovery');
+const { onExecute } = require('./alexa.onExecute');
+const { onReportState } = require('./alexa.onReportState');
+
+/**
+ * @description Add ability to connect to Alexa.
+ * @param {Object} gladys - Gladys instance.
+ * @param {string} serviceId - UUID of the service in DB.
+ * @example
+ * const alexaHandler = new AlexaHandler(gladys, serviceId);
+ */
+const AlexaHandler = function AlexaHandler(gladys, serviceId) {
+ this.gladys = gladys;
+ this.serviceId = serviceId;
+};
+
+AlexaHandler.prototype.onDiscovery = onDiscovery;
+AlexaHandler.prototype.onExecute = onExecute;
+AlexaHandler.prototype.onReportState = onReportState;
+
+module.exports = AlexaHandler;
diff --git a/server/services/alexa/lib/syncDeviceConverter.js b/server/services/alexa/lib/syncDeviceConverter.js
new file mode 100644
index 0000000000..e2cb16c201
--- /dev/null
+++ b/server/services/alexa/lib/syncDeviceConverter.js
@@ -0,0 +1,60 @@
+const get = require('get-value');
+const { mappings } = require('./deviceMappings');
+
+/**
+ * @description Format a Gladys device the Alexa way
+ * @example const deviceAlexa = syncDeviceConverter(device);
+ * @param {Object} device - The device to convert.
+ * @returns {Object|null} Return the alexa formatted device or null if not handled.
+ */
+function syncDeviceConverter(device) {
+ const endpoint = {
+ endpointId: device.selector,
+ friendlyName: device.name,
+ manufacturerName: 'Gladys Assistant',
+ description: device.name,
+ additionalAttributes: {},
+ displayCategories: [],
+ capabilities: [],
+ };
+
+ // We create a unique map of device features
+ const uniqueDeviceFeatures = new Map();
+ device.features.forEach((feature) => {
+ const key = `${feature.category}${feature.type}`;
+ if (!uniqueDeviceFeatures.has(key)) {
+ uniqueDeviceFeatures.set(key, feature);
+ }
+ });
+
+ uniqueDeviceFeatures.forEach((value, key) => {
+ const displayCategory = get(mappings, `${value.category}.category`);
+ // We add a display category if not already present
+ if (displayCategory && endpoint.displayCategories.indexOf(displayCategory) === -1) {
+ endpoint.displayCategories.push(displayCategory);
+ }
+ // we get the capability if handled
+ const capability = get(mappings, `${value.category}.capabilities.${value.type}`);
+ if (capability) {
+ endpoint.capabilities.push(capability);
+ }
+ });
+
+ // if nothing is handled in this device, return null
+ if (endpoint.capabilities.length === 0) {
+ return null;
+ }
+
+ endpoint.capabilities.push({
+ type: 'AlexaInterface',
+ interface: 'Alexa',
+ version: '3',
+ });
+
+ // otherwise, return the full endpoint
+ return endpoint;
+}
+
+module.exports = {
+ syncDeviceConverter,
+};
diff --git a/server/services/alexa/package-lock.json b/server/services/alexa/package-lock.json
new file mode 100644
index 0000000000..36234e65d3
--- /dev/null
+++ b/server/services/alexa/package-lock.json
@@ -0,0 +1,26 @@
+{
+ "name": "gladys-alexa",
+ "version": "1.0.0",
+ "lockfileVersion": 1,
+ "requires": true,
+ "dependencies": {
+ "get-value": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/get-value/-/get-value-3.0.1.tgz",
+ "integrity": "sha512-mKZj9JLQrwMBtj5wxi6MH8Z5eSKaERpAwjg43dPtlGI1ZVEgH/qC7T8/6R2OBSUA+zzHBZgICsVJaEIV2tKTDA==",
+ "requires": {
+ "isobject": "^3.0.1"
+ }
+ },
+ "isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
+ },
+ "uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
+ }
+ }
+}
diff --git a/server/services/alexa/package.json b/server/services/alexa/package.json
new file mode 100644
index 0000000000..de38bca2ae
--- /dev/null
+++ b/server/services/alexa/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "gladys-alexa",
+ "version": "1.0.0",
+ "main": "index.js",
+ "os": [
+ "darwin",
+ "linux",
+ "freebsd",
+ "win32"
+ ],
+ "cpu": [
+ "x64",
+ "arm",
+ "arm64"
+ ],
+ "scripts": {},
+ "dependencies": {
+ "get-value": "^3.0.1",
+ "uuid": "^8.3.2"
+ }
+}
diff --git a/server/services/index.js b/server/services/index.js
index 7f5480b3f8..e5818b3d92 100644
--- a/server/services/index.js
+++ b/server/services/index.js
@@ -1,3 +1,4 @@
+module.exports.alexa = require('./alexa');
module.exports.example = require('./example');
module.exports.caldav = require('./caldav');
module.exports.openweather = require('./openweather');
diff --git a/server/test/lib/gateway/gateway.forwardDeviceStateToAlexa.test.js b/server/test/lib/gateway/gateway.forwardDeviceStateToAlexa.test.js
new file mode 100644
index 0000000000..1df50a4e8b
--- /dev/null
+++ b/server/test/lib/gateway/gateway.forwardDeviceStateToAlexa.test.js
@@ -0,0 +1,399 @@
+const { fake, assert } = require('sinon');
+const proxyquire = require('proxyquire').noCallThru();
+const EventEmitter = require('events');
+const Promise = require('bluebird');
+const get = require('get-value');
+
+const GladysGatewayClientMock = require('./GladysGatewayClientMock.test');
+const { EVENTS } = require('../../../utils/constants');
+
+const event = new EventEmitter();
+
+const Gateway = proxyquire('../../../lib/gateway', {
+ '@gladysassistant/gladys-gateway-js': GladysGatewayClientMock,
+});
+
+const getConfig = require('../../../utils/getConfig');
+
+const sequelize = {
+ close: fake.resolves(null),
+};
+
+const system = {
+ getInfos: fake.resolves({
+ nodejs_version: 'v10.15.2',
+ gladys_version: 'v4.0.0',
+ is_docker: false,
+ }),
+ isDocker: fake.resolves(true),
+ saveLatestGladysVersion: fake.returns(null),
+ shutdown: fake.resolves(true),
+};
+
+const config = getConfig();
+
+const job = {
+ wrapper: (type, func) => {
+ return async () => {
+ return func();
+ };
+ },
+ updateProgress: fake.resolves({}),
+};
+
+describe('gateway.forwardDeviceStateToAlexa', () => {
+ const variable = {
+ getValue: fake.resolves(null),
+ setValue: fake.resolves(null),
+ };
+ const alexaService = {
+ alexaHandler: {
+ onDiscovery: fake.returns({ onDiscovery: true }),
+ onReportState: fake.returns({ onReportState: true }),
+ onExecute: fake.returns({ onExecute: true }),
+ },
+ };
+ it('should forward device state to alexa', async () => {
+ const serviceManager = {
+ getService: fake.returns(alexaService),
+ };
+ const stateManager = {
+ get: (key) => {
+ const feature = {
+ name: 'New device feature',
+ selector: 'my-device',
+ external_id: 'hue:binary:1',
+ category: 'light',
+ type: 'binary',
+ read_only: false,
+ has_feedback: false,
+ last_value: 0,
+ last_value_changed: null,
+ last_value_string: null,
+ last_daily_aggregate: null,
+ last_hourly_aggregate: null,
+ last_monthly_aggregate: null,
+ min: 0,
+ max: 1,
+ };
+ if (key === 'deviceFeature') {
+ return feature;
+ }
+
+ // deviceById
+ return {
+ id: '7f85c2f8-86cc-4600-84db-6c074dadb4e8',
+ name: 'Light',
+ selector: 'my-device',
+ external_id: 'test-device-external',
+ service_id: 'a810b8db-6d04-4697-bed3-c4b72c996279',
+ room_id: '2398c689-8b47-43cc-ad32-e98d9be098b5',
+ created_at: '2019-02-12 07:49:07.556 +00:00',
+ updated_at: '2019-02-12 07:49:07.556 +00:00',
+ features: [feature],
+ };
+ },
+ };
+ const gateway = new Gateway(variable, event, system, sequelize, config, {}, stateManager, serviceManager, job);
+ gateway.alexaForwardStateTimeout = 1;
+ gateway.connected = true;
+ gateway.alexaConnected = true;
+ gateway.gladysGatewayClient.alexaReportState = fake.resolves(null);
+ const newEvent = {
+ type: EVENTS.DEVICE.NEW_STATE,
+ device_feature: 'my-device',
+ };
+ await gateway.forwardDeviceStateToAlexa(newEvent);
+ await Promise.delay(100);
+ const calledParam = gateway.gladysGatewayClient.alexaReportState.lastArg;
+ assert.calledWith(gateway.gladysGatewayClient.alexaReportState, {
+ event: {
+ header: {
+ namespace: 'Alexa',
+ name: 'ChangeReport',
+ messageId: get(calledParam, 'event.header.messageId'),
+ payloadVersion: '3',
+ },
+ endpoint: { endpointId: 'my-device' },
+ payload: {
+ change: {
+ cause: { type: 'PHYSICAL_INTERACTION' },
+ properties: [
+ {
+ namespace: 'Alexa.PowerController',
+ name: 'powerState',
+ value: 'OFF',
+ timeOfSample: get(calledParam, 'event.payload.change.properties.0.timeOfSample'),
+ uncertaintyInMilliseconds: 0,
+ },
+ ],
+ },
+ },
+ },
+ context: {
+ properties: [
+ {
+ namespace: 'Alexa.PowerController',
+ name: 'powerState',
+ value: 'OFF',
+ timeOfSample: get(calledParam, 'context.properties.0.timeOfSample'),
+ uncertaintyInMilliseconds: 0,
+ },
+ ],
+ },
+ });
+ });
+ it('should forward only once', async () => {
+ const serviceManager = {
+ getService: fake.returns(alexaService),
+ };
+ const stateManager = {
+ get: (key) => {
+ const feature = {
+ name: 'New device feature',
+ selector: 'my-device',
+ external_id: 'hue:binary:1',
+ category: 'light',
+ type: 'binary',
+ read_only: false,
+ has_feedback: false,
+ last_value: 0,
+ last_value_changed: null,
+ last_value_string: null,
+ last_daily_aggregate: null,
+ last_hourly_aggregate: null,
+ last_monthly_aggregate: null,
+ min: 0,
+ max: 1,
+ };
+ if (key === 'deviceFeature') {
+ return feature;
+ }
+
+ // deviceById
+ return {
+ id: '7f85c2f8-86cc-4600-84db-6c074dadb4e8',
+ name: 'Light',
+ selector: 'my-device',
+ external_id: 'test-device-external',
+ service_id: 'a810b8db-6d04-4697-bed3-c4b72c996279',
+ room_id: '2398c689-8b47-43cc-ad32-e98d9be098b5',
+ created_at: '2019-02-12 07:49:07.556 +00:00',
+ updated_at: '2019-02-12 07:49:07.556 +00:00',
+ features: [feature],
+ };
+ },
+ };
+ const gateway = new Gateway(variable, event, system, sequelize, config, {}, stateManager, serviceManager, job);
+ gateway.alexaForwardStateTimeout = 30;
+ gateway.connected = true;
+ gateway.alexaConnected = true;
+ gateway.gladysGatewayClient.alexaReportState = fake.resolves(null);
+ const newEvent = {
+ type: EVENTS.DEVICE.NEW_STATE,
+ device_feature: 'my-device',
+ };
+ await gateway.forwardDeviceStateToAlexa(newEvent);
+ await gateway.forwardDeviceStateToAlexa(newEvent);
+ await Promise.delay(100);
+ assert.calledOnce(gateway.gladysGatewayClient.alexaReportState);
+ });
+ it('should not forward, unknown type of device', async () => {
+ const serviceManager = {
+ getService: fake.returns(alexaService),
+ };
+ const stateManager = {
+ get: (key) => {
+ const feature = {
+ name: 'New device feature',
+ selector: 'my-device',
+ external_id: 'hue:binary:1',
+ category: 'siren',
+ type: 'binary',
+ read_only: true,
+ has_feedback: false,
+ last_value: 0,
+ last_value_changed: null,
+ last_value_string: null,
+ last_daily_aggregate: null,
+ last_hourly_aggregate: null,
+ last_monthly_aggregate: null,
+ min: 0,
+ max: 1,
+ };
+
+ if (key === 'deviceFeature') {
+ return feature;
+ }
+ // deviceById
+ return {
+ id: '7f85c2f8-86cc-4600-84db-6c074dadb4e8',
+ name: 'Light',
+ selector: 'my-device',
+ external_id: 'test-device-external',
+ service_id: 'a810b8db-6d04-4697-bed3-c4b72c996279',
+ room_id: '2398c689-8b47-43cc-ad32-e98d9be098b5',
+ created_at: '2019-02-12 07:49:07.556 +00:00',
+ updated_at: '2019-02-12 07:49:07.556 +00:00',
+ features: [feature],
+ };
+ },
+ };
+ const gateway = new Gateway(variable, event, system, sequelize, config, {}, stateManager, serviceManager, job);
+ gateway.alexaForwardStateTimeout = 1;
+ gateway.connected = true;
+ gateway.alexaConnected = true;
+ gateway.gladysGatewayClient.alexaReportState = fake.resolves(null);
+ const newEvent = {
+ type: EVENTS.DEVICE.NEW_STATE,
+ device_feature: 'my-device',
+ };
+ await gateway.forwardDeviceStateToAlexa(newEvent);
+ await Promise.delay(100);
+ assert.notCalled(gateway.gladysGatewayClient.alexaReportState);
+ });
+ it('should not forward, alexa not connected', async () => {
+ const serviceManager = {
+ getService: fake.returns(alexaService),
+ };
+ const stateManager = {
+ get: (key) => {
+ const feature = {
+ name: 'New device feature',
+ selector: 'my-device',
+ external_id: 'hue:binary:1',
+ category: 'light',
+ type: 'binary',
+ read_only: true,
+ has_feedback: false,
+ last_value: 0,
+ last_value_changed: null,
+ last_value_string: null,
+ last_daily_aggregate: null,
+ last_hourly_aggregate: null,
+ last_monthly_aggregate: null,
+ min: 0,
+ max: 1,
+ };
+
+ if (key === 'deviceFeature') {
+ return feature;
+ }
+ // deviceById
+ return {
+ id: '7f85c2f8-86cc-4600-84db-6c074dadb4e8',
+ name: 'Light',
+ selector: 'my-device',
+ external_id: 'test-device-external',
+ service_id: 'a810b8db-6d04-4697-bed3-c4b72c996279',
+ room_id: '2398c689-8b47-43cc-ad32-e98d9be098b5',
+ created_at: '2019-02-12 07:49:07.556 +00:00',
+ updated_at: '2019-02-12 07:49:07.556 +00:00',
+ features: [feature],
+ };
+ },
+ };
+ const gateway = new Gateway(variable, event, system, sequelize, config, {}, stateManager, serviceManager, job);
+ gateway.alexaForwardStateTimeout = 1;
+ gateway.connected = true;
+ gateway.alexaConnected = false;
+ gateway.gladysGatewayClient.alexaReportState = fake.resolves(null);
+ const newEvent = {
+ type: EVENTS.DEVICE.NEW_STATE,
+ device_feature: 'my-device',
+ };
+ await gateway.forwardDeviceStateToAlexa(newEvent);
+ await Promise.delay(100);
+ assert.notCalled(gateway.gladysGatewayClient.alexaReportState);
+ });
+ it('forward should fail silently', async () => {
+ const serviceManager = {
+ getService: fake.returns(alexaService),
+ };
+ const stateManager = {
+ get: (key) => {
+ const feature = {
+ name: 'New device feature',
+ selector: 'my-device',
+ external_id: 'hue:binary:1',
+ category: 'light',
+ type: 'binary',
+ read_only: false,
+ has_feedback: false,
+ last_value: 0,
+ last_value_changed: null,
+ last_value_string: null,
+ last_daily_aggregate: null,
+ last_hourly_aggregate: null,
+ last_monthly_aggregate: null,
+ min: 0,
+ max: 1,
+ };
+ if (key === 'deviceFeature') {
+ return feature;
+ }
+
+ // deviceById
+ return {
+ id: '7f85c2f8-86cc-4600-84db-6c074dadb4e8',
+ name: 'Light',
+ selector: 'my-device',
+ external_id: 'test-device-external',
+ service_id: 'a810b8db-6d04-4697-bed3-c4b72c996279',
+ room_id: '2398c689-8b47-43cc-ad32-e98d9be098b5',
+ created_at: '2019-02-12 07:49:07.556 +00:00',
+ updated_at: '2019-02-12 07:49:07.556 +00:00',
+ features: [feature],
+ };
+ },
+ };
+ const gateway = new Gateway(variable, event, system, sequelize, config, {}, stateManager, serviceManager, job);
+ gateway.alexaForwardStateTimeout = 1;
+ gateway.connected = true;
+ gateway.alexaConnected = true;
+ gateway.gladysGatewayClient.alexaReportState = fake.rejects(null);
+ const newEvent = {
+ type: EVENTS.DEVICE.NEW_STATE,
+ device_feature: 'my-device',
+ };
+ await gateway.forwardDeviceStateToAlexa(newEvent);
+ await Promise.delay(100);
+ const calledParam = gateway.gladysGatewayClient.alexaReportState.lastArg;
+ assert.calledWith(gateway.gladysGatewayClient.alexaReportState, {
+ event: {
+ header: {
+ namespace: 'Alexa',
+ name: 'ChangeReport',
+ messageId: get(calledParam, 'event.header.messageId'),
+ payloadVersion: '3',
+ },
+ endpoint: { endpointId: 'my-device' },
+ payload: {
+ change: {
+ cause: { type: 'PHYSICAL_INTERACTION' },
+ properties: [
+ {
+ namespace: 'Alexa.PowerController',
+ name: 'powerState',
+ value: 'OFF',
+ timeOfSample: get(calledParam, 'event.payload.change.properties.0.timeOfSample'),
+ uncertaintyInMilliseconds: 0,
+ },
+ ],
+ },
+ },
+ },
+ context: {
+ properties: [
+ {
+ namespace: 'Alexa.PowerController',
+ name: 'powerState',
+ value: 'OFF',
+ timeOfSample: get(calledParam, 'context.properties.0.timeOfSample'),
+ uncertaintyInMilliseconds: 0,
+ },
+ ],
+ },
+ });
+ });
+});
diff --git a/server/test/lib/gateway/gateway.handleAlexaMessage.test.js b/server/test/lib/gateway/gateway.handleAlexaMessage.test.js
new file mode 100644
index 0000000000..c17db816c7
--- /dev/null
+++ b/server/test/lib/gateway/gateway.handleAlexaMessage.test.js
@@ -0,0 +1,165 @@
+const { fake, assert } = require('sinon');
+const proxyquire = require('proxyquire').noCallThru();
+const EventEmitter = require('events');
+
+const GladysGatewayClientMock = require('./GladysGatewayClientMock.test');
+
+const event = new EventEmitter();
+
+const Gateway = proxyquire('../../../lib/gateway', {
+ '@gladysassistant/gladys-gateway-js': GladysGatewayClientMock,
+});
+
+const getConfig = require('../../../utils/getConfig');
+
+const sequelize = {
+ close: fake.resolves(null),
+};
+
+const system = {
+ getInfos: fake.resolves({
+ nodejs_version: 'v10.15.2',
+ gladys_version: 'v4.0.0',
+ is_docker: false,
+ }),
+ isDocker: fake.resolves(true),
+ saveLatestGladysVersion: fake.returns(null),
+ shutdown: fake.resolves(true),
+};
+
+const config = getConfig();
+
+const job = {
+ wrapper: (type, func) => {
+ return async () => {
+ return func();
+ };
+ },
+ updateProgress: fake.resolves({}),
+};
+
+describe('gateway.handleAlexaMessage', () => {
+ const variable = {
+ getValue: fake.resolves(null),
+ setValue: fake.resolves(null),
+ };
+ const alexaService = {
+ alexaHandler: {
+ onDiscovery: fake.returns({ onDiscovery: true }),
+ onReportState: fake.returns({ onReportState: true }),
+ onExecute: fake.returns({ onExecute: true }),
+ },
+ };
+ it('should handle PowerController message', async () => {
+ const serviceManager = {
+ getService: fake.returns(alexaService),
+ };
+ const gateway = new Gateway(variable, event, system, sequelize, config, {}, {}, serviceManager, job);
+ const body = {
+ directive: {
+ header: {
+ namespace: 'Alexa.PowerController',
+ name: 'TurnOn',
+ payloadVersion: '3',
+ messageId: 'c43c5ef1-b456-4736-ba6b-4643a98a7e27',
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JFAIAAAAAAADNYsvnxph02bkNS9vIkVRS1S/HQ30Nab1ai4U8WdBDVhSBKEkvJkzXTZFidmkW/eI78kPC8zSg4HTO0I1BfpLZ3qKVHkvLija4pKuhadAHKg96ccMDKR7krNc3AZ5RaDrg1QTPGbEfKXbUoPMNNo9HyRJzoEaqphBRI2/aFLmHaHnENYM8Ou3y7CzFj41xQ3VBjKQdyb4cxD2MJrAln2X5t0vuMcxkgMJ0ZTt9L9N3aQKFx9Xi3RI91cR4cDajUxGGx1RzYa2t6oroos5tjN3IutEntO7V0iKO/9CMnerWuFbihll7EeiDxY33h2KcY4MCIg2zQKaBRnyHwin+R/e9A7Ozv3CR/Qvxj5CxmL9nHHFjZMRXsauNNfG5vzzo03H5WutpXjC/UwfPviGk0dG+FBH7AqQ4TH1RojoLS/a1mcpsxSORo/dezT3d9zxlD/8lcsMcWZao5mxEkQybkrOBxXVhgAJyyH+5X/RJjUWVjVBxR4ODIRie1RKuTcmla7VwqM8JocAUy9lWsCMXjW5KhNBnVca/xU8I/XfhaVD+LV+pqDDvgDmq/KVYyp8bbFKVdSQ9mFrVMpgt97lnMDd2oNASDET10grmQdwbn/FivkK2tnveVlaU7/BpnC+JpGBqHT0DSJucu0es0SLlEd875QAdGPJ4Eg+OD4t8z4NqXyyH2iqVhq+AwQDFjY6UpPaWkykN',
+ },
+ endpoint: { endpointId: 'device-1', cookie: {} },
+ payload: {},
+ },
+ };
+ const callback = fake.returns(null);
+ await gateway.handleAlexaMessage({ data: body }, '', callback);
+ assert.calledWith(callback, { onExecute: true });
+ });
+ it('should handle Discovery message', async () => {
+ const serviceManager = {
+ getService: fake.returns(alexaService),
+ };
+ const gateway = new Gateway(variable, event, system, sequelize, config, {}, {}, serviceManager, job);
+ const body = {
+ directive: {
+ header: {
+ namespace: 'Alexa.Discovery',
+ name: 'Discover',
+ messageId: 'message id',
+ payloadVersion: '3',
+ },
+ payload: {},
+ },
+ };
+ const callback = fake.returns(null);
+ await gateway.handleAlexaMessage({ data: body }, '', callback);
+ assert.calledWith(callback, { onDiscovery: true });
+ });
+ it('should handle Report state message', async () => {
+ const serviceManager = {
+ getService: fake.returns(alexaService),
+ };
+ const gateway = new Gateway(variable, event, system, sequelize, config, {}, {}, serviceManager, job);
+ const body = {
+ directive: {
+ header: {
+ namespace: 'Alexa',
+ name: 'ReportState',
+ payloadVersion: '3',
+ messageId: 'a05c8249-1cdd-41dd-bc1d-5a14ab4b98eb',
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JAAIAAAAAAADgCDHXLLn3nx8SmjtElD2w8CfsniSH6KxFhbRSgD/sELuMpZTr4Jl/E3Nip62gpI2QqFNm/TrQ/Pi+XSFtf/4AVCDxe4bV2FAXSVu61AsuUlhbdqdvjUoaHOuqSLW8F3Qj9z3HWhfvTCMEbbhw4XVDWOsyXb9nknvswimA+R4ftNdBx5POWZGxWtbvU+yeBStTV+QwSSZaHWjzQdi/LAo1KW35MkmLikny7Y7J097LTTL1Tof6IkLsi9/gxOtUUFvnD4yIkWeHTT110Ch6R4kDuonNtOiHsTmMMRtsY5kRWoIL9VMfX6QHWjamhvd+XJp4sXkLMBdtJ3aTzfsUNrQIdrcPTox9qTNjShunTlbAYkq1TSUXaylEGHvcwHrbo7ZoUlBvidqnJGUNRJPxOHHyfCm5VqFzuFI8AG1W/dj1W4Di0AAND/mwzjZKUTRsiX4uEaRw8/Na4Qj/GBMuT18hUoGpe7t/UYw5JFw+MXm0kn/5jKe9r62xil3TN8BK9ODQDP9zq08+iiT0CBtEX5F4Drrowb57IwcW7nt/hkCeeyR59B/Z6nPsSq0NQ+rd1w4a1iHIyaTU6acQsKwmaX1OeTvtT2p7U/HhqfhVMSqA7ybGhQDF4FPPzIbh+o+D1S+AX9m9nVSSJNwoevikdZimCbk1l1HmUrhz78GO+j0yFg==',
+ },
+ endpoint: { endpointId: 'device-1' },
+ payload: {},
+ },
+ };
+ const callback = fake.returns(null);
+ await gateway.handleAlexaMessage({ data: body }, '', callback);
+ assert.calledWith(callback, { onReportState: true });
+ });
+ it('should return 400 unknown', async () => {
+ const serviceManager = {
+ getService: fake.returns(alexaService),
+ };
+ const gateway = new Gateway(variable, event, system, sequelize, config, {}, {}, serviceManager, job);
+ const body = {
+ directive: {
+ header: {
+ namespace: 'Alexa',
+ name: 'UNKNOWN',
+ payloadVersion: '3',
+ messageId: 'a05c8249-1cdd-41dd-bc1d-5a14ab4b98eb',
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JAAIAAAAAAADgCDHXLLn3nx8SmjtElD2w8CfsniSH6KxFhbRSgD/sELuMpZTr4Jl/E3Nip62gpI2QqFNm/TrQ/Pi+XSFtf/4AVCDxe4bV2FAXSVu61AsuUlhbdqdvjUoaHOuqSLW8F3Qj9z3HWhfvTCMEbbhw4XVDWOsyXb9nknvswimA+R4ftNdBx5POWZGxWtbvU+yeBStTV+QwSSZaHWjzQdi/LAo1KW35MkmLikny7Y7J097LTTL1Tof6IkLsi9/gxOtUUFvnD4yIkWeHTT110Ch6R4kDuonNtOiHsTmMMRtsY5kRWoIL9VMfX6QHWjamhvd+XJp4sXkLMBdtJ3aTzfsUNrQIdrcPTox9qTNjShunTlbAYkq1TSUXaylEGHvcwHrbo7ZoUlBvidqnJGUNRJPxOHHyfCm5VqFzuFI8AG1W/dj1W4Di0AAND/mwzjZKUTRsiX4uEaRw8/Na4Qj/GBMuT18hUoGpe7t/UYw5JFw+MXm0kn/5jKe9r62xil3TN8BK9ODQDP9zq08+iiT0CBtEX5F4Drrowb57IwcW7nt/hkCeeyR59B/Z6nPsSq0NQ+rd1w4a1iHIyaTU6acQsKwmaX1OeTvtT2p7U/HhqfhVMSqA7ybGhQDF4FPPzIbh+o+D1S+AX9m9nVSSJNwoevikdZimCbk1l1HmUrhz78GO+j0yFg==',
+ },
+ endpoint: { endpointId: 'device-1' },
+ payload: {},
+ },
+ };
+ const callback = fake.returns(null);
+ await gateway.handleAlexaMessage({ data: body }, '', callback);
+ assert.calledWith(callback, { status: 400 });
+ });
+ it('should return 400 error', async () => {
+ const serviceManager = {
+ getService: fake.throws('ERROR'),
+ };
+ const gateway = new Gateway(variable, event, system, sequelize, config, {}, {}, serviceManager, job);
+ const body = {
+ directive: {
+ header: {
+ namespace: 'Alexa',
+ name: 'UNKNOWN',
+ payloadVersion: '3',
+ messageId: 'a05c8249-1cdd-41dd-bc1d-5a14ab4b98eb',
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JAAIAAAAAAADgCDHXLLn3nx8SmjtElD2w8CfsniSH6KxFhbRSgD/sELuMpZTr4Jl/E3Nip62gpI2QqFNm/TrQ/Pi+XSFtf/4AVCDxe4bV2FAXSVu61AsuUlhbdqdvjUoaHOuqSLW8F3Qj9z3HWhfvTCMEbbhw4XVDWOsyXb9nknvswimA+R4ftNdBx5POWZGxWtbvU+yeBStTV+QwSSZaHWjzQdi/LAo1KW35MkmLikny7Y7J097LTTL1Tof6IkLsi9/gxOtUUFvnD4yIkWeHTT110Ch6R4kDuonNtOiHsTmMMRtsY5kRWoIL9VMfX6QHWjamhvd+XJp4sXkLMBdtJ3aTzfsUNrQIdrcPTox9qTNjShunTlbAYkq1TSUXaylEGHvcwHrbo7ZoUlBvidqnJGUNRJPxOHHyfCm5VqFzuFI8AG1W/dj1W4Di0AAND/mwzjZKUTRsiX4uEaRw8/Na4Qj/GBMuT18hUoGpe7t/UYw5JFw+MXm0kn/5jKe9r62xil3TN8BK9ODQDP9zq08+iiT0CBtEX5F4Drrowb57IwcW7nt/hkCeeyR59B/Z6nPsSq0NQ+rd1w4a1iHIyaTU6acQsKwmaX1OeTvtT2p7U/HhqfhVMSqA7ybGhQDF4FPPzIbh+o+D1S+AX9m9nVSSJNwoevikdZimCbk1l1HmUrhz78GO+j0yFg==',
+ },
+ endpoint: { endpointId: 'device-1' },
+ payload: {},
+ },
+ };
+ const callback = fake.returns(null);
+ await gateway.handleAlexaMessage({ data: body }, '', callback);
+ assert.calledWith(callback, { status: 400 });
+ });
+});
diff --git a/server/test/lib/gateway/gateway.test.js b/server/test/lib/gateway/gateway.test.js
index 0cdd545f68..28e802d8ea 100644
--- a/server/test/lib/gateway/gateway.test.js
+++ b/server/test/lib/gateway/gateway.test.js
@@ -819,6 +819,78 @@ describe('gateway', () => {
expect(resultUnkown).to.deep.equal({ status: 400 });
expect(resultDisconnect).to.deep.equal({});
});
+ it('should handle a new gateway open api message: alexa-request', async function Test() {
+ this.timeout(10000);
+ const variable = {
+ setValue: fake.resolves(null),
+ destroy: fake.resolves(null),
+ };
+ const stateManager = {
+ get: fake.returns({
+ id: '0cd30aef-9c4e-4a23-88e3-3547971296e5',
+ }),
+ };
+ const serviceManager = {
+ getService: fake.returns({
+ alexaHandler: {
+ onDiscovery: fake.resolves({ onDiscovery: true }),
+ onReportState: fake.resolves({ onReportState: true }),
+ onExecute: fake.resolves({ onExecute: true }),
+ },
+ }),
+ };
+ const eventGateway = {
+ emit: fake.returns(null),
+ on: fake.returns(null),
+ };
+ const gateway = new Gateway(
+ variable,
+ eventGateway,
+ system,
+ sequelize,
+ config,
+ {},
+ stateManager,
+ serviceManager,
+ job,
+ );
+ await gateway.login('tony.stark@gladysassistant.com', 'warmachine123');
+ gateway.usersKeys = [
+ {
+ rsa_public_key: 'fingerprint',
+ ecdsa_public_key: 'fingerprint',
+ accepted: true,
+ },
+ ];
+
+ const promiseDiscovery = new Promise((resolve, reject) => {
+ gateway.handleNewMessage(
+ {
+ type: 'gladys-open-api',
+ action: 'alexa-request',
+ data: {
+ directive: {
+ header: {
+ namespace: 'Alexa.Discovery',
+ name: 'Discover',
+ messageId: 'd89e30ed-bbcb-4ec5-9684-8e5c14e3bffd',
+ payloadVersion: '3',
+ },
+ payload: {},
+ },
+ },
+ },
+ {
+ rsaPublicKeyRaw: 'key',
+ ecdsaPublicKeyRaw: 'key',
+ local_user_id: '0cd30aef-9c4e-4a23-88e3-3547971296e5',
+ },
+ resolve,
+ );
+ });
+ const resultDiscovery = await promiseDiscovery;
+ expect(resultDiscovery).to.deep.equal({ onDiscovery: true });
+ });
});
describe('gateway.forwardDeviceStateToGoogleHome', () => {
it('should forward an event to google home', async () => {
diff --git a/server/test/services/alexa/lib/alexa.onDiscovery.test.js b/server/test/services/alexa/lib/alexa.onDiscovery.test.js
new file mode 100644
index 0000000000..22063fac68
--- /dev/null
+++ b/server/test/services/alexa/lib/alexa.onDiscovery.test.js
@@ -0,0 +1,80 @@
+const sinon = require('sinon');
+const { expect } = require('chai');
+const get = require('get-value');
+
+const { assert, fake } = sinon;
+const AlexaHandler = require('../../../../services/alexa/lib');
+
+const gladys = {
+ stateManager: {
+ state: {
+ device: {},
+ },
+ },
+};
+const serviceId = 'd1e45425-fe25-4968-ac0f-bc695d5202d9';
+
+describe('alexa.onDiscovery', () => {
+ beforeEach(() => {
+ sinon.reset();
+ gladys.stateManager.state.device = {};
+ });
+
+ it('return one light with on/off capability', async () => {
+ gladys.stateManager.state.device = {
+ device_1: {
+ get: fake.returns({
+ name: 'Device 1',
+ selector: 'device-1',
+ external_id: 'device-1-external-id',
+ features: [
+ {
+ category: 'light',
+ type: 'binary',
+ },
+ ],
+ model: 'device-model',
+ room: {
+ name: 'living-room',
+ },
+ }),
+ },
+ };
+
+ const alexaHandler = new AlexaHandler(gladys, serviceId);
+ const result = alexaHandler.onDiscovery();
+ const expectedResult = {
+ event: {
+ header: {
+ namespace: 'Alexa.Discovery',
+ name: 'Discover.Response',
+ payloadVersion: '3',
+ messageId: get(result, 'event.header.messageId'),
+ },
+ payload: {
+ endpoints: [
+ {
+ endpointId: 'device-1',
+ friendlyName: 'Device 1',
+ manufacturerName: 'Gladys Assistant',
+ description: 'Device 1',
+ additionalAttributes: {},
+ displayCategories: ['LIGHT'],
+ capabilities: [
+ {
+ type: 'AlexaInterface',
+ interface: 'Alexa.PowerController',
+ version: '3',
+ properties: { supported: [{ name: 'powerState' }], proactivelyReported: true, retrievable: true },
+ },
+ { type: 'AlexaInterface', interface: 'Alexa', version: '3' },
+ ],
+ },
+ ],
+ },
+ },
+ };
+ expect(result).to.deep.eq(expectedResult);
+ assert.calledOnce(gladys.stateManager.state.device.device_1.get);
+ });
+});
diff --git a/server/test/services/alexa/lib/alexa.onExecute.test.js b/server/test/services/alexa/lib/alexa.onExecute.test.js
new file mode 100644
index 0000000000..4c5ae00c4e
--- /dev/null
+++ b/server/test/services/alexa/lib/alexa.onExecute.test.js
@@ -0,0 +1,434 @@
+const sinon = require('sinon');
+const { expect } = require('chai');
+const get = require('get-value');
+
+const { assert, fake } = sinon;
+const AlexaHandler = require('../../../../services/alexa/lib');
+const { EVENTS } = require('../../../../utils/constants');
+
+const serviceId = 'd1e45425-fe25-4968-ac0f-bc695d5202d9';
+
+const DEVICE_1_LIGHT = {
+ name: 'Device 1',
+ selector: 'device-1',
+ external_id: 'device-1-external-id',
+ features: [
+ {
+ category: 'light',
+ type: 'binary',
+ },
+ ],
+ model: 'device-model',
+ room: {
+ name: 'living-room',
+ },
+};
+
+const DEVICE_1_SWITCH = {
+ name: 'Device 1',
+ selector: 'device-1',
+ external_id: 'device-1-external-id',
+ features: [
+ {
+ category: 'switch',
+ type: 'binary',
+ },
+ ],
+ model: 'device-model',
+ room: {
+ name: 'living-room',
+ },
+};
+
+describe('alexa.onExecute', () => {
+ it('Should turn on the light', async () => {
+ const gladys = {
+ stateManager: {
+ get: () => {
+ return DEVICE_1_LIGHT;
+ },
+ state: {
+ device: {
+ device_1: {
+ get: fake.returns(DEVICE_1_LIGHT),
+ },
+ },
+ },
+ },
+ event: {
+ emit: fake.returns(null),
+ },
+ };
+
+ const alexaHandler = new AlexaHandler(gladys, serviceId);
+ const body = {
+ directive: {
+ header: {
+ namespace: 'Alexa.PowerController',
+ name: 'TurnOn',
+ payloadVersion: '3',
+ messageId: 'c43c5ef1-b456-4736-ba6b-4643a98a7e27',
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JFAIAAAAAAADNYsvnxph02bkNS9vIkVRS1S/HQ30Nab1ai4U8WdBDVhSBKEkvJkzXTZFidmkW/eI78kPC8zSg4HTO0I1BfpLZ3qKVHkvLija4pKuhadAHKg96ccMDKR7krNc3AZ5RaDrg1QTPGbEfKXbUoPMNNo9HyRJzoEaqphBRI2/aFLmHaHnENYM8Ou3y7CzFj41xQ3VBjKQdyb4cxD2MJrAln2X5t0vuMcxkgMJ0ZTt9L9N3aQKFx9Xi3RI91cR4cDajUxGGx1RzYa2t6oroos5tjN3IutEntO7V0iKO/9CMnerWuFbihll7EeiDxY33h2KcY4MCIg2zQKaBRnyHwin+R/e9A7Ozv3CR/Qvxj5CxmL9nHHFjZMRXsauNNfG5vzzo03H5WutpXjC/UwfPviGk0dG+FBH7AqQ4TH1RojoLS/a1mcpsxSORo/dezT3d9zxlD/8lcsMcWZao5mxEkQybkrOBxXVhgAJyyH+5X/RJjUWVjVBxR4ODIRie1RKuTcmla7VwqM8JocAUy9lWsCMXjW5KhNBnVca/xU8I/XfhaVD+LV+pqDDvgDmq/KVYyp8bbFKVdSQ9mFrVMpgt97lnMDd2oNASDET10grmQdwbn/FivkK2tnveVlaU7/BpnC+JpGBqHT0DSJucu0es0SLlEd875QAdGPJ4Eg+OD4t8z4NqXyyH2iqVhq+AwQDFjY6UpPaWkykN',
+ },
+ endpoint: { endpointId: 'device-1', cookie: {} },
+ payload: {},
+ },
+ user: { id: 'cbd42dc1-1b15-4c59-bea6-7e01968a9603', local_user_id: '275faa00-8a9c-4747-8fbe-417ddb966b16' },
+ };
+ const result = alexaHandler.onExecute(body);
+ assert.calledWith(gladys.event.emit, EVENTS.ACTION.TRIGGERED, {
+ type: 'device.set-value',
+ status: 'pending',
+ value: 1,
+ device: 'device-1',
+ feature_category: 'light',
+ feature_type: 'binary',
+ });
+ expect(result).to.deep.equal({
+ event: {
+ header: {
+ namespace: 'Alexa',
+ name: 'Response',
+ payloadVersion: '3',
+ messageId: get(result, 'event.header.messageId'),
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JFAIAAAAAAADNYsvnxph02bkNS9vIkVRS1S/HQ30Nab1ai4U8WdBDVhSBKEkvJkzXTZFidmkW/eI78kPC8zSg4HTO0I1BfpLZ3qKVHkvLija4pKuhadAHKg96ccMDKR7krNc3AZ5RaDrg1QTPGbEfKXbUoPMNNo9HyRJzoEaqphBRI2/aFLmHaHnENYM8Ou3y7CzFj41xQ3VBjKQdyb4cxD2MJrAln2X5t0vuMcxkgMJ0ZTt9L9N3aQKFx9Xi3RI91cR4cDajUxGGx1RzYa2t6oroos5tjN3IutEntO7V0iKO/9CMnerWuFbihll7EeiDxY33h2KcY4MCIg2zQKaBRnyHwin+R/e9A7Ozv3CR/Qvxj5CxmL9nHHFjZMRXsauNNfG5vzzo03H5WutpXjC/UwfPviGk0dG+FBH7AqQ4TH1RojoLS/a1mcpsxSORo/dezT3d9zxlD/8lcsMcWZao5mxEkQybkrOBxXVhgAJyyH+5X/RJjUWVjVBxR4ODIRie1RKuTcmla7VwqM8JocAUy9lWsCMXjW5KhNBnVca/xU8I/XfhaVD+LV+pqDDvgDmq/KVYyp8bbFKVdSQ9mFrVMpgt97lnMDd2oNASDET10grmQdwbn/FivkK2tnveVlaU7/BpnC+JpGBqHT0DSJucu0es0SLlEd875QAdGPJ4Eg+OD4t8z4NqXyyH2iqVhq+AwQDFjY6UpPaWkykN',
+ },
+ endpoint: { endpointId: 'device-1', cookie: {} },
+ payload: {},
+ },
+ context: {
+ properties: [
+ {
+ namespace: 'Alexa.PowerController',
+ name: 'powerState',
+ value: 'ON',
+ timeOfSample: get(result, 'context.properties.0.timeOfSample'),
+ uncertaintyInMilliseconds: 500,
+ },
+ ],
+ },
+ });
+ });
+ it('Should turn off the light', async () => {
+ const gladys = {
+ stateManager: {
+ get: () => {
+ return DEVICE_1_LIGHT;
+ },
+ state: {
+ device: {
+ device_1: {
+ get: fake.returns(DEVICE_1_LIGHT),
+ },
+ },
+ },
+ },
+ event: {
+ emit: fake.returns(null),
+ },
+ };
+
+ const alexaHandler = new AlexaHandler(gladys, serviceId);
+ const body = {
+ directive: {
+ header: {
+ namespace: 'Alexa.PowerController',
+ name: 'TurnOff',
+ payloadVersion: '3',
+ messageId: 'c43c5ef1-b456-4736-ba6b-4643a98a7e27',
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JFAIAAAAAAADNYsvnxph02bkNS9vIkVRS1S/HQ30Nab1ai4U8WdBDVhSBKEkvJkzXTZFidmkW/eI78kPC8zSg4HTO0I1BfpLZ3qKVHkvLija4pKuhadAHKg96ccMDKR7krNc3AZ5RaDrg1QTPGbEfKXbUoPMNNo9HyRJzoEaqphBRI2/aFLmHaHnENYM8Ou3y7CzFj41xQ3VBjKQdyb4cxD2MJrAln2X5t0vuMcxkgMJ0ZTt9L9N3aQKFx9Xi3RI91cR4cDajUxGGx1RzYa2t6oroos5tjN3IutEntO7V0iKO/9CMnerWuFbihll7EeiDxY33h2KcY4MCIg2zQKaBRnyHwin+R/e9A7Ozv3CR/Qvxj5CxmL9nHHFjZMRXsauNNfG5vzzo03H5WutpXjC/UwfPviGk0dG+FBH7AqQ4TH1RojoLS/a1mcpsxSORo/dezT3d9zxlD/8lcsMcWZao5mxEkQybkrOBxXVhgAJyyH+5X/RJjUWVjVBxR4ODIRie1RKuTcmla7VwqM8JocAUy9lWsCMXjW5KhNBnVca/xU8I/XfhaVD+LV+pqDDvgDmq/KVYyp8bbFKVdSQ9mFrVMpgt97lnMDd2oNASDET10grmQdwbn/FivkK2tnveVlaU7/BpnC+JpGBqHT0DSJucu0es0SLlEd875QAdGPJ4Eg+OD4t8z4NqXyyH2iqVhq+AwQDFjY6UpPaWkykN',
+ },
+ endpoint: { endpointId: 'device-1', cookie: {} },
+ payload: {},
+ },
+ user: { id: 'cbd42dc1-1b15-4c59-bea6-7e01968a9603', local_user_id: '275faa00-8a9c-4747-8fbe-417ddb966b16' },
+ };
+ const result = alexaHandler.onExecute(body);
+ assert.calledWith(gladys.event.emit, EVENTS.ACTION.TRIGGERED, {
+ type: 'device.set-value',
+ status: 'pending',
+ value: 0,
+ device: 'device-1',
+ feature_category: 'light',
+ feature_type: 'binary',
+ });
+ expect(result).to.deep.equal({
+ event: {
+ header: {
+ namespace: 'Alexa',
+ name: 'Response',
+ payloadVersion: '3',
+ messageId: get(result, 'event.header.messageId'),
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JFAIAAAAAAADNYsvnxph02bkNS9vIkVRS1S/HQ30Nab1ai4U8WdBDVhSBKEkvJkzXTZFidmkW/eI78kPC8zSg4HTO0I1BfpLZ3qKVHkvLija4pKuhadAHKg96ccMDKR7krNc3AZ5RaDrg1QTPGbEfKXbUoPMNNo9HyRJzoEaqphBRI2/aFLmHaHnENYM8Ou3y7CzFj41xQ3VBjKQdyb4cxD2MJrAln2X5t0vuMcxkgMJ0ZTt9L9N3aQKFx9Xi3RI91cR4cDajUxGGx1RzYa2t6oroos5tjN3IutEntO7V0iKO/9CMnerWuFbihll7EeiDxY33h2KcY4MCIg2zQKaBRnyHwin+R/e9A7Ozv3CR/Qvxj5CxmL9nHHFjZMRXsauNNfG5vzzo03H5WutpXjC/UwfPviGk0dG+FBH7AqQ4TH1RojoLS/a1mcpsxSORo/dezT3d9zxlD/8lcsMcWZao5mxEkQybkrOBxXVhgAJyyH+5X/RJjUWVjVBxR4ODIRie1RKuTcmla7VwqM8JocAUy9lWsCMXjW5KhNBnVca/xU8I/XfhaVD+LV+pqDDvgDmq/KVYyp8bbFKVdSQ9mFrVMpgt97lnMDd2oNASDET10grmQdwbn/FivkK2tnveVlaU7/BpnC+JpGBqHT0DSJucu0es0SLlEd875QAdGPJ4Eg+OD4t8z4NqXyyH2iqVhq+AwQDFjY6UpPaWkykN',
+ },
+ endpoint: { endpointId: 'device-1', cookie: {} },
+ payload: {},
+ },
+ context: {
+ properties: [
+ {
+ namespace: 'Alexa.PowerController',
+ name: 'powerState',
+ value: 'OFF',
+ timeOfSample: get(result, 'context.properties.0.timeOfSample'),
+ uncertaintyInMilliseconds: 500,
+ },
+ ],
+ },
+ });
+ });
+ it('Should turn on a powerplug', async () => {
+ const gladys = {
+ stateManager: {
+ get: () => {
+ return DEVICE_1_SWITCH;
+ },
+ state: {
+ device: {
+ device_1: {
+ get: fake.returns(DEVICE_1_SWITCH),
+ },
+ },
+ },
+ },
+ event: {
+ emit: fake.returns(null),
+ },
+ };
+
+ const alexaHandler = new AlexaHandler(gladys, serviceId);
+ const body = {
+ directive: {
+ header: {
+ namespace: 'Alexa.PowerController',
+ name: 'TurnOn',
+ payloadVersion: '3',
+ messageId: 'c43c5ef1-b456-4736-ba6b-4643a98a7e27',
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JFAIAAAAAAADNYsvnxph02bkNS9vIkVRS1S/HQ30Nab1ai4U8WdBDVhSBKEkvJkzXTZFidmkW/eI78kPC8zSg4HTO0I1BfpLZ3qKVHkvLija4pKuhadAHKg96ccMDKR7krNc3AZ5RaDrg1QTPGbEfKXbUoPMNNo9HyRJzoEaqphBRI2/aFLmHaHnENYM8Ou3y7CzFj41xQ3VBjKQdyb4cxD2MJrAln2X5t0vuMcxkgMJ0ZTt9L9N3aQKFx9Xi3RI91cR4cDajUxGGx1RzYa2t6oroos5tjN3IutEntO7V0iKO/9CMnerWuFbihll7EeiDxY33h2KcY4MCIg2zQKaBRnyHwin+R/e9A7Ozv3CR/Qvxj5CxmL9nHHFjZMRXsauNNfG5vzzo03H5WutpXjC/UwfPviGk0dG+FBH7AqQ4TH1RojoLS/a1mcpsxSORo/dezT3d9zxlD/8lcsMcWZao5mxEkQybkrOBxXVhgAJyyH+5X/RJjUWVjVBxR4ODIRie1RKuTcmla7VwqM8JocAUy9lWsCMXjW5KhNBnVca/xU8I/XfhaVD+LV+pqDDvgDmq/KVYyp8bbFKVdSQ9mFrVMpgt97lnMDd2oNASDET10grmQdwbn/FivkK2tnveVlaU7/BpnC+JpGBqHT0DSJucu0es0SLlEd875QAdGPJ4Eg+OD4t8z4NqXyyH2iqVhq+AwQDFjY6UpPaWkykN',
+ },
+ endpoint: { endpointId: 'device-1', cookie: {} },
+ payload: {},
+ },
+ user: { id: 'cbd42dc1-1b15-4c59-bea6-7e01968a9603', local_user_id: '275faa00-8a9c-4747-8fbe-417ddb966b16' },
+ };
+ const result = alexaHandler.onExecute(body);
+ assert.calledWith(gladys.event.emit, EVENTS.ACTION.TRIGGERED, {
+ type: 'device.set-value',
+ status: 'pending',
+ value: 1,
+ device: 'device-1',
+ feature_category: 'switch',
+ feature_type: 'binary',
+ });
+ expect(result).to.deep.equal({
+ event: {
+ header: {
+ namespace: 'Alexa',
+ name: 'Response',
+ payloadVersion: '3',
+ messageId: get(result, 'event.header.messageId'),
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JFAIAAAAAAADNYsvnxph02bkNS9vIkVRS1S/HQ30Nab1ai4U8WdBDVhSBKEkvJkzXTZFidmkW/eI78kPC8zSg4HTO0I1BfpLZ3qKVHkvLija4pKuhadAHKg96ccMDKR7krNc3AZ5RaDrg1QTPGbEfKXbUoPMNNo9HyRJzoEaqphBRI2/aFLmHaHnENYM8Ou3y7CzFj41xQ3VBjKQdyb4cxD2MJrAln2X5t0vuMcxkgMJ0ZTt9L9N3aQKFx9Xi3RI91cR4cDajUxGGx1RzYa2t6oroos5tjN3IutEntO7V0iKO/9CMnerWuFbihll7EeiDxY33h2KcY4MCIg2zQKaBRnyHwin+R/e9A7Ozv3CR/Qvxj5CxmL9nHHFjZMRXsauNNfG5vzzo03H5WutpXjC/UwfPviGk0dG+FBH7AqQ4TH1RojoLS/a1mcpsxSORo/dezT3d9zxlD/8lcsMcWZao5mxEkQybkrOBxXVhgAJyyH+5X/RJjUWVjVBxR4ODIRie1RKuTcmla7VwqM8JocAUy9lWsCMXjW5KhNBnVca/xU8I/XfhaVD+LV+pqDDvgDmq/KVYyp8bbFKVdSQ9mFrVMpgt97lnMDd2oNASDET10grmQdwbn/FivkK2tnveVlaU7/BpnC+JpGBqHT0DSJucu0es0SLlEd875QAdGPJ4Eg+OD4t8z4NqXyyH2iqVhq+AwQDFjY6UpPaWkykN',
+ },
+ endpoint: { endpointId: 'device-1', cookie: {} },
+ payload: {},
+ },
+ context: {
+ properties: [
+ {
+ namespace: 'Alexa.PowerController',
+ name: 'powerState',
+ value: 'ON',
+ timeOfSample: get(result, 'context.properties.0.timeOfSample'),
+ uncertaintyInMilliseconds: 500,
+ },
+ ],
+ },
+ });
+ });
+ it('Should turn off a powerplug', async () => {
+ const gladys = {
+ stateManager: {
+ get: () => {
+ return DEVICE_1_SWITCH;
+ },
+ state: {
+ device: {
+ device_1: {
+ get: fake.returns(DEVICE_1_SWITCH),
+ },
+ },
+ },
+ },
+ event: {
+ emit: fake.returns(null),
+ },
+ };
+
+ const alexaHandler = new AlexaHandler(gladys, serviceId);
+ const body = {
+ directive: {
+ header: {
+ namespace: 'Alexa.PowerController',
+ name: 'TurnOff',
+ payloadVersion: '3',
+ messageId: 'c43c5ef1-b456-4736-ba6b-4643a98a7e27',
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JFAIAAAAAAADNYsvnxph02bkNS9vIkVRS1S/HQ30Nab1ai4U8WdBDVhSBKEkvJkzXTZFidmkW/eI78kPC8zSg4HTO0I1BfpLZ3qKVHkvLija4pKuhadAHKg96ccMDKR7krNc3AZ5RaDrg1QTPGbEfKXbUoPMNNo9HyRJzoEaqphBRI2/aFLmHaHnENYM8Ou3y7CzFj41xQ3VBjKQdyb4cxD2MJrAln2X5t0vuMcxkgMJ0ZTt9L9N3aQKFx9Xi3RI91cR4cDajUxGGx1RzYa2t6oroos5tjN3IutEntO7V0iKO/9CMnerWuFbihll7EeiDxY33h2KcY4MCIg2zQKaBRnyHwin+R/e9A7Ozv3CR/Qvxj5CxmL9nHHFjZMRXsauNNfG5vzzo03H5WutpXjC/UwfPviGk0dG+FBH7AqQ4TH1RojoLS/a1mcpsxSORo/dezT3d9zxlD/8lcsMcWZao5mxEkQybkrOBxXVhgAJyyH+5X/RJjUWVjVBxR4ODIRie1RKuTcmla7VwqM8JocAUy9lWsCMXjW5KhNBnVca/xU8I/XfhaVD+LV+pqDDvgDmq/KVYyp8bbFKVdSQ9mFrVMpgt97lnMDd2oNASDET10grmQdwbn/FivkK2tnveVlaU7/BpnC+JpGBqHT0DSJucu0es0SLlEd875QAdGPJ4Eg+OD4t8z4NqXyyH2iqVhq+AwQDFjY6UpPaWkykN',
+ },
+ endpoint: { endpointId: 'device-1', cookie: {} },
+ payload: {},
+ },
+ user: { id: 'cbd42dc1-1b15-4c59-bea6-7e01968a9603', local_user_id: '275faa00-8a9c-4747-8fbe-417ddb966b16' },
+ };
+ const result = alexaHandler.onExecute(body);
+ assert.calledWith(gladys.event.emit, EVENTS.ACTION.TRIGGERED, {
+ type: 'device.set-value',
+ status: 'pending',
+ value: 0,
+ device: 'device-1',
+ feature_category: 'switch',
+ feature_type: 'binary',
+ });
+ expect(result).to.deep.equal({
+ event: {
+ header: {
+ namespace: 'Alexa',
+ name: 'Response',
+ payloadVersion: '3',
+ messageId: get(result, 'event.header.messageId'),
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JFAIAAAAAAADNYsvnxph02bkNS9vIkVRS1S/HQ30Nab1ai4U8WdBDVhSBKEkvJkzXTZFidmkW/eI78kPC8zSg4HTO0I1BfpLZ3qKVHkvLija4pKuhadAHKg96ccMDKR7krNc3AZ5RaDrg1QTPGbEfKXbUoPMNNo9HyRJzoEaqphBRI2/aFLmHaHnENYM8Ou3y7CzFj41xQ3VBjKQdyb4cxD2MJrAln2X5t0vuMcxkgMJ0ZTt9L9N3aQKFx9Xi3RI91cR4cDajUxGGx1RzYa2t6oroos5tjN3IutEntO7V0iKO/9CMnerWuFbihll7EeiDxY33h2KcY4MCIg2zQKaBRnyHwin+R/e9A7Ozv3CR/Qvxj5CxmL9nHHFjZMRXsauNNfG5vzzo03H5WutpXjC/UwfPviGk0dG+FBH7AqQ4TH1RojoLS/a1mcpsxSORo/dezT3d9zxlD/8lcsMcWZao5mxEkQybkrOBxXVhgAJyyH+5X/RJjUWVjVBxR4ODIRie1RKuTcmla7VwqM8JocAUy9lWsCMXjW5KhNBnVca/xU8I/XfhaVD+LV+pqDDvgDmq/KVYyp8bbFKVdSQ9mFrVMpgt97lnMDd2oNASDET10grmQdwbn/FivkK2tnveVlaU7/BpnC+JpGBqHT0DSJucu0es0SLlEd875QAdGPJ4Eg+OD4t8z4NqXyyH2iqVhq+AwQDFjY6UpPaWkykN',
+ },
+ endpoint: { endpointId: 'device-1', cookie: {} },
+ payload: {},
+ },
+ context: {
+ properties: [
+ {
+ namespace: 'Alexa.PowerController',
+ name: 'powerState',
+ value: 'OFF',
+ timeOfSample: get(result, 'context.properties.0.timeOfSample'),
+ uncertaintyInMilliseconds: 500,
+ },
+ ],
+ },
+ });
+ });
+ it('Should return error, device feature not found', async () => {
+ const gladys = {
+ stateManager: {
+ get: () => {
+ return null;
+ },
+ state: {
+ device: {
+ device_1: {
+ get: fake.returns({
+ name: 'Device 1',
+ selector: 'device-1',
+ external_id: 'device-1-external-id',
+ features: [
+ {
+ category: 'switch',
+ type: 'binary',
+ },
+ ],
+ model: 'device-model',
+ room: {
+ name: 'living-room',
+ },
+ }),
+ },
+ },
+ },
+ },
+ event: {
+ emit: fake.returns(null),
+ },
+ };
+
+ const alexaHandler = new AlexaHandler(gladys, serviceId);
+ const body = {
+ directive: {
+ header: {
+ namespace: 'Alexa.PowerController',
+ name: 'TurnOff',
+ payloadVersion: '3',
+ messageId: 'c43c5ef1-b456-4736-ba6b-4643a98a7e27',
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JFAIAAAAAAADNYsvnxph02bkNS9vIkVRS1S/HQ30Nab1ai4U8WdBDVhSBKEkvJkzXTZFidmkW/eI78kPC8zSg4HTO0I1BfpLZ3qKVHkvLija4pKuhadAHKg96ccMDKR7krNc3AZ5RaDrg1QTPGbEfKXbUoPMNNo9HyRJzoEaqphBRI2/aFLmHaHnENYM8Ou3y7CzFj41xQ3VBjKQdyb4cxD2MJrAln2X5t0vuMcxkgMJ0ZTt9L9N3aQKFx9Xi3RI91cR4cDajUxGGx1RzYa2t6oroos5tjN3IutEntO7V0iKO/9CMnerWuFbihll7EeiDxY33h2KcY4MCIg2zQKaBRnyHwin+R/e9A7Ozv3CR/Qvxj5CxmL9nHHFjZMRXsauNNfG5vzzo03H5WutpXjC/UwfPviGk0dG+FBH7AqQ4TH1RojoLS/a1mcpsxSORo/dezT3d9zxlD/8lcsMcWZao5mxEkQybkrOBxXVhgAJyyH+5X/RJjUWVjVBxR4ODIRie1RKuTcmla7VwqM8JocAUy9lWsCMXjW5KhNBnVca/xU8I/XfhaVD+LV+pqDDvgDmq/KVYyp8bbFKVdSQ9mFrVMpgt97lnMDd2oNASDET10grmQdwbn/FivkK2tnveVlaU7/BpnC+JpGBqHT0DSJucu0es0SLlEd875QAdGPJ4Eg+OD4t8z4NqXyyH2iqVhq+AwQDFjY6UpPaWkykN',
+ },
+ endpoint: { endpointId: 'device-1', cookie: {} },
+ payload: {},
+ },
+ user: { id: 'cbd42dc1-1b15-4c59-bea6-7e01968a9603', local_user_id: '275faa00-8a9c-4747-8fbe-417ddb966b16' },
+ };
+ expect(() => {
+ alexaHandler.onExecute(body);
+ }).to.throw('Device "device-1" not found');
+ assert.notCalled(gladys.event.emit);
+ });
+ it('Should return error, unknown directive', async () => {
+ const gladys = {
+ stateManager: {
+ get: () => {
+ return {
+ category: 'light',
+ type: 'binary',
+ };
+ },
+ state: {
+ device: {
+ device_1: {
+ get: fake.returns({
+ name: 'Device 1',
+ selector: 'device-1',
+ external_id: 'device-1-external-id',
+ features: [
+ {
+ category: 'light',
+ type: 'binary',
+ },
+ ],
+ model: 'device-model',
+ room: {
+ name: 'living-room',
+ },
+ }),
+ },
+ },
+ },
+ },
+ event: {
+ emit: fake.returns(null),
+ },
+ };
+
+ const alexaHandler = new AlexaHandler(gladys, serviceId);
+ const body = {
+ directive: {
+ header: {
+ namespace: 'Alexa.UNKNOWN_COMMAND',
+ name: 'TurnOff',
+ payloadVersion: '3',
+ messageId: 'c43c5ef1-b456-4736-ba6b-4643a98a7e27',
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JFAIAAAAAAADNYsvnxph02bkNS9vIkVRS1S/HQ30Nab1ai4U8WdBDVhSBKEkvJkzXTZFidmkW/eI78kPC8zSg4HTO0I1BfpLZ3qKVHkvLija4pKuhadAHKg96ccMDKR7krNc3AZ5RaDrg1QTPGbEfKXbUoPMNNo9HyRJzoEaqphBRI2/aFLmHaHnENYM8Ou3y7CzFj41xQ3VBjKQdyb4cxD2MJrAln2X5t0vuMcxkgMJ0ZTt9L9N3aQKFx9Xi3RI91cR4cDajUxGGx1RzYa2t6oroos5tjN3IutEntO7V0iKO/9CMnerWuFbihll7EeiDxY33h2KcY4MCIg2zQKaBRnyHwin+R/e9A7Ozv3CR/Qvxj5CxmL9nHHFjZMRXsauNNfG5vzzo03H5WutpXjC/UwfPviGk0dG+FBH7AqQ4TH1RojoLS/a1mcpsxSORo/dezT3d9zxlD/8lcsMcWZao5mxEkQybkrOBxXVhgAJyyH+5X/RJjUWVjVBxR4ODIRie1RKuTcmla7VwqM8JocAUy9lWsCMXjW5KhNBnVca/xU8I/XfhaVD+LV+pqDDvgDmq/KVYyp8bbFKVdSQ9mFrVMpgt97lnMDd2oNASDET10grmQdwbn/FivkK2tnveVlaU7/BpnC+JpGBqHT0DSJucu0es0SLlEd875QAdGPJ4Eg+OD4t8z4NqXyyH2iqVhq+AwQDFjY6UpPaWkykN',
+ },
+ endpoint: { endpointId: 'device-1', cookie: {} },
+ payload: {},
+ },
+ user: { id: 'cbd42dc1-1b15-4c59-bea6-7e01968a9603', local_user_id: '275faa00-8a9c-4747-8fbe-417ddb966b16' },
+ };
+ expect(() => {
+ alexaHandler.onExecute(body);
+ }).to.throw('Unkown directive Alexa.UNKNOWN_COMMAND');
+ });
+});
diff --git a/server/test/services/alexa/lib/alexa.onReportState.test.js b/server/test/services/alexa/lib/alexa.onReportState.test.js
new file mode 100644
index 0000000000..702aec9c5e
--- /dev/null
+++ b/server/test/services/alexa/lib/alexa.onReportState.test.js
@@ -0,0 +1,76 @@
+const sinon = require('sinon');
+const { expect } = require('chai');
+const get = require('get-value');
+
+const { fake } = sinon;
+const AlexaHandler = require('../../../../services/alexa/lib');
+
+const serviceId = 'd1e45425-fe25-4968-ac0f-bc695d5202d9';
+
+describe('alexa.onReportState', () => {
+ it('Should return current state of one feature', async () => {
+ const gladys = {
+ stateManager: {
+ get: fake.returns({
+ name: 'Device 1',
+ selector: 'device-1',
+ external_id: 'device-1-external-id',
+ features: [
+ {
+ category: 'light',
+ type: 'binary',
+ last_value: 1,
+ },
+ ],
+ model: 'device-model',
+ room: {
+ name: 'living-room',
+ },
+ }),
+ },
+ };
+
+ const alexaHandler = new AlexaHandler(gladys, serviceId);
+ const body = {
+ directive: {
+ header: {
+ namespace: 'Alexa',
+ name: 'ReportState',
+ payloadVersion: '3',
+ messageId: 'a05c8249-1cdd-41dd-bc1d-5a14ab4b98eb',
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JAAIAAAAAAADgCDHXLLn3nx8SmjtElD2w8CfsniSH6KxFhbRSgD/sELuMpZTr4Jl/E3Nip62gpI2QqFNm/TrQ/Pi+XSFtf/4AVCDxe4bV2FAXSVu61AsuUlhbdqdvjUoaHOuqSLW8F3Qj9z3HWhfvTCMEbbhw4XVDWOsyXb9nknvswimA+R4ftNdBx5POWZGxWtbvU+yeBStTV+QwSSZaHWjzQdi/LAo1KW35MkmLikny7Y7J097LTTL1Tof6IkLsi9/gxOtUUFvnD4yIkWeHTT110Ch6R4kDuonNtOiHsTmMMRtsY5kRWoIL9VMfX6QHWjamhvd+XJp4sXkLMBdtJ3aTzfsUNrQIdrcPTox9qTNjShunTlbAYkq1TSUXaylEGHvcwHrbo7ZoUlBvidqnJGUNRJPxOHHyfCm5VqFzuFI8AG1W/dj1W4Di0AAND/mwzjZKUTRsiX4uEaRw8/Na4Qj/GBMuT18hUoGpe7t/UYw5JFw+MXm0kn/5jKe9r62xil3TN8BK9ODQDP9zq08+iiT0CBtEX5F4Drrowb57IwcW7nt/hkCeeyR59B/Z6nPsSq0NQ+rd1w4a1iHIyaTU6acQsKwmaX1OeTvtT2p7U/HhqfhVMSqA7ybGhQDF4FPPzIbh+o+D1S+AX9m9nVSSJNwoevikdZimCbk1l1HmUrhz78GO+j0yFg==',
+ },
+ endpoint: { endpointId: 'device-1' },
+ payload: {},
+ },
+ user: { id: 'cbd42dc1-1b15-4c59-bea6-7e01968a9603', local_user_id: '275faa00-8a9c-4747-8fbe-417ddb966b16' },
+ };
+ const result = alexaHandler.onReportState(body);
+ expect(result).to.deep.equal({
+ event: {
+ header: {
+ namespace: 'Alexa',
+ name: 'StateReport',
+ payloadVersion: '3',
+ messageId: get(result, 'event.header.messageId'),
+ correlationToken:
+ 'AAAAAAAAAQBe8ATzt+PzWVqbUXXQAv6JAAIAAAAAAADgCDHXLLn3nx8SmjtElD2w8CfsniSH6KxFhbRSgD/sELuMpZTr4Jl/E3Nip62gpI2QqFNm/TrQ/Pi+XSFtf/4AVCDxe4bV2FAXSVu61AsuUlhbdqdvjUoaHOuqSLW8F3Qj9z3HWhfvTCMEbbhw4XVDWOsyXb9nknvswimA+R4ftNdBx5POWZGxWtbvU+yeBStTV+QwSSZaHWjzQdi/LAo1KW35MkmLikny7Y7J097LTTL1Tof6IkLsi9/gxOtUUFvnD4yIkWeHTT110Ch6R4kDuonNtOiHsTmMMRtsY5kRWoIL9VMfX6QHWjamhvd+XJp4sXkLMBdtJ3aTzfsUNrQIdrcPTox9qTNjShunTlbAYkq1TSUXaylEGHvcwHrbo7ZoUlBvidqnJGUNRJPxOHHyfCm5VqFzuFI8AG1W/dj1W4Di0AAND/mwzjZKUTRsiX4uEaRw8/Na4Qj/GBMuT18hUoGpe7t/UYw5JFw+MXm0kn/5jKe9r62xil3TN8BK9ODQDP9zq08+iiT0CBtEX5F4Drrowb57IwcW7nt/hkCeeyR59B/Z6nPsSq0NQ+rd1w4a1iHIyaTU6acQsKwmaX1OeTvtT2p7U/HhqfhVMSqA7ybGhQDF4FPPzIbh+o+D1S+AX9m9nVSSJNwoevikdZimCbk1l1HmUrhz78GO+j0yFg==',
+ },
+ endpoint: { endpointId: 'device-1' },
+ payload: {},
+ },
+ context: {
+ properties: [
+ {
+ namespace: 'Alexa.PowerController',
+ name: 'powerState',
+ value: 'ON',
+ timeOfSample: get(result, 'context.properties.0.timeOfSample'),
+ uncertaintyInMilliseconds: 0,
+ },
+ ],
+ },
+ });
+ });
+});
diff --git a/server/utils/constants.js b/server/utils/constants.js
index 931913d85a..302de44515 100644
--- a/server/utils/constants.js
+++ b/server/utils/constants.js
@@ -47,6 +47,7 @@ const SYSTEM_VARIABLE_NAMES = {
GLADYS_GATEWAY_USERS_KEYS: 'GLADYS_GATEWAY_USERS_KEYS',
GLADYS_GATEWAY_GOOGLE_HOME_USER_IS_CONNECTED_WITH_GATEWAY:
'GLADYS_GATEWAY_GOOGLE_HOME_USER_IS_CONNECTED_WITH_GATEWAY',
+ GLADYS_GATEWAY_ALEXA_USER_IS_CONNECTED_WITH_GATEWAY: 'GLADYS_GATEWAY_ALEXA_USER_IS_CONNECTED_WITH_GATEWAY',
TIMEZONE: 'TIMEZONE',
};