diff --git a/README.md b/README.md index 839b785c..5ef23b28 100755 --- a/README.md +++ b/README.md @@ -640,6 +640,145 @@ If `topic` is something else then `payload` must be an object and tells both the online: true } +#### - Audio/Video Receiver +#### - Remote Control +#### - Set-Top Box +#### - Sound Bar +#### - Speaker +#### - Streaming Box +#### - Streaming Sound Bar +#### - Streaming Stick +#### - Television +`topic` can be `currentApplication`, `currentInput`, `activityState`, `playbackState`, +`on`, `currentVolume`, `isMuted`, `toggles`, `modes` or something else. + +If `topic` is `currentApplication` then `payload` must be a string and indicates the current application running. + + msg.topic = 'currentApplication' + msg.payload = 'yourube' + +If `topic` is `currentInput` then `payload` must be a string and indicates the current input selected. + + msg.topic = 'currentInput' + msg.payload = 'hdmi_1' + +If `topic` is `activityState` then `payload` must be a string and indicates the active state of the media device. Supported values are `INACTIVE`, `STANDBY`, `ACTIVE`. + + msg.topic = 'activityState' + msg.payload = 'ACTIVE' + +If `topic` is `playbackState` then `payload` must be a string and indicates the playback state of the media device. Supported values are `PAUSED`, `PLAYING`, `FAST_FORWARDING`, `REWINDING`, `BUFFERING`, `STOPPED`. + + msg.topic = 'playbackState' + msg.payload = 'PAUSED' + +If `topic` is `on` then `payload` must be boolean and tells the state of the media devices. + + msg.topic = 'on' + msg.payload = true + +If `topic` is `currentVolume` then `payload` must be an integer and indicates the current volume level. + + msg.topic = 'currentVolume' + msg.payload = 5 + +If `topic` is `isMuted` then `payload` must be boolean and tells the mute state of the media devices. + + msg.topic = 'isMuted' + msg.payload = true + +If `topic` is `currentModeSettings` then `payload` must be an object and indicates the modes state of the media device. + + msg.topic = 'currentModeSettings' + msg.payload = { + "load_mode": "small_load", + "temp_mode": "cold_temp" + } + +If `topic` is `currentToggleSettings` then `payload` must be an object and indicates the toggles state of the media device. + + msg.topic = 'currentToggleSettings' + msg.payload = { + "sterilization_toggle": true, + "energysaving_toggle": false + } + +If `topic` is `applications` then `payload` must be json string, json object and tells the available applications of the media devices. The available applications will be saved on the applications file. + + msg.topic = 'applications' + msg.payload = {....} + +If `topic` is `applications` then `payload` is undefined the available applications will be loaded from the applications file. + + msg.topic = 'applications' + +If `topic` is `channels` then `payload` must be json string, json object and tells the available channels of the media devices. The available channels will be saved on the channels file. + + msg.topic = 'channels' + msg.payload = {....} + +If `topic` is `channels` then `payload` is undefined the available channels will be loaded from the channels file. + + msg.topic = 'channels' + +If `topic` is `inputs` then `payload` must be json string, json object and tells the available inputs of the media devices. The available inputs will be saved on the inputs file. + + msg.topic = 'inputs' + msg.payload = {....} + +If `topic` is `inputs` then `payload` is undefined the available inputs will be loaded from the inputs file. + + msg.topic = 'inputs' + +If `topic` is `modes` then `payload` must be json string, json object and tells the available modes of the media devices. The available modes will be saved on the modes file. + + msg.topic = 'modes' + msg.payload = {....} + +If `topic` is `modes` then `payload` is undefined the available modes will be loaded from the modes file. + + msg.topic = 'modes' + +If `topic` is `toggles` then `payload` must be json string, json object and tells the available toggles of the media devices. The available toggles will be saved on the toggles file. + + msg.topic = 'toggles' + msg.payload = {....} + +If `topic` is `toggles` then `payload` is undefined the available toggles will be loaded from the toggles file. + + msg.topic = 'toggles' + +If `topic` is `online` then `payload` must be boolean and tells the online state of the media devices. + + msg.topic = 'online' + msg.payload = true + +If `topic` is something else then `payload` must be an object and tells the online state, ambient and target temperature of the thermostate. + + msg.topic = 'set' + msg.payload = { + currentApplication: 'youtube', + currentInput: 'hdmi_1', + activityState: 'ACTIVE', + playbackState: 'PAUSED', + on: true, + currentVolume: 5, + isMuted: false, + currentToggleSettings:{ + "sterilization_toggle": true, + "energysaving_toggle": false + }, + currentModeSettings: { + "load_mode": "small_load", + "temp_mode": "cold_temp" + }, + online: true + } + +Example flow: + + [{"id":"985701ca.58de9","type":"google-media","z":"dfd6855a.a9da98","client":"","name":"Example Television","topic":"tv","device_type":"TV","has_apps":false,"has_channels":false,"has_inputs":false,"has_media_state":false,"has_on_off":false,"has_transport_control":false,"has_modes":false,"has_toggles":false,"available_applications_file":"applications.json","available_channels_file":"channels.json","available_inputs_file":"inputs.json","command_only_input_selector":"","ordered_inputs":"","support_activity_state":false,"support_playback_state":false,"command_only_on_off":false,"query_only_on_off":false,"supported_commands":[],"volume_max_level":"50","can_mute_and_unmute":"","volume_default_percentage":40,"level_step_size":1,"command_only_volume":false,"available_modes_file":"modes.json","command_only_modes":false,"query_only_modes":false,"available_toggles_file":"toggles.json","command_only_toggles":false,"query_only_toggles":false,"passthru":false,"x":530,"y":40,"wires":[["48723761.d78bb8"]]},{"id":"6637f52f.da97cc","type":"change","z":"dfd6855a.a9da98","name":"topic = online","rules":[{"t":"set","p":"topic","pt":"msg","to":"online","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":320,"y":40,"wires":[["985701ca.58de9"]]},{"id":"48723761.d78bb8","type":"function","z":"dfd6855a.a9da98","name":"Split","func":"return [\n { payload: msg.payload.online },\n];","outputs":1,"noerr":0,"x":710,"y":40,"wires":[["6f5daaf0.f5dce4"]],"outputLabels":["online"]},{"id":"980e90e8.c7796","type":"mqtt in","z":"dfd6855a.a9da98","name":"","topic":"home/tv/power","qos":"2","datatype":"auto","broker":"","x":100,"y":40,"wires":[["6637f52f.da97cc"]]},{"id":"6f5daaf0.f5dce4","type":"mqtt out","z":"dfd6855a.a9da98","name":"","topic":"home/tv/set-power","qos":"","retain":"","broker":"","x":900,"y":40,"wires":[]}] + #### - Management `topic` can be `restart_server`, `report_state` or `request_sync`. diff --git a/devices/locales/en-US/media.json b/devices/locales/en-US/media.json new file mode 100644 index 00000000..598e4c51 --- /dev/null +++ b/devices/locales/en-US/media.json @@ -0,0 +1,78 @@ +{ + "media": { + "label": { + "name": "Name", + "topic": "Out topic", + "device_type": "Device Type", + "transport_control": "Transport control", + "supported_commands": "Supported Commands", + "audio_video_receiver": "Audio Video Receiver", + "remotecontrol": "Remote Control", + "settop": "Set-Top Box", + "soundbar": "Sound Bar", + "speaker": "Speaker", + "streaming_box": "Streaming Box", + "streaming_soundbar": "Streaming Sound Bar", + "streaming_stick": "Streaming Stick", + "tv": "Television", + "caption_control": "Caption control", + "next": "Next", + "pause": "Pause", + "previous": "Previous", + "resume": "Resume", + "seek_relative": "Seek relative", + "seek_to_position": "Seek to position", + "set_repeat": "Set repeat", + "shuffle": "Shuffle", + "stop": "Stop", + "volume": "Volume", + "volume_max_level": "Volume max level", + "can_mute_and_unmute": "Can mute and unmute", + "volume_default_percentage": "Volume default percentage", + "level_step_size": "Level step size", + "command_only_volume": "Command only volume", + "on_off": "On/Off", + "command_only_on_off": "Command only On/Off", + "query_only_on_off": "Query only On/Off", + "media_state": "Media state", + "support_activity_state": "Support activity state", + "support_playback_state": "Support playback state", + "apps": "Apps", + "available_applications_file": "Available applications file", + "channels": "Channels", + "available_channels_file": "Available channels file", + "inputs": "Inputs", + "available_inputs_file": "Available inputs file", + "command_only_input_selector": "Command only input selector", + "ordered_inputs": "Ordered inputs", + "modes": "Modes", + "available_modes_file": "Available modes file", + "command_only_modes": "Command only modes", + "query_only_modes": "Query only modes", + "toggles": "Toggles", + "available_toggles_file": "Available toggles file", + "command_only_toggles": "Command only toggles", + "query_only_toggles": "Query only toggles" + }, + "placeholder": { + "name": "Name", + "topic": "Outgoing topic", + "volume_max_level": "Volume max level", + "can_mute_and_unmute": "Can mute and unmute", + "volume_default_percentage": "Volume default percentage", + "level_step_size": "Level step size", + "command_only_volume": "Command only volume", + "support_activity_state": "Support activity state", + "support_playback_state": "Support playback state", + "available_channels_file": "Available channels file", + "available_inputs_file": "Available inputs file", + "available_applications_file": "Available applications file", + "available_modes_file": "Available modes file", + "available_toggles_file": "Available toggles file" + }, + "errors": { + "missing-config": "Missing SmartHome configuration", + "missing-bridge": "Missing SmartHome" + } + } +} diff --git a/devices/locales/it_IT/media.json b/devices/locales/it_IT/media.json new file mode 100644 index 00000000..0c03514f --- /dev/null +++ b/devices/locales/it_IT/media.json @@ -0,0 +1,76 @@ +{ + "media": { + "label": { + "name": "Nome", + "topic": "Oggetto in uscita", + "device_type": "Tipo dispositivo", + "volume": "Volume", + "transport_control": "Controlli", + "supported_commands": "Comandi supportati", + "audio_video_receiver": "Ricevitore Audio/Video", + "remotecontrol": "Controllo remoto", + "settop": "Set-Top Box", + "soundbar": "Sound Bar", + "speaker": "Altoparlanti", + "streaming_box": "Streaming Box", + "streaming_soundbar": "Streaming Sound Bar", + "streaming_stick": "Streaming Stick", + "tv": "Televisore", + "caption_control": "Controllo didascalia", + "next": "Prossimo", + "pause": "Pausa", + "previous": "Precedente", + "resume": "Riprendi", + "seek_relative": "Posizione relativa", + "seek_to_position": "Vai a posizione", + "set_repeat": "Imposta ripetizione", + "shuffle": "Casuale", + "stop": "Stop", + "volume_max_level": "Massimo livello del volume", + "can_mute_and_unmute": "Puoi attivare e disattivare il muto", + "volume_default_percentage": "Percentuale del volume all'avvio", + "level_step_size": "Quantità di incremento del volume", + "command_only_volume": "Notifica soltanto il volume", + "on_off": "On/Off", + "command_only_on_off": "Notifica soltanto lo stato On/Off", + "query_only_on_off": "Legge soltanto lo stato On/Off", + "media_state": "Stato media", + "support_activity_state": "Supporta stato attività", + "support_playback_state": "Supporta stato riproduzione", + "apps": "Apps", + "available_applications_file": "File delle applicazioni disponibili", + "channels": "Canali", + "available_channels_file": "File dei canali disponibili", + "inputs": "Ingressi", + "available_inputs_file": "File degli ingressi disponibili", + "command_only_input_selector": "Comanda soltanto il selettore degli ingressi", + "ordered_inputs": "Ingressi ordinati", + "modes": "Modalità", + "available_modes_file": "File delle modalità disponibili", + "command_only_modes": "Notifica soltanto le modalità", + "query_only_modes": "Legge soltanto le modalità", + "toggles": "Impostazioni", + "available_toggles_file": "File delle impostazioni disponibili", + "command_only_toggles": "Notifica soltanto le impostazioni", + "query_only_toggles": "Legge soltanto le impostazioni" + }, + "placeholder": { + "name": "Nome", + "topic": "Oggetto corrente", + "volume_max_level": "Massimo livello del volume", + "can_mute_and_unmute": "Puoi attivare e disattivare il muto", + "volume_default_percentage": "Percentuale del volume all'avvio", + "level_step_size": "Quantità di incremento del volume", + "command_only_volume": "Commanda soltanto il volume", + "support_activity_state": "Supporta stato attività", + "support_playback_state": "Supporta stato riproduzione", + "available_channels_file": "File dei canali disponibili", + "available_inputs_file": "File degli ingressi disponibili", + "available_applications_file": "File delle applicazioni disponibili" + }, + "errors": { + "missing-config": "La configurazione di SmartHome non è disponibile", + "missing-bridge": "SmartHome non disponibile" + } + } +} diff --git a/devices/media.html b/devices/media.html new file mode 100644 index 00000000..570db177 --- /dev/null +++ b/devices/media.html @@ -0,0 +1,860 @@ + + + + + + + + diff --git a/devices/media.js b/devices/media.js new file mode 100644 index 00000000..05be60e8 --- /dev/null +++ b/devices/media.js @@ -0,0 +1,1078 @@ +/** + * NodeRED Google SmartHome + * Copyright (C) 2020 Claudio Chimera. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + **/ + +const { ok } = require('assert'); + +module.exports = function(RED) { + "use strict"; + + const formats = require('../formatvalues.js'); + const fs = require('fs'); + const path = require('path'); + + /****************************************************************************************************************** + * + * + */ + class MediaNode { + constructor(config) { + RED.nodes.createNode(this, config); + + this.client = config.client; + this.clientConn = RED.nodes.getNode(this.client); + this.topicOut = config.topic; + this.device_type = config.device_type; + this.has_apps = config.has_apps; + this.available_applications_file = config.available_applications_file; + this.available_applications = []; + this.has_channels = config.has_channels; + this.available_channels_file = config.available_channels_file; + this.available_channels = []; + this.has_inputs = config.has_inputs; + this.available_inputs_file = config.available_inputs_file; + this.available_inputs = []; + this.command_only_input_selector = config.command_only_input_selector; + this.ordered_inputs = config.ordered_inputs; + this.has_media_state = config.has_media_state; + this.support_activity_state = config.support_activity_state; + this.support_playback_state = config.support_playback_state; + this.has_on_off = config.has_on_off; + this.command_only_on_off = config.command_only_on_off; + this.query_only_on_off = config.query_only_on_off; + this.has_transport_control = config.has_transport_control; + this.supported_commands = config.supported_commands; + this.has_volume = true; // config.has_volume; + this.volume_max_level = parseInt(config.volume_max_level) || 100; + this.can_mute_and_unmute = config.can_mute_and_unmute; + this.volume_default_percentage = parseInt(config.volume_default_percentage) || 40; + this.level_step_size = parseInt(config.level_step_size) || 1; + this.command_only_volume = config.command_only_volume; + this.has_modes = config.has_modes; + this.available_modes_file = config.available_modes_file; + this.available_modes = []; + this.command_only_modes = config.command_only_modes; + this.query_only_modes = config.query_only_modes; + this.has_toggles = config.has_toggles; + this.available_toggles_file = config.available_toggles_file; + this.available_toggles = []; + this.command_only_toggles = config.command_only_toggles; + this.query_only_toggles = config.query_only_toggles; + this.last_channel_index = ''; + this.current_channel_index = -1; + this.current_input_index = -1; + + if (!this.clientConn) { + this.error(RED._("media.errors.missing-config")); + this.status({fill: "red", shape: "dot", text: "Missing config"}); + return; + } else if (typeof this.clientConn.register !== 'function') { + this.error(RED._("media.errors.missing-bridge")); + this.status({fill: "red", shape: "dot", text: "Missing SmartHome"}); + return; + } + + switch (this.device_type) { + case "AUDIO_VIDEO_RECEIVER": + // this.has_apps = true; + this.has_channels = false; + this.has_inputs = true; + // this.has_media_state = true; + this.has_on_off = true; + // this.has_transport_control = true; + this.has_volume = true; + this.has_modes = false; + this.has_toggles = false; + break; + case "REMOTECONTROL": + this.has_apps = true; + //this.has_channels = true; + this.has_inputs = true; + this.has_media_state = true; + this.has_on_off = true; + this.has_transport_control = true; + this.has_volume = true; + // this.has_modes = true; + // this.has_toggles = true; + break; + case "SETTOP": + this.has_apps = true; + //this.has_channels = true; + this.has_inputs = true; + this.has_media_state = true; + this.has_on_off = true; + this.has_transport_control = true; + this.has_volume = true; + this.has_modes = false; + this.has_toggles = false; + break; + case "SOUNDBAR": + case "SPEAKER": + // this.has_apps = true; + this.has_channels = false; + //this.has_inputs = true; + this.has_media_state = true; + //this.has_on_off = true; + this.has_transport_control = true; + this.has_volume = true; + this.has_modes = false; + this.has_toggles = false; + break; + case "STREAMING_BOX": + case "STREAMING_SOUNDBAR": + case "STREAMING_STICK": + this.has_apps = true; + this.has_channels = false; + //this.has_inputs = true; + this.has_media_state = true; + //this.has_on_off = true; + this.has_transport_control = true; + this.has_volume = true; + this.has_modes = false; + this.has_toggles = false; + break; + case "TV": + this.has_apps = true; + //this.has_channels = true; + this.has_inputs = true; + this.has_media_state = true; + this.has_on_off = true; + this.has_transport_control = true; + this.has_volume = true; + //this.has_modes = true; + //this.has_toggles = true; + break; + } + + let error_msg = ''; + if (this.has_apps) { + this.available_applications = this.loadJson(this.available_applications_file, []); + if (this.available_applications === undefined) { + error_msg += ' Applications file error.'; + RED.log.error("Applications " + this.available_applications_file + "file error.") + } + } else { + this.available_applications = undefined; + RED.log.debug("Applications disabled"); + } + + if (this.has_channels) { + this.available_channels = this.loadJson(this.available_channels_file, []); + if (this.available_channels === undefined) { + error_msg += ' Channels file error.'; + RED.log.error("Channels " + this.available_channels_file + "file error.") + } + } else { + this.available_channels = undefined; + RED.log.debug("Channels disabled"); + } + + if (this.has_inputs) { + this.available_inputs = this.loadJson(this.available_inputs_file, []); + if (this.available_inputs === undefined) { + error_msg += ' Inputs file error.'; + RED.log.error("Inputs " + this.available_inputs_file + "file error.") + } + } else { + this.available_inputs = undefined; + RED.log.debug("Inputs disabled"); + } + + if (this.has_modes) { + this.available_modes = this.loadJson(this.available_modes_file, []); + if (this.available_modes === undefined) { + error_msg += ' Modes file error.'; + RED.log.error("Modes " + this.available_modes_file + "file error.") + } + } else { + this.available_modes = undefined; + RED.log.debug("Modes disabled"); + } + + if (this.has_toggles) { + this.available_toggles = this.loadJson(this.available_toggles_file, []); + if (this.available_toggles === undefined) { + error_msg += ' Toggles file error.'; + RED.log.error("Toggles " + this.available_toggles_file + "file error.") + } + } else { + this.available_toggles = undefined; + RED.log.debug("Toggles disabled"); + } + + this.states = this.clientConn.register(this, 'media', config.name, this); + + if (error_msg.length == 0) { + this.status({fill: "yellow", shape: "dot", text: "Ready"}); + } else { + this.status({fill: "red", shape: "dot", text: error_msg}); + } + + this.on('input', this.onInput); + this.on('close', this.onClose); + } + + /****************************************************************************************************************** + * called to register device + * + */ + registerDevice(client, name, me) { + RED.log.debug("MediaNode(registerDevice) device_type " + me.device_type); + let states = { + online: true + }; + + const default_name = me.getDefaultName(me.device_type); + const default_name_type = default_name.replace(/\s+/g, '-').toLowerCase(); + let device = { + id: client.id, + properties: { + type: 'action.devices.types.' + me.device_type, + traits: me.getTraits(me.device_type), + name: { + defaultNames: ["Node-RED " + default_name], + name: name + }, + willReportState: true, + attributes: { + }, + deviceInfo: { + manufacturer: 'Node-RED', + model: 'nr-' + default_name_type + '-v1', + swVersion: '1.0', + hwVersion: '1.0' + }, + customData: { + "nodeid": client.id, + "type": default_name_type + } + } + }; + + device.states = states; + this.updateAttributesForTraits(me, device); + this.updateStatesForTraits(me, device); + + RED.log.debug("MediaNode(updated): device = " + JSON.stringify(device)); + + return device; + } + + updateAttributesForTraits(me, device) { + let attributes = device.properties.attributes; + + if (me.has_apps) { + attributes['availableApplications'] = me.available_applications; + } + if (me.has_inputs) { + attributes['availableInputs'] = me.available_inputs; + attributes['commandOnlyInputSelector'] = me.command_only_input_selector; + attributes['orderedInputs'] = me.ordered_inputs; + } + if (me.has_media_state) { + attributes['supportActivityState'] = me.support_activity_state; + attributes['supportPlaybackState'] = me.support_playback_state; + } + if (me.has_on_off) { + attributes['commandOnlyOnOff'] = me.command_only_on_off; + attributes['queryOnlyOnOff'] = me.query_only_on_off; + } + if (me.has_transport_control) { + attributes['transportControlSupportedCommands'] = me.supported_commands; + } + if (me.has_volume) { + attributes['volumeMaxLevel'] = me.volume_max_level; + attributes['volumeCanMuteAndUnmute'] = me.can_mute_and_unmute; + attributes['volumeDefaultPercentage'] = me.volume_default_percentage; + attributes['levelStepSize'] = me.level_step_size; + attributes['commandOnlyVolume'] = me.command_only_volume; + } + if (me.has_toggles) { + attributes['availableToggles'] = me.available_toggles; + attributes['commandOnlyToggles'] = me.command_only_toggles; + attributes['queryOnlyToggles'] = me.query_only_toggles; + } + if (me.has_modes) { + attributes['availableModes'] = me.available_modes; + attributes['commandOnlyModes'] = me.command_only_modes; + attributes['queryOnlyModes'] = me.query_only_modees; + } + if (me.has_channels) { + attributes['availableChannels'] = me.available_channels; + } + } + + updateStatesForTraits(me, device) { + let states = device.states; + + if (me.has_apps) { + states['currentApplication'] = ''; + } + if (me.has_inputs) { + states['currentInput'] = ''; + } + if (me.has_media_state) { + // INACTIVE STANDBY ACTIVE + states['activityState'] = 'INACTIVE'; + // PAUSED PLAYING FAST_FORWARDING REWINDING BUFFERING STOPPED + states['playbackState'] = 'STOPPED'; + } + if (me.has_on_off) { + states['on'] = false; + } + // if (me.has_transport_control) { + // } + if (me.has_volume) { + states['currentVolume'] = me.volume_default_percentage; + states['isMuted'] = false; + } + if (me.has_toggles) { + states['currentToggleSettings'] = {}; + this.updateTogglesState(me, device); + } + if (me.has_modes) { + states['currentModeSettings'] = {}; + this.updateModesState(me, device); + } + // if (me.has_channels) { + // } + } + + updateStatusIcon() { + if (this.states.on) { + this.status({fill: "green", shape: "dot", text: "ON"}); + } else { + this.status({fill: "red", shape: "dot", text: "OFF"}); + } + } + + /****************************************************************************************************************** + * called when state is updated from Google Assistant + * + */ + updated(device) { + let states = device.states; + let command = device.command; + RED.log.debug("MediaNode(updated): states = " + JSON.stringify(states)); + + Object.assign(this.states, states); + + this.updateStatusIcon(); + + let msg = { + topic: this.topicOut, + device_name: device.properties.name.name, + command: command, + payload: { + online: states.online + }, + }; + + Object.keys(states).forEach(function (key) { + msg.payload[key] = states[key]; + }); + + this.send(msg); + }; + + /****************************************************************************************************************** + * respond to inputs from NodeRED + * + */ + onInput(msg) { + const me = this; + RED.log.debug("MediaNode(input)"); + + let topicArr = String(msg.topic).split(this.topicDelim); + let topic = topicArr[topicArr.length - 1]; // get last part of topic + + RED.log.debug("MediaNode(input): topic = " + topic); + try { + if (topic.toUpperCase() === 'APPLICATIONS') { + if (this.has_apps) { + if (typeof msg.payload === undefined) { + this.available_applications = this.loadJson(this.available_applications_file, []); + if (this.available_applications === undefined) { + RED.log.error("Applications " + this.available_applications_file + "file not found.") + } + } else { + if (!this.writeJson(this.available_applications_file, msg.payload)) { + RED.log.error("Error saving Applications to file " + this.available_applications_file); + } else { + this.available_applications = msg.payload; + } + } + } else { + this.available_applications = []; + RED.log.error("Applications disabled"); + } + } else if (topic.toUpperCase() === 'CHANNELS') { + if (this.has_channels) { + if (typeof msg.payload === undefined) { + this.available_channels = this.loadJson(this.available_channels_file, []); + if (this.available_channels === undefined) { + RED.log.error("Channels " + this.available_channels_file + "file not found.") + } + } else { + if (!this.writeJson(this.available_channels_file, msg.payload)) { + RED.log.error("Error saving Channels to file " + this.available_channels_file); + } else { + this.available_channels = msg.payload; + } + } + } else { + this.available_channels = []; + RED.log.error("Channels disabled"); + } + } else if (topic.toUpperCase() === 'INPUTS') { + if (this.has_inputs) { + if (typeof msg.payload === undefined) { + this.available_inputs = this.loadJson(this.available_inputs_file, []); + if (this.available_inputs === undefined) { + RED.log.error("Inputs " + this.available_inputs_file + "file not found.") + } + } else { + if (!this.writeJson(this.available_inputs_file, msg.payload)) { + RED.log.error("Error saving Inputs to file " + this.available_inputs_file); + } else { + this.available_inputs = msg.payload; + } + } + } else { + this.available_inputs = []; + RED.log.error("Inputs disabled"); + } + } else if (topic.toUpperCase() === 'MODES') { + if (this.has_modes) { + if (typeof msg.payload === undefined) { + this.available_modes = this.loadJson(this.available_modes_file, []); + if (this.available_modes === undefined) { + RED.log.error("Modes " + this.available_modes_file + "file not found.") + } else { + this.updateModesState(me, me); + } + } else { + if (!this.writeJson(this.available_modes_file, msg.payload)) { + RED.log.error("Error saving Modes to file " + this.available_modes_file); + } else { + this.available_modes = msg.payload; + this.updateModesState(me, me); + } + } + } else { + this.available_modes = []; + RED.log.error("Modes disabled"); + } + } else if (topic.toUpperCase() === 'TOGGLES') { + if (this.has_toggles) { + if (typeof msg.payload === undefined) { + this.available_toggles = this.loadJson(this.available_toggles_file, []); + if (this.available_toggles === undefined) { + RED.log.error("Toggles " + this.available_toggles_file + "file not found.") + } else { + this.updateTogglesState(me, me); + } + } else { + if (!this.writeJson(this.available_toggles_file, msg.payload)) { + RED.log.error("Error saving Toggles to file " + this.available_toggles_file); + } else { + this.available_toggles = msg.payload; + this.updateTogglesState(me, me); + } + } + } else { + this.available_toggles = []; + RED.log.error("Toggles disabled"); + } + } else { + let state_key = ''; + Object.keys(this.states).forEach(function (key) { + if (topic.toUpperCase() == key.toUpperCase()) { + state_key = key; + RED.log.debug("MediaNode(input): found state " + key); + } + }); + + if (state_key !== '') { + const differs = me.setState(state_key, msg.payload, this.states); + if (differs) { + RED.log.debug("MediaNode(input): " + state_key + ' ' + msg.payload); + this.clientConn.setState(this, this.states); // tell Google ... + + if (this.passthru) { + msg.payload = this.states[state_key]; + this.send(msg); + } + + this.updateStatusIcon(); + } + } else { + RED.log.debug("MediaNode(input): some other topic"); + let differs = false; + Object.keys(this.states).forEach(function (key) { + if (msg.payload.hasOwnProperty(key)) { + RED.log.debug("MediaNode(input): set state " + key + ' to ' + msg.payload[key]); + if (me.setState(key, msg.payload[key], me.states)) { + differs = true; + } + } + }); + + if (differs) { + this.clientConn.setState(this, this.states); // tell Google ... + + if (this.passthru) { + msg.payload = this.states; + this.send(msg); + } + + this.updateStatusIcon(); + } + } + } + } catch (err) { + RED.log.error(err); + } + } + + onClose(removed, done) { + if (removed) { + // this node has been deleted + this.clientConn.remove(this, 'media'); + } else { + // this node is being restarted + this.clientConn.deregister(this, 'media'); + } + + done(); + } + + updateTogglesState(me, device) { + // Key/value pair with the toggle name of the device as the key, and the current state as the value. + let states = device.states || {}; + const currentToggleSettings = states['currentToggleSettings'] + let new_toggles = {}; + me.available_toggles.forEach(function (toggle) { + let value = false; + if (typeof currentToggleSettings[toggle.name] === 'boolean') { + value = currentToggleSettings[toggle.name]; + } + new_toggles[toggle.name] = value; + }); + states['currentToggleSettings'] = new_toggles; + } + + updateModesState(me, device) { + // Key/value pair with the mode name of the device as the key, and the current setting_name as the value. + RED.log.debug("Update Modes device"); + let states = device.states || {}; + const currentModeSettings = states['currentModeSettings'] + let new_modes = {}; + me.available_modes.forEach(function (mode) { + let value = ''; + if (typeof currentModeSettings[mode.name] === 'string') { + value = currentModeSettings[mode.name]; + } + new_modes[mode.name] = value; + }); + states['currentModeSettings'] = new_modes; + } + + getDefaultName(device_type) { + switch(device_type) { + case 'AUDIO_VIDEO_RECEIVER': + return "Audio-Video Receiver"; + case 'REMOTECONTROL': + return "Media Remote"; + case 'SETTOP': + return "Set-top Box"; + case 'SOUNDBAR': + return "Soundbar"; + case 'SPEAKER': + return "Speaker"; + case 'STREAMING_BOX': + return "Streaming Box"; + case 'STREAMING_SOUNDBAR': + return "Streaming Soundbar"; + case 'STREAMING_STICK': + return "Streaming Stick"; + case 'TV': + return "Television"; + } + return ''; + } + + getTraits(device_type) { + let traits=[ + "action.devices.traits.AppSelector", + "action.devices.traits.InputSelector", + "action.devices.traits.MediaState", + "action.devices.traits.OnOff", + "action.devices.traits.TransportControl", + "action.devices.traits.Volume" + ]; + + if ((device_type === "REMOTECONTROL") || + (device_type === "SETTOP") || + (device_type === "TV")) { + traits.push("action.devices.traits.Channel"); + } + if ((device_type === "REMOTECONTROL") || + (device_type === "TV")) { + traits.push("action.devices.traits.Modes"); + traits.push("action.devices.traits.Toggles"); + } + return traits; + } + + setState(key, value, states) { + const me = this; + let differs = false; + const old_state = states[key]; + let val_type = typeof old_state; + let new_state = undefined; + if (val_type === 'number') { + if (value % 1 === 0) { + new_state = formats.FormatValue(formats.Formats.INT, key, value); + } else { + new_state = formats.FormatValue(formats.Formats.FLOAT, key, value); + } + } else if (val_type === 'string') { + new_state = formats.FormatValue(formats.Formats.STRING, key, value); + } else if (val_type === 'boolean') { + new_state = formats.FormatValue(formats.Formats.BOOL, key, value); + } else if (val_type === 'object') { + Object.keys(old_state).forEach(function (key) { + if (typeof new_state[key] !== undefined) { + if (me.setState(key, new_state[key], old_State)) { + differs = true; + } + } + }); + } + if (val_type !== 'object') { + if (new_state !== undefined) { + differs = old_state !== new_state; + states[key] = new_state; + } + } + return differs; + } + + loadJson(filename, defaultValue) { + if (!filename.startsWith(path.sep)) { + const userDir = RED.settings.userDir; + filename = path.join(userDir, filename); + } + RED.log.debug('MediaNode:loadJson(): loading ' + filename); + + try { + let jsonFile = fs.readFileSync( + filename, + { + 'encoding': 'utf8', + 'flag': fs.constants.R_OK | fs.constants.W_OK | fs.constants.O_CREAT + }); + + if (jsonFile === '') { + RED.log.debug('MediaNode:loadJson(): empty data'); + return defaultValue; + } else { + RED.log.debug('MediaNode:loadJson(): data loaded'); + const json = JSON.parse(jsonFile); + RED.log.debug('MediaNode:loadAuth(): json = ' + JSON.stringify(json)); + return json; + } + } + catch (err) { + RED.log.error('Error on loading ' + filename + ': ' + err.toString()); + return undefined; + } + } + + writeJson(filename, value) { + if (!filename.startsWith(path.sep)) { + const userDir = RED.settings.userDir; + filename = path.join(userDir, filename); + } + RED.log.debug('MediaNode:writeJson(): loading ' + filename); + if (typeof value === 'object') { + value = JSON.stringify(value); + } + try { + fs.writeFileSync( + filename, + value, + { + 'encoding': 'utf8', + 'flag': fs.constants.W_OK | fs.constants.O_CREAT + }); + + RED.log.debug('MediaNode:writeJson(): data saved'); + return true; + } + catch (err) { + RED.log.error('Error on saving ' + filename + ': ' + err.toString()); + return false; + } + } + + execCommand(device, command) { + let me = this; + let params = {}; + let executionStates = []; + const ok_result = { + 'params' : params, + 'executionStates': executionStates + }; + + RED.log.debug("MediaNode:execCommand(command) " + JSON.stringify(command)); + RED.log.debug("MediaNode:execCommand(states) " + JSON.stringify(this.states)); + // RED.log.debug("MediaNode:execCommand(device) " + JSON.stringify(device)); + + if (!command.hasOwnProperty('params')) { + // TransportControl + if (command.command == 'action.devices.commands.mediaClosedCaptioningOff') { + executionStates.push('online', 'playbackState'); + return ok_result; + } + return false; + } + // Applications + if ((command.command == 'action.devices.commands.appInstall') || + (command.command == 'action.devices.commands.appSearch') || + (command.command == 'action.devices.commands.appSelect')) { + if (command.params.hasOwnProperty('newApplication')) { + const newApplication = command.params['newApplication']; + let application_index = -1; + this.available_applications.forEach(function(application, index) { + if (application.key === newApplication) { + application_index = index; + } + }); + if (application_index < 0) { + return { + status: 'ERROR', + errorCode: 'noAvailableApp' + }; + } + executionStates.push('online', 'currentApplication'); + params['currentApplication'] = newApplication; + return ok_result; + } + if (command.params.hasOwnProperty('newApplicationName')) { + const newApplicationName = command.params['newApplicationName']; + let application_key = ''; + this.available_applications.forEach(function(application, index) { + application.names.forEach(function(name) { + if (name.name_synonym.includes(newApplicationName)) { + application_key = application.key; + } + }); + }); + if (application_key === '') { + return { + status: 'ERROR', + errorCode: 'noAvailableApp' + }; + } + params['currentApplication'] = application_key; + executionStates.push('online', 'currentApplication'); + return ok_result; + } + } + // Inputs + else if (command.command == 'action.devices.commands.SetInput') { + if (command.params.hasOwnProperty('newInput')) { + const newInput = command.params['newInput']; + let current_input_index = -1; + this.available_inputs.forEach(function(input_element, index) { + if (input_element.key === newInput) { + current_input_index = index; + } + }); + if (current_input_index < 0) { + return { + status: 'ERROR', + errorCode: 'unsupportedInput' + }; + } + params['currentInput'] = newInput; + executionStates.push('online', 'currentInput'); + return ok_result; + } + } + else if (command.command == 'action.devices.commands.NextInput') { + this.current_input_index++; + if (this.current_input_index >= this.available_inputs.length) { + this.current_input_index = 0; + } + executionStates.push('online', 'currentInput'); + params['currentInput'] = this.available_inputs[this.current_input_index].names[0].name_synonym[0]; // Ignore Language? + return ok_result; + } + else if (command.command == 'action.devices.commands.PreviousInput') { + if (this.current_input_index <= 0) { + this.current_input_index = this.available_inputs.length; + } + this.current_input_index --; + executionStates.push('online', 'currentInput'); + params['currentInput'] = this.available_inputs[this.current_input_index].names[0].name_synonym[0]; // Ignore Language? + return ok_result; + } + // On/Off + /*else if (command.command == 'action.devices.commands.OnOff') { + if (command.params.hasOwnProperty('on')) { + const on_param = command.params['on']; + return ok_result; + } + }*/ + // TransportControl + else if (command.command == 'action.devices.commands.mediaStop') { + params['playbackState'] = 'STOPPED'; + executionStates.push('online', 'playbackState'); + return ok_result; + } + else if (command.command == 'action.devices.commands.mediaNext') { + params['playbackState'] = 'FAST_FORWARDING'; + executionStates.push('online', 'playbackState'); + return ok_result; + } + else if (command.command == 'action.devices.commands.mediaPrevious') { + params['playbackState'] = 'REWINDING'; + executionStates.push('online', 'playbackState'); + return ok_result; + } + else if (command.command == 'action.devices.commands.mediaPause') { + params['playbackState'] = 'PAUSED'; + executionStates.push('online', 'playbackState'); + return ok_result; + } + else if (command.command == 'action.devices.commands.mediaResume') { + params['playbackState'] = 'PLAYING'; + executionStates.push('online', 'playbackState'); + return ok_result; + } + else if (command.command == 'action.devices.commands.mediaSeekRelative') { + if (command.params.hasOwnProperty('relativePositionMs')) { + const relative_position_ms = command.params['relativePositionMs']; + params['playbackState'] = 'PLAYING'; + executionStates.push('online', 'playbackState'); + return ok_result; + } + } + else if (command.command == 'action.devices.commands.mediaSeekToPosition') { + if (command.params.hasOwnProperty('absPositionMs')) { + const abs_position_ms = command.params['absPositionMs']; + params['playbackState'] = 'PLAYING'; + executionStates.push('online', 'playbackState'); + return ok_result; + } + } + else if (command.command == 'action.devices.commands.mediaRepeatMode') { + // TODO + if (command.params.hasOwnProperty('isOn')) { + const is_on = command.params['isOn']; + return ok_result; + } + if (command.params.hasOwnProperty('isSingle')) { + const is_single = command.params['isSingle']; + return ok_result; + } + } + else if (command.command == 'action.devices.commands.mediaShuffle') { + // TODO + return ok_result; + } + else if (command.command == 'action.devices.commands.mediaClosedCaptioningOn') { + if (command.params.hasOwnProperty('closedCaptioningLanguage')) { + const closedCaptioningLanguage = command.params['closedCaptioningLanguage']; + params['playbackState'] = this.states['playbackState']; + } + if (command.params.hasOwnProperty('userQueryLanguage')) { + const userQueryLanguage = command.params['userQueryLanguage']; + params['playbackState'] = this.states['playbackState']; + } + executionStates.push('online', 'playbackState'); + return ok_result; + } + // Volume + else if (command.command == 'action.devices.commands.mute') { + if (command.params.hasOwnProperty('mute')) { + const mute = command.params['mute']; + params['isMuted'] = mute; + executionStates.push('online', 'isMuted', 'currentVolume'); + return ok_result; + } + } + else if (command.command == 'action.devices.commands.setVolume') { + if (command.params.hasOwnProperty('volumeLevel')) { + let volumeLevel = command.params['volumeLevel']; + if (volumeLevel > this.volumeMaxLevel) { + volumeLevel = this.volumeMaxLevel; + } + params['currentVolume'] = volumeLevel; + executionStates.push('online', 'isMuted', 'currentVolume'); + return ok_result; + } + } + else if (command.command == 'action.devices.commands.volumeRelative') { + if (command.params.hasOwnProperty('relativeSteps')) { + const relativeSteps = command.params['relativeSteps']; + let current_volume = this.states['currentVolume']; + if (current_volume >= this.volumeMaxLevel && relativeSteps > 0) { + return { + status: 'ERROR', + errorCode: 'volumeAlreadyMax' + }; + } else if (current_volume <= 0 && relativeSteps < 0) { + return { + status: 'ERROR', + errorCode: 'volumeAlreadyMin' + }; + } + current_volume += relativeSteps; + if (current_volume > this.volumeMaxLevel) { + current_volume = volumeMaxLevel; + } else if (current_volume < 0) { + current_volume = 0; + } + params['currentVolume'] = current_volume; + executionStates.push('online', 'currentVolume'); + return ok_result; + } + } + // Channels + else if (command.command == 'action.devices.commands.selectChannel') { + if (command.params.hasOwnProperty('channelCode')) { + const channelCode = command.params['channelCode']; + let new_channel_index = -1; + let new_channel_key = ''; + this.available_channels.forEach(function(channel, index) { + if (channel.key === channelCode) { + new_channel_index = index; + new_channel_key = channel.key; + } + }); + if (new_channel_index < 0) { + return { + status: 'ERROR', + errorCode: 'noAvailableChannel' + }; + } + this.current_channel_index = new_channel_index; + params['currentChannel'] = new_channel_key; + // executionStates.push('online', 'currentChannel'); + return ok_result; + } + /*if (command.params.hasOwnProperty('channelName')) { + const channelName = command.params['channelName']; + }*/ + if (command.params.hasOwnProperty('channelNumber')) { + const channelNumber = command.params['channelNumber']; + let new_channel_index = -1; + let new_channel_key = ''; + this.available_channels.forEach(function(channel, index) { + if (channel.number === channelNumber) { + new_channel_index = index; + new_channel_key = channel.key; + } + }); + if (new_channel_index < 0) { + return { + status: 'ERROR', + errorCode: 'noAvailableChannel' + }; + } + me.current_channel_index = new_channel_index; + params['currentChannel'] = new_channel_key; + // executionStates.push('online', 'currentChannel'); + return ok_result; + } + } + else if (command.command == 'action.devices.commands.relativeChannel') { + if (command.params.hasOwnProperty('relativeChannelChange')) { + const relativeChannelChange = command.params['relativeChannelChange']; + let current_channel_index = this.current_channel_index; + if (current_channel_index < 0) { + current_channel_index = 0; + } + current_channel_index += relativeChannelChange; + const channels_num = this.available_channels.length; + if (current_channel_index < 0) { + current_channel_index += channels_num; + } else if (current_channel_index >= channels_num) { + current_channel_index -= channels_num; + } + if (this.current_channel_index != current_channel_index) { + this.last_channel_index = this.current_channel_index; + this.current_channel_index = current_channel_index; + } + params['currentChannel'] = this.available_channels[current_channel_index].key; + // executionStates.push('online', 'currentChannel'); + return ok_result; + } + } + else if (command.command == 'action.devices.commands.returnChannel') { + if (this.last_channel_index >= 0) { + const current_channel_index = this.current_channel_index; + this.current_channel_index = this.last_channel_index; + this.last_channel_index = current_channel_index; + } + if (this.current_channel_index < 0) { + this.current_channel_index = 0; + } + params['currentChannel'] = this.available_channels[this.current_channel_index].key; + // executionStates.push('online', 'currentChannel'); + return ok_result; + } + // Modes + else if (command.command == 'action.devices.commands.SetModes') { + if (command.params.hasOwnProperty('updateModeSettings')) { + const updateModeSettings = command.params['updateModeSettings']; + let modes = this.states['currentModeSettings']; + this.available_modes.forEach(function (mode) { + if (typeof updateModeSettings[mode.name] === 'string') { + modes[mode.name] = updateModeSettings[mode]; + } + }); + params['currentModeSettings'] = modes; + executionStates.push('online', 'currentModeSettings'); + return ok_result; + } + } + // Traits + else if (command.command == 'action.devices.commands.SetToggles') { + if (command.params.hasOwnProperty('updateToggleSettings')) { + const updateToggleSettings = command.params['updateToggleSettings']; + let toggles = this.states['currentToggleSettings']; + this.available_toggles.forEach(function (toggle) { + if (typeof updateToggleSettings[toggle].name === 'boolean') { + toggles[toggle.name] = updateToggleSettings[toggle.name]; + } + }); + params['currentToggleSettings'] = toggles; + executionStates.push('online', 'currentToggleSettings'); + return ok_result; + } + } + return false; + } + } + + RED.nodes.registerType("google-media", MediaNode); +} diff --git a/lib/Devices.js b/lib/Devices.js index 5c3269b3..b5c32e59 100755 --- a/lib/Devices.js +++ b/lib/Devices.js @@ -116,7 +116,12 @@ class Devices { } if (typeof this._nodes[device.id].execCommand === 'function') { - return this._nodes[device.id].execCommand(device, command); + try { + return this._nodes[device.id].execCommand(device, command); + } catch (err) { + RED.log.error(err); + return false; + } } me.debug('Device:execCommand(): device has no execCommand'); return false; diff --git a/lib/HttpActions.js b/lib/HttpActions.js index eb14aa2b..a0f8ddc1 100755 --- a/lib/HttpActions.js +++ b/lib/HttpActions.js @@ -381,12 +381,18 @@ class HttpActions { states: {}, }; + let result = {}; if (command.hasOwnProperty('command')) { curDevice.command = command.command; - const result = this.execCommand(curDevice, command); + result = this.execCommand(curDevice, command); this.debug('HttpActions:execCommand(): result = ' + JSON.stringify(result)); if (result) { - return result; + if (result.hasOwnProperty('status')) { + return result; + } + if (result.hasOwnProperty("params") && Object.keys(result.params).length > 0) { + command.params = result.params; + } } } @@ -457,10 +463,15 @@ class HttpActions { me.reportState(id, s[id]); }); + let executionStates = execDevice.executionStates; + if (result.hasOwnProperty('executionStates') && Object.keys(result.executionStates).length > 0) { + executionStates = result['executionStates']; + } + return { status: 'SUCCESS', states: execDevice.states, - executionStates: execDevice.executionStates, + executionStates: executionStates, }; } // diff --git a/package.json b/package.json index f3549ae6..df4ad625 100755 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "light": "devices/light.js", "outlet": "devices/outlet.js", "camera": "devices/camera.js", + "media": "devices/media.js", "scene": "devices/scene.js", "shutter": "devices/shutter.js", "thermostat": "devices/thermostat.js",