From bfbf817633a5af62b92a32811638b6f699b067f7 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 1 Dec 2023 13:42:03 +0100 Subject: [PATCH] Sonos Integration (#1949) --- front/src/assets/integrations/cover/sonos.jpg | Bin 0 -> 20571 bytes front/src/components/app.jsx | 7 + .../components/boxs/music/EditMusicBox.jsx | 66 ++++++ front/src/components/boxs/music/MusicBox.jsx | 160 ++++++++++++++ front/src/config/demo.js | 44 ++++ front/src/config/i18n/en.json | 50 ++++- front/src/config/i18n/fr.json | 51 ++++- front/src/config/integrations/devices.json | 5 + front/src/routes/dashboard/Box.jsx | 3 + .../dashboard/edit-dashboard/EditBox.jsx | 3 + .../integration/all/sonos/SonosDeviceBox.jsx | 197 ++++++++++++++++++ .../integration/all/sonos/SonosPage.jsx | 61 ++++++ .../all/sonos/device-page/DeviceTab.jsx | 132 ++++++++++++ .../all/sonos/device-page/EmptyState.jsx | 23 ++ .../all/sonos/device-page/index.js | 16 ++ .../all/sonos/device-page/style.css | 7 + .../all/sonos/discover-page/DiscoverTab.jsx | 84 ++++++++ .../all/sonos/discover-page/EmptyState.jsx | 13 ++ .../all/sonos/discover-page/index.js | 16 ++ .../all/sonos/discover-page/style.css | 7 + server/models/dashboard.js | 1 + server/services/index.js | 1 + server/services/sonos/api/sonos.controller.js | 20 ++ server/services/sonos/index.js | 49 +++++ server/services/sonos/lib/index.js | 21 ++ server/services/sonos/lib/sonos.init.js | 13 ++ .../sonos/lib/sonos.onAvTransportEvent.js | 34 +++ .../services/sonos/lib/sonos.onVolumeEvent.js | 19 ++ server/services/sonos/lib/sonos.scan.js | 33 +++ server/services/sonos/lib/sonos.setValue.js | 32 +++ server/services/sonos/package-lock.json | 134 ++++++++++++ server/services/sonos/package.json | 18 ++ .../sonos/utils/convertToGladysDevice.js | 82 ++++++++ .../sonos/api/sonos.controller.test.js | 36 ++++ server/test/services/sonos/index.test.js | 41 ++++ .../services/sonos/lib/sonos.init.test.js | 149 +++++++++++++ .../lib/sonos.onAvTransportEvent.test.js | 78 +++++++ .../sonos/lib/sonos.onVolumeEvent.test.js | 67 ++++++ .../services/sonos/lib/sonos.setValue.test.js | 171 +++++++++++++++ server/utils/constants.js | 17 ++ 40 files changed, 1959 insertions(+), 2 deletions(-) create mode 100644 front/src/assets/integrations/cover/sonos.jpg create mode 100644 front/src/components/boxs/music/EditMusicBox.jsx create mode 100644 front/src/components/boxs/music/MusicBox.jsx create mode 100644 front/src/routes/integration/all/sonos/SonosDeviceBox.jsx create mode 100644 front/src/routes/integration/all/sonos/SonosPage.jsx create mode 100644 front/src/routes/integration/all/sonos/device-page/DeviceTab.jsx create mode 100644 front/src/routes/integration/all/sonos/device-page/EmptyState.jsx create mode 100644 front/src/routes/integration/all/sonos/device-page/index.js create mode 100644 front/src/routes/integration/all/sonos/device-page/style.css create mode 100644 front/src/routes/integration/all/sonos/discover-page/DiscoverTab.jsx create mode 100644 front/src/routes/integration/all/sonos/discover-page/EmptyState.jsx create mode 100644 front/src/routes/integration/all/sonos/discover-page/index.js create mode 100644 front/src/routes/integration/all/sonos/discover-page/style.css create mode 100644 server/services/sonos/api/sonos.controller.js create mode 100644 server/services/sonos/index.js create mode 100644 server/services/sonos/lib/index.js create mode 100644 server/services/sonos/lib/sonos.init.js create mode 100644 server/services/sonos/lib/sonos.onAvTransportEvent.js create mode 100644 server/services/sonos/lib/sonos.onVolumeEvent.js create mode 100644 server/services/sonos/lib/sonos.scan.js create mode 100644 server/services/sonos/lib/sonos.setValue.js create mode 100644 server/services/sonos/package-lock.json create mode 100644 server/services/sonos/package.json create mode 100644 server/services/sonos/utils/convertToGladysDevice.js create mode 100644 server/test/services/sonos/api/sonos.controller.test.js create mode 100644 server/test/services/sonos/index.test.js create mode 100644 server/test/services/sonos/lib/sonos.init.test.js create mode 100644 server/test/services/sonos/lib/sonos.onAvTransportEvent.test.js create mode 100644 server/test/services/sonos/lib/sonos.onVolumeEvent.test.js create mode 100644 server/test/services/sonos/lib/sonos.setValue.test.js diff --git a/front/src/assets/integrations/cover/sonos.jpg b/front/src/assets/integrations/cover/sonos.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a89425b90960eb3f97b85c0f8294f32a57cbb174 GIT binary patch literal 20571 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 literal 0 HcmV?d00001 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,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 ( +
+
+
{device.name}
+
+
+
+
+ {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;