diff --git a/front/src/assets/integrations/cover/sonos.jpg b/front/src/assets/integrations/cover/sonos.jpg
new file mode 100644
index 0000000000..a89425b909
Binary files /dev/null and b/front/src/assets/integrations/cover/sonos.jpg differ
diff --git a/front/src/components/app.jsx b/front/src/components/app.jsx
index a63c43cccd..73d834c571 100644
--- a/front/src/components/app.jsx
+++ b/front/src/components/app.jsx
@@ -139,6 +139,10 @@ import TuyaEditPage from '../routes/integration/all/tuya/edit-page';
import TuyaSetupPage from '../routes/integration/all/tuya/setup-page';
import TuyaDiscoverPage from '../routes/integration/all/tuya/discover-page';
+// Sonos integration
+import SonosDevicePage from '../routes/integration/all/sonos/device-page';
+import SonosDiscoveryPage from '../routes/integration/all/sonos/discover-page';
+
// MELCloud integration
import MELCloudPage from '../routes/integration/all/melcloud/device-page';
import MELCloudEditPage from '../routes/integration/all/melcloud/edit-page';
@@ -275,6 +279,9 @@ const AppRouter = connect(
+
+
+
diff --git a/front/src/components/boxs/music/EditMusicBox.jsx b/front/src/components/boxs/music/EditMusicBox.jsx
new file mode 100644
index 0000000000..1cb7c9a014
--- /dev/null
+++ b/front/src/components/boxs/music/EditMusicBox.jsx
@@ -0,0 +1,66 @@
+import { Component } from 'preact';
+import { Text } from 'preact-i18n';
+import Select from 'react-select';
+import { connect } from 'unistore/preact';
+
+import BaseEditBox from '../baseEditBox';
+import { DEVICE_FEATURE_CATEGORIES } from '../../../../../server/utils/constants';
+
+class EditMusicBoxComponent extends Component {
+ updateDevice = option => {
+ this.props.updateBoxConfig(this.props.x, this.props.y, {
+ device: option ? option.value : null
+ });
+ };
+
+ getDevices = async () => {
+ try {
+ await this.setState({
+ error: false
+ });
+ const musicDevices = await this.props.httpClient.get('/api/v1/device', {
+ device_feature_category: DEVICE_FEATURE_CATEGORIES.MUSIC
+ });
+ const musicDevicesOptions = musicDevices.map(d => ({
+ label: d.name,
+ value: d.selector
+ }));
+ this.setState({
+ musicDevicesOptions
+ });
+ } catch (e) {
+ console.error(e);
+ this.setState({
+ error: true
+ });
+ }
+ };
+
+ componentDidMount() {
+ this.getDevices();
+ }
+
+ render(props, { musicDevicesOptions }) {
+ let optionSelected = null;
+ if (musicDevicesOptions && props.box.device) {
+ optionSelected = musicDevicesOptions.find(o => o.value === props.box.device);
+ }
+ return (
+
+
+
+
+
+
+ );
+ }
+}
+
+export default connect('httpClient', {})(EditMusicBoxComponent);
diff --git a/front/src/components/boxs/music/MusicBox.jsx b/front/src/components/boxs/music/MusicBox.jsx
new file mode 100644
index 0000000000..94173e67fa
--- /dev/null
+++ b/front/src/components/boxs/music/MusicBox.jsx
@@ -0,0 +1,160 @@
+import { Component } from 'preact';
+import { connect } from 'unistore/preact';
+
+import {
+ WEBSOCKET_MESSAGE_TYPES,
+ DEVICE_FEATURE_TYPES,
+ MUSIC_PLAYBACK_STATE
+} from '../../../../../server/utils/constants';
+
+class MusicComponent extends Component {
+ state = {
+ isPlaying: null
+ };
+ getDevice = async () => {
+ try {
+ await this.setState({
+ error: false
+ });
+ const musicDevice = await this.props.httpClient.get(`/api/v1/device/${this.props.box.device}`, {});
+ const playFeature = musicDevice.features.find(f => f.type === DEVICE_FEATURE_TYPES.MUSIC.PLAY);
+ const pauseFeature = musicDevice.features.find(f => f.type === DEVICE_FEATURE_TYPES.MUSIC.PAUSE);
+ const previousFeature = musicDevice.features.find(f => f.type === DEVICE_FEATURE_TYPES.MUSIC.PREVIOUS);
+ const nextFeature = musicDevice.features.find(f => f.type === DEVICE_FEATURE_TYPES.MUSIC.NEXT);
+ const volumeFeature = musicDevice.features.find(f => f.type === DEVICE_FEATURE_TYPES.MUSIC.VOLUME);
+ const playBackStateFeature = musicDevice.features.find(f => f.type === DEVICE_FEATURE_TYPES.MUSIC.PLAYBACK_STATE);
+ const isPlaying = playBackStateFeature.last_value === MUSIC_PLAYBACK_STATE.PLAYING;
+ this.setState({
+ musicDevice,
+ playFeature,
+ pauseFeature,
+ previousFeature,
+ nextFeature,
+ volumeFeature,
+ playBackStateFeature,
+ isPlaying
+ });
+ } catch (e) {
+ console.error(e);
+ this.setState({
+ error: true
+ });
+ }
+ };
+
+ setValueDevice = async (deviceFeature, value) => {
+ try {
+ await this.setState({ error: false });
+ await this.props.httpClient.post(`/api/v1/device_feature/${deviceFeature.selector}/value`, {
+ value
+ });
+ } catch (e) {
+ console.error(e);
+ this.setState({ error: true });
+ }
+ };
+
+ play = async () => {
+ await this.setState({ isPlaying: true });
+ await this.setValueDevice(this.state.playFeature, 1);
+ };
+ pause = async () => {
+ await this.setState({ isPlaying: false });
+ await this.setValueDevice(this.state.pauseFeature, 1);
+ };
+ next = async () => {
+ await this.setValueDevice(this.state.nextFeature, 1);
+ };
+ previous = async () => {
+ await this.setValueDevice(this.state.previousFeature, 1);
+ };
+ changeVolume = async e => {
+ const volume = parseInt(e.target.value, 10);
+ const newVolumeFeature = { ...this.state.volumeFeature, last_value: volume };
+ await this.setState({ volumeFeature: newVolumeFeature });
+ await this.setValueDevice(this.state.volumeFeature, volume, 10);
+ };
+
+ updateDeviceStateWebsocket = payload => {
+ if (payload.device_feature_selector === this.state.playBackStateFeature.selector) {
+ const isPlaying = payload.last_value === MUSIC_PLAYBACK_STATE.PLAYING;
+ this.setState({ isPlaying });
+ }
+ if (payload.device_feature_selector === this.state.volumeFeature.selector) {
+ const newVolumeFeature = { ...this.state.volumeFeature, last_value: payload.last_value };
+ this.setState({ volumeFeature: newVolumeFeature });
+ }
+ };
+
+ componentDidMount() {
+ this.getDevice();
+ this.props.session.dispatcher.addListener(
+ WEBSOCKET_MESSAGE_TYPES.DEVICE.NEW_STATE,
+ this.updateDeviceStateWebsocket
+ );
+ }
+
+ componentWillUnmount() {
+ this.props.session.dispatcher.removeListener(
+ WEBSOCKET_MESSAGE_TYPES.DEVICE.NEW_STATE,
+ this.updateDeviceStateWebsocket
+ );
+ }
+
+ render(props, { isPlaying, musicDevice, previousFeature, nextFeature, volumeFeature }) {
+ return (
+
+
+
+
+
+ {previousFeature && (
+
+ )}
+
+
+ {!isPlaying && (
+
+ )}
+ {isPlaying && (
+
+ )}
+
+
+ {nextFeature && (
+
+ )}
+
+
+ {volumeFeature && (
+
+ )}
+
+
+ );
+ }
+}
+
+export default connect('httpClient,session', {})(MusicComponent);
diff --git a/front/src/config/demo.js b/front/src/config/demo.js
index 3a55d154ba..90399ebb1e 100644
--- a/front/src/config/demo.js
+++ b/front/src/config/demo.js
@@ -3485,6 +3485,50 @@ const data = {
.subtract(3, 'hour')
.toDate()
}
+ ],
+ 'get /api/v1/service/sonos/device': [
+ {
+ id: 'c0e21639-4fe9-4d1c-ad65-33255d21bf0d',
+ name: 'Sonos Speaker',
+ external_id: 'sonos:uuid',
+ features: [
+ {
+ name: 'Sonos Play',
+ category: 'music',
+ type: 'play',
+ min: 1,
+ max: 1
+ }
+ ]
+ }
+ ],
+ 'get /api/v1/service/sonos/discover': [
+ {
+ name: 'Sonos Speaker',
+ external_id: 'sonos:uuid',
+ features: [
+ {
+ name: 'Sonos Play',
+ category: 'music',
+ type: 'play',
+ min: 1,
+ max: 1
+ }
+ ]
+ },
+ {
+ name: 'Sonos Speaker',
+ external_id: 'sonos:another_uuid',
+ features: [
+ {
+ name: 'Sonos Play',
+ category: 'music',
+ type: 'play',
+ min: 1,
+ max: 1
+ }
+ ]
+ }
]
};
diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json
index 40ac835d15..9a185284f7 100644
--- a/front/src/config/i18n/en.json
+++ b/front/src/config/i18n/en.json
@@ -251,7 +251,8 @@
"chart": "Chart",
"ecowatt": "Ecowatt (France)",
"clock": "Clock",
- "scene": "Scene"
+ "scene": "Scene",
+ "music": "Music"
},
"boxes": {
"column": "Column {{index}}",
@@ -387,6 +388,9 @@
"alarmStatusText": "Your house is ",
"alarmArming": "Your house is being armed...",
"cancelAlarmArming": "Cancel"
+ },
+ "music": {
+ "selectDeviceLabel": "Select device to control"
}
}
},
@@ -894,6 +898,41 @@
"conflictError": "Current device is already in Gladys."
}
},
+ "sonos": {
+ "title": "Sonos",
+ "description": "Control Sonos devices on your local network",
+ "deviceTab": "Devices",
+ "discoverTab": "Sonos Discovery",
+ "setupTab": "Setup",
+ "documentation": "Sonos Documentation",
+ "discoverDeviceDescr": "Automatically scan for Sonos devices",
+ "nameLabel": "Device Name",
+ "namePlaceholder": "Enter your device name",
+ "hostLabel": "IP Address",
+ "roomLabel": "Room",
+ "saveButton": "Save",
+ "alreadyCreatedButton": "Already Created",
+ "deleteButton": "Delete",
+ "device": {
+ "title": "Sonos Devices in Gladys",
+ "editButton": "Edit",
+ "noDeviceFound": "No Sonos devices found.",
+ "featuresLabel": "Features"
+ },
+ "discover": {
+ "title": "Devices detected on your local network",
+ "description": "Sonos devices are automatically discovered.",
+ "error": "Error discovering Sonos devices. Is your Sonos speaker powered on and accessible on the local network?",
+ "noDeviceFound": "No Sonos devices were discovered.",
+ "errorWhileScanning": "An error occurred during scanning.",
+ "scan": "Scan"
+ },
+ "error": {
+ "defaultError": "An error occurred while saving the device.",
+ "defaultDeletionError": "An error occurred while deleting the device.",
+ "conflictError": "The current device is already in Gladys."
+ }
+ },
"melcloud": {
"title": "MELCloud",
"description": "Control your MELCloud devices (works with the cloud)",
@@ -2697,6 +2736,15 @@
"shortCategoryName": "Surface",
"decimal": "Surface"
},
+ "music": {
+ "shortCategoryName": "Music",
+ "volume": "Music volume",
+ "play": "Music play button",
+ "pause": "Music pause button",
+ "previous": "Music previous button",
+ "next": "Music next button",
+ "playback_state": "Music playback state"
+ },
"unknown": {
"shortCategoryName": "Unknown",
"unknown": "Unknown"
diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json
index 1c0bfd67c9..4cc234b364 100644
--- a/front/src/config/i18n/fr.json
+++ b/front/src/config/i18n/fr.json
@@ -251,7 +251,8 @@
"chart": "Graphique",
"ecowatt": "Ecowatt ( France )",
"clock": "Horloge",
- "scene": "Scène"
+ "scene": "Scène",
+ "music": "Musique"
},
"boxes": {
"column": "Colonne {{index}}",
@@ -387,6 +388,9 @@
"alarmStatusText": "Votre maison est ",
"alarmArming": "Votre maison est en train d'être armée...",
"cancelAlarmArming": "Annuler"
+ },
+ "music": {
+ "selectDeviceLabel": "Sélectionner l'appareil à contrôler"
}
}
},
@@ -1020,6 +1024,42 @@
"conflictError": "L'appareil actuel est déjà dans Gladys."
}
},
+ "sonos": {
+ "title": "Sonos",
+ "description": "Contrôler les appareils Sonos sur votre réseau local",
+ "deviceTab": "Appareils",
+ "discoverTab": "Découverte Sonos",
+ "setupTab": "Configuration",
+ "documentation": "Documentation Sonos",
+ "discoverDeviceDescr": "Scanner automatiquement les appareils Sonos",
+ "nameLabel": "Nom de l'appareil",
+ "namePlaceholder": "Entrez le nom de votre appareil",
+ "hostLabel": "Adresse IP",
+ "roomLabel": "Pièce",
+ "saveButton": "Sauvegarder",
+ "alreadyCreatedButton": "Déjà créé",
+ "deleteButton": "Supprimer",
+ "device": {
+ "title": "Appareils Sonos dans Gladys",
+ "editButton": "Editer",
+ "noDeviceFound": "Aucun appareil Sonos trouvé.",
+ "featuresLabel": "Fonctionnalités"
+ },
+ "discover": {
+ "title": "Appareils détectés sur votre réseau local",
+ "description": "Les appareils Sonos sont automatiquement découverts.",
+ "error": "Erreur de découverte des appareils Sonos. Est-ce que votre enceinte Sonos est sous tension et accessible sur le réseau local ?",
+ "noDeviceFound": "Aucun appareil Sonos n'a été découvert.",
+ "errorWhileScanning": "Une erreur est survenue lors du scan.",
+ "scan": "Scanner"
+ },
+
+ "error": {
+ "defaultError": "Une erreur s'est produite lors de l'enregistrement de l'appareil.",
+ "defaultDeletionError": "Une erreur s'est produite lors de la suppression de l'appareil.",
+ "conflictError": "L'appareil actuel est déjà dans Gladys."
+ }
+ },
"melcloud": {
"title": "MELCloud",
"description": "Contrôler vos appareils MELCloud",
@@ -2698,6 +2738,15 @@
"shortCategoryName": "Surface",
"decimal": "Surface"
},
+ "music": {
+ "shortCategoryName": "Musique",
+ "volume": "Volume de la musique",
+ "play": "Bouton lecture musique",
+ "pause": "Bouton pause musique",
+ "previous": "Bouton précédent musique",
+ "next": "Bouton suivant musique",
+ "playback_state": "Etat de la lecture musique"
+ },
"unknown": {
"shortCategoryName": "Inconnu",
"unknown": "Inconnu"
diff --git a/front/src/config/integrations/devices.json b/front/src/config/integrations/devices.json
index 95915d48de..ab4e5b6fd1 100644
--- a/front/src/config/integrations/devices.json
+++ b/front/src/config/integrations/devices.json
@@ -73,5 +73,10 @@
"key": "nodeRed",
"link": "node-red",
"img": "/assets/integrations/cover/node-red.jpg"
+ },
+ {
+ "key": "sonos",
+ "link": "sonos",
+ "img": "/assets/integrations/cover/sonos.jpg"
}
]
diff --git a/front/src/routes/dashboard/Box.jsx b/front/src/routes/dashboard/Box.jsx
index 3ba0122319..e482d90c57 100644
--- a/front/src/routes/dashboard/Box.jsx
+++ b/front/src/routes/dashboard/Box.jsx
@@ -10,6 +10,7 @@ import EcowattBox from '../../components/boxs/ecowatt/Ecowatt';
import ClockBox from '../../components/boxs/clock/Clock';
import SceneBox from '../../components/boxs/scene/SceneBox';
import AlarmBox from '../../components/boxs/alarm/Alarm';
+import MusicBox from '../../components/boxs/music/MusicBox';
const Box = ({ children, ...props }) => {
switch (props.box.type) {
@@ -37,6 +38,8 @@ const Box = ({ children, ...props }) => {
return ;
case 'alarm':
return ;
+ case 'music':
+ return ;
}
};
diff --git a/front/src/routes/dashboard/edit-dashboard/EditBox.jsx b/front/src/routes/dashboard/edit-dashboard/EditBox.jsx
index 8c600f5aa7..44198bde21 100644
--- a/front/src/routes/dashboard/edit-dashboard/EditBox.jsx
+++ b/front/src/routes/dashboard/edit-dashboard/EditBox.jsx
@@ -1,6 +1,7 @@
import EditWeatherBox from '../../../components/boxs/weather/EditWeatherBox';
import EditRoomTemperatureBox from '../../../components/boxs/room-temperature/EditRoomTemperatureBox';
import EditRoomHumidityBox from '../../../components/boxs/room-humidity/EditRoomHumidityBox';
+import EditMusicBox from '../../../components/boxs/music/EditMusicBox';
import EditCameraBox from '../../../components/boxs/camera/EditCamera';
import EditAtHomeBox from '../../../components/boxs/user-presence/EditUserPresenceBox';
import EditDevicesInRoom from '../../../components/boxs/device-in-room/EditDeviceInRoom';
@@ -39,6 +40,8 @@ const Box = ({ children, ...props }) => {
return ;
case 'alarm':
return ;
+ case 'music':
+ return ;
default:
return ;
}
diff --git a/front/src/routes/integration/all/sonos/SonosDeviceBox.jsx b/front/src/routes/integration/all/sonos/SonosDeviceBox.jsx
new file mode 100644
index 0000000000..bef88b81db
--- /dev/null
+++ b/front/src/routes/integration/all/sonos/SonosDeviceBox.jsx
@@ -0,0 +1,197 @@
+import { Component } from 'preact';
+import { Text, Localizer, MarkupText } from 'preact-i18n';
+import cx from 'classnames';
+import get from 'get-value';
+
+import { connect } from 'unistore/preact';
+
+class SonosDeviceBox extends Component {
+ componentWillMount() {
+ this.setState({
+ device: this.props.device
+ });
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.setState({
+ device: nextProps.device
+ });
+ }
+
+ updateName = e => {
+ this.setState({
+ device: {
+ ...this.state.device,
+ name: e.target.value
+ }
+ });
+ };
+
+ updateRoom = e => {
+ this.setState({
+ device: {
+ ...this.state.device,
+ room_id: e.target.value
+ }
+ });
+ };
+
+ saveDevice = async () => {
+ this.setState({
+ loading: true,
+ errorMessage: null
+ });
+ try {
+ let deviceDidNotExist = this.state.device.id === undefined;
+ const savedDevice = await this.props.httpClient.post(`/api/v1/device`, this.state.device);
+ if (deviceDidNotExist) {
+ savedDevice.alreadyExist = true;
+ }
+ this.setState({
+ device: savedDevice
+ });
+ } catch (e) {
+ let errorMessage = 'integration.sonos.error.defaultError';
+ if (e.response.status === 409) {
+ errorMessage = 'integration.sonos.error.conflictError';
+ }
+ this.setState({
+ errorMessage
+ });
+ }
+ this.setState({
+ loading: false
+ });
+ };
+
+ deleteDevice = async () => {
+ this.setState({
+ loading: true,
+ errorMessage: null,
+ tooMuchStatesError: false,
+ statesNumber: undefined
+ });
+ try {
+ if (this.state.device.created_at) {
+ await this.props.httpClient.delete(`/api/v1/device/${this.state.device.selector}`);
+ }
+ this.props.getSonosDevices();
+ } catch (e) {
+ const status = get(e, 'response.status');
+ const dataMessage = get(e, 'response.data.message');
+ if (status === 400 && dataMessage && dataMessage.includes('Too much states')) {
+ const statesNumber = new Intl.NumberFormat().format(dataMessage.split(' ')[0]);
+ this.setState({ tooMuchStatesError: true, statesNumber });
+ } else {
+ this.setState({
+ errorMessage: 'integration.sonos.error.defaultDeletionError'
+ });
+ }
+ }
+ this.setState({
+ loading: false
+ });
+ };
+
+ render(
+ { deviceIndex, editable, deleteButton, housesWithRooms },
+ { device, loading, errorMessage, tooMuchStatesError, statesNumber }
+ ) {
+ const validModel = device.features && device.features.length > 0;
+
+ return (
+
+
+
+
+
+
+
+ {errorMessage && (
+
+
+
+ )}
+ {tooMuchStatesError && (
+
+
+
+ )}
+
+
+
+ }
+ disabled={!editable || !validModel}
+ />
+
+
+
+ {housesWithRooms && (
+
+
+
+
+ )}
+
+
+ {device.alreadyExist && (
+
+ )}
+
+ {!device.alreadyExist && (
+
+ )}
+
+ {deleteButton && (
+
+ )}
+
+
+
+
+
+
+ );
+ }
+}
+
+export default connect('httpClient', {})(SonosDeviceBox);
diff --git a/front/src/routes/integration/all/sonos/SonosPage.jsx b/front/src/routes/integration/all/sonos/SonosPage.jsx
new file mode 100644
index 0000000000..0ee6470c3f
--- /dev/null
+++ b/front/src/routes/integration/all/sonos/SonosPage.jsx
@@ -0,0 +1,61 @@
+import { Text } from 'preact-i18n';
+import { Link } from 'preact-router/match';
+import DeviceConfigurationLink from '../../../../components/documentation/DeviceConfigurationLink';
+
+const SonosPage = ({ children, user }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{children}
+
+
+
+
+
+);
+
+export default SonosPage;
diff --git a/front/src/routes/integration/all/sonos/device-page/DeviceTab.jsx b/front/src/routes/integration/all/sonos/device-page/DeviceTab.jsx
new file mode 100644
index 0000000000..e4f31a9f76
--- /dev/null
+++ b/front/src/routes/integration/all/sonos/device-page/DeviceTab.jsx
@@ -0,0 +1,132 @@
+import { Text, Localizer } from 'preact-i18n';
+import cx from 'classnames';
+
+import EmptyState from './EmptyState';
+import { RequestStatus } from '../../../../../utils/consts';
+import style from './style.css';
+import CardFilter from '../../../../../components/layout/CardFilter';
+import SonosDeviceBox from '../SonosDeviceBox';
+import debounce from 'debounce';
+import { Component } from 'preact';
+import { connect } from 'unistore/preact';
+
+class DeviceTab extends Component {
+ constructor(props) {
+ super(props);
+ this.debouncedSearch = debounce(this.search, 200).bind(this);
+ }
+
+ componentWillMount() {
+ this.getSonosDevices();
+ this.getHouses();
+ }
+
+ async getSonosDevices() {
+ this.setState({
+ getSonosStatus: RequestStatus.Getting
+ });
+ try {
+ const options = {
+ order_dir: this.state.orderDir || 'asc'
+ };
+ if (this.state.search && this.state.search.length) {
+ options.search = this.state.search;
+ }
+
+ const sonosDevices = await this.props.httpClient.get('/api/v1/service/sonos/device', options);
+ this.setState({
+ sonosDevices,
+ getSonosStatus: RequestStatus.Success
+ });
+ } catch (e) {
+ this.setState({
+ getSonosStatus: e.message
+ });
+ }
+ }
+
+ async getHouses() {
+ this.setState({
+ housesGetStatus: RequestStatus.Getting
+ });
+ try {
+ const params = {
+ expand: 'rooms'
+ };
+ const housesWithRooms = await this.props.httpClient.get(`/api/v1/house`, params);
+ this.setState({
+ housesWithRooms,
+ housesGetStatus: RequestStatus.Success
+ });
+ } catch (e) {
+ this.setState({
+ housesGetStatus: RequestStatus.Error
+ });
+ }
+ }
+
+ async search(e) {
+ await this.setState({
+ search: e.target.value
+ });
+ this.getSonosDevices();
+ }
+ async changeOrderDir(e) {
+ await this.setState({
+ orderDir: e.target.value
+ });
+ this.getSonosDevices();
+ }
+
+ render({}, { orderDir, search, getSonosStatus, sonosDevices, housesWithRooms }) {
+ return (
+
+
+
+
+
+
+
+ {sonosDevices &&
+ sonosDevices.length > 0 &&
+ sonosDevices.map((device, index) => (
+
+ ))}
+ {!sonosDevices || (sonosDevices.length === 0 && )}
+
+
+
+
+
+ );
+ }
+}
+
+export default connect('httpClient', {})(DeviceTab);
diff --git a/front/src/routes/integration/all/sonos/device-page/EmptyState.jsx b/front/src/routes/integration/all/sonos/device-page/EmptyState.jsx
new file mode 100644
index 0000000000..43d207c6d8
--- /dev/null
+++ b/front/src/routes/integration/all/sonos/device-page/EmptyState.jsx
@@ -0,0 +1,23 @@
+import { Text } from 'preact-i18n';
+import { Link } from 'preact-router/match';
+import cx from 'classnames';
+import style from './style.css';
+
+const EmptyState = () => (
+
+);
+
+export default EmptyState;
diff --git a/front/src/routes/integration/all/sonos/device-page/index.js b/front/src/routes/integration/all/sonos/device-page/index.js
new file mode 100644
index 0000000000..a84697ccac
--- /dev/null
+++ b/front/src/routes/integration/all/sonos/device-page/index.js
@@ -0,0 +1,16 @@
+import { Component } from 'preact';
+import { connect } from 'unistore/preact';
+import DeviceTab from './DeviceTab';
+import SonosPage from '../SonosPage';
+
+class DevicePage extends Component {
+ render(props, {}) {
+ return (
+
+
+
+ );
+ }
+}
+
+export default connect('user', {})(DevicePage);
diff --git a/front/src/routes/integration/all/sonos/device-page/style.css b/front/src/routes/integration/all/sonos/device-page/style.css
new file mode 100644
index 0000000000..4804f6a3b0
--- /dev/null
+++ b/front/src/routes/integration/all/sonos/device-page/style.css
@@ -0,0 +1,7 @@
+.emptyStateDivBox {
+ margin-top: 35px;
+}
+
+.tuyaListBody {
+ min-height: 200px
+}
diff --git a/front/src/routes/integration/all/sonos/discover-page/DiscoverTab.jsx b/front/src/routes/integration/all/sonos/discover-page/DiscoverTab.jsx
new file mode 100644
index 0000000000..a5bb464c32
--- /dev/null
+++ b/front/src/routes/integration/all/sonos/discover-page/DiscoverTab.jsx
@@ -0,0 +1,84 @@
+import { Text } from 'preact-i18n';
+import cx from 'classnames';
+
+import EmptyState from './EmptyState';
+import style from './style.css';
+import SonosDeviceBox from '../SonosDeviceBox';
+import { connect } from 'unistore/preact';
+import { Component } from 'preact';
+
+class DiscoverTab extends Component {
+ getDiscoveredDevices = async () => {
+ this.setState({
+ loading: true
+ });
+ try {
+ const discoveredDevices = await this.props.httpClient.get('/api/v1/service/sonos/discover');
+ const existingSonosDevices = await this.props.httpClient.get('/api/v1/service/sonos/device', {});
+ discoveredDevices.forEach(discoveredDevice => {
+ const existingDevice = existingSonosDevices.find(d => d.external_id === discoveredDevice.external_id);
+ if (existingDevice) {
+ discoveredDevice.alreadyExist = true;
+ }
+ });
+ this.setState({
+ discoveredDevices,
+ loading: false,
+ errorLoading: false
+ });
+ } catch (e) {
+ this.setState({
+ loading: false,
+ errorLoading: true
+ });
+ }
+ };
+ async componentWillMount() {
+ this.getDiscoveredDevices();
+ }
+
+ render(props, { loading, errorLoading, discoveredDevices }) {
+ return (
+
+
+
+
+
+
+ {errorLoading && (
+
+
+
+ )}
+
+
+
+
+ {discoveredDevices &&
+ discoveredDevices.map((device, index) => (
+
+ ))}
+ {!discoveredDevices || (discoveredDevices.length === 0 && )}
+
+
+
+
+
+ );
+ }
+}
+
+export default connect('httpClient', {})(DiscoverTab);
diff --git a/front/src/routes/integration/all/sonos/discover-page/EmptyState.jsx b/front/src/routes/integration/all/sonos/discover-page/EmptyState.jsx
new file mode 100644
index 0000000000..5e36d57c41
--- /dev/null
+++ b/front/src/routes/integration/all/sonos/discover-page/EmptyState.jsx
@@ -0,0 +1,13 @@
+import { MarkupText } from 'preact-i18n';
+import cx from 'classnames';
+import style from './style.css';
+
+const EmptyState = ({}) => (
+
+);
+
+export default EmptyState;
diff --git a/front/src/routes/integration/all/sonos/discover-page/index.js b/front/src/routes/integration/all/sonos/discover-page/index.js
new file mode 100644
index 0000000000..71b4b749f0
--- /dev/null
+++ b/front/src/routes/integration/all/sonos/discover-page/index.js
@@ -0,0 +1,16 @@
+import { Component } from 'preact';
+import { connect } from 'unistore/preact';
+import DiscoverTab from './DiscoverTab';
+import SonosPage from '../SonosPage';
+
+class SonosDiscoverPage extends Component {
+ render(props) {
+ return (
+
+
+
+ );
+ }
+}
+
+export default connect('user', {})(SonosDiscoverPage);
diff --git a/front/src/routes/integration/all/sonos/discover-page/style.css b/front/src/routes/integration/all/sonos/discover-page/style.css
new file mode 100644
index 0000000000..ec85712c29
--- /dev/null
+++ b/front/src/routes/integration/all/sonos/discover-page/style.css
@@ -0,0 +1,7 @@
+.emptyStateDivBox {
+ margin-top: 89px;
+}
+
+.sonosListBody {
+ min-height: 200px;
+}
diff --git a/server/models/dashboard.js b/server/models/dashboard.js
index fb5440d000..4b86f40303 100644
--- a/server/models/dashboard.js
+++ b/server/models/dashboard.js
@@ -13,6 +13,7 @@ const boxesSchema = Joi.array().items(
camera: Joi.string(),
name: Joi.string().allow(''),
modes: Joi.object(),
+ device: Joi.string(),
device_features: Joi.array().items(Joi.string()),
device_feature_names: Joi.array().items(Joi.string()),
device_feature: Joi.string(),
diff --git a/server/services/index.js b/server/services/index.js
index 83c929aee9..7e3ba5d726 100644
--- a/server/services/index.js
+++ b/server/services/index.js
@@ -23,3 +23,4 @@ module.exports['nextcloud-talk'] = require('./nextcloud-talk');
module.exports.tuya = require('./tuya');
module.exports.melcloud = require('./melcloud');
module.exports['node-red'] = require('./node-red');
+module.exports.sonos = require('./sonos');
diff --git a/server/services/sonos/api/sonos.controller.js b/server/services/sonos/api/sonos.controller.js
new file mode 100644
index 0000000000..783368c160
--- /dev/null
+++ b/server/services/sonos/api/sonos.controller.js
@@ -0,0 +1,20 @@
+const asyncMiddleware = require('../../../api/middlewares/asyncMiddleware');
+
+module.exports = function SonosController(sonosHandler) {
+ /**
+ * @api {get} /api/v1/service/sonos/discover Retrieve sonos devices from local network
+ * @apiName discover
+ * @apiGroup Sonos
+ */
+ async function discover(req, res) {
+ const devices = await sonosHandler.scan();
+ res.json(devices);
+ }
+
+ return {
+ 'get /api/v1/service/sonos/discover': {
+ authenticated: true,
+ controller: asyncMiddleware(discover),
+ },
+ };
+};
diff --git a/server/services/sonos/index.js b/server/services/sonos/index.js
new file mode 100644
index 0000000000..3c9a156d4c
--- /dev/null
+++ b/server/services/sonos/index.js
@@ -0,0 +1,49 @@
+const logger = require('../../utils/logger');
+const SonosHandler = require('./lib');
+const sonosController = require('./api/sonos.controller');
+
+module.exports = function SonosService(gladys, serviceId) {
+ // @ts-ignore
+ const sonosLib = require('@svrooij/sonos');
+ const sonosHandler = new SonosHandler(gladys, sonosLib, serviceId);
+
+ /**
+ * @public
+ * @description This function starts the sonos service service.
+ * @example
+ * gladys.services['sonos'].start();
+ */
+ async function start() {
+ logger.info('Starting Sonos service');
+ sonosHandler.init();
+ }
+
+ /**
+ * @public
+ * @description This function stops the sonos service.
+ * @example
+ * gladys.services['sonos'].stop();
+ */
+ async function stop() {
+ logger.info('Stopping sonos service');
+ }
+
+ /**
+ * @public
+ * @description This function return true if the service is used.
+ * @returns {Promise} Resolves with a boolean.
+ * @example
+ * const isUsed = await gladys.services['sonos'].isUsed();
+ */
+ async function isUsed() {
+ return sonosHandler.devices.length > 0;
+ }
+
+ return Object.freeze({
+ start,
+ stop,
+ isUsed,
+ device: sonosHandler,
+ controllers: sonosController(sonosHandler),
+ });
+};
diff --git a/server/services/sonos/lib/index.js b/server/services/sonos/lib/index.js
new file mode 100644
index 0000000000..bc279cafc9
--- /dev/null
+++ b/server/services/sonos/lib/index.js
@@ -0,0 +1,21 @@
+const { init } = require('./sonos.init');
+const { scan } = require('./sonos.scan');
+const { setValue } = require('./sonos.setValue');
+const { onAvTransportEvent } = require('./sonos.onAvTransportEvent');
+const { onVolumeEvent } = require('./sonos.onVolumeEvent');
+
+const SonosHandler = function SonosHandler(gladys, sonosLib, serviceId) {
+ this.gladys = gladys;
+ this.sonosLib = sonosLib;
+ this.serviceId = serviceId;
+ this.manager = null;
+ this.devices = [];
+};
+
+SonosHandler.prototype.init = init;
+SonosHandler.prototype.scan = scan;
+SonosHandler.prototype.setValue = setValue;
+SonosHandler.prototype.onAvTransportEvent = onAvTransportEvent;
+SonosHandler.prototype.onVolumeEvent = onVolumeEvent;
+
+module.exports = SonosHandler;
diff --git a/server/services/sonos/lib/sonos.init.js b/server/services/sonos/lib/sonos.init.js
new file mode 100644
index 0000000000..38ba6b33e5
--- /dev/null
+++ b/server/services/sonos/lib/sonos.init.js
@@ -0,0 +1,13 @@
+/**
+ * @description This will init the Sonos library.
+ * @example sonos.init();
+ */
+async function init() {
+ const { SonosManager } = this.sonosLib;
+ this.manager = new SonosManager();
+ await this.scan();
+}
+
+module.exports = {
+ init,
+};
diff --git a/server/services/sonos/lib/sonos.onAvTransportEvent.js b/server/services/sonos/lib/sonos.onAvTransportEvent.js
new file mode 100644
index 0000000000..5d3a3e3f48
--- /dev/null
+++ b/server/services/sonos/lib/sonos.onAvTransportEvent.js
@@ -0,0 +1,34 @@
+const { EVENTS, MUSIC_PLAYBACK_STATE } = require('../../../utils/constants');
+
+const SONOS_PLAYBACK_STATES = {
+ PLAYING: 'PLAYING',
+ PAUSED_PLAYBACK: 'PAUSED_PLAYBACK',
+};
+
+/**
+ * @description When the playback state change.
+ * @param {string} deviceUuid - Sonos internal UUID.
+ * @param {object} data - Sonos event.
+ * @example onAvTransportEvent('toto', data);
+ */
+async function onAvTransportEvent(deviceUuid, data) {
+ if (
+ data.TransportState === SONOS_PLAYBACK_STATES.PLAYING ||
+ data.TransportState === SONOS_PLAYBACK_STATES.PAUSED_PLAYBACK
+ ) {
+ const playBackState =
+ data.TransportState === SONOS_PLAYBACK_STATES.PLAYING
+ ? MUSIC_PLAYBACK_STATE.PLAYING
+ : MUSIC_PLAYBACK_STATE.PAUSED;
+
+ const newState = {
+ device_feature_external_id: `sonos:${deviceUuid}:playback-state`,
+ state: playBackState,
+ };
+ this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, newState);
+ }
+}
+
+module.exports = {
+ onAvTransportEvent,
+};
diff --git a/server/services/sonos/lib/sonos.onVolumeEvent.js b/server/services/sonos/lib/sonos.onVolumeEvent.js
new file mode 100644
index 0000000000..2616538311
--- /dev/null
+++ b/server/services/sonos/lib/sonos.onVolumeEvent.js
@@ -0,0 +1,19 @@
+const { EVENTS } = require('../../../utils/constants');
+
+/**
+ * @description When the volume change.
+ * @param {string} deviceUuid - Sonos internal UUID.
+ * @param {number} volume - Sonos volume level.
+ * @example onAvTransportEvent('toto', data);
+ */
+async function onVolumeEvent(deviceUuid, volume) {
+ const newState = {
+ device_feature_external_id: `sonos:${deviceUuid}:volume`,
+ state: volume,
+ };
+ this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, newState);
+}
+
+module.exports = {
+ onVolumeEvent,
+};
diff --git a/server/services/sonos/lib/sonos.scan.js b/server/services/sonos/lib/sonos.scan.js
new file mode 100644
index 0000000000..633b168a3d
--- /dev/null
+++ b/server/services/sonos/lib/sonos.scan.js
@@ -0,0 +1,33 @@
+const { convertToGladysDevice } = require('../utils/convertToGladysDevice');
+
+/**
+ * @description This will scan the network for sonos devices.
+ * @returns {Promise} Resolves with device array.
+ * @example sonos.scan();
+ */
+async function scan() {
+ await this.manager.InitializeWithDiscovery(10);
+ this.devices = this.manager.Devices.map((d) =>
+ convertToGladysDevice(this.serviceId, {
+ name: d.name,
+ host: d.host,
+ port: d.port,
+ uuid: d.uuid,
+ }),
+ );
+ this.manager.Devices.forEach((device) => {
+ device.AVTransportService.Events.removeAllListeners(this.sonosLib.ServiceEvents.ServiceEvent);
+ device.Events.removeAllListeners(this.sonosLib.SonosEvents.Volume);
+ device.Events.on(this.sonosLib.SonosEvents.Volume, (volume) => {
+ this.onVolumeEvent(device.uuid, volume);
+ });
+ device.AVTransportService.Events.on(this.sonosLib.ServiceEvents.Data, (data) => {
+ this.onAvTransportEvent(device.uuid, data);
+ });
+ });
+ return this.devices;
+}
+
+module.exports = {
+ scan,
+};
diff --git a/server/services/sonos/lib/sonos.setValue.js b/server/services/sonos/lib/sonos.setValue.js
new file mode 100644
index 0000000000..188b76b6f3
--- /dev/null
+++ b/server/services/sonos/lib/sonos.setValue.js
@@ -0,0 +1,32 @@
+const { DEVICE_FEATURE_TYPES } = require('../../../utils/constants');
+/**
+ * @description Send the new device value over device protocol.
+ * @param {object} device - Updated Gladys device.
+ * @param {object} deviceFeature - Updated Gladys device feature.
+ * @param {string|number} value - The new device feature value.
+ * @example
+ * setValue(device, deviceFeature, 0);
+ */
+async function setValue(device, deviceFeature, value) {
+ const deviceUuid = device.external_id.split(':')[1];
+ const sonosDevice = this.manager.Devices.find((d) => d.uuid === deviceUuid);
+ if (deviceFeature.type === DEVICE_FEATURE_TYPES.MUSIC.PLAY) {
+ await sonosDevice.Play();
+ }
+ if (deviceFeature.type === DEVICE_FEATURE_TYPES.MUSIC.PAUSE) {
+ await sonosDevice.Pause();
+ }
+ if (deviceFeature.type === DEVICE_FEATURE_TYPES.MUSIC.NEXT) {
+ await sonosDevice.Next();
+ }
+ if (deviceFeature.type === DEVICE_FEATURE_TYPES.MUSIC.PREVIOUS) {
+ await sonosDevice.Previous();
+ }
+ if (deviceFeature.type === DEVICE_FEATURE_TYPES.MUSIC.VOLUME) {
+ await sonosDevice.SetVolume(value);
+ }
+}
+
+module.exports = {
+ setValue,
+};
diff --git a/server/services/sonos/package-lock.json b/server/services/sonos/package-lock.json
new file mode 100644
index 0000000000..d68f6490cf
--- /dev/null
+++ b/server/services/sonos/package-lock.json
@@ -0,0 +1,134 @@
+{
+ "name": "gladys-sonos",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "gladys-sonos",
+ "version": "1.0.0",
+ "cpu": [
+ "x64",
+ "arm",
+ "arm64"
+ ],
+ "os": [
+ "darwin",
+ "linux",
+ "win32"
+ ],
+ "dependencies": {
+ "@svrooij/sonos": "^2.5.0"
+ }
+ },
+ "node_modules/@svrooij/sonos": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@svrooij/sonos/-/sonos-2.5.0.tgz",
+ "integrity": "sha512-QcRJRo9aILj5/6ikebQsGZL9kqGP9Er6XyWJG5akHI0XIALMk/kLyXVrAnYZ2jef3Hj2GCCHCqCNkbAIqCZ53Q==",
+ "dependencies": {
+ "debug": "4.3.1",
+ "fast-xml-parser": "3.19.0",
+ "guid-typescript": "^1.0.9",
+ "html-entities": "^2.3.2",
+ "node-fetch": "^2.6.1",
+ "typed-emitter": "^1.3.1"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+ "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fast-xml-parser": {
+ "version": "3.19.0",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.19.0.tgz",
+ "integrity": "sha512-4pXwmBplsCPv8FOY1WRakF970TjNGnGnfbOnLqjlYvMiF1SR3yOHyxMR/YCXpPTOspNF5gwudqktIP4VsWkvBg==",
+ "bin": {
+ "xml2js": "cli.js"
+ },
+ "funding": {
+ "type": "paypal",
+ "url": "https://paypal.me/naturalintelligence"
+ }
+ },
+ "node_modules/guid-typescript": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
+ "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="
+ },
+ "node_modules/html-entities": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz",
+ "integrity": "sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/mdevils"
+ },
+ {
+ "type": "patreon",
+ "url": "https://patreon.com/mdevils"
+ }
+ ]
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ },
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
+ },
+ "node_modules/typed-emitter": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz",
+ "integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg=="
+ },
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ }
+ }
+}
diff --git a/server/services/sonos/package.json b/server/services/sonos/package.json
new file mode 100644
index 0000000000..c918b0bb63
--- /dev/null
+++ b/server/services/sonos/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "gladys-sonos",
+ "version": "1.0.0",
+ "main": "index.js",
+ "os": [
+ "darwin",
+ "linux",
+ "win32"
+ ],
+ "cpu": [
+ "x64",
+ "arm",
+ "arm64"
+ ],
+ "dependencies": {
+ "@svrooij/sonos": "^2.5.0"
+ }
+}
diff --git a/server/services/sonos/utils/convertToGladysDevice.js b/server/services/sonos/utils/convertToGladysDevice.js
new file mode 100644
index 0000000000..2745480ce9
--- /dev/null
+++ b/server/services/sonos/utils/convertToGladysDevice.js
@@ -0,0 +1,82 @@
+const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../utils/constants');
+
+const convertToGladysDevice = (serviceId, device) => {
+ return {
+ name: device.name,
+ external_id: `sonos:${device.uuid}`,
+ service_id: serviceId,
+ should_poll: false,
+ features: [
+ {
+ name: `${device.name} - Play`,
+ external_id: `sonos:${device.uuid}:play`,
+ category: DEVICE_FEATURE_CATEGORIES.MUSIC,
+ type: DEVICE_FEATURE_TYPES.MUSIC.PLAY,
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ },
+ {
+ name: `${device.name} - Pause`,
+ external_id: `sonos:${device.uuid}:pause`,
+ category: DEVICE_FEATURE_CATEGORIES.MUSIC,
+ type: DEVICE_FEATURE_TYPES.MUSIC.PAUSE,
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ },
+ {
+ name: `${device.name} - Previous`,
+ external_id: `sonos:${device.uuid}:previous`,
+ category: DEVICE_FEATURE_CATEGORIES.MUSIC,
+ type: DEVICE_FEATURE_TYPES.MUSIC.PREVIOUS,
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ },
+ {
+ name: `${device.name} - Next`,
+ external_id: `sonos:${device.uuid}:next`,
+ category: DEVICE_FEATURE_CATEGORIES.MUSIC,
+ type: DEVICE_FEATURE_TYPES.MUSIC.NEXT,
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ },
+ {
+ name: `${device.name} - Volume`,
+ external_id: `sonos:${device.uuid}:volume`,
+ category: DEVICE_FEATURE_CATEGORIES.MUSIC,
+ type: DEVICE_FEATURE_TYPES.MUSIC.VOLUME,
+ min: 0,
+ max: 100,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ },
+ {
+ name: `${device.name} - PlayBack State`,
+ external_id: `sonos:${device.uuid}:playback-state`,
+ category: DEVICE_FEATURE_CATEGORIES.MUSIC,
+ type: DEVICE_FEATURE_TYPES.MUSIC.PLAYBACK_STATE,
+ min: 0,
+ max: 1,
+ keep_history: false,
+ read_only: true,
+ has_feedback: false,
+ },
+ ],
+ };
+};
+
+module.exports = {
+ convertToGladysDevice,
+};
diff --git a/server/test/services/sonos/api/sonos.controller.test.js b/server/test/services/sonos/api/sonos.controller.test.js
new file mode 100644
index 0000000000..8cc3a6d975
--- /dev/null
+++ b/server/test/services/sonos/api/sonos.controller.test.js
@@ -0,0 +1,36 @@
+const sinon = require('sinon');
+const SonosController = require('../../../../services/sonos/api/sonos.controller');
+
+const { assert, fake } = sinon;
+
+const sonosHandler = {
+ scan: fake.resolves([
+ {
+ host: '192.168.1.1',
+ },
+ ]),
+};
+
+describe('SonosController GET /api/v1/service/sonos/discover', () => {
+ let controller;
+
+ beforeEach(() => {
+ controller = SonosController(sonosHandler);
+ sinon.reset();
+ });
+
+ it('should return discovered devices', async () => {
+ const req = {};
+ const res = {
+ json: fake.returns([]),
+ };
+
+ await controller['get /api/v1/service/sonos/discover'].controller(req, res);
+ assert.calledOnce(sonosHandler.scan);
+ assert.calledWith(res.json, [
+ {
+ host: '192.168.1.1',
+ },
+ ]);
+ });
+});
diff --git a/server/test/services/sonos/index.test.js b/server/test/services/sonos/index.test.js
new file mode 100644
index 0000000000..4f4f5f0b4c
--- /dev/null
+++ b/server/test/services/sonos/index.test.js
@@ -0,0 +1,41 @@
+const sinon = require('sinon');
+const { expect } = require('chai');
+const proxyquire = require('proxyquire').noCallThru();
+
+const { assert, fake } = sinon;
+
+const SonosHandlerMock = sinon.stub();
+SonosHandlerMock.prototype.init = fake.returns(null);
+SonosHandlerMock.prototype.devices = [];
+
+const SonosService = proxyquire('../../../services/sonos/index', { './lib': SonosHandlerMock });
+
+const gladys = {};
+const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0';
+
+describe('SonosService', () => {
+ const sonosService = SonosService(gladys, serviceId);
+
+ beforeEach(() => {
+ sinon.reset();
+ });
+
+ afterEach(() => {
+ sinon.reset();
+ });
+
+ it('should start service', async () => {
+ await sonosService.start();
+ assert.calledOnce(sonosService.device.init);
+ });
+
+ it('should stop service', async () => {
+ sonosService.stop();
+ assert.notCalled(sonosService.device.init);
+ });
+
+ it('isUsed: should return false, service not used', async () => {
+ const used = await sonosService.isUsed();
+ expect(used).to.equal(false);
+ });
+});
diff --git a/server/test/services/sonos/lib/sonos.init.test.js b/server/test/services/sonos/lib/sonos.init.test.js
new file mode 100644
index 0000000000..4e02cd01e1
--- /dev/null
+++ b/server/test/services/sonos/lib/sonos.init.test.js
@@ -0,0 +1,149 @@
+const { expect } = require('chai');
+const sinon = require('sinon');
+
+const { assert, fake } = sinon;
+
+const SonosHandler = require('../../../../services/sonos/lib');
+
+const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0';
+
+const gladys = {};
+
+const SonosManager = sinon.stub();
+SonosManager.prototype.InitializeWithDiscovery = fake.returns(null);
+SonosManager.prototype.Devices = [
+ {
+ this_attribute_should_not_exist: 'test',
+ host: '192.168.1.1',
+ port: 1400,
+ name: 'My sonos',
+ uuid: 'test-uuid',
+ AVTransportService: {
+ Events: {
+ removeAllListeners: fake.returns(null),
+ // @ts-ignore
+ on: (type, cb) => {
+ cb({ TransportState: 'PLAYING' });
+ },
+ },
+ },
+ Events: {
+ removeAllListeners: fake.returns(null),
+ // @ts-ignore
+ on: (type, cb) => {
+ cb(12);
+ },
+ },
+ },
+];
+
+const sonosLib = {
+ SonosManager,
+ ServiceEvents: {
+ ServiceEvent: 'test',
+ },
+ SonosEvents: {
+ Volume: 'test',
+ },
+};
+
+describe('SonosHandler.init', () => {
+ const sonosHandler = new SonosHandler(gladys, sonosLib, serviceId);
+
+ beforeEach(() => {
+ sinon.reset();
+ });
+
+ afterEach(() => {
+ sinon.reset();
+ });
+
+ it('should init sonos & scan network', async () => {
+ sonosHandler.onAvTransportEvent = fake.returns(null);
+ sonosHandler.onVolumeEvent = fake.returns(null);
+ await sonosHandler.init();
+ assert.calledOnce(sonosHandler.manager.InitializeWithDiscovery);
+ // @ts-ignore
+ assert.calledWith(sonosHandler.onAvTransportEvent, 'test-uuid', {
+ TransportState: 'PLAYING',
+ });
+ // @ts-ignore
+ assert.calledWith(sonosHandler.onVolumeEvent, 'test-uuid', 12);
+ expect(sonosHandler.devices).deep.equal([
+ {
+ name: 'My sonos',
+ external_id: 'sonos:test-uuid',
+ service_id: 'ffa13430-df93-488a-9733-5c540e9558e0',
+ should_poll: false,
+ features: [
+ {
+ name: 'My sonos - Play',
+ external_id: 'sonos:test-uuid:play',
+ category: 'music',
+ type: 'play',
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ },
+ {
+ name: 'My sonos - Pause',
+ external_id: 'sonos:test-uuid:pause',
+ category: 'music',
+ type: 'pause',
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ },
+ {
+ name: 'My sonos - Previous',
+ external_id: 'sonos:test-uuid:previous',
+ category: 'music',
+ type: 'previous',
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ },
+ {
+ name: 'My sonos - Next',
+ external_id: 'sonos:test-uuid:next',
+ category: 'music',
+ type: 'next',
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ },
+ {
+ name: 'My sonos - Volume',
+ external_id: 'sonos:test-uuid:volume',
+ category: 'music',
+ type: 'volume',
+ min: 0,
+ max: 100,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ },
+ {
+ name: 'My sonos - PlayBack State',
+ external_id: 'sonos:test-uuid:playback-state',
+ category: 'music',
+ type: 'playback_state',
+ min: 0,
+ max: 1,
+ keep_history: false,
+ read_only: true,
+ has_feedback: false,
+ },
+ ],
+ },
+ ]);
+ });
+});
diff --git a/server/test/services/sonos/lib/sonos.onAvTransportEvent.test.js b/server/test/services/sonos/lib/sonos.onAvTransportEvent.test.js
new file mode 100644
index 0000000000..58c96edab9
--- /dev/null
+++ b/server/test/services/sonos/lib/sonos.onAvTransportEvent.test.js
@@ -0,0 +1,78 @@
+const sinon = require('sinon');
+
+const { assert, fake } = sinon;
+
+const SonosHandler = require('../../../../services/sonos/lib');
+
+const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0';
+
+const gladys = {
+ event: {
+ emit: fake.returns(null),
+ },
+};
+
+const SonosManager = sinon.stub();
+SonosManager.prototype.InitializeWithDiscovery = fake.returns(null);
+SonosManager.prototype.Devices = [
+ {
+ this_attribute_should_not_exist: 'test',
+ host: '192.168.1.1',
+ port: 1400,
+ name: 'My sonos',
+ uuid: 'test-uuid',
+ AVTransportService: {
+ Events: {
+ removeAllListeners: fake.returns(null),
+ on: fake.returns(null),
+ },
+ },
+ Events: {
+ removeAllListeners: fake.returns(null),
+ on: fake.returns(null),
+ },
+ },
+];
+
+const sonosLib = {
+ SonosManager,
+ ServiceEvents: {
+ ServiceEvent: 'test',
+ },
+ SonosEvents: {
+ Volume: 'test',
+ },
+};
+
+describe('SonosHandler.onAvTransportEvent', () => {
+ const sonosHandler = new SonosHandler(gladys, sonosLib, serviceId);
+
+ beforeEach(() => {
+ sinon.reset();
+ });
+
+ afterEach(() => {
+ sinon.reset();
+ });
+
+ it('should propagate new playing playback state to Gladys', async () => {
+ await sonosHandler.init();
+ assert.calledOnce(sonosHandler.manager.InitializeWithDiscovery);
+ await sonosHandler.onAvTransportEvent('test-uuid', { TransportState: 'PLAYING' });
+ assert.calledWith(gladys.event.emit, 'device.new-state', {
+ device_feature_external_id: 'sonos:test-uuid:playback-state',
+ state: 1,
+ });
+ });
+ it('should propagate new paused playback state to Gladys', async () => {
+ await sonosHandler.init();
+ assert.calledOnce(sonosHandler.manager.InitializeWithDiscovery);
+ await sonosHandler.onAvTransportEvent('test-uuid', {
+ TransportState: 'PAUSED_PLAYBACK',
+ });
+ assert.calledWith(gladys.event.emit, 'device.new-state', {
+ device_feature_external_id: 'sonos:test-uuid:playback-state',
+ state: 0,
+ });
+ });
+});
diff --git a/server/test/services/sonos/lib/sonos.onVolumeEvent.test.js b/server/test/services/sonos/lib/sonos.onVolumeEvent.test.js
new file mode 100644
index 0000000000..ab3ce7c996
--- /dev/null
+++ b/server/test/services/sonos/lib/sonos.onVolumeEvent.test.js
@@ -0,0 +1,67 @@
+const sinon = require('sinon');
+
+const { assert, fake } = sinon;
+
+const SonosHandler = require('../../../../services/sonos/lib');
+
+const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0';
+
+const gladys = {
+ event: {
+ emit: fake.returns(null),
+ },
+};
+
+const SonosManager = sinon.stub();
+SonosManager.prototype.InitializeWithDiscovery = fake.returns(null);
+SonosManager.prototype.Devices = [
+ {
+ this_attribute_should_not_exist: 'test',
+ host: '192.168.1.1',
+ port: 1400,
+ name: 'My sonos',
+ uuid: 'test-uuid',
+ AVTransportService: {
+ Events: {
+ removeAllListeners: fake.returns(null),
+ on: fake.returns(null),
+ },
+ },
+ Events: {
+ removeAllListeners: fake.returns(null),
+ on: fake.returns(null),
+ },
+ },
+];
+
+const sonosLib = {
+ SonosManager,
+ ServiceEvents: {
+ ServiceEvent: 'test',
+ },
+ SonosEvents: {
+ Volume: 'test',
+ },
+};
+
+describe('SonosHandler.onVolumeEvent', () => {
+ const sonosHandler = new SonosHandler(gladys, sonosLib, serviceId);
+
+ beforeEach(() => {
+ sinon.reset();
+ });
+
+ afterEach(() => {
+ sinon.reset();
+ });
+
+ it('should propagate new volume state to Gladys', async () => {
+ await sonosHandler.init();
+ assert.calledOnce(sonosHandler.manager.InitializeWithDiscovery);
+ await sonosHandler.onVolumeEvent('test-uuid', 10);
+ assert.calledWith(gladys.event.emit, 'device.new-state', {
+ device_feature_external_id: 'sonos:test-uuid:volume',
+ state: 10,
+ });
+ });
+});
diff --git a/server/test/services/sonos/lib/sonos.setValue.test.js b/server/test/services/sonos/lib/sonos.setValue.test.js
new file mode 100644
index 0000000000..103f702e33
--- /dev/null
+++ b/server/test/services/sonos/lib/sonos.setValue.test.js
@@ -0,0 +1,171 @@
+const sinon = require('sinon');
+
+const { assert, fake } = sinon;
+
+const SonosHandler = require('../../../../services/sonos/lib');
+
+const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0';
+
+const gladys = {};
+
+const devicePlay = fake.resolves(null);
+const devicePause = fake.resolves(null);
+const devicePrevious = fake.resolves(null);
+const deviceNext = fake.resolves(null);
+const deviceSetVolume = fake.resolves(null);
+
+const SonosManager = sinon.stub();
+SonosManager.prototype.InitializeWithDiscovery = fake.returns(null);
+SonosManager.prototype.Devices = [
+ {
+ this_attribute_should_not_exist: 'test',
+ host: '192.168.1.1',
+ port: 1400,
+ name: 'My sonos',
+ uuid: 'test-uuid',
+ Play: devicePlay,
+ Pause: devicePause,
+ Previous: devicePrevious,
+ Next: deviceNext,
+ SetVolume: deviceSetVolume,
+ AVTransportService: {
+ Events: {
+ removeAllListeners: fake.returns(null),
+ on: fake.returns(null),
+ },
+ },
+ Events: {
+ removeAllListeners: fake.returns(null),
+ on: fake.returns(null),
+ },
+ },
+];
+
+const sonosLib = {
+ SonosManager,
+ ServiceEvents: {
+ ServiceEvent: 'test',
+ },
+ SonosEvents: {
+ Volume: 'test',
+ },
+};
+
+describe('SonosHandler.setValue', () => {
+ const sonosHandler = new SonosHandler(gladys, sonosLib, serviceId);
+
+ beforeEach(async () => {
+ sinon.reset();
+ await sonosHandler.init();
+ });
+
+ afterEach(() => {
+ sinon.reset();
+ });
+
+ it('should press play 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',
+ external_id: 'sonos:test-uuid:play',
+ category: 'music',
+ type: 'play',
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ };
+ await sonosHandler.setValue(device, deviceFeature, 1);
+ assert.calledOnce(devicePlay);
+ });
+ it('should press pause 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 - Pause',
+ external_id: 'sonos:test-uuid:pause',
+ category: 'music',
+ type: 'pause',
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ };
+ await sonosHandler.setValue(device, deviceFeature, 1);
+ assert.calledOnce(devicePause);
+ });
+ it('should press next 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 - Next',
+ external_id: 'sonos:test-uuid:next',
+ category: 'music',
+ type: 'next',
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ };
+ await sonosHandler.setValue(device, deviceFeature, 1);
+ assert.calledOnce(deviceNext);
+ });
+ it('should press previous 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 - Previous',
+ external_id: 'sonos:test-uuid:previous',
+ category: 'music',
+ type: 'previous',
+ min: 1,
+ max: 1,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ };
+ await sonosHandler.setValue(device, deviceFeature, 1);
+ assert.calledOnce(devicePrevious);
+ });
+ it('should setVolume 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 - Volume',
+ external_id: 'sonos:test-uuid:volume',
+ category: 'music',
+ type: 'volume',
+ min: 0,
+ max: 100,
+ keep_history: false,
+ read_only: false,
+ has_feedback: false,
+ };
+ await sonosHandler.setValue(device, deviceFeature, 46);
+ assert.calledWith(deviceSetVolume, 46);
+ });
+});
diff --git a/server/utils/constants.js b/server/utils/constants.js
index c350067757..4f4d4a8d70 100644
--- a/server/utils/constants.js
+++ b/server/utils/constants.js
@@ -58,6 +58,11 @@ const AC_MODE = {
FAN: 4,
};
+const MUSIC_PLAYBACK_STATE = {
+ PLAYING: 1,
+ PAUSED: 0,
+};
+
const USER_ROLE = {
ADMIN: 'admin',
HABITANT: 'habitant',
@@ -405,6 +410,7 @@ const DEVICE_FEATURE_CATEGORIES = {
LIGHT: 'light',
LIGHT_SENSOR: 'light-sensor',
MOTION_SENSOR: 'motion-sensor',
+ MUSIC: 'music',
OPENING_SENSOR: 'opening-sensor',
PM25_SENSOR: 'pm25-sensor',
FORMALDEHYD_SENSOR: 'formaldehyd-sensor',
@@ -532,6 +538,14 @@ const DEVICE_FEATURE_TYPES = {
FORWARD: 'forward',
RECORD: 'record',
},
+ MUSIC: {
+ VOLUME: 'volume',
+ PLAY: 'play',
+ PAUSE: 'pause',
+ PREVIOUS: 'previous',
+ NEXT: 'next',
+ PLAYBACK_STATE: 'playback_state',
+ },
ENERGY_SENSOR: {
BINARY: 'binary',
POWER: 'power',
@@ -942,6 +956,7 @@ const DASHBOARD_BOX_TYPE = {
ECOWATT: 'ecowatt',
CLOCK: 'clock',
SCENE: 'scene',
+ MUSIC: 'music',
};
const ERROR_MESSAGES = {
@@ -1106,3 +1121,5 @@ module.exports.DEFAULT_VALUE_TEMPERATURE = DEFAULT_VALUE_TEMPERATURE;
module.exports.ALARM_MODES = ALARM_MODES;
module.exports.ALARM_MODES_LIST = ALARM_MODES_LIST;
+
+module.exports.MUSIC_PLAYBACK_STATE = MUSIC_PLAYBACK_STATE;