@tak-ps/node-cot
Version:
Lightweight JavaScript library for parsing and manipulating TAK messages
1,187 lines (994 loc) • 45.4 kB
text/typescript
import crypto from 'node:crypto';
import protobuf from 'protobufjs';
import Err from '@openaddresses/batch-error';
import { diff } from 'json-diff-ts';
import xmljs from 'xml-js';
import type { Static } from '@sinclair/typebox';
import type {
Feature,
Polygon,
Position,
FeaturePropertyMission,
FeaturePropertyMissionLayer,
} from './types/feature.js';
import type {
MartiDest,
MartiDestAttributes,
Link,
LinkAttributes,
VideoAttributes,
SensorAttributes,
VideoConnectionEntryAttributes,
} from './types/types.js'
import {
InputFeature,
} from './types/feature.js';
import Sensor from './sensor.js';
import type { AllGeoJSON } from "@turf/helpers";
import PointOnFeature from '@turf/point-on-feature';
import Truncate from '@turf/truncate';
import Ellipse from '@turf/ellipse';
import Util from './utils/util.js';
import Color from './utils/color.js';
import JSONCoT, { Detail } from './types/types.js'
import AJV from 'ajv';
import fs from 'fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// GeoJSON Geospatial ops will truncate to the below
const COORDINATE_PRECISION = 6;
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);
const checkFeat = (new AJV({
allErrors: true,
coerceTypes: true,
allowUnionTypes: true
}))
.compile(InputFeature);
/**
* 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 default class CoT {
raw: Static<typeof JSONCoT>;
// Key/Value JSON Records - not currently support by TPC Clients
// but used for styling/dynamic overrides and hopefully eventually
// merged into the CoT spec
metadata: Record<string, unknown>;
// Does the CoT belong to a folder - defaults to "/"
path: string;
constructor(cot: Buffer | Static<typeof JSONCoT> | object | string) {
if (typeof cot === 'string' || cot instanceof Buffer) {
const raw = xmljs.xml2js(String(cot), { compact: true });
this.raw = raw as Static<typeof JSONCoT>;
} else {
this.raw = cot as Static<typeof JSONCoT>;
}
this.metadata = {};
this.path = '/';
if (!this.raw.event._attributes.uid) this.raw.event._attributes.uid = Util.cot_uuid();
if (process.env.DEBUG_COTS) console.log(JSON.stringify(this.raw))
checkXML(this.raw);
if (checkXML.errors) throw new Err(400, null, `${checkXML.errors[0].message} (${checkXML.errors[0].instancePath})`);
if (!this.raw.event.detail) this.raw.event.detail = {};
if (!this.raw.event.detail['_flow-tags_']) this.raw.event.detail['_flow-tags_'] = {};
this.raw.event.detail['_flow-tags_'][`NodeCoT-${pkg.version}`] = new Date().toISOString()
if (this.raw.event.detail.archive && Object.keys(this.raw.event.detail.archive).length === 0) {
this.raw.event.detail.archive = { _attributes: {} };
}
}
/**
* 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
*/
isDiff(
cot: CoT,
opts = {
diffMetadata: false,
diffStaleStartTime: false,
diffDest: false,
diffFlow: false
}
): boolean {
const a = this.to_geojson() as Static<typeof InputFeature>;
const b = cot.to_geojson() as Static<typeof InputFeature>;
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;
}
/**
* Returns or sets the Callsign of the CoT
*/
callsign(callsign?: string): string {
if (!this.raw.event.detail) this.raw.event.detail = {};
if (callsign && !this.raw.event.detail.contact) {
this.raw.event.detail.contact = { _attributes: { callsign } };
} else if (callsign && this.raw.event.detail.contact) {
this.raw.event.detail.contact._attributes.callsign = callsign;
}
if (this.raw.event.detail.contact && this.raw.event.detail.contact._attributes && typeof this.raw.event.detail.contact._attributes.callsign === 'string') {
return this.raw.event.detail.contact._attributes.callsign;
} else {
return 'UNKNOWN'
}
}
/**
* Returns or sets the UID of the CoT
*/
uid(uid?: string): string {
if (uid) this.raw.event._attributes.uid = uid;
return this.raw.event._attributes.uid;
}
/**
* Add a given Dest tag to a CoT
*/
addDest(dest: Static<typeof MartiDestAttributes>): CoT {
if (!this.raw.event.detail) this.raw.event.detail = {};
if (!this.raw.event.detail.marti) this.raw.event.detail.marti = {};
let destArr: Array<Static<typeof MartiDest>> = [];
if (this.raw.event.detail.marti.dest && !Array.isArray(this.raw.event.detail.marti.dest)) {
destArr = [this.raw.event.detail.marti.dest]
} else if (this.raw.event.detail.marti.dest && Array.isArray(this.raw.event.detail.marti.dest)) {
destArr = this.raw.event.detail.marti.dest;
}
destArr.push({ _attributes: dest });
this.raw.event.detail.marti.dest = destArr;
return this;
}
addVideo(
video: Static<typeof VideoAttributes>,
connection?: Static<typeof VideoConnectionEntryAttributes>
): CoT {
if (!this.raw.event.detail) this.raw.event.detail = {};
if (this.raw.event.detail.__video) throw new Err(400, null, 'A video stream already exists on this CoT');
if (!video.url) throw new Err(400, null, 'A Video URL must be provided');
if (!video.uid && connection && connection.uid) {
video.uid = connection.uid
} else if (video.uid && connection && !connection.uid) {
connection.uid = video.uid;
} else if (!video.uid) {
video.uid = crypto.randomUUID();
}
this.raw.event.detail.__video = {
_attributes: video
};
if (connection) {
this.raw.event.detail.__video.ConnectionEntry = {
_attributes: connection
}
} else {
this.raw.event.detail.__video.ConnectionEntry = {
_attributes: {
uid: video.uid,
networkTimeout: 12000,
path: '',
protocol: 'raw',
bufferTime: -1,
address: video.url,
port: -1,
roverPort: -1,
rtspReliable: 0,
ignoreEmbeddedKLV: false,
alias: this.callsign()
}
}
}
return this;
}
position(position?: Static<typeof Position>): Static<typeof Position> {
if (position) {
this.raw.event.point._attributes.lon = String(position[0]);
this.raw.event.point._attributes.lat = String(position[1]);
}
return [
Number(this.raw.event.point._attributes.lon),
Number(this.raw.event.point._attributes.lat)
];
}
sensor(sensor?: Static<typeof SensorAttributes>): Static<typeof Polygon> | null {
if (!this.raw.event.detail) this.raw.event.detail = {};
if (sensor) {
this.raw.event.detail.sensor = {
_attributes: sensor
}
}
if (!this.raw.event.detail.sensor || !this.raw.event.detail.sensor._attributes) {
return null;
}
return new Sensor(
this.position(),
this.raw.event.detail.sensor._attributes
).to_geojson();
};
addLink(link: Static<typeof LinkAttributes>): CoT {
if (!this.raw.event.detail) this.raw.event.detail = {};
let linkArr: Array<Static<typeof Link>> = [];
if (this.raw.event.detail.link && !Array.isArray(this.raw.event.detail.link)) {
linkArr = [this.raw.event.detail.link]
} else if (this.raw.event.detail.link && Array.isArray(this.raw.event.detail.link)) {
linkArr = this.raw.event.detail.link;
}
linkArr.push({ _attributes: link });
this.raw.event.detail.link = linkArr;
return this;
}
/**
* Return an ATAK Compliant Protobuf
*/
to_proto(version = 1): Uint8Array {
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 = { ...this.raw.event.detail };
const msg: any = {
cotEvent: {
...this.raw.event._attributes,
sendTime: new Date(this.raw.event._attributes.time).getTime(),
startTime: new Date(this.raw.event._attributes.start).getTime(),
staleTime: new Date(this.raw.event._attributes.stale).getTime(),
...this.raw.event.point._attributes,
detail: {
xmlDetail: ''
}
}
};
let key: keyof Static<typeof Detail>;
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 = xmljs.js2xml({
...detail,
metadata: this.metadata
}, { compact: true });
return ProtoMessage.encode(msg).finish();
}
/**
* Return a GeoJSON Feature from an XML CoT message
*/
to_geojson(): Static<typeof Feature> {
const raw: Static<typeof JSONCoT> = JSON.parse(JSON.stringify(this.raw));
if (!raw.event.detail) raw.event.detail = {};
if (!raw.event.detail.contact) raw.event.detail.contact = { _attributes: { callsign: 'UNKNOWN' } };
if (!raw.event.detail.contact._attributes) raw.event.detail.contact._attributes = { callsign: 'UNKNOWN' };
const feat: Static<typeof Feature> = {
id: raw.event._attributes.uid,
type: 'Feature',
properties: {
callsign: raw.event.detail.contact._attributes.callsign || 'UNKNOWN',
center: [ Number(raw.event.point._attributes.lon), Number(raw.event.point._attributes.lat), Number(raw.event.point._attributes.hae) ],
type: raw.event._attributes.type,
how: raw.event._attributes.how || '',
time: raw.event._attributes.time,
start: raw.event._attributes.start,
stale: raw.event._attributes.stale,
},
geometry: {
type: 'Point',
coordinates: [ Number(raw.event.point._attributes.lon), Number(raw.event.point._attributes.lat), Number(raw.event.point._attributes.hae) ]
}
};
const contact = JSON.parse(JSON.stringify(raw.event.detail.contact._attributes));
delete contact.callsign;
if (Object.keys(contact).length) {
feat.properties.contact = contact;
}
if (raw.event.detail.remarks && raw.event.detail.remarks._text) {
feat.properties.remarks = raw.event.detail.remarks._text;
}
if (raw.event.detail.fileshare) {
feat.properties.fileshare = raw.event.detail.fileshare._attributes;
if (feat.properties.fileshare && typeof feat.properties.fileshare.sizeInBytes === 'string') {
feat.properties.fileshare.sizeInBytes = parseInt(feat.properties.fileshare.sizeInBytes)
}
}
if (raw.event.detail.sensor) {
feat.properties.sensor = raw.event.detail.sensor._attributes;
}
if (raw.event.detail.__video && raw.event.detail.__video._attributes) {
feat.properties.video = raw.event.detail.__video._attributes;
if (raw.event.detail.__video.ConnectionEntry) {
feat.properties.video.connection = raw.event.detail.__video.ConnectionEntry._attributes;
}
}
if (raw.event.detail.__geofence) {
feat.properties.geofence = raw.event.detail.__geofence._attributes;
}
if (raw.event.detail.ackrequest) {
feat.properties.ackrequest = raw.event.detail.ackrequest._attributes;
}
if (raw.event.detail.attachment_list) {
feat.properties.attachments = JSON.parse(raw.event.detail.attachment_list._attributes.hashes);
}
if (raw.event.detail.link) {
if (!Array.isArray(raw.event.detail.link)) raw.event.detail.link = [raw.event.detail.link];
feat.properties.links = raw.event.detail.link.filter((link: Static<typeof Link>) => {
return !!link._attributes.url
}).map((link: Static<typeof Link>): Static<typeof LinkAttributes> => {
return link._attributes;
});
if (!feat.properties.links || !feat.properties.links.length) delete feat.properties.links;
}
if (raw.event.detail.archive) {
feat.properties.archived = true;
}
if (raw.event.detail.__chat) {
feat.properties.chat = {
...raw.event.detail.__chat._attributes,
chatgrp: raw.event.detail.__chat.chatgrp
}
}
if (raw.event.detail.track && raw.event.detail.track._attributes) {
if (raw.event.detail.track._attributes.course) feat.properties.course = Number(raw.event.detail.track._attributes.course);
if (raw.event.detail.track._attributes.slope) feat.properties.slope = Number(raw.event.detail.track._attributes.slope);
if (raw.event.detail.track._attributes.course) feat.properties.speed = Number(raw.event.detail.track._attributes.speed);
}
if (raw.event.detail.marti && raw.event.detail.marti.dest) {
if (!Array.isArray(raw.event.detail.marti.dest)) raw.event.detail.marti.dest = [raw.event.detail.marti.dest];
const dest: Array<Static<typeof MartiDestAttributes>> = raw.event.detail.marti.dest.map((d: Static<typeof MartiDest>) => {
return { ...d._attributes };
});
feat.properties.dest = dest.length === 1 ? dest[0] : dest
}
if (raw.event.detail.usericon && raw.event.detail.usericon._attributes && raw.event.detail.usericon._attributes.iconsetpath) {
feat.properties.icon = raw.event.detail.usericon._attributes.iconsetpath;
}
if (raw.event.detail.uid && raw.event.detail.uid._attributes && raw.event.detail.uid._attributes.Droid) {
feat.properties.droid = raw.event.detail.uid._attributes.Droid;
}
if (raw.event.detail.takv && raw.event.detail.takv._attributes) {
feat.properties.takv = raw.event.detail.takv._attributes;
}
if (raw.event.detail.__group && raw.event.detail.__group._attributes) {
feat.properties.group = raw.event.detail.__group._attributes;
}
if (raw.event.detail['_flow-tags_'] && raw.event.detail['_flow-tags_']._attributes) {
feat.properties.flow = raw.event.detail['_flow-tags_']._attributes;
}
if (raw.event.detail.status && raw.event.detail.status._attributes) {
feat.properties.status = raw.event.detail.status._attributes;
}
if (raw.event.detail.mission && raw.event.detail.mission._attributes) {
const mission: Static<typeof FeaturePropertyMission> = {
...raw.event.detail.mission._attributes
};
if (raw.event.detail.mission && raw.event.detail.mission.MissionChanges) {
const changes =
Array.isArray(raw.event.detail.mission.MissionChanges)
? raw.event.detail.mission.MissionChanges
: [ raw.event.detail.mission.MissionChanges ]
mission.missionChanges = []
for (const change of changes) {
mission.missionChanges.push({
contentUid: change.MissionChange.contentUid._text,
creatorUid: change.MissionChange.creatorUid._text,
isFederatedChange: change.MissionChange.isFederatedChange._text,
missionName: change.MissionChange.missionName._text,
timestamp: change.MissionChange.timestamp._text,
type: change.MissionChange.type._text,
details: {
...change.MissionChange.details._attributes,
...change.MissionChange.details.location
? change.MissionChange.details.location._attributes
: {}
}
})
}
}
if (raw.event.detail.mission && raw.event.detail.mission.missionLayer) {
const missionLayer: Static<typeof FeaturePropertyMissionLayer> = {};
if (raw.event.detail.mission.missionLayer.name && raw.event.detail.mission.missionLayer.name._text) {
missionLayer.name = raw.event.detail.mission.missionLayer.name._text;
}
if (raw.event.detail.mission.missionLayer.parentUid && raw.event.detail.mission.missionLayer.parentUid._text) {
missionLayer.parentUid = raw.event.detail.mission.missionLayer.parentUid._text;
}
if (raw.event.detail.mission.missionLayer.type && raw.event.detail.mission.missionLayer.type._text) {
missionLayer.type = raw.event.detail.mission.missionLayer.type._text;
}
if (raw.event.detail.mission.missionLayer.uid && raw.event.detail.mission.missionLayer.uid._text) {
missionLayer.uid = raw.event.detail.mission.missionLayer.uid._text;
}
mission.missionLayer = missionLayer;
}
feat.properties.mission = mission;
}
if (raw.event.detail.precisionlocation && raw.event.detail.precisionlocation._attributes) {
feat.properties.precisionlocation = raw.event.detail.precisionlocation._attributes;
}
if (['u-d-f', 'u-d-r', 'b-m-r'].includes(raw.event._attributes.type) && Array.isArray(raw.event.detail.link)) {
const coordinates = [];
for (const l of raw.event.detail.link) {
if (!l._attributes.point) continue;
coordinates.push(l._attributes.point.split(',').map((p: string) => { return Number(p.trim()) }).splice(0, 2).reverse());
}
if (raw.event.detail.strokeColor && raw.event.detail.strokeColor._attributes && raw.event.detail.strokeColor._attributes.value) {
const stroke = new Color(Number(raw.event.detail.strokeColor._attributes.value));
feat.properties.stroke = stroke.as_hex();
feat.properties['stroke-opacity'] = stroke.as_opacity() / 255;
}
if (raw.event.detail.strokeWeight && raw.event.detail.strokeWeight._attributes && raw.event.detail.strokeWeight._attributes.value) {
feat.properties['stroke-width'] = Number(raw.event.detail.strokeWeight._attributes.value);
}
if (raw.event.detail.strokeStyle && raw.event.detail.strokeStyle._attributes && raw.event.detail.strokeStyle._attributes.value) {
feat.properties['stroke-style'] = raw.event.detail.strokeStyle._attributes.value;
}
if (raw.event._attributes.type === 'u-d-r' || (coordinates[0][0] === coordinates[coordinates.length -1][0] && coordinates[0][1] === coordinates[coordinates.length -1][1])) {
if (raw.event._attributes.type === 'u-d-r') {
// CoT rectangles are only 4 points - GeoJSON needs to be closed
coordinates.push(coordinates[0])
}
feat.geometry = {
type: 'Polygon',
coordinates: [coordinates]
}
if (raw.event.detail.fillColor && raw.event.detail.fillColor._attributes && raw.event.detail.fillColor._attributes.value) {
const fill = new Color(Number(raw.event.detail.fillColor._attributes.value));
feat.properties['fill-opacity'] = fill.as_opacity() / 255;
feat.properties['fill'] = fill.as_hex();
}
} else {
feat.geometry = {
type: 'LineString',
coordinates
}
}
} else if (raw.event._attributes.type.startsWith('u-d-c-c')) {
if (!raw.event.detail.shape) throw new Err(400, null, 'u-d-c-c (Circle) must define shape value')
if (
!raw.event.detail.shape.ellipse
|| !raw.event.detail.shape.ellipse._attributes
) throw new Err(400, null, 'u-d-c-c (Circle) must define ellipse shape value')
const ellipse = {
major: Number(raw.event.detail.shape.ellipse._attributes.major),
minor: Number(raw.event.detail.shape.ellipse._attributes.minor),
angle: Number(raw.event.detail.shape.ellipse._attributes.angle)
}
feat.geometry = Truncate(Ellipse(
feat.geometry.coordinates as number[],
Number(ellipse.major) / 1000,
Number(ellipse.minor) / 1000,
{
angle: ellipse.angle
}
), {
precision: COORDINATE_PRECISION,
mutate: true
}).geometry as Static<typeof Polygon>;
feat.properties.shape = {};
feat.properties.shape.ellipse = ellipse;
} else if (raw.event._attributes.type.startsWith('b-m-p-s-p-i')) {
// TODO: Currently the "shape" tag is only parsed here - asking ARA for clarification if it is a general use tag
if (raw.event.detail.shape && raw.event.detail.shape.polyline && raw.event.detail.shape.polyline.vertex) {
const coordinates = [];
const vertices = Array.isArray(raw.event.detail.shape.polyline.vertex) ? raw.event.detail.shape.polyline.vertex : [raw.event.detail.shape.polyline.vertex];
for (const v of vertices) {
coordinates.push([Number(v._attributes.lon), Number(v._attributes.lat)]);
}
if (coordinates.length === 1) {
feat.geometry = { type: 'Point', coordinates: coordinates[0] }
} else if (raw.event.detail.shape.polyline._attributes && raw.event.detail.shape.polyline._attributes.closed === true) {
coordinates.push(coordinates[0]);
feat.geometry = { type: 'Polygon', coordinates: [coordinates] }
} else {
feat.geometry = { type: 'LineString', coordinates }
}
}
if (
raw.event.detail.shape
&& raw.event.detail.shape.polyline
&& raw.event.detail.shape.polyline._attributes
&& raw.event.detail.shape.polyline._attributes
) {
if (raw.event.detail.shape.polyline._attributes.fillColor) {
const fill = new Color(Number(raw.event.detail.shape.polyline._attributes.fillColor));
feat.properties['fill-opacity'] = fill.as_opacity() / 255;
feat.properties['fill'] = fill.as_hex();
}
if (raw.event.detail.shape.polyline._attributes.color) {
const stroke = new Color(Number(raw.event.detail.shape.polyline._attributes.color));
feat.properties.stroke = stroke.as_hex();
feat.properties['stroke-opacity'] = stroke.as_opacity() / 255;
}
}
}
if (raw.event.detail.color && raw.event.detail.color._attributes && raw.event.detail.color._attributes.argb) {
const color = new Color(Number(raw.event.detail.color._attributes.argb));
feat.properties['marker-color'] = color.as_hex();
feat.properties['marker-opacity'] = color.as_opacity() / 255;
}
feat.properties.metadata = this.metadata;
feat.path = this.path;
return feat;
}
to_xml(): string {
return xmljs.js2xml(this.raw, { compact: true });
}
is_stale(): boolean {
return new Date(this.raw.event._attributes.stale) < new Date();
}
/**
* Determines if the CoT message represents a Tasking Message
*
* @return {boolean}
*/
is_tasking(): boolean {
return !!this.raw.event._attributes.type.match(/^t-/)
}
/**
* Determines if the CoT message represents a Chat Message
*
* @return {boolean}
*/
is_chat(): boolean {
return !!(this.raw.event.detail && this.raw.event.detail.__chat);
}
/**
* Determines if the CoT message represents a Friendly Element
*
* @return {boolean}
*/
is_friend(): boolean {
return !!this.raw.event._attributes.type.match(/^a-f-/)
}
/**
* Determines if the CoT message represents a Hostile Element
*
* @return {boolean}
*/
is_hostile(): boolean {
return !!this.raw.event._attributes.type.match(/^a-h-/)
}
/**
* Determines if the CoT message represents a Unknown Element
*
* @return {boolean}
*/
is_unknown(): boolean {
return !!this.raw.event._attributes.type.match(/^a-u-/)
}
/**
* Determines if the CoT message represents a Pending Element
*
* @return {boolean}
*/
is_pending(): boolean {
return !!this.raw.event._attributes.type.match(/^a-p-/)
}
/**
* Determines if the CoT message represents an Assumed Element
*
* @return {boolean}
*/
is_assumed(): boolean {
return !!this.raw.event._attributes.type.match(/^a-a-/)
}
/**
* Determines if the CoT message represents a Neutral Element
*
* @return {boolean}
*/
is_neutral(): boolean {
return !!this.raw.event._attributes.type.match(/^a-n-/)
}
/**
* Determines if the CoT message represents a Suspect Element
*
* @return {boolean}
*/
is_suspect(): boolean {
return !!this.raw.event._attributes.type.match(/^a-s-/)
}
/**
* Determines if the CoT message represents a Joker Element
*
* @return {boolean}
*/
is_joker(): boolean {
return !!this.raw.event._attributes.type.match(/^a-j-/)
}
/**
* Determines if the CoT message represents a Faker Element
*
* @return {boolean}
*/
is_faker(): boolean {
return !!this.raw.event._attributes.type.match(/^a-k-/)
}
/**
* Determines if the CoT message represents an Element
*
* @return {boolean}
*/
is_atom(): boolean {
return !!this.raw.event._attributes.type.match(/^a-/)
}
/**
* Determines if the CoT message represents an Airborne Element
*
* @return {boolean}
*/
is_airborne(): boolean {
return !!this.raw.event._attributes.type.match(/^a-.-A/)
}
/**
* Determines if the CoT message represents a Ground Element
*
* @return {boolean}
*/
is_ground(): boolean {
return !!this.raw.event._attributes.type.match(/^a-.-G/)
}
/**
* Determines if the CoT message represents an Installation
*
* @return {boolean}
*/
is_installation(): boolean {
return !!this.raw.event._attributes.type.match(/^a-.-G-I/)
}
/**
* Determines if the CoT message represents a Vehicle
*
* @return {boolean}
*/
is_vehicle(): boolean {
return !!this.raw.event._attributes.type.match(/^a-.-G-E-V/)
}
/**
* Determines if the CoT message represents Equipment
*
* @return {boolean}
*/
is_equipment(): boolean {
return !!this.raw.event._attributes.type.match(/^a-.-G-E/)
}
/**
* Determines if the CoT message represents a Surface Element
*
* @return {boolean}
*/
is_surface(): boolean {
return !!this.raw.event._attributes.type.match(/^a-.-S/)
}
/**
* Determines if the CoT message represents a Subsurface Element
*
* @return {boolean}
*/
is_subsurface(): boolean {
return !!this.raw.event._attributes.type.match(/^a-.-U/)
}
/**
* Determines if the CoT message represents a UAV Element
*
* @return {boolean}
*/
is_uav(): boolean {
return !!this.raw.event._attributes.type.match(/^a-f-A-M-F-Q-r/)
}
/**
* Parse an ATAK compliant Protobuf to a JS Object
*/
static from_proto(raw: Uint8Array, version = 1): CoT {
const ProtoMessage = RootMessage.lookupType(`atakmap.commoncommo.protobuf.v${version}.TakMessage`)
// TODO Type this
const msg: any = ProtoMessage.decode(raw);
if (!msg.cotEvent) throw new Err(400, null, 'No cotEvent Data');
const detail: Record<string, any> = {};
const metadata: Record<string, unknown> = {};
for (const key in msg.cotEvent.detail) {
if (key === 'xmlDetail') {
const parsed: any = xmljs.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,
},
}
}
});
cot.metadata = metadata;
return cot;
}
/**
* Return a CoT Message
*/
static ping(): CoT {
return new CoT({
event: {
_attributes: Util.cot_event_attr('t-x-c-t', 'h-g-i-g-o'),
detail: {},
point: Util.cot_point()
}
});
}
/**
* Return an CoT Message given a GeoJSON Feature
*
* @param {Object} feature GeoJSON Point Feature
*
* @return {CoT}
*/
static from_geojson(feature: Static<typeof InputFeature>): CoT {
checkFeat(feature);
if (checkFeat.errors) throw new Err(400, null, `${checkFeat.errors[0].message} (${checkFeat.errors[0].instancePath})`);
const cot: Static<typeof JSONCoT> = {
event: {
_attributes: Util.cot_event_attr(
feature.properties.type || 'a-f-G',
feature.properties.how || 'm-g',
feature.properties.time,
feature.properties.start,
feature.properties.stale
),
point: Util.cot_point(),
detail: Util.cot_event_detail(feature.properties.callsign)
}
};
if (feature.id) cot.event._attributes.uid = String(feature.id);
if (feature.properties.callsign && !feature.id) cot.event._attributes.uid = feature.properties.callsign;
if (!cot.event.detail) cot.event.detail = {};
if (feature.properties.droid) {
cot.event.detail.uid = { _attributes: { Droid: feature.properties.droid } };
}
if (feature.properties.archived) {
cot.event.detail.archive = { _attributes: { } };
}
if (feature.properties.links) {
if (!cot.event.detail.link) cot.event.detail.link = [];
else if (!Array.isArray(cot.event.detail.link)) cot.event.detail.link = [cot.event.detail.link];
cot.event.detail.link.push(...feature.properties.links.map((link: Static<typeof LinkAttributes>) => {
return { _attributes: link };
}))
}
if (feature.properties.dest) {
const dest = !Array.isArray(feature.properties.dest) ? [ feature.properties.dest ] : feature.properties.dest;
cot.event.detail.marti = {
dest: dest.map((dest) => {
return { _attributes: { ...dest } };
})
}
}
if (feature.properties.takv) {
cot.event.detail.takv = { _attributes: { ...feature.properties.takv } };
}
if (feature.properties.geofence) {
cot.event.detail.__geofence = { _attributes: { ...feature.properties.geofence } };
}
if (feature.properties.sensor) {
cot.event.detail.sensor = { _attributes: { ...feature.properties.sensor } };
}
if (feature.properties.ackrequest) {
cot.event.detail.ackrequest = { _attributes: { ...feature.properties.ackrequest } };
}
if (feature.properties.video) {
if (feature.properties.video.connection) {
const video = JSON.parse(JSON.stringify(feature.properties.video));
const connection = video.connection;
delete video.connection;
cot.event.detail.__video = {
_attributes: { ...video },
ConnectionEntry: {
_attributes: connection
}
}
} else {
cot.event.detail.__video = { _attributes: { ...feature.properties.video } };
}
}
if (feature.properties.attachments) {
cot.event.detail.attachment_list = { _attributes: { hashes: JSON.stringify(feature.properties.attachments) } };
}
if (feature.properties.contact) {
cot.event.detail.contact = {
_attributes: {
...feature.properties.contact,
callsign: feature.properties.callsign || 'UNKNOWN',
}
};
}
if (feature.properties.fileshare) {
cot.event.detail.fileshare = { _attributes: { ...feature.properties.fileshare } };
}
if (feature.properties.course !== undefined || feature.properties.speed !== undefined || feature.properties.slope !== undefined) {
cot.event.detail.track = {
_attributes: Util.cot_track_attr(feature.properties.course, feature.properties.speed, feature.properties.slope)
}
}
if (feature.properties.group) {
cot.event.detail.__group = { _attributes: { ...feature.properties.group } }
}
if (feature.properties.flow) {
cot.event.detail['_flow-tags_'] = { _attributes: { ...feature.properties.flow } }
}
if (feature.properties.status) {
cot.event.detail.status = { _attributes: { ...feature.properties.status } }
}
if (feature.properties.precisionlocation) {
cot.event.detail.precisionlocation = { _attributes: { ...feature.properties.precisionlocation } }
}
if (feature.properties.icon) {
cot.event.detail.usericon = { _attributes: { iconsetpath: feature.properties.icon } }
}
if (feature.properties.mission) {
cot.event.detail.mission = {
_attributes: {
type: feature.properties.mission.type,
guid: feature.properties.mission.guid,
tool: feature.properties.mission.tool,
name: feature.properties.mission.name,
authorUid: feature.properties.mission.authorUid,
}
}
if (feature.properties.mission.missionLayer) {
cot.event.detail.mission.missionLayer = {};
if (feature.properties.mission.missionLayer.name) {
cot.event.detail.mission.missionLayer.name = { _text: feature.properties.mission.missionLayer.name };
}
if (feature.properties.mission.missionLayer.parentUid) {
cot.event.detail.mission.missionLayer.parentUid = { _text: feature.properties.mission.missionLayer.parentUid };
}
if (feature.properties.mission.missionLayer.type) {
cot.event.detail.mission.missionLayer.type = { _text: feature.properties.mission.missionLayer.type };
}
if (feature.properties.mission.missionLayer.uid) {
cot.event.detail.mission.missionLayer.uid = { _text: feature.properties.mission.missionLayer.uid };
}
}
}
cot.event.detail.remarks = { _attributes: { }, _text: feature.properties.remarks || '' };
if (!feature.geometry) {
throw new Err(400, null, 'Must have Geometry');
} else if (!['Point', 'Polygon', 'LineString'].includes(feature.geometry.type)) {
throw new Err(400, null, 'Unsupported Geometry Type');
}
if (feature.geometry.type === 'Point') {
cot.event.point._attributes.lon = String(feature.geometry.coordinates[0]);
cot.event.point._attributes.lat = String(feature.geometry.coordinates[1]);
cot.event.point._attributes.hae = String(feature.geometry.coordinates[2] || '0.0');
if (feature.properties['marker-color']) {
const color = new Color(feature.properties['marker-color'] || -1761607936);
color.a = feature.properties['marker-opacity'] !== undefined ? feature.properties['marker-opacity'] * 255 : 128;
cot.event.detail.color = { _attributes: { argb: String(color.as_32bit()) } };
}
} else if (feature.geometry.type === 'Polygon' && feature.properties.type === 'u-d-c-c') {
if (!feature.properties.shape || !feature.properties.shape.ellipse) {
throw new Err(400, null, 'u-d-c-c (Circle) must define a feature.properties.shape.ellipse property')
}
cot.event.detail.shape = { ellipse: { _attributes: feature.properties.shape.ellipse } }
if (feature.properties.center) {
cot.event.point._attributes.lon = String(feature.properties.center[0]);
cot.event.point._attributes.lat = String(feature.properties.center[1]);
} else {
const centre = PointOnFeature(feature as AllGeoJSON);
cot.event.point._attributes.lon = String(centre.geometry.coordinates[0]);
cot.event.point._attributes.lat = String(centre.geometry.coordinates[1]);
cot.event.point._attributes.hae = '0.0';
}
} else if (['Polygon', 'LineString'].includes(feature.geometry.type)) {
const stroke = new Color(feature.properties.stroke || -1761607936);
stroke.a = feature.properties['stroke-opacity'] !== undefined ? feature.properties['stroke-opacity'] * 255 : 128;
cot.event.detail.strokeColor = { _attributes: { value: String(stroke.as_32bit()) } };
if (!feature.properties['stroke-width']) feature.properties['stroke-width'] = 3;
cot.event.detail.strokeWeight = { _attributes: {
value: String(feature.properties['stroke-width'])
} };
if (!feature.properties['stroke-style']) feature.properties['stroke-style'] = 'solid';
cot.event.detail.strokeStyle = { _attributes: {
value: feature.properties['stroke-style']
} };
if (feature.geometry.type === 'LineString') {
cot.event._attributes.type = 'u-d-f';
if (!cot.event.detail.link) cot.event.detail.link = [];
else if (!Array.isArray(cot.event.detail.link)) cot.event.detail.link = [cot.event.detail.link]
for (const coord of feature.geometry.coordinates) {
cot.event.detail.link.push({
_attributes: { point: `${coord[1]},${coord[0]}` }
});
}
} else if (feature.geometry.type === 'Polygon') {
cot.event._attributes.type = 'u-d-f';
if (!cot.event.detail.link) cot.event.detail.link = [];
else if (!Array.isArray(cot.event.detail.link)) cot.event.detail.link = [cot.event.detail.link]
// Inner rings are not yet supported
for (const coord of feature.geometry.coordinates[0]) {
cot.event.detail.link.push({
_attributes: { point: `${coord[1]},${coord[0]}` }
});
}
const fill = new Color(feature.properties.fill || -1761607936);
fill.a = feature.properties['fill-opacity'] !== undefined ? feature.properties['fill-opacity'] * 255 : 128;
cot.event.detail.fillColor = { _attributes: { value: String(fill.as_32bit()) } };
}
cot.event.detail.labels_on = { _attributes: { value: 'false' } };
cot.event.detail.tog = { _attributes: { enabled: '0' } };
if (feature.properties.center && Array.isArray(feature.properties.center) && feature.properties.center.length >= 2) {
cot.event.point._attributes.lon = String(feature.properties.center[0]);
cot.event.point._attributes.lat = String(feature.properties.center[1]);
if (feature.properties.center.length >= 3) {
cot.event.point._attributes.hae = String(feature.properties.center[2] || '0.0');
} else {
cot.event.point._attributes.hae = '0.0';
}
} else {
const centre = PointOnFeature(feature as AllGeoJSON);
cot.event.point._attributes.lon = String(centre.geometry.coordinates[0]);
cot.event.point._attributes.lat = String(centre.geometry.coordinates[1]);
cot.event.point._attributes.hae = '0.0';
}
}
const newcot = new CoT(cot);
if (feature.properties.metadata) {
newcot.metadata = feature.properties.metadata
}
if (feature.path) {
newcot.path = feature.path
}
return newcot;
}
}