node-red-contrib-xkeys_lcd
Version:
Xkeys LCD node for Node-RED using Dynamic Control Data Protocol (DCDP)
431 lines (392 loc) • 13.6 kB
JavaScript
module.exports = function(RED) {
const XKEYS_VENDOR_ID = "1523";
var mqtt = require('mqtt');
const connectUrl = 'mqtt://localhost';
const qos = 0;
var httpAdminDataProducts = {};
var httpAdminDataDevices = [];
var httpAdminDataBacklightOn = true;
const defaultText = "Clockwork Sport"
var textLines = [defaultText,""];
var textLines_line_numbers = [];
for (var i=0;i<textLines.length;i++) { textLines_line_numbers.push(i+1); }
var backlight_on = true;
function XkeysLCD(config) {
RED.nodes.createNode(this, config);
var node = this;
node.config = config;
//node.log(`node.config = ${JSON.stringify(node.config)}`);
//node.log(`myId = ${node.config.id}`);
var flowContext = this.context().flow;
var client = mqtt.connect(connectUrl);
client.on('reconnect', (error) => {
node.log('reconnecting:', error)
})
client.on('error', (error) => {
node.log('Connection failed:', error)
})
client.on('connect', () => {
node.log(`connected`)
client.subscribe({'/dcdp/server/#':{qos:qos}}, function (err, granted) {
if (!err) {
node.log(`Subscribed OK, granted: ${JSON.stringify(granted)}`);
client.publish('/dcdp/node', JSON.stringify({msg_type:"all_product_data"}));
client.publish('/dcdp/node', JSON.stringify({msg_type:"list_attached"}));
} else {
node.log(`Subscription failed: ${err}`);
}
})
})
client.on('close', () => {
node.log(`connection closed`);
})
client.on('message', (topic, message) => {
//node.log(`received topic:${topic}, msg: ${message}`);
var message_obj = "";
try {
message_obj = JSON.parse(message);
//node.log(`SID = ${message_obj.server_id}`);
if (message_obj.msg_type == "hello") {
node.log(`Hello from DCDP server at ${message_obj.server_id} - must have just (re)started `);
// In case dcdp-server restarted with updated devices/product list
client.publish('/dcdp/node', JSON.stringify({msg_type:"all_product_data"}));
client.publish('/dcdp/node', JSON.stringify({msg_type:"list_attached"}));
}
else if (message_obj.msg_type == "list_attached_result") {
// data should be an array of infos
httpAdminDataDevices = [];
message_obj.devices.forEach( (device) => {
// Limit to xkeys devices
if (device.vendorId == XKEYS_VENDOR_ID) {
httpAdminDataDevices.push(device);
}
});
// Based on attached deviceList, are we connected to something?
var pid_list = this.config.pid_list || "[]";
if (device_connected(this.config.vendor_id || XKEYS_VENDOR_ID, JSON.parse(pid_list), this.config.unit_id, this.config.duplicate_id)) {
node.status( {fill:"green",shape:"dot",text:"connected"} );
} else {
node.status( {fill:"red",shape:"ring",text:"disconnected"} );
textLines = [defaultText,""];
}
}
else if (message_obj.msg_type == "all_product_data_result") {
// data should be a dict of product objects
httpAdminDataProducts = {};
Object.keys(message_obj.data).forEach( (device) => {
// Restrict to X-keys products
if (message_obj.data[device].vendorId == XKEYS_VENDOR_ID ) {
httpAdminDataProducts[device] = message_obj.data[device];
}
});
}
else if ((message_obj.msg_type == "command_result") && (message_obj.command_type == "write_lcd_display")) {
node.log(`command_result: ${message}`);
}
else {
// Logging here may be useful but is quietened for production
//node.log(`Received unhandled request: ${message_obj.msg_type}`);
}
}
catch (e) {
node.log(`ERROR parsing message: ${e}`);
}
})
/*
* Input messages
* We expect msg.payload of various styles:
* msg.payload = { lcdtext: [line1Text, line2Text] | TEXTSTRING, linenum: LINENUM, backlight: TRUEFALSE, unit_id: UID }
* where only lcdtext is required, the others optional
*
* msg.payload = { lcdtext: TEXTSTRING }
* in which case, other option values from node.config are used
*/
node.on('input', function(incoming) {
var mkeys = Object.keys(incoming.payload);
//node.log(`node.config = ${JSON.stringify(node.config)}`);
//console.log(`Incoming: ${JSON.stringify(incoming)}`);
if (mkeys.includes("lcdtext")) {
/* This 'msg' object what is ultimately to feed method parameters
* We build it up from the 'incoming' received here.
*/
var msg = {"payload":{}};
/*
* We assume the LCD device has 2 lines.
* This is true right now when the only such device is the XK16LCD.
*/
var default_linenum;
if (node.config.linenum) {
default_linenum = node.config.linenum - 1;
} else {
default_linenum = 1; // The second line
}
// Does it contain a line number preference?
var line_number;
if (mkeys.includes("linenum")) {
line_number = incoming.payload.linenum - 1;
} else {
line_number = default_linenum; // already zero based
}
var incoming_text;
if (typeof(incoming.payload.lcdtext) == "string") {
incoming_text = incoming.payload.lcdtext;
} else if (Array.isArray(incoming.payload.lcdtext)) {
incoming_text = incoming.payload.lcdtext;
} else {
node.log("Bad lcdtext format, so discontinuing ...");
return;
}
// Interpret text " " as "delete the line"
// Interpret text "" as "leave line alone"
// i.e. use previous text and
// only update textLines if lcdtext has something to give
if (Array.isArray(incoming_text)) {
var temp_line_number_array = [];
for (var i=0;i<incoming_text.length;i++) {
if (incoming_text[i].length > 0) {
textLines[i] = incoming_text[i];
temp_line_number_array.push(i);
}
}
line_number = temp_line_number_array;
} else {
if (incoming_text.length > 0) {
textLines[line_number] = incoming_text;
}
}
msg.payload["lcdtext"] = textLines;
/* Disable this for now - not working properly
// Does it contain a backlight instruction?
// Otherwise default to backlight ON
if (mkeys.includes("backlight")) {
msg.payload["backlight"] = incoming.payload.backlight;
httpAdminDataBacklightOn = incoming.payload.backlight;
} else {
msg.payload["backlight"] = httpAdminDataBacklightOn;
}
*/
httpAdminDataBacklightOn = node.config.lcd_backlight || "true";
//console.log(`httpAdminDataBacklightOn: ${httpAdminDataBacklightOn}, ${(httpAdminDataBacklightOn=="true")?1:0}`)
//console.log(`msg.payload = ${JSON.stringify(msg.payload)}`);
var pid_list = "[]";
var unit_id = "";
var duplicate_id = "";
/* If product_id is specified, install it into pid_list
* (similar to node configuration editor).
* Otherwise use pid_list "as is".
*/
if (mkeys.includes("product_id")) {
var temp = [];
temp.push(parseInt(msg.payload.product_id));
pid_list = JSON.stringify(temp);
} else {
if (mkeys.includes("pid_list")) {
pid_list = incoming.payload.pid_list;
} else {
pid_list = node.config.pid_list;
}
}
//console.log(`pid_list: ${pid_list}`);
if (mkeys.includes("unit_id")) {
} else {
unit_id = incoming.payload.unit_id;
}
if (mkeys.includes("duplicate_id")) {
duplicate_id = incoming.payload.duplicate_id;
} else {
duplicate_id = node.config.duplicate_id;
}
/* Find first member of pid_list that matches an attached device.
*/
var product_id;
try {
var configured_pid_list = JSON.parse(pid_list);
configured_pid_list.every( (pid) => {
if (httpAdminDataDevices.find( (device) => device.productId == pid)) {
product_id = pid.toString();
return false;
}
return true;
});
}
catch {
node.log(`Caught exception parsing pid_list. ${err}`);
}
//console.log(`product_id = ${product_id}`);
/* An empty pid_list will not have given us a valid product_id.
* That's OK since empty pid_list => all possible product_ids.
* However if no product_id has been found when pid_list is not empty,
* it means no attached device matched what was in the pid_list
* i.e. nothing to do
*/
if (!product_id) {
if (JSON.parse(pid_list).length > 0) {
node.log(`No device matching product_id or pid_list was found. Discontinuing.`);
return;
}
else if (JSON.parse(pid_list).length == 0) {
/* No product_id has been found/calculated and pid_list is empty.
* Could be ANY or NONE selected - but is wrong if NONE.
*/
if (node.config.device == "NONE") {
node.log(`Shouldn't be texting a NONE device! Discontinuing ...`);
return;
}
}
}
/* Generate a list of device_quad targets.
*/
var quad_list = [];
var quad_list_regex_string = XKEYS_VENDOR_ID;
if (product_id) {
quad_list_regex_string += "-" + product_id;
} else {
quad_list_regex_string += "-\[0-9\]+";
}
if (unit_id) {
quad_list_regex_string += "-" + unit_id;
} else {
quad_list_regex_string += "-\[0-9\]+";
}
if (duplicate_id) {
quad_list_regex_string += "-" + duplicate_id;
} else {
quad_list_regex_string += "-\[0-9\]+";
}
//console.log(`quad_list_regex_string = ${quad_list_regex_string}`);
var regex = new RegExp(quad_list_regex_string);
httpAdminDataDevices.forEach( (dev) => {
//console.log(`dev: ${JSON.stringify(dev.device_quad)}`);
if (regex.test(dev.device_quad)) {
quad_list.push(dev.device_quad);
}
});
//console.log(`quad_list = ${JSON.stringify(quad_list)}`);
if (quad_list.length == 0 ) {
node.log(`No devices matched: discontinuing`);
return;
}
/* Prepare output msg but only bother if we have a device with matching device_quad.
*/
httpAdminDataDevices.every( (device) => {
if (!device.hasLCD) {
// Don't consider devices without hasLCD property
return true;
}
if (quad_list.includes(device.device_quad)) {
const command_message = {
msg_type : "command",
command_type : "write_lcd_display",
vendor_id : device.device_quad.split('-')[0],
product_id : device.device_quad.split('-')[1],
unit_id : device.device_quad.split('-')[2],
duplicate_id : device.device_quad.split('-')[3],
line : textLines_line_numbers,
text : msg.payload.lcdtext,
backlight : (httpAdminDataBacklightOn=="true")?1:0
}
node.log(`command_message = ${JSON.stringify(command_message)}`);
client.publish('/dcdp/node', JSON.stringify(command_message));
// Don't break the every loop on first match - find all matches.
//return false;
}
return true;
});
} else {
console.log("Not interested in this msg (no \"lcdtext\" field)");
}
})
this.on('close', function(done) {
client.end();
done();
})
// Does any attached device match specified vendor_id, pids, unit_id & dup_id ?
function device_connected(...deviceArgs) {
const vendor_id = deviceArgs[0];
const pids = deviceArgs[1];
const unit_id = deviceArgs[2];
const dup_id = deviceArgs[3];
var device_matched = false;
var regex_string = ""
var regex;
if (pids.length == 0) {
if (vendor_id) {
regex_string = vendor_id + "-";
} else {
regex_string = "\[0-9\]+-";
}
// No product_ids provided => ANY product_id
regex_string = regex_string + "\[0-9\]+-";
if (unit_id) {
regex_string = regex_string + unit_id + "-";
} else {
regex_string = regex_string + "\[0-9\]+-";
}
if (dup_id) {
regex_string = regex_string + dup_id;
} else {
regex_string = regex_string + "\[0-9\]+";
}
regex = new RegExp(regex_string);
httpAdminDataDevices.forEach( (item) => {
if (regex.test(item.device_quad)) { device_matched = true; }
})
} else {
// An array of endpoints provided
pids.forEach(function (item) {
if (vendor_id) {
regex_string = vendor_id + "-";
} else {
regex_string = "\[0-9\]+-";
}
regex_string = regex_string + item + "-";
if (unit_id) {
regex_string = regex_string + unit_id + "-";
} else {
regex_string = regex_string + "\[0-9\]+-";
}
if (dup_id) {
regex_string = regex_string + dup_id;
} else {
regex_string = regex_string + "\[0-9\]+";
}
regex = new RegExp(regex_string);
httpAdminDataDevices.forEach( (item) => {
if (regex.test(item.device_quad)) { device_matched = true; }
});
});
}
return device_matched;
} // function device_connected
} // function XkeysLCD
RED.nodes.registerType("xkeys_lcd", XkeysLCD);
RED.httpAdmin.get("/xkeys_lcd/products", function (req, res) {
res.json(httpAdminDataProducts);
});
RED.httpAdmin.get("/xkeys_lcd/devices", function (req, res) {
res.json(httpAdminDataDevices);
});
RED.httpAdmin.get("/xkeys_lcd/backlight_on", function (req, res) {
res.json(httpAdminDataBacklightOn);
});
RED.httpAdmin.post("/xkeys_lcd_inject/:id", RED.auth.needsPermission("xkeys_lcd_inject.write"), function(req,res) {
var node = RED.nodes.getNode(req.params.id);
if (node != null) {
try {
if (req.body) {
node.receive(req.body);
} else {
node.receive();
}
res.sendStatus(200);
}
catch (err) {
res.sendStatus(500);
node.error(RED._("inject.failed",{error:err.toString()}));
}
} else {
console.log("bad post");
res.sendStatus(404);
}
});
}