Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add "Speak on speaker" TTS action in scenes + in Sonos integration #1974

Merged
merged 10 commits into from
Dec 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion front/src/config/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -1695,6 +1702,9 @@
},
"mqtt": {
"send": "Send MQTT Message"
},
"music": {
"play-notification": "Talk on a speaker"
}
},
"variables": {
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion front/src/config/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -1697,6 +1704,9 @@
},
"mqtt": {
"send": "Envoyer un message MQTT"
},
"music": {
"play-notification": "Parler sur une enceinte"
}
},
"variables": {
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 14 additions & 1 deletion front/src/routes/scene/edit-scene/ActionCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -367,6 +369,17 @@ const ActionCard = ({ children, ...props }) => {
triggersVariables={props.triggersVariables}
/>
)}
{props.action.type === ACTIONS.MUSIC.PLAY_NOTIFICATION && (
<PlayNotification
action={props.action}
columnIndex={props.columnIndex}
index={props.index}
updateActionProperty={props.updateActionProperty}
actionsGroupsBefore={props.actionsGroupsBefore}
variables={props.variables}
triggersVariables={props.triggersVariables}
/>
)}
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
115 changes: 115 additions & 0 deletions front/src/routes/scene/edit-scene/actions/PlayNotification.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<p>
<Text id="editScene.actionsCard.playNotification.description" />
</p>
<div class="form-group">
<label class="form-label">
<Text id="editScene.actionsCard.playNotification.deviceLabel" />
<span class="form-required">
<Text id="global.requiredField" />
</span>
</label>
<Select
styles={{
// Fixes the overlapping problem of the component
menu: provided => ({ ...provided, zIndex: 2 })
}}
options={devicesOptions}
value={selectedDeviceFeatureOption}
onChange={this.handleDeviceChange}
/>
</div>
<div class="form-group">
<label class="form-label">
<Text id="editScene.actionsCard.playNotification.textLabel" />{' '}
<span class="form-required">
<Text id="global.requiredField" />
</span>
</label>
<div class="mb-1 small">
<Text id="editScene.actionsCard.playNotification.variablesExplanation" />
</div>
<div className="tags-input">
<TextWithVariablesInjected
text={props.action.text}
triggersVariables={props.triggersVariables}
actionsGroupsBefore={props.actionsGroupsBefore}
variables={props.variables}
updateText={this.updateText}
/>
</div>
</div>
<p class="small">
<Text id="editScene.actionsCard.playNotification.needGladysPlus" />
</p>
</div>
);
}
}

export default connect('httpClient', {})(PlayNotification);
6 changes: 5 additions & 1 deletion server/lib/device/device.setValue.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
34 changes: 34 additions & 0 deletions server/lib/gateway/gateway.getTTSApiUrl.js
Original file line number Diff line number Diff line change
@@ -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,
};
4 changes: 4 additions & 0 deletions server/lib/gateway/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
15 changes: 15 additions & 0 deletions server/lib/scene/scene.actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
14 changes: 7 additions & 7 deletions server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions server/services/sonos/lib/sonos.setValue.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
11 changes: 11 additions & 0 deletions server/services/sonos/utils/convertToGladysDevice.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
],
};
};
Expand Down
Loading