node-red-contrib-tak-registration
Version:
A Node-RED node to register to TAK and to help wrap files as datapackages to send to TAK
550 lines (509 loc) • 28.9 kB
JavaScript
const { methodToString } = require('adm-zip/util');
const { isArray } = require('util');
module.exports = function (RED) {
"use strict";
const os = require('os');
const AdmZip = require('adm-zip');
const axios = require('axios').default;
const crypto = require("crypto");
const FormData = require('form-data')
const { v4: uuidv4 } = require('uuid');
const uuid = require('uuid');
const turf = require("@turf/turf");
const ver = require('./package.json').version;
const teamList = ["Cyan", "Red", "Green", "Blue", "Magenta", "Yellow", "Orange", "Maroon", "Purple", "Dark Blue", "Dark Green", "Teal", "Brown"];
function TakRegistrationNode(n) {
RED.nodes.createNode(this, n);
const invalid = "9999999.0";
this.group = n.group;
this.role = n.role || "Gateway";
this.ntype = n.ntype || "a-f-G-I-B";
this.lat = n.latitude;
this.lon = n.longitude;
this.callsign = n.callsign;
this.repeat = n.repeat;
this.host = n.dphost;
this.uuid = "GATEWAY-" + (crypto.createHash('md5').update(Buffer.from(this.id)).digest('hex')).slice(0, 16);
var node = this;
node.alt = invalid;
var globalContext = this.context().global;
var g = {};
g[node.uuid] = node.callsign;
globalContext.set("_takgatewayid", g);
var gr = {};
gr[node.callsign] = node.uuid;
globalContext.set("_takgatewaycs", gr);
globalContext.set("_takdphost", node.host);
if (node.role !== "Gateway") { node.ntype = "a-f-G-U-C" }
if (node.repeat > 2147483) {
node.error("TAK Heartbeat interval is too long.");
delete node.repeat;
}
var convertWMtoKMLColour = function (colour, opacity) {
if (opacity == undefined) { opacity = 100; }
var alfa = parseInt(opacity * 255 / 100).toString(16);
return alfa + colour;
};
var convertWMtoCOTColour = function (colour, opacity) {
var c;
if (opacity != undefined) {
c = Buffer.from(parseInt(opacity * 255 / 100).toString(16) + colour, "hex");
}
else {
c = Buffer.from("FF" + colour, "hex");
}
return c.readInt32BE()
};
var findCentroidOfPoints = function (points) {
if (points.length < 4) { // pad if necessary (needs 4 points minimum)
points.push(points[2]);
points.unshift(points[0]);
}
var poly = turf.polygon([points]);
var centroid = turf.centroid(poly);
return centroid;
};
var sendIt = function () {
node.emit("input", {
time: new Date().toISOString(),
etime: new Date(Date.now() + (2 * node.repeat)).toISOString(),
lat: node.lat,
lon: node.lon,
alt: node.alt,
callsign: node.callsign,
group: node.group,
role: node.role,
type: node.ntype,
heartbeat: true
});
};
node.repeaterSetup = function () {
node.repeat = node.repeat * 1000;
if (RED.settings.verbose) {
node.log(RED._("inject.repeat", node));
}
node.interval_id = setInterval(sendIt, node.repeat);
};
node.repeaterSetup();
setTimeout(sendIt, 2500);
node.on("input", function (msg) {
if (msg.heartbeat) { // Register gateway and do the heartbeats
var template = `<event version="2.0" uid="${node.uuid}" type="${msg.type}" how="h-e" time="${msg.time}" start="${msg.time}" stale="${msg.etime}"><point lat="${msg.lat}" lon="${msg.lon}" hae="${msg.alt}" ce="9999999" le="9999999"/><detail><takv device="${os.hostname()}" os="${os.platform()}" platform="NRedTAK" version="${ver}"/><contact endpoint="*:-1:stcp" callsign="${msg.callsign}"/><uid Droid="${msg.callsign}"/><__group name="${msg.group}" role="${msg.role}"/><status battery="99"/><track course="9999999.0" speed="0"/></detail></event>`;
node.send({ payload: template, topic: "TAKreg" });
node.status({ fill: "green", shape: "dot", text: node.repeat / 1000 + "s - " + node.callsign });
return;
}
// if it's just a simple filename and buffer payload then make it look like an attachment etc...
if (msg.hasOwnProperty("filename") && Buffer.isBuffer(msg.payload) && !msg.hasOwnProperty("attachments")) {
msg.attachments = [{
filename: msg.filename.split('/').pop(),
content: msg.payload
}]
if (!msg.hasOwnProperty("topic")) { msg.topic = "File - " + msg.filename.split('/').pop(); }
delete msg.filename;
delete msg.payload;
}
// If there are attachments handle them first. (Datapackage)
if (msg.hasOwnProperty("attachments") && Array.isArray(msg.attachments) && msg.attachments.length > 0) {
if (!msg.sendTo) { node.error("Missing 'sendTo' user TAK callsign property.", msg); return; }
var UUID = uuid.v5(msg.topic, 'd5d4a57d-48fb-58b6-93b8-d9fde658481a');
var fnam = msg.topic || msg.attachments[0].filename.split('.')[0];
var fname = fnam + '.zip';
var da = new Date();
var dn = da.toISOString().split('-')[2].split('.')[0];
var calls = msg.from || node.callsign;
calls = calls + '.' + dn.split('T')[0] + '.' + dn.split('T')[1].split(':').join('');
var mf = `<MissionPackageManifest version="2"><Configuration>
<Parameter name="uid" value="${UUID}"/>
<Parameter name="name" value="${msg.topic}"/>
<Parameter name="onReceiveImport" value="true"/>
<Parameter name="callsign" value="${calls}"/>
</Configuration><Contents>\n`;
var zip = new AdmZip();
for (var i = 0; i < msg.attachments.length; i++) {
var data;
if (Buffer.isBuffer(msg.attachments[i].content)) {
data = msg.attachments[i].content;
}
else if (Array.isArray(msg.attachments[i].content)) {
data = Buffer.from(msg.attachments[i].content);
}
else if (!Array.isArray(msg.attachments[i].content) && msg.attachments[i].content.hasOwnProperty("data")) {
data = Buffer.from(msg.attachments[i].content.data);
}
var hash = crypto.createHash('md5').update(data).digest('hex');
var fhash = hash + '/' + msg.attachments[i].filename;
zip.addFile(fhash, data, "Added by Node-RED");
mf += `<Content ignore="false" zipEntry="${fhash}"><Parameter name="uid" value="${UUID}"/></Content>\n`;
}
if (msg.hasOwnProperty("lat") && msg.hasOwnProperty("lon")) {
var timeo = new Date(Date.now() + (1000*60*60*4)).toISOString(); // stale time to 4 hours
var cott = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<event version="2.0" uid="${UUID}" type="b-i-x-i" time="${da.toISOString()}" start="${da.toISOString()}" stale="${timeo}" how="h-g-i-g-o">
<point lat="${msg.lat}" lon="${msg.lon}" hae="${msg.alt || "9999999.0"}" ce="9999999.0" le="9999999.0" />
<detail>
<status readiness="true" />
<contact callsign="${calls}" />
<remarks>${msg.remarks || ''}</remarks>
<color argb="-1" />
<link uid="${node.uuid}" production_time="${da.toISOString()}" type="a-f-G-U-C" parent_callsign="${msg.from || node.callsign}" relation="p-p" />
<archive />
</detail>
</event>`
cott = cott.replace(/>\s+</g, "><");
var hsh = crypto.createHash('md5').update(cott).digest('hex');
zip.addFile(hsh+'/'+hsh+'.cot', cott, "Added by Node-RED");
mf += `<Content ignore="false" zipEntry="${hsh+'/'+hsh+'.cot'}"><Parameter name="uid" value="${UUID}"/></Content>\n`;
}
mf += `</Contents></MissionPackageManifest>`;
mf = mf.replace(/>\s+</g, "><");
zip.addFile('MANIFEST/manifest.xml', Buffer.from(mf, 'utf8'), msg.topic);
var zipbuff = zip.toBuffer();
msg = {
from: msg.from || node.callsign || "Anonymous",
sendTo: msg.sendTo,
lat: msg.lat || node.lat || 0,
lon: msg.lon || node.lon || 0,
assetfile: fname,
len: zipbuff.length,
uid: node.uuid,
hash: crypto.createHash('sha256').update(zipbuff).digest('hex')
}
let formData = new FormData();
const opts = { filename: fname, contentType: 'application/x-zip-compressed' };
formData.append('assetfile', zipbuff, opts);
const url = encodeURI(node.host + '/Marti/sync/missionupload?hash=' + msg.hash + '&filename=' + fname + '&creatorUid=' + node.uuid);
axios({
method: 'post',
url: url,
headers: formData.getHeaders(),
data: formData
})
.then(function (response) {
const urlp = encodeURI(node.host + '/Marti/api/sync/metadata/' + msg.hash + '/tool');
var priv = (msg.sendTo === "public") ? "public" : "private";
axios({
method: 'put',
url: urlp,
data: priv
})
.then(function (response) {
if (priv === "private") {
const start = new Date().toISOString();
const stale = new Date(new Date().getTime() + (10000)).toISOString();
var m = `<event version="2.0" uid="${uuidv4()}" type="b-f-t-r" how="h-e" time="${start}" start="${start}" stale="${stale}">
<point lat="${msg.lat}" lon="${msg.lon}" hae="${msg.alt || 9999999.0}" ce="9999999.0" le="9999999.0" />
<detail>
<fileshare filename="${fname}" senderUrl="${node.host}/Marti/sync/content?hash=${msg.hash}" sizeInBytes="${msg.len}" sha256="${msg.hash}" senderUid="${msg.uid}" senderCallsign="${msg.from}" name="${fnam}" />`
if (msg.sendTo !== "broadcast") {
var t = msg.sendTo;
if (!Array.isArray(t)) { t = [t]; }
m += '<marti>' + t.map(v => '<dest callsign="' + v + '"/>') + '</marti>';
}
m += '</detail></event>';
node.log("DP: " + node.host + "/Marti/sync/content?hash=" + msg.hash);
msg.payload = m.replace(/>\s+</g, "><");
msg.topic = "b-f-t-r";
node.send(msg);
}
})
.catch(function (error) {
node.error(error.message, error);
})
})
.catch(function (error) {
node.error(error.message, error);
})
}
// Otherwise if it's a string maybe it's raw cot xml - or NMEA from GPS - or maybe a simple chat message
else if (typeof msg.payload === "string") {
if (msg.payload.trim().startsWith('<') && msg.payload.trim().endsWith('>')) { // Assume it's proper XML event so pass straight through
msg.topic = msg.payload.split('type="')[1].split('"')[0];
node.send(msg);
}
else if (msg.payload.trim().startsWith('$GPGGA')) { // maybe it's an NMEA string
// console.log("It's NMEA",msg.payload);
var nm = msg.payload.trim().split(',');
if (nm[0] === '$GPGGA' && nm[6] > 0) {
const la = parseInt(nm[2].substr(0, 2)) + parseFloat(nm[2].substr(2)) / 60;
node.lat = ((nm[3] === "N") ? la : -la).toFixed(6);
const lo = parseInt(nm[4].substr(0, 3)) + parseFloat(nm[4].substr(3)) / 60;
node.lon = ((nm[5] === "E") ? lo : -lo).toFixed(6);
node.alt = nm[9];
}
}
else if (msg.hasOwnProperty("sendTo")) {
// simple text payload and no attachments so guess it's a chat message...
// node.log("Geochat to " + msg.sendTo);
if (!Array.isArray(msg.sendTo)) { msg.sendTo = msg.sendTo.split(','); }
const start = new Date().toISOString();
const stale = new Date(new Date().getTime() + (10000)).toISOString();
const mid = uuidv4();
var type = "a-f-G-I-B";
var par = '';
for (var t = 0; t < msg.sendTo.length; t++) {
var m = RED.util.cloneMessage(msg);
const to = m.sendTo[t];
m.sendTo = to;
const toid = globalContext.get("_takgatewaycs")[m.sendTo] || m.sendTo;
var ma = `<marti><dest callsign="${m.sendTo}"/></marti>`;
if (m.sendTo === "broadcast") { m.sendTo = "All Chat Rooms"; }
if (m.sendTo === "All Chat Rooms") { ma = ""; }
if (teamList.includes(m.sendTo)) { par = 'parent="TeamGroups"'; }
var xm = `<event version="2.0" uid="GeoChat.${node.uuid}.${toid}.${mid}" type="b-t-f" time="${start}" start="${start}" stale="${stale}" how="h-g-i-g-o">
<point lat="${node.lat}" lon="${node.lon}" hae="9999999.0" ce="9999999.0" le="9999999.0"/>
<detail>
<__chat ${par} groupOwner="false" messageId="${mid}" chatroom="${m.sendTo}" id="${toid}" senderCallsign="${node.callsign}">
<chatgrp uid0="${node.uuid}" uid1="${toid}" id="${toid}"/>
</__chat>
<link uid="${node.uuid}" type="${type}" relation="p-p"/>
<remarks source="BAO.F.ATAK.${node.uuid}" to="${toid}" time="${start}">${msg.payload}</remarks>
${ma}
<track speed="0.0" course="0.0"/>
</detail>
</event>`;
// console.log(xm);
m.payload = xm.replace(/>\s+</g, "><");
m.topic = "b-t-f";
node.send(m);
}
}
}
// Just has lat, lon (and alt) but no name - assume it's our local position we're updating
else if (typeof msg.payload === "object" && !msg.payload.hasOwnProperty("name") && msg.payload.hasOwnProperty("lat") && msg.payload.hasOwnProperty("lon")) {
node.lat = msg.payload.lat;
node.lon = msg.payload.lon;
if (msg.payload.hasOwnProperty("alt")) { node.alt = parseInt(msg.payload.alt); }
}
// Handle a generic worldmap style object
else if (typeof msg.payload === "object" && msg.payload.hasOwnProperty("name")) {
var shapeXML = ``;
var linkXML = ``;
var d = new Date();
var st = d.toISOString();
var ttl = ((msg.payload.ttl || 0) * 1000) || 60000;
var tag = msg.payload.remarks || "";
if (msg.payload.tag) { tag += " " + msg.payload.tag }
if (msg.payload.layer) { tag += " #" + msg.payload.layer }
else { tag += " #Worldmap"; }
// Handle simple markers
if (msg.payload.hasOwnProperty("lat") && msg.payload.hasOwnProperty("lon")) {
var type = msg.payload.cottype || "a-u-g-u";
if (!msg.payload.cottype && !msg.payload.SIDC && msg.payload.aistype) {
msg.payload.SIDC = ais2sidc(msg.payload.aistype);
}
if (!msg.payload.cottype && msg.payload.SIDC) {
var s = msg.payload.SIDC.split('-')[0].toLowerCase();
if (s.startsWith('s')) {
type = s.split('').join('-').replace('s-', 'a-').replace('-p-', '-');
}
}
if (msg.payload.icon === 'fa-circle fa-fw') {
type = 'b-m-p-s-m';
shapeXML = '<color argb="' + convertWMtoCOTColour(msg.payload.iconColor.replace('#', '')) + '"/>';
shapeXML = shapeXML + '<usericon iconsetpath="COT_MAPPING_SPOTMAP/b-m-p-s-m/-16711681"/>';
}
}
// for markers that aren't us, then need to add a link tag
if (msg.payload.hasOwnProperty("name")) {
linkXML = `<link uid="${node.uuid}" production_time="${st}" type="${node.ntype}" parent_callsign="${node.callsign}" relation="p-p"/>`;
}
// Handle Worldmap drawing shapes
if (msg.payload.hasOwnProperty("action") && msg.payload.action === "draw") {
ttl = 24 * 60 * 60 * 1000; /// set TTL to 1 day for shapes...
var shape = {
"strokeColor": (msg.payload.options.color || "910000").replace('#', ''),
"fillColor": (msg.payload.options.color || "910000").replace('#', ''),
"fillOpacity": msg.payload.options.opacity * 100 || 50,
"strokeWeight": msg.payload.options.weight || 2
};
if ("radius" in msg.payload) {
// Ellipse
shape.type = "ellipse";
shape.radius = {
"major": msg.payload.radius,
"minor": msg.payload.radius
};
}
else if ("line" in msg.payload) {
// Line
delete shape.fillColor;
delete shape.fillOpacity;
shape.type = "line";
shape.points = [];
var lineCentPoints = [];
for (var p = 0; p < msg.payload.line.length; p++) {
shape.points.push({
lat: msg.payload.line[p].lat,
lon: msg.payload.line[p].lng
});
lineCentPoints.push([msg.payload.line[p].lat, msg.payload.line[p].lng]);
}
// Find the Centroid of the object.
lineCentPoints.push([msg.payload.line[0].lat, msg.payload.line[0].lng]);
var lineCent = findCentroidOfPoints(lineCentPoints);
msg.payload.lat = lineCent.geometry.coordinates[0];
msg.payload.lon = lineCent.geometry.coordinates[1];
}
else if ("area" in msg.payload) {
// Polygon / Rectangle
shape.type = "poly";
shape.points = [];
var polyCentPoints = [];
for (var a = 0; a < msg.payload.area.length; a++) {
shape.points.push({
lat: msg.payload.area[a].lat,
lon: msg.payload.area[a].lng
});
polyCentPoints.push([msg.payload.area[a].lat, msg.payload.area[a].lng]);
}
shape.points.push({
lat: msg.payload.area[0].lat,
lon: msg.payload.area[0].lng
});
// Find the Centroid of the object.
polyCentPoints.push([msg.payload.area[0].lat, msg.payload.area[0].lng]);
var polyCent = findCentroidOfPoints(polyCentPoints);
msg.payload.lat = polyCent.geometry.coordinates[0];
msg.payload.lon = polyCent.geometry.coordinates[1];
}
// console.log("SHAPE",shape)
if (shape.type === 'ellipse') {
type = "u-d-c-c";
shapeXML = `
<shape>
<ellipse major="${shape.radius.major}" minor="${shape.radius.minor}" angle="360" />
<link relation="p-c" uid="${msg.payload.name}.Style" type="b-x-KmlStyle">
<Style>
<LineStyle>
<color>${convertWMtoKMLColour(shape.strokeColor)}</color>
<width>${shape.weight || 2.0}</width>
</LineStyle>
<PolyStyle>
<color>${convertWMtoKMLColour(shape.fillColor, shape.fillOpacity)}</color>
</PolyStyle>
</Style>
</link>
</shape>`;
}
else if (shape.type === 'line' || shape.type === 'poly') {
var linkArrayXML = "";
for (var l = 0; l < shape.points.length; l++) {
// linkArrayXML += `<link uid="${msg.payload.name}.l" point="${shape.points[l].lat},${shape.points[l].lon},${shape.points[l].alt || invalid}"/>\n`;
linkArrayXML += `<link point="${shape.points[l].lat},${shape.points[l].lon}"/>\n`;
}
shapeXML = `
${linkArrayXML}
<strokeColor value="${convertWMtoCOTColour(shape.strokeColor)}"/>
<strokeWeight value="${shape.weight || 2.0}"/>
<strokeStyle value="solid"/>
<color value="${convertWMtoCOTColour(shape.strokeColor)}"/>
<labels_on value="false"/>`;
if (shape.type === 'line') {
type = "u-d-f";
}
if (shape.type === 'poly') {
shapeXML += `<fillColor value="${convertWMtoCOTColour(shape.fillColor, shape.fillOpacity)}"/>`;
type = "u-d-f";
if (shape.points.length === 4) {
type = "u-d-r";
}
}
}
}
var et = Date.now() + ttl;
et = (new Date(et)).toISOString();
msg.payload = `<event version="2.0" uid="${msg.payload.name}" type="${type}" time="${st}" start="${st}" stale="${et}" how="h-e">
<point lat="${msg.payload.lat || 0}" lon="${msg.payload.lon || 0}" hae="${parseInt(msg.payload.alt || invalid)}" le="9999999.0" ce="9999999.0"/>
<detail>
<takv device="${os.hostname()}" os="${os.platform()}" platform="NRedTAK" version="${ver}"/>
<track course="${msg.payload.bearing || 9999999.0}" speed="${parseInt(msg.payload.speed) || 0}"/>}
<contact callsign="${msg.payload.name}"/>
${linkXML}
<remarks source="${node.callsign}">${tag}</remarks>
${shapeXML}
</detail>
</event>`
msg.payload = msg.payload.replace(/>\s+</g, "><");
msg.topic = type;
node.send(msg);
}
// Maybe a simple event json update (eg from an ingest - tweak and send back)
// Note this is not 100% reverse of the ingest... but seems to work mostly...
else if (typeof msg.payload === "object" && msg.payload.hasOwnProperty("event")) {
const ev = msg.payload.event;
msg.topic = ev.type;
msg.payload = `<event version="${ev.version}" uid="${ev.uid}" type="${ev.type}" time="${ev.time}" start="${ev.start}" stale="${ev.stale}" how="${ev.how}">
<point lat="${ev.point.lat || 0}" lon="${ev.point.lon || 0}" hae="${ev.detail?.height?.value || ev.point.hae || 9999999.0}" le="${ev.point.le}" ce="${ev.point.ce}"/>
<detail>
<takv device="${os.hostname()}" os="${os.platform()}" platform="NRedTAK" version="${ver}"/>`
if (ev.detail?.track) {
msg.payload += `<track speed="${ev.detail.track.speed}" course="${ev.detail.track.course}"/>`;
}
if (ev.detail?.color) {
msg.payload += `<color argb="${ev.detail.color.argb}"/>`;
}
msg.payload += `<contact callsign="${ev.detail?.contact?.callsign}"/>
<remarks source="${node.callsign}">${msg.remarks || ev.detail?.remarks}</remarks>
</detail>
</event>`
msg.payload = msg.payload.replace(/>\s+</g, "><");
node.send(msg);
}
// Drop anything we don't handle yet.
else {
node.log("Dropped: " + JSON.stringify(msg.payload));
}
});
node.on("close", function() {
// var tim = new Date().toISOString();
// var template = `<?xml version="1.0" encoding="utf-8" standalone="yes"?><event version="2.0" uid="${node.uuid}" type="t-x-d-d" how="h-g-i-g-o" time="${tim}" start="${tim}" stale="${tim}"><detail><link uid="${node.uuid}" relation="p-p" type="a-f-G-I-B" /></detail><point le="9999999.0" ce="9999999.0" hae="9999999.0" lon="0" lat="0" /></event>"`;
// node.send({payload:template}); // This never happens in time so not useful
clearInterval(this.interval_id);
if (RED.settings.verbose) { this.log(RED._("inject.stopped")); }
});
}
var aisToSidc1 = {
4: "SFSPXA------",
5: "SFSPXM------",
6: "SFSPXMP-----",
7: "SFSPXMC-----",
8: "SFSPXMO-----",
9: "SFSPXM------"
}
var aisToSidc2 = {
30: "SFSPXF------",
31: "SFSPXMTO----",
32: "SFSPXMTO--NS",
33: "SFSPXFDR----",
34: "SFUPND------",
35: "SFSP--------",
36: "SFSPXR------",
37: "SFSPXA------",
40: "SFSPXA------", //-
50: "SFSPXM------", //-
52: "SFSPXMTU----",
53: "SFSPNS------",
55: "SFSPXL------",
58: "SFSPNM------",
60: "SFSPXMP-----", //-
70: "SFSPXMC-----", //-
71: "SFSPXMH-----",
72: "SFSPXMH-----",
73: "SFSPXMH-----",
74: "SFSPXMH-----",
80: "SFSPXMO-----", //-
90: "SFSPXM------", //-
}
var ais2sidc = function (aisType) {
//aisType = Number(aisType);
if (aisType >= 100) { return "GNMPOHTH----"; }
aisType = aisToSidc2[aisType];
if (aisType && isNaN(aisType)) { return aisType; }
aisType = parseInt(aisType / 10);
aisType = aisToSidc1[aisType];
if (aisType && isNaN(aisType)) { return aisType; }
return "SFSPXM------";
}
RED.nodes.registerType("tak registration", TakRegistrationNode);
};