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 (
+
+
+
+
+
+
+
+ {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);
+ });
+});