node-red-contrib-musiccast
Version:
A Node-RED collection for monitoring and controlling a Yamaha Musiccast network.
645 lines (532 loc) • 23.5 kB
JavaScript
/*
Apache-2.0
Copyright (c) 2023 Vahdettin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
*/
'use strict'
const axios = require('axios');
const xml2js = require('xml2js');
module.exports = function (RED) {
let mcData = require('./lib/be');
function MusiccastNodeConfig(config) {
const callHeaders = require("./lib/constants").callHeaders;
RED.nodes.createNode(this, config);
//console.log(config);
this.name = config.name;
this.timeouts = {};
this.commands = require('./lib/constants').commands;
this.responseCodes = require('./lib/constants').responses;
this.zone_list = require('./lib/constants').zones;
this.input_list = require("./lib/constants").inputs;
/*
Below vars needed for runtime and back end
*/
this.device_list = config.device_list || {};
this.commandTimeout = parseInt(config.command_timeout) || 2000;
this.servicesTimeout = parseInt(config.services_timeout) || 5000;
this.api_port = 80;
this.f_use_debug = config.f_use_debug;
this.f_use_debug_api_resp = config.f_use_debug_api_resp;
this.f_blank_stale_status = config.f_blank_stale_status;
let debug = false;
if (config.f_use_debug === true) {
debug = true;
}
let node = this;
/*
Send a command request to a device
*/
this.sendCommand = function (params, opts) {
try {
let device,
command,
baseUrl,
prom;
//swap the device name for the device object
if (params.device) {
device = this.findDevice(params.device);
}
if (typeof (device) !== 'object') {
return Promise.reject('Invalid device.');
}
//swap the command name def for the object
if (params.command) {
command = this.getCommand(params.command);
}
if (typeof (command) !== 'object') {
return Promise.reject('Invalid command.');
}
if(params.attributes){
if(!JSON.stringify(params.attributes)){
return Promise.reject('Invalid attributes.');
}
}
let targetAddress = device.address;
let timeout = node.commandTimeout;
if (opts) {
if (opts.isServiceCall) {
timeout = node.servicesTimeout;
}
}
let xml = null;
switch (command.type) {
case 'xmlput':
//basic POST with XML body
baseUrl = 'http://' + targetAddress + '/YamahaRemoteControl/ctrl';
if (command.att_f) {
xml = command.att_f(Object.assign(params, params.attributes), this);
if (debug) node.log("API call: M: xmlput \nURL:\t" + baseUrl + "\nAttributes:\t" + xml);
prom = axios.post(baseUrl, xml,
{
headers: Object.assign(callHeaders,{'Content-Type': 'text/xml'}),
responseType: 'xml',
timeout: timeout,
transformResponse: [function (data) {
return node.parseResponse(data, "xmlput");
}]
})
} else {
return Promise.reject("invalid_command_def");
}
break;
case 'xmlget':
//basic GET with XML body
baseUrl = 'http://' + targetAddress + '/YamahaRemoteControl/ctrl';
if (command.att_f) {
xml = command.att_f(Object.assign(params, params.attributes), this);
if (debug) node.log("API call: M: xmlget \nURL:\t" + baseUrl + "\nAttributes:\t" + xml);
prom = axios.post(baseUrl, xml,
{
headers: Object.assign(callHeaders,{'Content-Type': 'text/xml'}),
responseType: 'xml',
timeout: timeout,
transformResponse: [function (data) {
return node.parseResponse(data, "xmlget");
}]
})
} else {
return Promise.reject("invalid_command_def");
}
break;
case 'jsonpost':
//basic POST with JSON body
baseUrl = 'http://' + targetAddress + ':' + node.api_port + '/YamahaExtendedControl/v1/' + params.topic + '/' + command.name;
if (debug) node.log("API call: M: POST \nURL:\t" + baseUrl + "\nAttributes:\t" + JSON.stringify(params.attributes));
prom = axios.post(baseUrl,params.attributes || {},
{
headers: callHeaders,
timeout: timeout,
transformResponse: [function (data) {
return node.parseResponse(data, "jsonpost");
}]
})
break;
default:
//basic GET with query string
baseUrl = 'http://' + targetAddress + ':' + node.api_port + '/YamahaExtendedControl/v1/' + params.topic + '/' + command.name;
if (debug) node.log("API call: M: get \nURL:\t" + baseUrl + "\nAttributes:\t" + JSON.stringify(params.attributes));
prom = axios.get(baseUrl,
{
headers: callHeaders,
params: params.attributes || {},
timeout: timeout,
transformResponse: [function (data) {
return node.parseResponse(data);
}]
})
}
return prom;
} catch (err) {
return Promise.reject(err);
}
};
/*
Create command registry
*/
this.regCommands = function (commands) {
let list = {};
try {
for (const c in commands) {
if (commands.hasOwnProperty(c)) {
let cmd = commands[c];
if(!cmd.att_f){
cmd.att_f = function (dummy,dummy1) {
return dummy;
}
}
if(!cmd.status_f){
cmd.status_f = function (dummy){
return "";
}
}
list[c] = cmd;
}
}
return list;
} catch (err) {
console.warn(err);
return {};
}
};
/*
Create zone member list
*/
this.regZones = function (devices) {
let list = {};
try {
for (const d in devices) {
if (devices.hasOwnProperty(d)) {
if (devices[d].zone_list) {
for (const z in JSON.parse(devices[d].zone_list)) {
if (!list[z]) list[z] = [];
if (!list[z].includes(d)) {
list[z].push(d);
}
}
}
}
}
return list;
} catch (err) {
console.warn(err);
return {};
}
};
/*
Find a device object by alt criteria such as label.
Return primary device if nothing found.
*/
this.findDevice = function (crit) {
let mc = this;
let device = null;
try {
for (const d in mc.device_list) {
if (mc.device_list.hasOwnProperty(d)) {
if (crit === d || crit === mc.device_list[d].name || crit === mc.device_list[d].uuid || crit === mc.device_list[d].serial) {
device = mc.device_list[d];
}
}
}
if (device) {
if (debug) node.log('Returning device [' + device.name + ' (' + device.address + ')] for lookup "' + crit + '"');
}
return device;
} catch (err) {
console.warn(err);
return device;
}
};
this.getSystemBase = function (command = "") {
try {
let def = this.getCommand(command,"system");
if(def && def.device_command_type && def.device_command_type === "clock"){
if (debug) node.log('Returning system base [clock] for lookup "' + command + '"');
return "clock";
} else {
if (debug) node.log('Returning system base [system] for lookup "' + command + '"');
return "system";
}
} catch (err) {
throw new Error(err);
if (debug) node.log('Returning system base [system] for lookup "' + command + '"');
return "system";
}
}
this.getSourceBase = function (sourceText) {
try {
if(sourceText) sourceText = sourceText.toLowerCase();
if(this.input_list[sourceText].play_info_type){
if (debug) node.log('Returning source base [' + this.input_list[sourceText].play_info_type + '] for lookup "' + sourceText + '"');
}
return this.input_list[sourceText].play_info_type
} catch (err) {
throw new Error("Invalid value for 'sourceText'");
}
}
/*
Find a zone by id or label label.
*/
this.findZone = function (crit = "main") {
let zone = null;
try {
for (const z in this.zone_list) {
if (this.zone_list.hasOwnProperty(z)) {
if (this.zone_list[z].id === crit || this.zone_list[z].label === crit || z === crit || this.zone_list[z].alt_id === crit) {
zone = this.zone_list[z];
if (debug) node.log('Returning zone [' + zone.label + ' (' + zone.id + ')] for lookup "' + crit + '"');
}
}
}
return zone;
} catch (err) {
console.warn(err);
return zone;
}
};
this.zoneLabel = function (zoneName) {
for (const z in this.zone_list) {
if (this.zone_list.hasOwnProperty(z)) {
return this.zone_list[z].label;
}
}
};
this.isObject = (obj) => {
return Object.prototype.toString.call(obj) === '[object Object]';
};
this.isJSON = function(obj){
try{
if(this.isObject(obj) && JSON.parse(obj)){
return true;
} else {
return false;
}
} catch (err){
//console.warn(err);
return false;
}
}
this.extIncoming = function(type,config,msg, valDefault = null){
let node = this,
ret = valDefault;
try {
switch (type) {
case "device":
switch (config.type_device) {
case "str":
ret = config.device;
break;
case "device":
ret = config.device;
break;
case "global":
ret = node.context().global.get("musiccast_device");
break;
case "flow":
ret = node.context().flow.get("musiccast_device");
break;
case "msg":
ret = msg.device || valDefault;
}
ret = node.findDevice(ret).name
break;
case "zone":
switch (config.type_zone) {
case "str":
ret = config.zone;
break;
case "zone":
ret = config.zone;
break;
case "global":
ret = node.context().global.get("musiccast_zone");
break;
case "flow":
ret = node.context().flow.get("musiccast_zone");
break;
case "msg":
ret = msg.zone || valDefault;
}
//zones can sometimes have labels that differ from sys name. handle those.
ret = node.findZone(ret).id
break;
case "system":
switch (config.type_inp) {
case "str":
ret = config.inp;
break;
case "input":
ret = config.inp;
break;
case "global":
ret = node.context().global.get("musiccast_input");
break;
case "flow":
ret = node.context().flow.get("musiccast_input");
break;
case "msg":
ret = msg.input || valDefault;
}
ret = node.getSourceBase(ret);
break;
case "input":
switch (config.type_inp) {
case "str":
ret = config.inp;
break;
case "input":
ret = config.inp;
break;
case "global":
ret = node.context().global.get("musiccast_input");
break;
case "flow":
ret = node.context().flow.get("musiccast_input");
break;
case "msg":
ret = msg.input || valDefault;
}
ret = node.getSourceBase(ret);
break;
case "command":
switch (config.type_command) {
case "str":
ret = config.command;
break;
case "command":
ret = config.command;
break;
case "msg":
ret = msg.command || valDefault;
}
break;
case "attributes":
/*
Backwards compatibility for new msg.attributes
*/
switch (config.type_payload) {
case "json":
ret = JSON.parse(config.payload) || {};
break;
case "msg":
if (this.isJSON(msg) && msg.attributes) {
ret = JSON.parse(msg.attributes);
} else if (this.isObject(msg) && msg.attributes) {
ret = msg.attributes;
} else if (this.isJSON(msg) && msg.payload) {
ret = JSON.parse(msg.payload);
} else if (this.isObject(msg) && msg.payload) {
ret = msg.payload;
} else {
ret = {};
}
}
}
return ret;
} catch (err){
//console.warn(err);
throw new Error("Invalid value supplied for '" + (type || "Unknown") + "'");
}
}
/*
Add on the translated response message depending on response type
*/
this.parseResponse = function (data, type = 'json') {
let mc = this;
try {
switch (type) {
case "xmlput":
xml2js.parseString(data, function (err, result) {
data = {
response_code: parseInt(result.YAMAHA_AV.$.RC),
response_message: mc.responseCodes[parseInt(result.YAMAHA_AV.$.RC)].msg || "Invalid message for response code."
}
});
break;
case "xmlget":
console.log("response: " + JSON.stringify(data));
xml2js.parseString(data, function (err, result) {
data = {
response_code: parseInt(result.YAMAHA_AV.$.RC),
response_message: mc.responseCodes[parseInt(result.YAMAHA_AV.$.RC)].msg || "Invalid message for response code."
}
});
break;
default:
data = JSON.parse(data);
data.response_message = mc.responseCodes[parseInt(data.response_code)].msg || "Invalid message for response code.";
}
return data;
} catch (err) {
console.warn(err);
return {response_code: -1, response_message: "Unreadable response type received."};
}
}
this.showPalStatus = function (nodeTarget,contents) {
try {
if (node.timeouts[nodeTarget.id]) {
clearTimeout(nodeTarget.id);
node.timeouts[nodeTarget.id] = null;
}
nodeTarget.status(contents);
if(node.f_blank_stale_status && node.f_blank_stale_status === true) {
node.timeouts[nodeTarget.id] = setTimeout(function () {
nodeTarget.status({});
}, 30000)
}
} catch (err){
console.warn(err);
}
}
this.genStatusLabel = function (command, response) {
let mc = this;
try {
let defCommand = mc.getCommand(command);
if (defCommand && defCommand.status_f && response.data) {
return defCommand.status_f(response.data) || false;
} else {
return false;
}
} catch (err) {
console.warn(err);
return false;
}
};
this.debug = function (msg) {
if (debug) node.log(msg);
};
this.getCommand = function (commandName,type) {
let mc = this;
let ret = null;
try {
for (const c in mc.commands) {
if (mc.commands.hasOwnProperty(c)) {
let commandsByType = mc.commands[c].commands;
for (const cbt in commandsByType) {
if (commandsByType.hasOwnProperty(cbt) && cbt === commandName) {
if(type && type === c || !type){
ret = commandsByType[cbt];
}
}
}
}
}
return ret;
} catch (err) {
console.warn(err);
return null;
}
}
this.handle = function (refNode, errorName, errorMessage) {
if (!refNode.errO) {
refNode.errO = {
errors: []
}
}
refNode.errO.errors.push({name: errorName, message: errorMessage})
};
node.on("close", function (done) {
for(const n in node.timeouts){
try {
if (node.timeouts.hasOwnProperty(n)) {
clearTimeout(node.timeouts[n]);
node.timeouts[n] = null;
}
} catch(err){
console.warn(err)
}
}
done();
});
node.langs = require("./lib/constants").langs;
}
RED.nodes.registerType("musiccast-config", MusiccastNodeConfig);
//data lookup for UI
mcData.be(RED)
};