@tak-ps/node-cot
Version:
Lightweight JavaScript library for parsing and manipulating TAK messages
208 lines • 7.82 kB
JavaScript
import protobuf from 'protobufjs';
import Err from '@openaddresses/batch-error';
import { xml2js, js2xml } from 'xml-js';
import { diff } from 'json-diff-ts';
import { from_geojson } from './parser/from_geojson.js';
import { normalize_geojson } from './parser/normalize_geojson.js';
import { to_geojson } from './parser/to_geojson.js';
import { InputFeature, } from './types/feature.js';
import JSONCoT, { Detail } from './types/types.js';
import CoT from './cot.js';
import AJV from 'ajv';
import fs from 'fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const protoPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'proto', 'takmessage.proto');
const RootMessage = await protobuf.load(protoPath);
const pkg = JSON.parse(String(fs.readFileSync(new URL('../package.json', import.meta.url))));
const checkXML = (new AJV({
allErrors: true,
coerceTypes: true,
allowUnionTypes: true
}))
.compile(JSONCoT);
/**
* Convert to and from an XML CoT message
* @class
*
* @param cot A string/buffer containing the XML representation or the xml-js object tree
*
* @prop raw Raw XML-JS representation of CoT
*/
export class CoTParser {
static validate(cot, opts = {
flow: true
}) {
if (opts.flow === undefined)
opts.flow = true;
checkXML(cot.raw);
if (checkXML.errors)
throw new Err(400, null, `${checkXML.errors[0].message} (${checkXML.errors[0].instancePath})`);
if (opts.flow) {
if (!cot.raw.event.detail)
cot.raw.event.detail = {};
if (!cot.raw.event.detail['_flow-tags_']) {
cot.raw.event.detail['_flow-tags_'] = {};
}
cot.raw.event.detail['_flow-tags_'][`NodeCoT-${pkg.version}`] = new Date().toISOString();
}
return cot;
}
/**
* Detect difference between CoT messages
* Note: This diffs based on GeoJSON Representation of message
* So if unknown properties are present they will be excluded from the diff
*/
static async isDiff(aCoT, bCoT, opts = {
diffMetadata: false,
diffStaleStartTime: false,
diffDest: false,
diffFlow: false
}) {
const a = await this.to_geojson(aCoT);
const b = await this.to_geojson(bCoT);
if (!opts.diffDest) {
delete a.properties.dest;
delete b.properties.dest;
}
if (!opts.diffMetadata) {
delete a.properties.metadata;
delete b.properties.metadata;
}
if (!opts.diffFlow) {
delete a.properties.flow;
delete b.properties.flow;
}
if (!opts.diffStaleStartTime) {
delete a.properties.time;
delete a.properties.stale;
delete a.properties.start;
delete b.properties.time;
delete b.properties.stale;
delete b.properties.start;
}
const diffs = diff(a, b);
return diffs.length > 0;
}
static from_xml(raw, opts = {}) {
const cot = new CoT(xml2js(String(raw), { compact: true }), opts);
return this.validate(cot);
}
static to_xml(cot) {
return js2xml(cot.raw, { compact: true });
}
/**
* Return an ATAK Compliant Protobuf
*/
static async to_proto(cot, version = 1) {
if (version < 1 || version > 1)
throw new Err(400, null, `Unsupported Proto Version: ${version}`);
const ProtoMessage = RootMessage.lookupType(`atakmap.commoncommo.protobuf.v${version}.TakMessage`);
// The spread operator is important to make sure the delete doesn't modify the underlying detail object
const detail = { ...cot.raw.event.detail };
const msg = {
cotEvent: {
...cot.raw.event._attributes,
sendTime: new Date(cot.raw.event._attributes.time).getTime(),
startTime: new Date(cot.raw.event._attributes.start).getTime(),
staleTime: new Date(cot.raw.event._attributes.stale).getTime(),
...cot.raw.event.point._attributes,
detail: {
xmlDetail: ''
}
}
};
let key;
for (key in detail) {
if (['contact', 'group', 'precisionlocation', 'status', 'takv', 'track'].includes(key)) {
msg.cotEvent.detail[key] = detail[key]._attributes;
delete detail[key];
}
}
msg.cotEvent.detail.xmlDetail = js2xml({
...detail,
metadata: cot.metadata
}, { compact: true });
return ProtoMessage.encode(msg).finish();
}
/**
* Return a GeoJSON Feature from an XML CoT message
*/
static async to_geojson(cot) {
return await to_geojson(cot);
}
/**
* Parse an ATAK compliant Protobuf to a JS Object
*/
static async from_proto(raw, version = 1, opts = {}) {
const ProtoMessage = RootMessage.lookupType(`atakmap.commoncommo.protobuf.v${version}.TakMessage`);
// TODO Type this
const msg = ProtoMessage.decode(raw);
if (!msg.cotEvent)
throw new Err(400, null, 'No cotEvent Data');
const detail = {};
const metadata = {};
for (const key in msg.cotEvent.detail) {
if (key === 'xmlDetail') {
const parsed = xml2js(`<detail>${msg.cotEvent.detail.xmlDetail}</detail>`, { compact: true });
Object.assign(detail, parsed.detail);
if (detail.metadata) {
for (const key in detail.metadata) {
metadata[key] = detail.metadata[key]._text;
}
delete detail.metadata;
}
}
else if (key === 'group') {
if (msg.cotEvent.detail[key]) {
detail.__group = { _attributes: msg.cotEvent.detail[key] };
}
}
else if (['contact', 'precisionlocation', 'status', 'takv', 'track'].includes(key)) {
if (msg.cotEvent.detail[key]) {
detail[key] = { _attributes: msg.cotEvent.detail[key] };
}
}
}
const cot = new CoT({
event: {
_attributes: {
version: '2.0',
uid: msg.cotEvent.uid, type: msg.cotEvent.type, how: msg.cotEvent.how,
qos: msg.cotEvent.qos, opex: msg.cotEvent.opex, access: msg.cotEvent.access,
time: new Date(msg.cotEvent.sendTime.toNumber()).toISOString(),
start: new Date(msg.cotEvent.startTime.toNumber()).toISOString(),
stale: new Date(msg.cotEvent.staleTime.toNumber()).toISOString(),
},
detail,
point: {
_attributes: {
lat: msg.cotEvent.lat,
lon: msg.cotEvent.lon,
hae: msg.cotEvent.hae,
le: msg.cotEvent.le,
ce: msg.cotEvent.ce,
},
}
}
}, opts);
cot.metadata = metadata;
return this.validate(cot);
}
static async normalize_geojson(feature) {
const feat = await normalize_geojson(feature);
return feat;
}
/**
* Return an CoT Message given a GeoJSON Feature
*
* @param {Object} feature GeoJSON Point Feature
*
* @return {CoT}
*/
static async from_geojson(feature, opts = {}) {
const cot = await from_geojson(feature, opts);
return this.validate(cot);
}
}
//# sourceMappingURL=parser.js.map