UNPKG

node-red-contrib-castv2

Version:

A Node-Red node that provides basic Google Cast functionality based on the node-castv2-client package.

785 lines (691 loc) 30.5 kB
module.exports = function(RED) { "use strict"; const util = require('util'); const net = require('net'); const BonjourService = require('bonjour-service'); const Client = require('castv2-client').Client; const MediaReceiverBase = require('./lib/MediaReceiverBase'); const DefaultMediaReceiver = require('./lib/DefaultMediaReceiver'); const DefaultMediaReceiverAdapter = require('./lib/DefaultMediaReceiverAdapter'); const DashCastReceiver = require('./lib/DashCastReceiver'); const DashCastReceiverAdapter = require('./lib/DashCastReceiverAdapter'); const GenericMediaReceiver = require('./lib/GenericMediaReceiver'); const GenericMediaReceiverAdapter = require('./lib/GenericMediaReceiverAdapter'); const YouTubeReceiver = require('./lib/YouTubeReceiver'); const YouTubeReceiverAdapter = require('./lib/YouTubeReceiverAdapter'); function CastV2ConnectionNode(config) { RED.nodes.createNode(this, config); let node = this; // Settings this.name = config.name; this.target = config.target; this.host = config.host; this.port = config.port; // Connection state this.connected = false; this.connecting = false; this.connectedIp = null; this.closing = false; // Nodes subscribed to this connection this.registeredNodes = {}; this.platformStatus = null; // Platform commands handled by client directly this.platformCommands = [ "CLOSE", "GET_VOLUME", "GET_CAST_STATUS", "MUTE", "UNMUTE", "VOLUME" ]; /* * Launches session */ this.launchAsync = function(castV2App) { if (!node.connected) { throw new Error("Not connected"); } return node.client.launchAsync(castV2App); }; /* * Join session */ this.joinSessionAsync = function(activeSession, castv2App) { if (!node.connected) { throw new Error("Not connected"); } return node.client.joinAsync(activeSession, castv2App) }; /* * Registers a node */ this.register = function(castV2Node) { node.registeredNodes[castV2Node.id] = castV2Node; if (Object.keys(node.registeredNodes).length === 1) { node.connect(); } }; /* * Deregisters a node */ this.deregister = function(castV2Node, done) { delete node.registeredNodes[castV2Node.id]; if (node.closing) { return done(); } if (Object.keys(node.registeredNodes).length === 0) { if (node.connected || node.connecting) { node.disconnect(); } } done(); }; /* * Call status() on all registered nodes */ this.setStatusOfRegisteredNodes = function(status) { for (let id in node.registeredNodes) { if (node.registeredNodes.hasOwnProperty(id)) { node.registeredNodes[id].status(status); } } } /* * Call send() on all registered nodes */ this.sendToRegisteredNodes = function(msg) { RED.util.setMessageProperty(msg, "_castTarget", node.connectedIp); for (let id in node.registeredNodes) { if (node.registeredNodes.hasOwnProperty(id)) { node.registeredNodes[id].send(msg); } } } /* * Joins all nodes matching current sessions */ this.joinNodes = function() { if (!node.connected || !node.platformStatus) { throw new Error("Not connected"); } // Update all registered nodes for (let id in node.registeredNodes) { if (node.registeredNodes.hasOwnProperty(id)) { let activeSession = null; let mediaSupportingApp = null; let castV2App = null; if (node.platformStatus.applications) { activeSession = node.platformStatus.applications .find(session => node.registeredNodes[id].supportedApplications.some(supportedApp => supportedApp.APP_ID === session.appId)); if (activeSession) { castV2App = node.registeredNodes[id].supportedApplications.find(supportedApp => supportedApp.APP_ID === activeSession.appId); } else { mediaSupportingApp = node.platformStatus.applications .find(session => session.namespaces != null && session.namespaces.some(namespace => namespace.name === 'urn:x-cast:com.google.cast.media')); } } if (activeSession && castV2App) { node.registeredNodes[id].join(activeSession, castV2App); } else if (mediaSupportingApp) { node.registeredNodes[id].join(mediaSupportingApp, GenericMediaReceiver); } else { node.registeredNodes[id].unjoin(); } } } }; /* * Disconnect handler */ this.disconnect = function() { if (node.connected || node.connecting) { try { node.client.close(); } catch (exception) { } } // Set connection status node.connectedIp = null; node.connected = false; node.connecting = false; // Disconnect all active sessions for (let id in node.registeredNodes) { if (node.registeredNodes.hasOwnProperty(id)) { node.registeredNodes[id].unjoin(); } } // Reset client node.client = null; node.platformStatus = null; node.setStatusOfRegisteredNodes({ fill: "red", shape: "ring", text: "disconnected" }); }; /* * Reconnect handler */ this.reconnect = function() { node.connected = false; node.connecting = false; if (!node.closing && Object.keys(node.registeredNodes).length > 0) { clearTimeout(node.reconnectTimeOut); node.reconnectTimeOut = setTimeout(() => { node.connect(); }, 3000); } }; /* * Connect handler */ this.connect = function() { if (!node.connected && !node.connecting) { node.reconnectTimeOut = null; node.connecting = true; try { node.client = new Client(); // The underlying castv2 client emits for all channels of all clients // This is typically 3 channels for the actual connection, and at least 2 per receiver node.client.client.setMaxListeners(100); // Setup promisified methods node.client.connectAsync = connectOptions => new Promise(resolve => node.client.connect(connectOptions, resolve)); node.client.getAppAvailabilityAsync = util.promisify(node.client.getAppAvailability); node.client.getSessionsAsync = util.promisify(node.client.getSessions); node.client.joinAsync = util.promisify(node.client.join); node.client.launchAsync = util.promisify(node.client.launch); node.client.getStatusAsync = util.promisify(node.client.getStatus); node.client.getVolumeAsync = util.promisify(node.client.getVolume); node.client.setVolumeAsync = util.promisify(node.client.setVolume); node.client.stopAsync = util.promisify(node.client.stop); // Register secondary error handler node.client.on("error", function(error) { console.log(error); }); // Register error handler node.client.once("error", function(error) { node.disconnect(); node.reconnect(); }); // Register disconnect handlers node.client.client.once("close", function() { node.disconnect(); node.reconnect(); }); // Register platform status handler node.client.on("status", function(status) { node.platformStatus = status; node.joinNodes(); node.sendToRegisteredNodes({ platform: status }); }); // Alert connecting state node.setStatusOfRegisteredNodes({ fill: "yellow", shape: "ring", text: "connecting" }); // Connect discoverCastTargetsAsync() .then(castTargets => { if (node.target) { // Use target if supplied let discoveredTarget = castTargets.find(x => x.name === node.target); return { host: discoveredTarget != null ? discoveredTarget.address : "0.0.0.0", port: discoveredTarget != null ? discoveredTarget.port : 8009 }; } else { return { host: node.host, port: node.port || 8009 }; } }) .then(connectOptions => { node.connectedIp = connectOptions.host; return node.client.connectAsync(connectOptions); }) .then(() => { node.connected = true; node.connecting = false; // Set registered node status node.setStatusOfRegisteredNodes({ fill: "green", shape: "ring", text: "connected" }); return node.client.getStatusAsync(); }) .then(status => { node.platformStatus = status; // Send initial cast device platform status node.sendToRegisteredNodes({ platform: status }); // Join all nodes node.joinNodes(); }) .catch(error => { console.log(error); node.disconnect(); node.reconnect(); }); } catch (exception) { console.log(exception); } } }; /* * Close handler */ this.on('close', function(done) { try { node.closing = true; node.disconnect(); done(); } catch(error) { // Swallow any failures here done(); } }); /* * Cast command handler */ this.sendPlatformCommandAsync = function(command, receiver) { if (!node.connected) { throw new Error("Not connected"); } // Check for platform commands first switch (command.type) { case "CLOSE": if (receiver) { return node.client.stopAsync(receiver) .then(applications => { return node.client.getStatusAsync(); }); } else { return node.client.getStatusAsync(); } break; case "GET_VOLUME": return node.client.getVolumeAsync() .then(volume => node.client.getStatusAsync()); break; case "GET_CAST_STATUS": return node.client.getStatusAsync(); break; case "MUTE": return node.client.setVolumeAsync({ muted: true }) .then(volume => node.client.getStatusAsync()); break; case "UNMUTE": return node.client.setVolumeAsync({ muted: false }) .then(volume => node.client.getStatusAsync()); break; case "VOLUME": if (command.volume && command.volume >= 0 && command.volume <= 100) { return node.client.setVolumeAsync({ level: command.volume / 100 }) .then(volume => node.client.getStatusAsync()); } else { throw new Error("Malformed command"); } break; default: // If it got this far just error throw new Error("Malformed command"); break; } }; } RED.nodes.registerType("castv2-connection", CastV2ConnectionNode); function CastV2SenderNode(config) { RED.nodes.createNode(this, config); // Settings this.name = config.name; this.connection = config.connection; this.clientNode = RED.nodes.getNode(this.connection); // Internal state this.supportedApplications = [ DefaultMediaReceiver, DashCastReceiver, YouTubeReceiver ]; this.receiver = null; this.adapter = null; this.launching = false; // Media control commands handled by any active receiver this.mediaCommands = [ "GET_STATUS", "PAUSE", "PLAY", "QUEUE_NEXT", "QUEUE_PREV", "SEEK", "STOP" ]; let node = this; /* * Joins this node to the active receiver on the client connection */ this.join = function(activeSession, castV2App) { // Ignore launches triggered by self launching in sendCommandAsync if (node.launching) return; // Only join if not already joined up if (node.receiver == null || !(node.receiver instanceof castV2App)) { node.clientNode.joinSessionAsync(activeSession, castV2App) .then(receiver => node.initReceiver(receiver, castV2App)); } }; /* * Disconnects this node from the active receiver on the client connection */ this.unjoin = function() { node.adapter = null; if (node.receiver != null) { try { node.receiver.close(); } catch (e) { } node.receiver = null; } node.status({ fill: "green", shape: "ring", text: "connected" }); }; /* * Initializes a receiver after launch or join */ this.initReceiver = function(receiver, castV2App) { node.adapter = node.getAdapter(castV2App); node.receiver = node.adapter.initReceiver(node, receiver); node.receiver.on("status", function(status) { if (status) { const msg = { _castTarget: node.clientNode.connectedIp, platform: node.clientNode.platformStatus, payload: status }; node.send(msg); } }); node.receiver.once("close", function() { node.adapter = null; node.receiver = null; node.status({ fill: "green", shape: "ring", text: "connected" }); }); node.status({ fill: "green", shape: "dot", text: "joined" }); // Send initial receiver state if (typeof node.receiver.getStatusAsync === "function") { node.receiver.getStatusAsync() .then(status => { if (status) { const msg = { _castTarget: node.clientNode.connectedIp, platform: node.clientNode.platformStatus, payload: status }; node.send(msg); } }); } }; /* * Gets adapter for specified application */ this.getAdapter = function(castV2App) { switch (castV2App.APP_ID) { case DefaultMediaReceiver.APP_ID: return DefaultMediaReceiverAdapter; break; case GenericMediaReceiver.APP_ID: return GenericMediaReceiverAdapter; break; case DashCastReceiver.APP_ID: return DashCastReceiverAdapter; break; case YouTubeReceiver.APP_ID: return YouTubeReceiverAdapter; break; default: return null; break; } } /* * Gets application for command */ this.getCommandApp = function(command) { switch (command.app) { case "DefaultMediaReceiver": return DefaultMediaReceiver; break; case "DashCast": return DashCastReceiver; break; case "YouTube": return YouTubeReceiver; break; default: return null; break; } } /* * General command handler */ this.sendCommandAsync = function(command) { let isPlatformCommand = node.clientNode.platformCommands.includes(command.type); let isMediaCommand = node.mediaCommands.includes(command.type); if (isPlatformCommand) { return node.clientNode.sendPlatformCommandAsync(command, node.receiver); } else if (isMediaCommand) { // If no active receiver, error if (!node.receiver || !node.adapter) { // Calling GET_STATUS without a receiver should just return null if (command.type === "GET_STATUS") { return Promise.resolve(null); } throw new Error("No active receiver application"); } // Ensure the receiver supports media commands if (!(node.receiver instanceof MediaReceiverBase)) { throw new Error("Receiver does not support media commands"); } return node.sendMediaCommandAsync(command); } else { // App specific command, determine app let castV2App = node.getCommandApp(command); // If no active receiver, launch and try again if (!node.receiver || !node.adapter || !(node.receiver instanceof castV2App)) { node.launching = true; return node.clientNode.launchAsync(castV2App) .then(receiver => { node.initReceiver(receiver, castV2App); node.launching = false; return node.adapter.sendAppCommandAsync(node.receiver, command); }) .catch(error => { // Ensure on failure we cleanup launching lock node.launching = false; throw error; }); } return node.adapter.sendAppCommandAsync(node.receiver, command); } }; /* * Media command handler */ this.sendMediaCommandAsync = function(command) { if (command.type === "GET_STATUS") { return node.receiver.getStatusAsync(); } else { // Initialize media controller by calling getStatus first return node.receiver.getStatusAsync() .then(status => { // Theres not actually anything playing, exit gracefully if (!status) throw new Error("not playing"); /* * Execute media control command * status.supportedMediaCommands bitmask * 1 Pause * 2 Seek * 4 Stream volume * 8 Stream mute * 16 Skip forward * 32 Skip backward * 64 Queue Next * 128 Queue Prev * 256 Queue Shuffle * 1024 Queue Repeat All * 2048 Queue Repeat One * 3072 Queue Repeat */ switch (command.type) { case "PAUSE": if (status.supportedMediaCommands & 1) { return node.receiver.pauseAsync(); } break; case "PLAY": return node.receiver.playAsync(); break; case "SEEK": if (status.supportedMediaCommands & 2 && command.time) { return node.receiver.seekAsync(command.time); } break; case "STOP": return node.receiver.stopAsync(); break; case "QUEUE_NEXT": // bypass check as workaround of a google issue that does not add // the QUEUE_NEXT bit in supportedMediaCommands for DefaultMediaReceiver // https://issuetracker.google.com/issues/139939455 let bypassCheckNext = node.receiver.session.appId === DefaultMediaReceiver.APP_ID && status.items.length > 1; if (bypassCheckNext || status.supportedMediaCommands & 64) { return node.receiver.queueNextAsync(); } break; case "QUEUE_PREV": // bypass check as workaround of a google issue that does not add // the QUEUE_PREV bit in supportedMediaCommands for DefaultMediaReceiver // https://issuetracker.google.com/issues/139939455 let bypassCheckPrev = node.receiver.session.appId === DefaultMediaReceiver.APP_ID && status.items.length > 1; if (bypassCheckPrev || status.supportedMediaCommands & 128) { return node.receiver.queuePrevAsync(); } break; default: throw new Error("Malformed media control command"); break; } }); } }; if (node.clientNode) { node.status({ fill: "red", shape: "ring", text: "disconnected" }); node.clientNode.register(node); if (node.clientNode.connected) { node.status({ fill: "green", shape: "ring", text: "connected" }); } /* * Node-red input handler */ this.on("input", function(msg, send, done) { // For maximum backwards compatibility, check that send exists. // If this node is installed in Node-RED 0.x, it will need to // fallback to using `node.send` send = send || function() { node.send.apply(node, arguments); }; // Reset the node status if (node.receiver != null && node.adapter != null) { node.status({ fill: "green", shape: "dot", text: "joined" }); } else if (node.clientNode.connected) { node.status({ fill: "green", shape: "ring", text: "connected" }); } else { node.status({ fill: "red", shape: "ring", text: "disconnected" }); } const errorHandler = function(error) { node.status({ fill: "red", shape: "ring", text: "error" }); if (done) { done(error); } else { node.error(error, error.message); } }; try { // Validate incoming message if (msg.payload == null || typeof msg.payload !== "object") { msg.payload = { type: "GET_CAST_STATUS" }; } if (msg.payload.app == null) { msg.payload.app = "DefaultMediaReceiver"; } node.sendCommandAsync(msg.payload) .then(status => { // Handle solicited messages status = status || null; if (msg.payload.type === "GET_CAST_STATUS" || msg.payload.type === "GET_VOLUME") { RED.util.setMessageProperty(msg, "_castTarget", node.clientNode.connectedIp); RED.util.setMessageProperty(msg, "platform", status); send(msg); } else if (msg.payload.type === "GET_STATUS") { RED.util.setMessageProperty(msg, "_castTarget", node.clientNode.connectedIp); RED.util.setMessageProperty(msg, "platform", node.clientNode.platformStatus); RED.util.setMessageProperty(msg, "payload", status); send(msg); } if (done) done(); }) .catch(error => errorHandler(error)); } catch (exception) { errorHandler(exception); } }); /* * Node-red close handler */ this.on('close', function(done) { try { if (node.clientNode) { node.clientNode.deregister(node, function() { node.adapter = null; if (node.receiver != null) { try { node.receiver.close(); } catch (e) { } node.receiver = null; } node.launching = false; done(); }); } else { done(); } } catch(error) { // swallow any errors here done(); } }); } else { node.status({ fill: "red", shape: "ring", text: "unconfigured" }); } } RED.nodes.registerType("castv2-sender", CastV2SenderNode); /* * Expose discover endpoint for connection targets */ RED.httpAdmin.get('/googleCastDevices', (req, res) => { discoverCastTargetsAsync() .then(castTargets => { res.json(castTargets); }) .catch(error => res.send(500)); }); /* * Discover cast targets */ function discoverCastTargetsAsync() { return new Promise((resolve, reject) => { try { const bonjour = new BonjourService.Bonjour(); const castTargets = []; const bonjourBrowser = bonjour.find( { type: 'googlecast' }, service => { castTargets.push({ name: service.txt.fn, address: service.addresses.find(address => net.isIPv4(address)), port: service.port }); }); // await responses setTimeout(() => { try { bonjourBrowser.stop(); bonjour.destroy(); resolve(castTargets); } catch (error) { reject(error); } }, 3000); } catch (error) { reject(error); } }); }; }