diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json
index 9a185284f7..853c97c69c 100644
--- a/front/src/config/i18n/en.json
+++ b/front/src/config/i18n/en.json
@@ -1638,6 +1638,13 @@
"messageLabel": "Message",
"variablesExplanation": "To inject a variable in the text, press '{{ '. To set a variable value, you need to use the 'Get device value' box before this one.",
"messagePlaceholder": "My message"
+ },
+ "playNotification": {
+ "description": "This action will make Gladys speak on the selected speaker.",
+ "needGladysPlus": "Requires Gladys Plus as Text-To-Speech APIs are paid.",
+ "deviceLabel": "Speaker",
+ "textLabel": "Message to speak on the speaker",
+ "variablesExplanation": "To inject a variable, type '{{ '. Note: You must have defined a variable beforehand in a 'Retrieve Last State' action placed before this message block."
}
},
"actions": {
@@ -1695,6 +1702,9 @@
},
"mqtt": {
"send": "Send MQTT Message"
+ },
+ "music": {
+ "play-notification": "Talk on a speaker"
}
},
"variables": {
@@ -2743,7 +2753,8 @@
"pause": "Music pause button",
"previous": "Music previous button",
"next": "Music next button",
- "playback_state": "Music playback state"
+ "playback_state": "Music playback state",
+ "play_notification": "Broadcast a notification"
},
"unknown": {
"shortCategoryName": "Unknown",
diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json
index 4cc234b364..8a4878b471 100644
--- a/front/src/config/i18n/fr.json
+++ b/front/src/config/i18n/fr.json
@@ -1640,6 +1640,13 @@
"messageLabel": "Message",
"variablesExplanation": "Pour injecter une variable, tapez '{{ '. Attention, vous devez avoir défini une variable auparavant dans une action 'Récupérer le dernier état' placé avant ce bloc message.",
"messagePlaceholder": "Mon message"
+ },
+ "playNotification": {
+ "description": "Cette action fera parler Gladys sur l'enceinte sélectionnée.",
+ "needGladysPlus": "Nécessite Gladys Plus car les API de \"Text To Speech\" sont payantes.",
+ "deviceLabel": "Enceinte",
+ "textLabel": "Message à dire sur l'enceinte",
+ "variablesExplanation": "Pour injecter une variable, tapez '{{ '. Attention, vous devez avoir défini une variable auparavant dans une action 'Récupérer le dernier état' placé avant ce bloc message."
}
},
"actions": {
@@ -1697,6 +1704,9 @@
},
"mqtt": {
"send": "Envoyer un message MQTT"
+ },
+ "music": {
+ "play-notification": "Parler sur une enceinte"
}
},
"variables": {
@@ -2745,7 +2755,8 @@
"pause": "Bouton pause musique",
"previous": "Bouton précédent musique",
"next": "Bouton suivant musique",
- "playback_state": "Etat de la lecture musique"
+ "playback_state": "Etat de la lecture musique",
+ "play_notification": "Diffuser une notification"
},
"unknown": {
"shortCategoryName": "Inconnu",
diff --git a/front/src/routes/scene/edit-scene/ActionCard.jsx b/front/src/routes/scene/edit-scene/ActionCard.jsx
index a3e0305611..e8d9ca58e7 100644
--- a/front/src/routes/scene/edit-scene/ActionCard.jsx
+++ b/front/src/routes/scene/edit-scene/ActionCard.jsx
@@ -28,6 +28,7 @@ import SendMessageCameraParams from './actions/SendMessageCameraParams';
import CheckAlarmMode from './actions/CheckAlarmMode';
import SetAlarmMode from './actions/SetAlarmMode';
import SendMqttMessage from './actions/SendMqttMessage';
+import PlayNotification from './actions/PlayNotification';
const deleteActionFromColumn = (columnIndex, rowIndex, deleteAction) => () => {
deleteAction(columnIndex, rowIndex);
@@ -58,7 +59,8 @@ const ACTION_ICON = {
[ACTIONS.ECOWATT.CONDITION]: 'fe fe-zap',
[ACTIONS.ALARM.CHECK_ALARM_MODE]: 'fe fe-bell',
[ACTIONS.ALARM.SET_ALARM_MODE]: 'fe fe-bell',
- [ACTIONS.MQTT.SEND]: 'fe fe-message-square'
+ [ACTIONS.MQTT.SEND]: 'fe fe-message-square',
+ [ACTIONS.MUSIC.PLAY_NOTIFICATION]: 'fe fe-speaker'
};
const ACTION_CARD_TYPE = 'ACTION_CARD_TYPE';
@@ -367,6 +369,17 @@ const ActionCard = ({ children, ...props }) => {
triggersVariables={props.triggersVariables}
/>
)}
+ {props.action.type === ACTIONS.MUSIC.PLAY_NOTIFICATION && (
+
+ )}
diff --git a/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx b/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx
index 8a542024a9..17e8f5ed6e 100644
--- a/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx
+++ b/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx
@@ -30,7 +30,8 @@ const ACTION_LIST = [
ACTIONS.ECOWATT.CONDITION,
ACTIONS.ALARM.CHECK_ALARM_MODE,
ACTIONS.ALARM.SET_ALARM_MODE,
- ACTIONS.MQTT.SEND
+ ACTIONS.MQTT.SEND,
+ ACTIONS.MUSIC.PLAY_NOTIFICATION
];
const TRANSLATIONS = ACTION_LIST.reduce((acc, action) => {
diff --git a/front/src/routes/scene/edit-scene/actions/PlayNotification.jsx b/front/src/routes/scene/edit-scene/actions/PlayNotification.jsx
new file mode 100644
index 0000000000..96cb7eff9c
--- /dev/null
+++ b/front/src/routes/scene/edit-scene/actions/PlayNotification.jsx
@@ -0,0 +1,115 @@
+import Select from 'react-select';
+import { Component } from 'preact';
+import { connect } from 'unistore/preact';
+import { Text } from 'preact-i18n';
+
+import { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } from '../../../../../../server/utils/constants';
+
+import TextWithVariablesInjected from '../../../../components/scene/TextWithVariablesInjected';
+
+class PlayNotification extends Component {
+ getOptions = async () => {
+ try {
+ const devices = await this.props.httpClient.get('/api/v1/device', {
+ device_feature_category: DEVICE_FEATURE_CATEGORIES.MUSIC,
+ device_feature_type: DEVICE_FEATURE_TYPES.MUSIC.PLAY_NOTIFICATION
+ });
+ const devicesOptions = devices.map(device => ({
+ value: device.selector,
+ label: device.name
+ }));
+
+ await this.setState({ devicesOptions });
+ this.refreshSelectedOptions(this.props);
+ return devicesOptions;
+ } catch (e) {
+ console.error(e);
+ }
+ };
+ updateText = text => {
+ this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'text', text);
+ };
+ handleDeviceChange = selectedOption => {
+ if (selectedOption && selectedOption.value) {
+ this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'device', selectedOption.value);
+ } else {
+ this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'device', null);
+ }
+ };
+
+ refreshSelectedOptions = nextProps => {
+ let selectedDeviceFeatureOption = '';
+ if (nextProps.action.device && this.state.devicesOptions) {
+ const deviceFeatureOption = this.state.devicesOptions.find(option => option.value === nextProps.action.device);
+
+ if (deviceFeatureOption) {
+ selectedDeviceFeatureOption = deviceFeatureOption;
+ }
+ }
+ this.setState({ selectedDeviceFeatureOption });
+ };
+ constructor(props) {
+ super(props);
+ this.props = props;
+ this.state = {
+ selectedDeviceFeatureOption: ''
+ };
+ }
+ componentDidMount() {
+ this.getOptions();
+ }
+ componentWillReceiveProps(nextProps) {
+ this.refreshSelectedOptions(nextProps);
+ }
+ render(props, { selectedDeviceFeatureOption, devicesOptions }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default connect('httpClient', {})(PlayNotification);
diff --git a/server/lib/device/device.setValue.js b/server/lib/device/device.setValue.js
index 8a5d52bb1b..dc472c2078 100644
--- a/server/lib/device/device.setValue.js
+++ b/server/lib/device/device.setValue.js
@@ -19,7 +19,11 @@ async function setValue(device, deviceFeature, value) {
throw new NotFoundError(`Function device.setValue in service ${device.service.name} does not exist.`);
}
await service.device.setValue(device, deviceFeature, value);
- if (!deviceFeature.has_feedback) {
+ // If device has feedback, the feedback will be sent and saved
+ // If value is a string, no need to save it
+ // @ts-ignore
+ const valueIsString = typeof value === 'string' || value instanceof String;
+ if (!deviceFeature.has_feedback && !valueIsString) {
await this.saveState(deviceFeature, value);
}
}
diff --git a/server/lib/gateway/gateway.getTTSApiUrl.js b/server/lib/gateway/gateway.getTTSApiUrl.js
new file mode 100644
index 0000000000..94227716ec
--- /dev/null
+++ b/server/lib/gateway/gateway.getTTSApiUrl.js
@@ -0,0 +1,34 @@
+const get = require('get-value');
+const logger = require('../../utils/logger');
+const { Error403, Error429 } = require('../../utils/httpErrors');
+
+/**
+ * @description Ask OpenAI a question.
+ * @param {object} body - The query to ask.
+ * @returns {Promise} Resolve with OpenAI response.
+ * @example
+ * openAIAsk({
+ * question
+ * })
+ */
+async function getTTSApiUrl(body) {
+ try {
+ const response = await this.gladysGatewayClient.ttsGetToken(body);
+ return response;
+ } catch (e) {
+ logger.debug(e);
+ const status = get(e, 'response.status');
+ const message = get(e, 'response.data.error_message');
+ if (status === 403) {
+ throw new Error403(message);
+ }
+ if (status === 429) {
+ throw new Error429(message);
+ }
+ throw e;
+ }
+}
+
+module.exports = {
+ getTTSApiUrl,
+};
diff --git a/server/lib/gateway/index.js b/server/lib/gateway/index.js
index 85d16bd2d5..2c59872c00 100644
--- a/server/lib/gateway/index.js
+++ b/server/lib/gateway/index.js
@@ -29,6 +29,7 @@ const { saveUsersKeys } = require('./gateway.saveUsersKeys');
const { refreshUserKeys } = require('./gateway.refreshUserKeys');
const { getEcowattSignals } = require('./gateway.getEcowattSignals');
const { openAIAsk } = require('./gateway.openAIAsk');
+const { getTTSApiUrl } = require('./gateway.getTTSApiUrl');
const { forwardMessageToOpenAI } = require('./gateway.forwardMessageToOpenAI');
// Enedis API
@@ -124,4 +125,7 @@ Gateway.prototype.enedisGetConsumptionLoadCurve = enedisGetConsumptionLoadCurve;
Gateway.prototype.enedisGetDailyConsumption = enedisGetDailyConsumption;
Gateway.prototype.enedisGetDailyConsumptionMaxPower = enedisGetDailyConsumptionMaxPower;
+// TTS API
+Gateway.prototype.getTTSApiUrl = getTTSApiUrl;
+
module.exports = Gateway;
diff --git a/server/lib/scene/scene.actions.js b/server/lib/scene/scene.actions.js
index b752daa3b2..4c651f996a 100644
--- a/server/lib/scene/scene.actions.js
+++ b/server/lib/scene/scene.actions.js
@@ -465,6 +465,21 @@ const actionsFunc = {
mqttService.device.publish(action.topic, messageWithVariables);
}
},
+ [ACTIONS.MUSIC.PLAY_NOTIFICATION]: async (self, action, scope) => {
+ // Get device
+ const device = self.stateManager.get('device', action.device);
+ const deviceFeature = getDeviceFeature(
+ device,
+ DEVICE_FEATURE_CATEGORIES.MUSIC,
+ DEVICE_FEATURE_TYPES.MUSIC.PLAY_NOTIFICATION,
+ );
+ // replace variable in text
+ const messageWithVariables = Handlebars.compile(action.text)(scope);
+ // Get TTS URL
+ const { url } = await self.gateway.getTTSApiUrl({ text: messageWithVariables });
+ // Play TTS Notification on device
+ await self.device.setValue(device, deviceFeature, url);
+ },
};
module.exports = {
diff --git a/server/package-lock.json b/server/package-lock.json
index ee0ce3c6b4..04966fa533 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -8,7 +8,7 @@
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
- "@gladysassistant/gladys-gateway-js": "^4.12.0",
+ "@gladysassistant/gladys-gateway-js": "^4.14.0",
"@hapi/joi": "^17.1.0",
"@hapi/joi-date": "^2.0.1",
"@nlpjs/similarity": "^4.26.1",
@@ -705,9 +705,9 @@
"optional": true
},
"node_modules/@gladysassistant/gladys-gateway-js": {
- "version": "4.12.0",
- "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.12.0.tgz",
- "integrity": "sha512-kZU9euieVmM1vt2rE3Q1+rGswim2TUAH1DGCCC2lnLQ1vFfSOLNP6omPbFjOaB8spvA+sRc2UR/xkBFHZPjOcA==",
+ "version": "4.14.0",
+ "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.14.0.tgz",
+ "integrity": "sha512-w8+igEEpfqM8Si4JKnMKWCh4c4onqXOx+hTn5qoy7DED9eowv2qtuptUAMvQJXy1zVedOhiQFtFoJ/2eBoTZUg==",
"dependencies": {
"@ctrlpanel/pbkdf2": "^1.0.0",
"array-buffer-to-hex": "^1.0.0",
@@ -12269,9 +12269,9 @@
"optional": true
},
"@gladysassistant/gladys-gateway-js": {
- "version": "4.12.0",
- "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.12.0.tgz",
- "integrity": "sha512-kZU9euieVmM1vt2rE3Q1+rGswim2TUAH1DGCCC2lnLQ1vFfSOLNP6omPbFjOaB8spvA+sRc2UR/xkBFHZPjOcA==",
+ "version": "4.14.0",
+ "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.14.0.tgz",
+ "integrity": "sha512-w8+igEEpfqM8Si4JKnMKWCh4c4onqXOx+hTn5qoy7DED9eowv2qtuptUAMvQJXy1zVedOhiQFtFoJ/2eBoTZUg==",
"requires": {
"@ctrlpanel/pbkdf2": "^1.0.0",
"array-buffer-to-hex": "^1.0.0",
diff --git a/server/package.json b/server/package.json
index d86b87d5b8..50bd77cfbe 100644
--- a/server/package.json
+++ b/server/package.json
@@ -73,7 +73,7 @@
"supertest": "^3.4.2"
},
"dependencies": {
- "@gladysassistant/gladys-gateway-js": "^4.12.0",
+ "@gladysassistant/gladys-gateway-js": "^4.14.0",
"@hapi/joi": "^17.1.0",
"@hapi/joi-date": "^2.0.1",
"@nlpjs/similarity": "^4.26.1",
diff --git a/server/services/sonos/lib/sonos.setValue.js b/server/services/sonos/lib/sonos.setValue.js
index 188b76b6f3..8d5e18688c 100644
--- a/server/services/sonos/lib/sonos.setValue.js
+++ b/server/services/sonos/lib/sonos.setValue.js
@@ -25,6 +25,15 @@ async function setValue(device, deviceFeature, value) {
if (deviceFeature.type === DEVICE_FEATURE_TYPES.MUSIC.VOLUME) {
await sonosDevice.SetVolume(value);
}
+
+ if (deviceFeature.type === DEVICE_FEATURE_TYPES.MUSIC.PLAY_NOTIFICATION) {
+ await sonosDevice.PlayNotification({
+ trackUri: value,
+ onlyWhenPlaying: false,
+ volume: 45, // Set the volume for the notification (and revert back afterwards)
+ timeout: 10,
+ });
+ }
}
module.exports = {
diff --git a/server/services/sonos/utils/convertToGladysDevice.js b/server/services/sonos/utils/convertToGladysDevice.js
index 2745480ce9..c4aa0706c6 100644
--- a/server/services/sonos/utils/convertToGladysDevice.js
+++ b/server/services/sonos/utils/convertToGladysDevice.js
@@ -73,6 +73,17 @@ const convertToGladysDevice = (serviceId, device) => {
read_only: true,
has_feedback: false,
},
+ {
+ name: `${device.name} - Play Notification`,
+ external_id: `sonos:${device.uuid}:play-notification`,
+ category: DEVICE_FEATURE_CATEGORIES.MUSIC,
+ type: DEVICE_FEATURE_TYPES.MUSIC.PLAY_NOTIFICATION,
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ },
],
};
};
diff --git a/server/test/lib/gateway/GladysGatewayClientMock.test.js b/server/test/lib/gateway/GladysGatewayClientMock.test.js
index ffe2a8882d..c9f1c7131d 100644
--- a/server/test/lib/gateway/GladysGatewayClientMock.test.js
+++ b/server/test/lib/gateway/GladysGatewayClientMock.test.js
@@ -96,6 +96,7 @@ const GladysGatewayClientMock = function GladysGatewayClientMock() {
enedisFunction: 'enedisGetDailyConsumptionMaxPower',
}),
getEcowattSignals: fake.resolves({ signals: [] }),
+ ttsGetToken: fake.resolves({ url: 'http://test.com' }),
openAIAsk: fake.resolves({ answer: 'this is the answer' }),
};
};
diff --git a/server/test/lib/gateway/gateway.getTTSApiUrl.test.js b/server/test/lib/gateway/gateway.getTTSApiUrl.test.js
new file mode 100644
index 0000000000..71b7d8b2ec
--- /dev/null
+++ b/server/test/lib/gateway/gateway.getTTSApiUrl.test.js
@@ -0,0 +1,89 @@
+const { expect, assert } = require('chai');
+const { fake } = require('sinon');
+const proxyquire = require('proxyquire').noCallThru();
+const EventEmitter = require('events');
+const GladysGatewayClientMock = require('./GladysGatewayClientMock.test');
+const { Error403, Error429 } = require('../../../utils/httpErrors');
+
+const event = new EventEmitter();
+
+const job = {
+ wrapper: (type, func) => {
+ return async () => {
+ return func();
+ };
+ },
+ updateProgress: fake.resolves({}),
+};
+
+class AxiosForbiddenError extends Error {
+ constructor(message) {
+ super();
+ this.response = {
+ status: 403,
+ };
+ }
+}
+
+class AxiosTooManyRequestsError extends Error {
+ constructor(message) {
+ super();
+ this.response = {
+ status: 429,
+ };
+ }
+}
+
+describe('gateway.getTTSApiUrl', () => {
+ const variable = {
+ getValue: fake.resolves(null),
+ setValue: fake.resolves(null),
+ };
+ const system = {
+ getInfos: fake.resolves({ gladys_version: 'v4.12.2' }),
+ };
+ it('should return url', async () => {
+ const Gateway = proxyquire('../../../lib/gateway', {
+ '@gladysassistant/gladys-gateway-js': GladysGatewayClientMock,
+ });
+ const gateway = new Gateway(variable, event, system, {}, {}, {}, {}, {}, job);
+ const data = await gateway.getTTSApiUrl();
+ expect(data).to.deep.equal({ url: 'http://test.com' });
+ });
+ it('should return 429', async () => {
+ const tooManyRequests = function gladysGatewayJsMock() {
+ return {
+ ttsGetToken: fake.rejects(new AxiosTooManyRequestsError()),
+ };
+ };
+ const Gateway = proxyquire('../../../lib/gateway', {
+ '@gladysassistant/gladys-gateway-js': tooManyRequests,
+ });
+ const gateway = new Gateway(variable, event, system, {}, {}, {}, {}, {}, job);
+ await assert.isRejected(gateway.getTTSApiUrl(), Error429);
+ });
+ it('should return 403', async () => {
+ const forbidden = function gladysGatewayJsMock() {
+ return {
+ ttsGetToken: fake.rejects(new AxiosForbiddenError()),
+ };
+ };
+ const Gateway = proxyquire('../../../lib/gateway', {
+ '@gladysassistant/gladys-gateway-js': forbidden,
+ });
+ const gateway = new Gateway(variable, event, system, {}, {}, {}, {}, {}, job);
+ await assert.isRejected(gateway.getTTSApiUrl(), Error403);
+ });
+ it('should return error', async () => {
+ const forbidden = function gladysGatewayJsMock() {
+ return {
+ ttsGetToken: fake.rejects(new Error('test error')),
+ };
+ };
+ const Gateway = proxyquire('../../../lib/gateway', {
+ '@gladysassistant/gladys-gateway-js': forbidden,
+ });
+ const gateway = new Gateway(variable, event, system, {}, {}, {}, {}, {}, job);
+ await assert.isRejected(gateway.getTTSApiUrl(), Error, 'test error');
+ });
+});
diff --git a/server/test/lib/scene/actions/scene.action.playNotification.test.js b/server/test/lib/scene/actions/scene.action.playNotification.test.js
new file mode 100644
index 0000000000..c5fbdb99c2
--- /dev/null
+++ b/server/test/lib/scene/actions/scene.action.playNotification.test.js
@@ -0,0 +1,56 @@
+const { fake, assert } = require('sinon');
+const EventEmitter = require('events');
+
+const { ACTIONS, DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants');
+const { executeActions } = require('../../../../lib/scene/scene.executeActions');
+
+const StateManager = require('../../../../lib/state');
+
+const event = new EventEmitter();
+
+describe('scene.play-notification', () => {
+ it('should play notification with injected value', async () => {
+ const stateManager = new StateManager(event);
+ const deviceFeature = {
+ category: DEVICE_FEATURE_CATEGORIES.MUSIC,
+ type: DEVICE_FEATURE_TYPES.MUSIC.PLAY_NOTIFICATION,
+ last_value: 15,
+ };
+ const oneDevice = {
+ features: [deviceFeature],
+ };
+ stateManager.setState('deviceFeature', 'my-device-feature', deviceFeature);
+ stateManager.setState('device', 'my-device', oneDevice);
+ const message = {
+ sendToUser: fake.resolves(null),
+ };
+ const gateway = {
+ getTTSApiUrl: fake.resolves({ url: 'http://test.com' }),
+ };
+ const device = {
+ setValue: fake.resolves(null),
+ };
+ const scope = {};
+ await executeActions(
+ { stateManager, event, message, gateway, device },
+ [
+ [
+ {
+ type: ACTIONS.DEVICE.GET_VALUE,
+ device_feature: 'my-device-feature',
+ },
+ ],
+ [
+ {
+ type: ACTIONS.MUSIC.PLAY_NOTIFICATION,
+ device: 'my-device',
+ text: 'Temperature in the living room is {{0.0.last_value}} °C.',
+ },
+ ],
+ ],
+ scope,
+ );
+ assert.calledWith(gateway.getTTSApiUrl, { text: 'Temperature in the living room is 15 °C.' });
+ assert.calledWith(device.setValue, oneDevice, deviceFeature, 'http://test.com');
+ });
+});
diff --git a/server/test/services/sonos/lib/sonos.init.test.js b/server/test/services/sonos/lib/sonos.init.test.js
index 4e02cd01e1..d7447078de 100644
--- a/server/test/services/sonos/lib/sonos.init.test.js
+++ b/server/test/services/sonos/lib/sonos.init.test.js
@@ -142,6 +142,17 @@ describe('SonosHandler.init', () => {
read_only: true,
has_feedback: false,
},
+ {
+ name: 'My sonos - Play Notification',
+ external_id: 'sonos:test-uuid:play-notification',
+ category: 'music',
+ type: 'play_notification',
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ },
],
},
]);
diff --git a/server/test/services/sonos/lib/sonos.setValue.test.js b/server/test/services/sonos/lib/sonos.setValue.test.js
index 103f702e33..74ef8475cd 100644
--- a/server/test/services/sonos/lib/sonos.setValue.test.js
+++ b/server/test/services/sonos/lib/sonos.setValue.test.js
@@ -13,6 +13,7 @@ const devicePause = fake.resolves(null);
const devicePrevious = fake.resolves(null);
const deviceNext = fake.resolves(null);
const deviceSetVolume = fake.resolves(null);
+const devicePlayNotification = fake.resolves(null);
const SonosManager = sinon.stub();
SonosManager.prototype.InitializeWithDiscovery = fake.returns(null);
@@ -28,6 +29,7 @@ SonosManager.prototype.Devices = [
Previous: devicePrevious,
Next: deviceNext,
SetVolume: deviceSetVolume,
+ PlayNotification: devicePlayNotification,
AVTransportService: {
Events: {
removeAllListeners: fake.returns(null),
@@ -168,4 +170,30 @@ describe('SonosHandler.setValue', () => {
await sonosHandler.setValue(device, deviceFeature, 46);
assert.calledWith(deviceSetVolume, 46);
});
+ it('should play notification on Sonos', async () => {
+ const device = {
+ name: 'My sonos',
+ external_id: 'sonos:test-uuid',
+ service_id: 'ffa13430-df93-488a-9733-5c540e9558e0',
+ should_poll: false,
+ };
+ const deviceFeature = {
+ name: 'My sonos - Play notification',
+ external_id: 'sonos:test-uuid:play-notification',
+ category: 'music',
+ type: 'play_notification',
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ };
+ await sonosHandler.setValue(device, deviceFeature, 'http://test.com');
+ assert.calledWith(devicePlayNotification, {
+ onlyWhenPlaying: false,
+ timeout: 10,
+ trackUri: 'http://test.com',
+ volume: 45,
+ });
+ });
});
diff --git a/server/utils/constants.js b/server/utils/constants.js
index 4f4d4a8d70..59e74a77af 100644
--- a/server/utils/constants.js
+++ b/server/utils/constants.js
@@ -359,6 +359,9 @@ const ACTIONS = {
MQTT: {
SEND: 'mqtt.send',
},
+ MUSIC: {
+ PLAY_NOTIFICATION: 'music.play-notification',
+ },
};
const INTENTS = {
@@ -545,6 +548,7 @@ const DEVICE_FEATURE_TYPES = {
PREVIOUS: 'previous',
NEXT: 'next',
PLAYBACK_STATE: 'playback_state',
+ PLAY_NOTIFICATION: 'play_notification',
},
ENERGY_SENSOR: {
BINARY: 'binary',