diff --git a/front/src/assets/integrations/cover/google-cast.jpg b/front/src/assets/integrations/cover/google-cast.jpg new file mode 100644 index 0000000000..a99b3178f3 Binary files /dev/null and b/front/src/assets/integrations/cover/google-cast.jpg differ diff --git a/front/src/components/app.jsx b/front/src/components/app.jsx index e921dfff8c..2962f58aae 100644 --- a/front/src/components/app.jsx +++ b/front/src/components/app.jsx @@ -148,6 +148,10 @@ import NetatmoDiscoverPage from '../routes/integration/all/netatmo/discover-page import SonosDevicePage from '../routes/integration/all/sonos/device-page'; import SonosDiscoveryPage from '../routes/integration/all/sonos/discover-page'; +// Google Cast integration +import GoogleCastDevicePage from '../routes/integration/all/google-cast/device-page'; +import GoogleCastDiscoveryPage from '../routes/integration/all/google-cast/discover-page'; + // ZWaveJS-UI integration import ZwaveJSUIDevicePage from '../routes/integration/all/zwavejs-ui/device-page'; import ZwaveJSUIDiscoveryPage from '../routes/integration/all/zwavejs-ui/discover-page'; @@ -296,6 +300,9 @@ const AppRouter = connect( + + + diff --git a/front/src/components/boxs/music/EditMusicBox.jsx b/front/src/components/boxs/music/EditMusicBox.jsx index 1cb7c9a014..cd9d9c0243 100644 --- a/front/src/components/boxs/music/EditMusicBox.jsx +++ b/front/src/components/boxs/music/EditMusicBox.jsx @@ -4,7 +4,7 @@ import Select from 'react-select'; import { connect } from 'unistore/preact'; import BaseEditBox from '../baseEditBox'; -import { DEVICE_FEATURE_CATEGORIES } from '../../../../../server/utils/constants'; +import { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } from '../../../../../server/utils/constants'; class EditMusicBoxComponent extends Component { updateDevice = option => { @@ -18,9 +18,12 @@ class EditMusicBoxComponent extends Component { await this.setState({ error: false }); - const musicDevices = await this.props.httpClient.get('/api/v1/device', { + let musicDevices = await this.props.httpClient.get('/api/v1/device', { device_feature_category: DEVICE_FEATURE_CATEGORIES.MUSIC }); + // We only keep music player with at least the "play" capability + // Some music devices like the Nest Mini only exposes the "notification" for now + musicDevices = musicDevices.filter(d => d.features.find(f => f.type === DEVICE_FEATURE_TYPES.MUSIC.PLAY)); const musicDevicesOptions = musicDevices.map(d => ({ label: d.name, value: d.selector diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index e8bad61f59..6d6b9b5510 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -1081,6 +1081,41 @@ "conflictError": "Das aktuelle Gerät ist bereits in Gladys vorhanden." } }, + "google-cast": { + "title": "Google Cast", + "description": "Steuern Sie Google Cast-Geräte in Ihrem lokalen Netzwerk", + "deviceTab": "Geräte", + "discoverTab": "Google Cast Entdeckung", + "setupTab": "Einrichtung", + "documentation": "Google Cast Dokumentation", + "discoverDeviceDescr": "Google Cast-Geräte automatisch scannen", + "nameLabel": "Gerätename", + "namePlaceholder": "Geben Sie den Namen Ihres Geräts ein", + "hostLabel": "IP-Adresse", + "roomLabel": "Zimmer", + "saveButton": "Speichern", + "alreadyCreatedButton": "Bereits erstellt", + "deleteButton": "Löschen", + "device": { + "title": "Google Cast-Geräte in Gladys", + "editButton": "Bearbeiten", + "noDeviceFound": "Kein Google Cast-Gerät gefunden.", + "featuresLabel": "Funktionen" + }, + "discover": { + "title": "Geräte in Ihrem lokalen Netzwerk erkannt", + "description": "Google Cast-Geräte werden automatisch erkannt.", + "error": "Fehler beim Entdecken der Google Cast-Geräte. Ist Ihr Google Cast-Lautsprecher eingeschaltet und im lokalen Netzwerk erreichbar?", + "noDeviceFound": "Keine Google Cast-Geräte gefunden.", + "errorWhileScanning": "Beim Scannen ist ein Fehler aufgetreten.", + "scan": "Scannen" + }, + "error": { + "defaultError": "Beim Speichern des Geräts ist ein Fehler aufgetreten.", + "defaultDeletionError": "Beim Löschen des Geräts ist ein Fehler aufgetreten.", + "conflictError": "Das aktuelle Gerät ist bereits in Gladys vorhanden." + } + }, "zwavejs-ui": { "title": "ZWave JS UI", "description": "Steuern Sie Ihre Geräte in Z-Wave JS UI", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index b198184a57..29d9eb7c0a 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -1081,6 +1081,41 @@ "conflictError": "The current device is already in Gladys." } }, + "google-cast": { + "title": "Google Cast", + "description": "Control Google Cast devices on your local network", + "deviceTab": "Devices", + "discoverTab": "Google Cast Discovery", + "setupTab": "Setup", + "documentation": "Google Cast Documentation", + "discoverDeviceDescr": "Automatically scan for Google Cast devices", + "nameLabel": "Device Name", + "namePlaceholder": "Enter your device name", + "hostLabel": "IP Address", + "roomLabel": "Room", + "saveButton": "Save", + "alreadyCreatedButton": "Already Created", + "deleteButton": "Delete", + "device": { + "title": "Google Cast Devices in Gladys", + "editButton": "Edit", + "noDeviceFound": "No Google Cast device found.", + "featuresLabel": "Features" + }, + "discover": { + "title": "Devices detected on your local network", + "description": "Google Cast devices are automatically discovered.", + "error": "Error discovering Google Cast devices. Is your Google Cast speaker powered on and accessible on the local network?", + "noDeviceFound": "No Google Cast devices have been discovered.", + "errorWhileScanning": "An error occurred while 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." + } + }, "zwavejs-ui": { "title": "ZWave JS UI", "description": "Control your devices in Z-Wave JS UI", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 3cf77e714a..28744efe99 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -1209,6 +1209,41 @@ "conflictError": "L'appareil actuel est déjà dans Gladys." } }, + "google-cast": { + "title": "Google Cast", + "description": "Contrôler les appareils Google Cast sur votre réseau local", + "deviceTab": "Appareils", + "discoverTab": "Découverte Google Cast", + "setupTab": "Configuration", + "documentation": "Documentation Google Cast", + "discoverDeviceDescr": "Scanner automatiquement les appareils Google Cast", + "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 Google Cast dans Gladys", + "editButton": "Éditer", + "noDeviceFound": "Aucun appareil Google Cast trouvé.", + "featuresLabel": "Fonctionnalités" + }, + "discover": { + "title": "Appareils détectés sur votre réseau local", + "description": "Les appareils Google Cast sont automatiquement découverts.", + "error": "Erreur de découverte des appareils Google Cast. Est-ce que votre enceinte Google Cast est sous tension et accessible sur le réseau local ?", + "noDeviceFound": "Aucun appareil Google Cast 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." + } + }, "zwavejs-ui": { "title": "ZWave JS UI", "description": "Contrôler vos appareils dans Z-Wave JS UI", diff --git a/front/src/config/integrations/devices.json b/front/src/config/integrations/devices.json index 88f52ebcd0..945a3b34f0 100644 --- a/front/src/config/integrations/devices.json +++ b/front/src/config/integrations/devices.json @@ -88,5 +88,10 @@ "key": "zwavejs-ui", "link": "zwavejs-ui", "img": "/assets/integrations/cover/zwave-js-ui.jpg" + }, + { + "key": "google-cast", + "link": "google-cast", + "img": "/assets/integrations/cover/google-cast.jpg" } ] diff --git a/front/src/routes/integration/all/google-cast/GoogleCastDeviceBox.jsx b/front/src/routes/integration/all/google-cast/GoogleCastDeviceBox.jsx new file mode 100644 index 0000000000..2e523e9338 --- /dev/null +++ b/front/src/routes/integration/all/google-cast/GoogleCastDeviceBox.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 GoogleCastDeviceBox 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.google-cast.error.defaultError'; + if (e.response.status === 409) { + errorMessage = 'integration.google-cast.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.getGoogleCastDevices(); + } 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.google-cast.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', {})(GoogleCastDeviceBox); diff --git a/front/src/routes/integration/all/google-cast/GoogleCastPage.jsx b/front/src/routes/integration/all/google-cast/GoogleCastPage.jsx new file mode 100644 index 0000000000..ada667052f --- /dev/null +++ b/front/src/routes/integration/all/google-cast/GoogleCastPage.jsx @@ -0,0 +1,62 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; +import DeviceConfigurationLink from '../../../../components/documentation/DeviceConfigurationLink'; + +const GoogleCastPage = ({ children, user }) => ( +
+
+
+
+
+
+

+ +

+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ +
{children}
+
+
+
+
+
+); + +export default GoogleCastPage; diff --git a/front/src/routes/integration/all/google-cast/device-page/DeviceTab.jsx b/front/src/routes/integration/all/google-cast/device-page/DeviceTab.jsx new file mode 100644 index 0000000000..d4dd1c1490 --- /dev/null +++ b/front/src/routes/integration/all/google-cast/device-page/DeviceTab.jsx @@ -0,0 +1,133 @@ +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 GoogleCastDeviceBox from '../GoogleCastDeviceBox'; +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.getGoogleCastDevices(); + this.getHouses(); + } + + getGoogleCastDevices = async () => { + this.setState({ + getGoogleCastStatus: 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 googleCastDevices = await this.props.httpClient.get('/api/v1/service/google-cast/device', options); + this.setState({ + googleCastDevices, + getGoogleCastStatus: RequestStatus.Success + }); + } catch (e) { + this.setState({ + getGoogleCastStatus: 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.getGoogleCastDevices(); + } + + changeOrderDir = async e => { + await this.setState({ + orderDir: e.target.value + }); + this.getGoogleCastDevices(); + }; + + render({}, { orderDir, search, getGoogleCastStatus, googleCastDevices, housesWithRooms }) { + return ( +
+
+

+ +

+
+ + } + /> + +
+
+
+
+
+
+
+ {googleCastDevices && + googleCastDevices.length > 0 && + googleCastDevices.map((device, index) => ( + + ))} + {!googleCastDevices || (googleCastDevices.length === 0 && )} +
+
+
+
+
+ ); + } +} + +export default connect('httpClient', {})(DeviceTab); diff --git a/front/src/routes/integration/all/google-cast/device-page/EmptyState.jsx b/front/src/routes/integration/all/google-cast/device-page/EmptyState.jsx new file mode 100644 index 0000000000..f0825347db --- /dev/null +++ b/front/src/routes/integration/all/google-cast/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/google-cast/device-page/index.js b/front/src/routes/integration/all/google-cast/device-page/index.js new file mode 100644 index 0000000000..094b82183f --- /dev/null +++ b/front/src/routes/integration/all/google-cast/device-page/index.js @@ -0,0 +1,11 @@ +import { connect } from 'unistore/preact'; +import DeviceTab from './DeviceTab'; +import GoogleCastPage from '../GoogleCastPage'; + +const DevicePage = props => ( + + + +); + +export default connect('user', {})(DevicePage); diff --git a/front/src/routes/integration/all/google-cast/device-page/style.css b/front/src/routes/integration/all/google-cast/device-page/style.css new file mode 100644 index 0000000000..4804f6a3b0 --- /dev/null +++ b/front/src/routes/integration/all/google-cast/device-page/style.css @@ -0,0 +1,7 @@ +.emptyStateDivBox { + margin-top: 35px; +} + +.tuyaListBody { + min-height: 200px +} diff --git a/front/src/routes/integration/all/google-cast/discover-page/DiscoverTab.jsx b/front/src/routes/integration/all/google-cast/discover-page/DiscoverTab.jsx new file mode 100644 index 0000000000..d7fcbceee6 --- /dev/null +++ b/front/src/routes/integration/all/google-cast/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 GoogleCastDeviceBox from '../GoogleCastDeviceBox'; +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/google-cast/discover'); + const existingGoogleCastDevices = await this.props.httpClient.get('/api/v1/service/google-cast/device', {}); + discoveredDevices.forEach(discoveredDevice => { + const existingDevice = existingGoogleCastDevices.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/google-cast/discover-page/EmptyState.jsx b/front/src/routes/integration/all/google-cast/discover-page/EmptyState.jsx new file mode 100644 index 0000000000..561ddc0952 --- /dev/null +++ b/front/src/routes/integration/all/google-cast/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/google-cast/discover-page/index.js b/front/src/routes/integration/all/google-cast/discover-page/index.js new file mode 100644 index 0000000000..18a350a567 --- /dev/null +++ b/front/src/routes/integration/all/google-cast/discover-page/index.js @@ -0,0 +1,11 @@ +import { connect } from 'unistore/preact'; +import DiscoverTab from './DiscoverTab'; +import GoogleCastPage from '../GoogleCastPage'; + +const GoogleCastDiscoverPage = props => ( + + + +); + +export default connect('user', {})(GoogleCastDiscoverPage); diff --git a/front/src/routes/integration/all/google-cast/discover-page/style.css b/front/src/routes/integration/all/google-cast/discover-page/style.css new file mode 100644 index 0000000000..c166fc20ec --- /dev/null +++ b/front/src/routes/integration/all/google-cast/discover-page/style.css @@ -0,0 +1,7 @@ +.emptyStateDivBox { + margin-top: 89px; +} + +.googleCastListBody { + min-height: 200px; +} diff --git a/server/services/google-cast/api/google_cast.controller.js b/server/services/google-cast/api/google_cast.controller.js new file mode 100644 index 0000000000..ff165a2174 --- /dev/null +++ b/server/services/google-cast/api/google_cast.controller.js @@ -0,0 +1,20 @@ +const asyncMiddleware = require('../../../api/middlewares/asyncMiddleware'); + +module.exports = function GoogleCastController(googleCastHandler) { + /** + * @api {get} /api/v1/service/google-cast/discover Retrieve googleCast devices from local network + * @apiName discover + * @apiGroup GoogleCast + */ + async function discover(req, res) { + const devices = await googleCastHandler.scan(); + res.json(devices); + } + + return { + 'get /api/v1/service/google-cast/discover': { + authenticated: true, + controller: asyncMiddleware(discover), + }, + }; +}; diff --git a/server/services/google-cast/index.js b/server/services/google-cast/index.js new file mode 100644 index 0000000000..c74d950e22 --- /dev/null +++ b/server/services/google-cast/index.js @@ -0,0 +1,50 @@ +const logger = require('../../utils/logger'); +const GoogleCastHandler = require('./lib'); +const googleCastController = require('./api/google_cast.controller'); + +module.exports = function GoogleCastService(gladys, serviceId) { + // @ts-ignore + const googleCastv2Lib = require('castv2-client'); + const bonjourLib = require('bonjour')(); + const googleCastHandler = new GoogleCastHandler(gladys, googleCastv2Lib, bonjourLib, serviceId); + + /** + * @public + * @description This function starts the googleCast service service. + * @example + * gladys.services['googleCast'].start(); + */ + async function start() { + logger.info('Starting GoogleCast service'); + googleCastHandler.init(); + } + + /** + * @public + * @description This function stops the googleCast service. + * @example + * gladys.services['googleCast'].stop(); + */ + async function stop() { + logger.info('Stopping GoogleCast service'); + } + + /** + * @public + * @description This function return true if the service is used. + * @returns {Promise} Resolves with a boolean. + * @example + * const isUsed = await gladys.services['googleCast'].isUsed(); + */ + async function isUsed() { + return googleCastHandler.devices.length > 0; + } + + return Object.freeze({ + start, + stop, + isUsed, + device: googleCastHandler, + controllers: googleCastController(googleCastHandler), + }); +}; diff --git a/server/services/google-cast/lib/google_cast.init.js b/server/services/google-cast/lib/google_cast.init.js new file mode 100644 index 0000000000..c366b3c067 --- /dev/null +++ b/server/services/google-cast/lib/google_cast.init.js @@ -0,0 +1,11 @@ +/** + * @description Scan the network to find IP addresses of Google Cast. + * @example init(); + */ +async function init() { + await this.scan(); +} + +module.exports = { + init, +}; diff --git a/server/services/google-cast/lib/google_cast.scan.js b/server/services/google-cast/lib/google_cast.scan.js new file mode 100644 index 0000000000..d365055fd0 --- /dev/null +++ b/server/services/google-cast/lib/google_cast.scan.js @@ -0,0 +1,42 @@ +const { convertToGladysDevice } = require('../utils/convertToGladysDevice'); +const logger = require('../../../utils/logger'); + +/** + * @description Scan network for Google Cast devices. + * @returns {Promise} Resolve with discovered devices. + * @example scan(); + */ +async function scan() { + return new Promise((resolve, reject) => { + const devices = []; + const deviceIpAddresses = new Map(); + + const browser = this.bonjourLib.find({ type: 'googlecast' }); + + browser.on('up', (service) => { + logger.debug('Google Cast: Found device "%s" at %s:%d', service.name, service.referer.address, service.port); + devices.push(convertToGladysDevice(this.serviceId, service)); + deviceIpAddresses.set(service.name, service.referer.address); + }); + + browser.on('error', (err) => { + logger.error('Google Cast: Error during bonjour scan', err); + browser.stop(); + reject(err); + }); + + // Stop the browser after a certain time to finish the scan + setTimeout(() => { + browser.stop(); + this.devices = devices; + this.deviceIpAddresses = deviceIpAddresses; + resolve(devices); + }, this.scanTimeout); + + // Start the browser immediately as bonjour automatically starts + }); +} + +module.exports = { + scan, +}; diff --git a/server/services/google-cast/lib/google_cast.setValue.js b/server/services/google-cast/lib/google_cast.setValue.js new file mode 100644 index 0000000000..9224d8de11 --- /dev/null +++ b/server/services/google-cast/lib/google_cast.setValue.js @@ -0,0 +1,62 @@ +const { DEVICE_FEATURE_TYPES } = require('../../../utils/constants'); +const logger = require('../../../utils/logger'); +/** + * @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 deviceName = device.external_id.split(':')[1]; + const ipAddress = this.deviceIpAddresses.get(deviceName); + if (!ipAddress) { + throw new Error('Device not found on network'); + } + + if (deviceFeature.type === DEVICE_FEATURE_TYPES.MUSIC.PLAY_NOTIFICATION) { + const { Client, DefaultMediaReceiver } = this.googleCastLib; + const client = new Client(); + + client.connect(ipAddress, () => { + logger.debug('Google Cast Connected, launching app ...'); + + client.launch(DefaultMediaReceiver, (err, player) => { + const media = { + // Here you can plug an URL to any mp4, webm, mp3 or jpg file with the proper contentType. + contentId: value, + contentType: 'audio/mp3', + streamType: 'BUFFERED', // or LIVE + + // Title and cover displayed while buffering + metadata: { + type: 0, + metadataType: 0, + title: 'Gladys Assistant Speaking...', + images: [], + }, + }; + + player.on('status', (status) => { + logger.debug('status broadcast playerState=%s', status.playerState); + }); + + logger.debug('app "%s" launched, loading media %s ...', player.session.displayName, media.contentId); + + player.load(media, { autoplay: true }, (loadError, status) => { + logger.debug('media loaded playerState=%s', status.playerState); + }); + }); + }); + + client.on('error', (err) => { + logger.error('Error: %s', err.message); + client.close(); + }); + } +} + +module.exports = { + setValue, +}; diff --git a/server/services/google-cast/lib/index.js b/server/services/google-cast/lib/index.js new file mode 100644 index 0000000000..883058838c --- /dev/null +++ b/server/services/google-cast/lib/index.js @@ -0,0 +1,19 @@ +const { init } = require('./google_cast.init'); +const { scan } = require('./google_cast.scan'); +const { setValue } = require('./google_cast.setValue'); + +const GoogleCastHandler = function GoogleCastHandler(gladys, googleCastLib, bonjourLib, serviceId) { + this.gladys = gladys; + this.googleCastLib = googleCastLib; + this.bonjourLib = bonjourLib; + this.serviceId = serviceId; + this.devices = []; + this.deviceIpAddresses = new Map(); + this.scanTimeout = 5000; +}; + +GoogleCastHandler.prototype.init = init; +GoogleCastHandler.prototype.scan = scan; +GoogleCastHandler.prototype.setValue = setValue; + +module.exports = GoogleCastHandler; diff --git a/server/services/google-cast/package-lock.json b/server/services/google-cast/package-lock.json new file mode 100644 index 0000000000..c06757b265 --- /dev/null +++ b/server/services/google-cast/package-lock.json @@ -0,0 +1,576 @@ +{ + "name": "gladys-google-cast", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gladys-google-cast", + "version": "1.0.0", + "cpu": [ + "x64", + "arm", + "arm64" + ], + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "bonjour": "^3.5.0", + "castv2-client": "^1.2.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" + }, + "node_modules/@types/node": { + "version": "20.12.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", + "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==" + }, + "node_modules/bonjour": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/bonjour/-/bonjour-3.5.0.tgz", + "integrity": "sha512-RaVTblr+OnEli0r/ud8InrU7D+G0y6aJhlxaLa6Pwty4+xoxboF1BsUI45tujvRpbj9dQVoglChqonGAsjEBYg==", + "dependencies": { + "array-flatten": "^2.1.0", + "deep-equal": "^1.0.1", + "dns-equal": "^1.0.0", + "dns-txt": "^2.0.2", + "multicast-dns": "^6.0.1", + "multicast-dns-service-types": "^1.1.0" + } + }, + "node_modules/buffer-indexof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-indexof/-/buffer-indexof-1.1.1.tgz", + "integrity": "sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g==" + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/castv2": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/castv2/-/castv2-0.1.10.tgz", + "integrity": "sha512-3QWevHrjT22KdF08Y2a217IYCDQDP7vEJaY4n0lPBeC5UBYbMFMadDfVTsaQwq7wqsEgYUHElPGm3EO1ey+TNw==", + "dependencies": { + "debug": "^4.1.1", + "protobufjs": "^6.8.8" + } + }, + "node_modules/castv2-client": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/castv2-client/-/castv2-client-1.2.0.tgz", + "integrity": "sha512-2diOsC0vSSxa3QEOgoGBy9fZRHzNXatHz464Kje2OpwQ7GM5vulyrD0gLFOQ1P4rgLAFsYiSGQl4gK402nEEuA==", + "dependencies": { + "castv2": "~0.1.4", + "debug": "^2.2.0" + } + }, + "node_modules/castv2/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/castv2/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/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==" + }, + "node_modules/dns-packet": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-1.3.4.tgz", + "integrity": "sha512-BQ6F4vycLXBvdrJZ6S3gZewt6rcrks9KBgM9vrhW+knGRqc8uEdT7fuCwloc7nny5xNoMJ17HGH0R/6fpo8ECA==", + "dependencies": { + "ip": "^1.1.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/dns-txt": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/dns-txt/-/dns-txt-2.0.2.tgz", + "integrity": "sha512-Ix5PrWjphuSoUXV/Zv5gaFHjnaJtb02F2+Si3Ht9dyJ87+Z/lMmy+dpNHtTGraNK958ndXq2i+GLkWsWHcKaBQ==", + "dependencies": { + "buffer-indexof": "^1.0.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", + "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==" + }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/multicast-dns": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-6.2.3.tgz", + "integrity": "sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g==", + "dependencies": { + "dns-packet": "^1.3.1", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/multicast-dns-service-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz", + "integrity": "sha512-cnAsSVxIDsYt0v7HmC0hWZFwwXSh+E6PgCrREDuN/EsjgLwA5XRmlMHhSiDPrt6HxY1gTivEa/Zh7GtODoLevQ==" + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/protobufjs": { + "version": "6.11.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", + "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "dependencies": { + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + } + } +} diff --git a/server/services/google-cast/package.json b/server/services/google-cast/package.json new file mode 100644 index 0000000000..6032a4dffa --- /dev/null +++ b/server/services/google-cast/package.json @@ -0,0 +1,19 @@ +{ + "name": "gladys-google-cast", + "version": "1.0.0", + "main": "index.js", + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm", + "arm64" + ], + "dependencies": { + "bonjour": "^3.5.0", + "castv2-client": "^1.2.0" + } +} diff --git a/server/services/google-cast/utils/convertToGladysDevice.js b/server/services/google-cast/utils/convertToGladysDevice.js new file mode 100644 index 0000000000..955efa563f --- /dev/null +++ b/server/services/google-cast/utils/convertToGladysDevice.js @@ -0,0 +1,27 @@ +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../utils/constants'); + +const convertToGladysDevice = (serviceId, device) => { + return { + name: device.name, + external_id: `google-cast:${device.name}`, + service_id: serviceId, + should_poll: false, + features: [ + { + name: `${device.name} - Play Notification`, + external_id: `google-cast:${device.name}:play-notification`, + category: DEVICE_FEATURE_CATEGORIES.MUSIC, + type: DEVICE_FEATURE_TYPES.MUSIC.PLAY_NOTIFICATION, + min: 1, + max: 1, + keep_history: false, + read_only: false, + has_feedback: false, + }, + ], + }; +}; + +module.exports = { + convertToGladysDevice, +}; diff --git a/server/services/index.js b/server/services/index.js index 5bb249c4f6..dec39add7c 100644 --- a/server/services/index.js +++ b/server/services/index.js @@ -27,3 +27,4 @@ module.exports['node-red'] = require('./node-red'); module.exports.netatmo = require('./netatmo'); module.exports.sonos = require('./sonos'); module.exports['zwavejs-ui'] = require('./zwavejs-ui'); +module.exports['google-cast'] = require('./google-cast'); diff --git a/server/test/services/google-cast/api/google_cast.controller.test.js b/server/test/services/google-cast/api/google_cast.controller.test.js new file mode 100644 index 0000000000..6419611bee --- /dev/null +++ b/server/test/services/google-cast/api/google_cast.controller.test.js @@ -0,0 +1,36 @@ +const sinon = require('sinon'); +const GoogleCastController = require('../../../../services/google-cast/api/google_cast.controller'); + +const { assert, fake } = sinon; + +const googleCastHandler = { + scan: fake.resolves([ + { + name: 'my name', + }, + ]), +}; + +describe('GoogleCastController GET /api/v1/service/google-cast/discover', () => { + let controller; + + beforeEach(() => { + controller = GoogleCastController(googleCastHandler); + sinon.reset(); + }); + + it('should return discovered devices', async () => { + const req = {}; + const res = { + json: fake.returns([]), + }; + + await controller['get /api/v1/service/google-cast/discover'].controller(req, res); + assert.calledOnce(googleCastHandler.scan); + assert.calledWith(res.json, [ + { + name: 'my name', + }, + ]); + }); +}); diff --git a/server/test/services/google-cast/index.test.js b/server/test/services/google-cast/index.test.js new file mode 100644 index 0000000000..40d1b8f7ae --- /dev/null +++ b/server/test/services/google-cast/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 GoogleCastHandlerMock = sinon.stub(); +GoogleCastHandlerMock.prototype.init = fake.returns(null); +GoogleCastHandlerMock.prototype.devices = []; + +const GoogleCastService = proxyquire('../../../services/google-cast/index', { './lib': GoogleCastHandlerMock }); + +const gladys = {}; +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +describe('GoogleCastService', () => { + const googleCastService = GoogleCastService(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should start service', async () => { + await googleCastService.start(); + assert.calledOnce(googleCastService.device.init); + }); + + it('should stop service', async () => { + googleCastService.stop(); + assert.notCalled(googleCastService.device.init); + }); + + it('isUsed: should return false, service not used', async () => { + const used = await googleCastService.isUsed(); + expect(used).to.equal(false); + }); +}); diff --git a/server/test/services/google-cast/lib/google_cast.init.test.js b/server/test/services/google-cast/lib/google_cast.init.test.js new file mode 100644 index 0000000000..e0b5ebd719 --- /dev/null +++ b/server/test/services/google-cast/lib/google_cast.init.test.js @@ -0,0 +1,69 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const GoogleCastHandler = require('../../../../services/google-cast/lib'); + +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +const gladys = {}; + +const fakeBrowser = { + stop: fake.returns(null), + on: (event, cb) => { + if (event === 'up') { + cb({ + name: 'nest-mini', + referer: { + address: '192.168.1.1', + }, + port: 8999, + }); + } + }, +}; + +const googleCastLib = {}; + +const bonjourLib = { + find: fake.returns(fakeBrowser), +}; + +describe('GoogleCastHandler.init', () => { + const googleCastHandler = new GoogleCastHandler(gladys, googleCastLib, bonjourLib, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should init googleCast & scan network', async () => { + googleCastHandler.scanTimeout = 1; + await googleCastHandler.init(); + expect(googleCastHandler.devices).deep.equal([ + { + name: 'nest-mini', + external_id: 'google-cast:nest-mini', + service_id: 'ffa13430-df93-488a-9733-5c540e9558e0', + should_poll: false, + features: [ + { + name: 'nest-mini - Play Notification', + external_id: 'google-cast:nest-mini:play-notification', + category: 'music', + type: 'play_notification', + min: 1, + max: 1, + keep_history: false, + read_only: false, + has_feedback: false, + }, + ], + }, + ]); + }); +}); diff --git a/server/test/services/google-cast/lib/google_cast.setValue.test.js b/server/test/services/google-cast/lib/google_cast.setValue.test.js new file mode 100644 index 0000000000..0059dd0ab3 --- /dev/null +++ b/server/test/services/google-cast/lib/google_cast.setValue.test.js @@ -0,0 +1,93 @@ +const sinon = require('sinon'); +const { assert } = require('chai'); + +const { fake } = sinon; + +const GoogleCastHandler = require('../../../../services/google-cast/lib'); + +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +const gladys = {}; + +const player = { + session: { + playerName: 'Nest mini', + }, + on: (event, cb) => { + cb({ playerState: 'paused' }); + }, + load: (param1, param2, cb) => { + cb(null, { playerState: 'playing' }); + }, +}; + +class GoogleCastClient { + // eslint-disable-next-line class-methods-use-this + connect(ipAddress, cb) { + cb(); + } + + // eslint-disable-next-line class-methods-use-this + launch(receiver, cb) { + cb(null, player); + } + + // eslint-disable-next-line class-methods-use-this + on(event, cb) { + cb({ message: 'this is an error' }); + } + + // eslint-disable-next-line class-methods-use-this + close() {} +} + +const googleCastLib = { + Client: GoogleCastClient, + DefaultMediaReceiver: null, +}; + +const fakeBrowser = { + stop: fake.returns(null), + on: (event, cb) => { + if (event === 'up') { + cb({ + name: 'nest-mini', + referer: { + address: '192.168.1.1', + }, + port: 8999, + }); + } + }, +}; + +const bonjourLib = { + find: fake.returns(fakeBrowser), +}; + +describe('GoogleCastHandler.setValue', () => { + const googleCastHandler = new GoogleCastHandler(gladys, googleCastLib, bonjourLib, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should talk on speaker', async () => { + googleCastHandler.scanTimeout = 1; + const devices = await googleCastHandler.scan(); + const device = devices[0]; + await googleCastHandler.setValue(device, device.features[0], 'http://play-url.com'); + }); + it('should return device not found', async () => { + googleCastHandler.scanTimeout = 1; + const device = { + external_id: 'google_cast:toto', + }; + const promise = googleCastHandler.setValue(device, {}, 'http://play-url.com'); + await assert.isRejected(promise, 'Device not found on network'); + }); +}); diff --git a/server/test/services/google-cast/lib/google_cast_scan.test.js b/server/test/services/google-cast/lib/google_cast_scan.test.js new file mode 100644 index 0000000000..ba18e2e99e --- /dev/null +++ b/server/test/services/google-cast/lib/google_cast_scan.test.js @@ -0,0 +1,87 @@ +const { expect, assert } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const GoogleCastHandler = require('../../../../services/google-cast/lib'); + +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +const gladys = {}; + +const fakeBrowser = { + stop: fake.returns(null), + on: (event, cb) => { + if (event === 'up') { + cb({ + name: 'nest-mini', + referer: { + address: '192.168.1.1', + }, + port: 8999, + }); + } + }, +}; + +const googleCastLib = {}; + +const bonjourLib = { + find: fake.returns(fakeBrowser), +}; + +describe('GoogleCastHandler.scan', () => { + const googleCastHandler = new GoogleCastHandler(gladys, googleCastLib, bonjourLib, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should can network', async () => { + googleCastHandler.scanTimeout = 1; + const devices = await googleCastHandler.scan(); + expect(devices).deep.equal([ + { + name: 'nest-mini', + external_id: 'google-cast:nest-mini', + service_id: 'ffa13430-df93-488a-9733-5c540e9558e0', + should_poll: false, + features: [ + { + name: 'nest-mini - Play Notification', + external_id: 'google-cast:nest-mini:play-notification', + category: 'music', + type: 'play_notification', + min: 1, + max: 1, + keep_history: false, + read_only: false, + has_feedback: false, + }, + ], + }, + ]); + }); + it('should can network and reject', async () => { + const fakeBrowserWithError = { + stop: fake.returns(null), + on: (event, cb) => { + if (event === 'error') { + cb({}); + } + }, + }; + + const bonjourLibWithError = { + find: fake.returns(fakeBrowserWithError), + }; + const googleCastHandlerWithError = new GoogleCastHandler(gladys, googleCastLib, bonjourLibWithError, serviceId); + googleCastHandlerWithError.scanTimeout = 1; + const promise = googleCastHandlerWithError.scan(); + await assert.isRejected(promise); + }); +});