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,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;