@tak-ps/node-cot 12.37.0 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.github/workflows/doc.yml +1 -1
  2. package/.github/workflows/release.yml +3 -3
  3. package/.github/workflows/test.yml +4 -1
  4. package/CHANGELOG.md +9 -0
  5. package/dist/index.js +5 -3
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/{chat.js → builders/chat.js} +2 -2
  8. package/dist/lib/builders/chat.js.map +1 -0
  9. package/dist/lib/{fileshare.js → builders/fileshare.js} +2 -2
  10. package/dist/lib/builders/fileshare.js.map +1 -0
  11. package/dist/lib/{force-delete.js → builders/force-delete.js} +2 -2
  12. package/dist/lib/builders/force-delete.js.map +1 -0
  13. package/dist/lib/builders/route.js +23 -0
  14. package/dist/lib/builders/route.js.map +1 -0
  15. package/dist/lib/cot.js +7 -723
  16. package/dist/lib/cot.js.map +1 -1
  17. package/dist/lib/data-package.js +3 -2
  18. package/dist/lib/data-package.js.map +1 -1
  19. package/dist/lib/parser.js +769 -0
  20. package/dist/lib/parser.js.map +1 -0
  21. package/dist/lib/types/types.js +58 -17
  22. package/dist/lib/types/types.js.map +1 -1
  23. package/dist/lib/utils/util.js +5 -5
  24. package/dist/lib/utils/util.js.map +1 -1
  25. package/dist/package.json +4 -5
  26. package/dist/tsconfig.tsbuildinfo +1 -1
  27. package/index.ts +6 -3
  28. package/lib/{chat.ts → builders/chat.ts} +3 -3
  29. package/lib/{fileshare.ts → builders/fileshare.ts} +4 -4
  30. package/lib/{force-delete.ts → builders/force-delete.ts} +3 -3
  31. package/lib/builders/route.ts +27 -0
  32. package/lib/cot.ts +17 -858
  33. package/lib/data-package.ts +3 -2
  34. package/lib/parser.ts +935 -0
  35. package/lib/types/types.ts +66 -17
  36. package/lib/utils/util.ts +5 -5
  37. package/package.json +4 -5
  38. package/tsconfig.json +0 -3
  39. package/dist/lib/chat.js.map +0 -1
  40. package/dist/lib/fileshare.js.map +0 -1
  41. package/dist/lib/force-delete.js.map +0 -1
package/lib/parser.ts ADDED
@@ -0,0 +1,935 @@
1
+ import protobuf from 'protobufjs';
2
+ import Err from '@openaddresses/batch-error';
3
+ import { xml2js, js2xml } from 'xml-js';
4
+ import { diff } from 'json-diff-ts';
5
+ import crypto from 'node:crypto';
6
+ import type { Static } from '@sinclair/typebox';
7
+ import type {
8
+ Feature,
9
+ Polygon,
10
+ FeaturePropertyMission,
11
+ FeaturePropertyMissionLayer,
12
+ } from './types/feature.js';
13
+ import type {
14
+ MartiDest,
15
+ MartiDestAttributes,
16
+ Link,
17
+ LinkAttributes,
18
+ } from './types/types.js'
19
+ import {
20
+ InputFeature,
21
+ } from './types/feature.js';
22
+ import type { AllGeoJSON } from "@turf/helpers";
23
+ import Ellipse from '@turf/ellipse';
24
+ import PointOnFeature from '@turf/point-on-feature';
25
+ import Truncate from '@turf/truncate';
26
+ import { destination } from '@turf/destination';
27
+ import Util from './utils/util.js';
28
+ import Color from './utils/color.js';
29
+ import JSONCoT, { Detail } from './types/types.js'
30
+ import CoT from './cot.js';
31
+ import type { CoTOptions } from './cot.js';
32
+ import AJV from 'ajv';
33
+ import fs from 'fs';
34
+ import path from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+
37
+ // GeoJSON Geospatial ops will truncate to the below
38
+ const COORDINATE_PRECISION = 6;
39
+
40
+ const protoPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'proto', 'takmessage.proto');
41
+ const RootMessage = await protobuf.load(protoPath);
42
+
43
+ const pkg = JSON.parse(String(fs.readFileSync(new URL('../package.json', import.meta.url))));
44
+
45
+ const checkXML = (new AJV({
46
+ allErrors: true,
47
+ coerceTypes: true,
48
+ allowUnionTypes: true
49
+ }))
50
+ .compile(JSONCoT);
51
+
52
+ const checkFeat = (new AJV({
53
+ allErrors: true,
54
+ coerceTypes: true,
55
+ allowUnionTypes: true
56
+ }))
57
+ .compile(InputFeature);
58
+
59
+ /**
60
+ * Convert to and from an XML CoT message
61
+ * @class
62
+ *
63
+ * @param cot A string/buffer containing the XML representation or the xml-js object tree
64
+ *
65
+ * @prop raw Raw XML-JS representation of CoT
66
+ */
67
+ export class CoTParser {
68
+ static validate(
69
+ cot: CoT,
70
+ opts: {
71
+ flow: boolean
72
+ } = {
73
+ flow: true
74
+ }
75
+ ): CoT {
76
+ if (opts.flow === undefined) opts.flow = true;
77
+
78
+ checkXML(cot.raw);
79
+ if (checkXML.errors) throw new Err(400, null, `${checkXML.errors[0].message} (${checkXML.errors[0].instancePath})`);
80
+
81
+ if (opts.flow) {
82
+ if (!cot.raw.event.detail) cot.raw.event.detail = {};
83
+
84
+ if (!cot.raw.event.detail['_flow-tags_']) {
85
+ cot.raw.event.detail['_flow-tags_'] = {};
86
+ }
87
+
88
+ cot.raw.event.detail['_flow-tags_'][`NodeCoT-${pkg.version}`] = new Date().toISOString()
89
+ }
90
+
91
+ return cot;
92
+ }
93
+
94
+ /**
95
+ * Detect difference between CoT messages
96
+ * Note: This diffs based on GeoJSON Representation of message
97
+ * So if unknown properties are present they will be excluded from the diff
98
+ */
99
+ static isDiff(
100
+ aCoT: CoT,
101
+ bCoT: CoT,
102
+ opts = {
103
+ diffMetadata: false,
104
+ diffStaleStartTime: false,
105
+ diffDest: false,
106
+ diffFlow: false
107
+ }
108
+ ): boolean {
109
+ const a = this.to_geojson(aCoT) as Static<typeof InputFeature>;
110
+ const b = this.to_geojson(bCoT) as Static<typeof InputFeature>;
111
+
112
+ if (!opts.diffDest) {
113
+ delete a.properties.dest;
114
+ delete b.properties.dest;
115
+ }
116
+
117
+ if (!opts.diffMetadata) {
118
+ delete a.properties.metadata;
119
+ delete b.properties.metadata;
120
+ }
121
+
122
+ if (!opts.diffFlow) {
123
+ delete a.properties.flow;
124
+ delete b.properties.flow;
125
+ }
126
+
127
+ if (!opts.diffStaleStartTime) {
128
+ delete a.properties.time;
129
+ delete a.properties.stale;
130
+ delete a.properties.start;
131
+ delete b.properties.time;
132
+ delete b.properties.stale;
133
+ delete b.properties.start;
134
+ }
135
+
136
+ const diffs = diff(a, b);
137
+
138
+ return diffs.length > 0;
139
+ }
140
+
141
+
142
+ static from_xml(
143
+ raw: Buffer | string,
144
+ opts: CoTOptions = {}
145
+ ): CoT {
146
+ const cot = new CoT(
147
+ xml2js(String(raw), { compact: true }) as Static<typeof JSONCoT>,
148
+ opts
149
+ );
150
+
151
+ return this.validate(cot);
152
+ }
153
+
154
+ static to_xml(cot: CoT): string {
155
+ return js2xml(cot.raw, { compact: true });
156
+ }
157
+
158
+ /**
159
+ * Return an ATAK Compliant Protobuf
160
+ */
161
+ static to_proto(cot: CoT, version = 1): Uint8Array {
162
+ if (version < 1 || version > 1) throw new Err(400, null, `Unsupported Proto Version: ${version}`);
163
+ const ProtoMessage = RootMessage.lookupType(`atakmap.commoncommo.protobuf.v${version}.TakMessage`)
164
+
165
+ // The spread operator is important to make sure the delete doesn't modify the underlying detail object
166
+ const detail = { ...cot.raw.event.detail };
167
+
168
+ const msg: any = {
169
+ cotEvent: {
170
+ ...cot.raw.event._attributes,
171
+ sendTime: new Date(cot.raw.event._attributes.time).getTime(),
172
+ startTime: new Date(cot.raw.event._attributes.start).getTime(),
173
+ staleTime: new Date(cot.raw.event._attributes.stale).getTime(),
174
+ ...cot.raw.event.point._attributes,
175
+ detail: {
176
+ xmlDetail: ''
177
+ }
178
+ }
179
+ };
180
+
181
+ let key: keyof Static<typeof Detail>;
182
+ for (key in detail) {
183
+ if(['contact', 'group', 'precisionlocation', 'status', 'takv', 'track'].includes(key)) {
184
+ msg.cotEvent.detail[key] = detail[key]._attributes;
185
+ delete detail[key]
186
+ }
187
+ }
188
+
189
+ msg.cotEvent.detail.xmlDetail = js2xml({
190
+ ...detail,
191
+ metadata: cot.metadata
192
+ }, { compact: true });
193
+
194
+ return ProtoMessage.encode(msg).finish();
195
+ }
196
+
197
+ /**
198
+ * Return a GeoJSON Feature from an XML CoT message
199
+ */
200
+ static to_geojson(cot: CoT): Static<typeof Feature> {
201
+ const raw: Static<typeof JSONCoT> = JSON.parse(JSON.stringify(cot.raw));
202
+ if (!raw.event.detail) raw.event.detail = {};
203
+ if (!raw.event.detail.contact) raw.event.detail.contact = { _attributes: { callsign: 'UNKNOWN' } };
204
+ if (!raw.event.detail.contact._attributes) raw.event.detail.contact._attributes = { callsign: 'UNKNOWN' };
205
+
206
+ const feat: Static<typeof Feature> = {
207
+ id: raw.event._attributes.uid,
208
+ type: 'Feature',
209
+ properties: {
210
+ callsign: raw.event.detail.contact._attributes.callsign || 'UNKNOWN',
211
+ center: [ Number(raw.event.point._attributes.lon), Number(raw.event.point._attributes.lat), Number(raw.event.point._attributes.hae) ],
212
+ type: raw.event._attributes.type,
213
+ how: raw.event._attributes.how || '',
214
+ time: raw.event._attributes.time,
215
+ start: raw.event._attributes.start,
216
+ stale: raw.event._attributes.stale,
217
+ },
218
+ geometry: {
219
+ type: 'Point',
220
+ coordinates: [ Number(raw.event.point._attributes.lon), Number(raw.event.point._attributes.lat), Number(raw.event.point._attributes.hae) ]
221
+ }
222
+ };
223
+
224
+ const contact = JSON.parse(JSON.stringify(raw.event.detail.contact._attributes));
225
+ delete contact.callsign;
226
+ if (Object.keys(contact).length) {
227
+ feat.properties.contact = contact;
228
+ }
229
+
230
+ if (cot.creator()) {
231
+ feat.properties.creator = cot.creator();
232
+ }
233
+
234
+ if (raw.event.detail.remarks && raw.event.detail.remarks._text) {
235
+ feat.properties.remarks = raw.event.detail.remarks._text;
236
+ }
237
+
238
+ if (raw.event.detail.fileshare) {
239
+ feat.properties.fileshare = raw.event.detail.fileshare._attributes;
240
+ if (feat.properties.fileshare && typeof feat.properties.fileshare.sizeInBytes === 'string') {
241
+ feat.properties.fileshare.sizeInBytes = parseInt(feat.properties.fileshare.sizeInBytes)
242
+ }
243
+ }
244
+
245
+ if (raw.event.detail.__milsym) {
246
+ feat.properties.milsym = {
247
+ id: raw.event.detail.__milsym._attributes.id
248
+ }
249
+ }
250
+
251
+ if (raw.event.detail.sensor) {
252
+ feat.properties.sensor = raw.event.detail.sensor._attributes;
253
+ }
254
+
255
+ if (raw.event.detail.range) {
256
+ feat.properties.range = raw.event.detail.range._attributes.value;
257
+ }
258
+
259
+ if (raw.event.detail.bearing) {
260
+ feat.properties.bearing = raw.event.detail.bearing._attributes.value;
261
+ }
262
+
263
+ if (raw.event.detail.__video && raw.event.detail.__video._attributes) {
264
+ feat.properties.video = raw.event.detail.__video._attributes;
265
+
266
+ if (raw.event.detail.__video.ConnectionEntry) {
267
+ feat.properties.video.connection = raw.event.detail.__video.ConnectionEntry._attributes;
268
+ }
269
+ }
270
+
271
+ if (raw.event.detail.__geofence) {
272
+ feat.properties.geofence = raw.event.detail.__geofence._attributes;
273
+ }
274
+
275
+ if (raw.event.detail.ackrequest) {
276
+ feat.properties.ackrequest = raw.event.detail.ackrequest._attributes;
277
+ }
278
+
279
+ if (raw.event.detail.attachment_list) {
280
+ feat.properties.attachments = JSON.parse(raw.event.detail.attachment_list._attributes.hashes);
281
+ }
282
+
283
+ if (raw.event.detail.link) {
284
+ if (!Array.isArray(raw.event.detail.link)) raw.event.detail.link = [raw.event.detail.link];
285
+
286
+ feat.properties.links = raw.event.detail.link.filter((link: Static<typeof Link>) => {
287
+ return !!link._attributes.url
288
+ }).map((link: Static<typeof Link>): Static<typeof LinkAttributes> => {
289
+ return link._attributes;
290
+ });
291
+
292
+ if (!feat.properties.links || !feat.properties.links.length) delete feat.properties.links;
293
+ }
294
+
295
+ if (raw.event.detail.archive) {
296
+ feat.properties.archived = true;
297
+ }
298
+
299
+ if (raw.event.detail.__chat) {
300
+ feat.properties.chat = {
301
+ ...raw.event.detail.__chat._attributes,
302
+ chatgrp: raw.event.detail.__chat.chatgrp
303
+ }
304
+ }
305
+
306
+ if (raw.event.detail.track && raw.event.detail.track._attributes) {
307
+ if (raw.event.detail.track._attributes.course) feat.properties.course = Number(raw.event.detail.track._attributes.course);
308
+ if (raw.event.detail.track._attributes.slope) feat.properties.slope = Number(raw.event.detail.track._attributes.slope);
309
+ if (raw.event.detail.track._attributes.course) feat.properties.speed = Number(raw.event.detail.track._attributes.speed);
310
+ }
311
+
312
+ if (raw.event.detail.marti && raw.event.detail.marti.dest) {
313
+ if (!Array.isArray(raw.event.detail.marti.dest)) raw.event.detail.marti.dest = [raw.event.detail.marti.dest];
314
+
315
+ const dest: Array<Static<typeof MartiDestAttributes>> = raw.event.detail.marti.dest.map((d: Static<typeof MartiDest>) => {
316
+ return { ...d._attributes };
317
+ });
318
+
319
+ feat.properties.dest = dest.length === 1 ? dest[0] : dest
320
+ }
321
+
322
+ if (raw.event.detail.usericon && raw.event.detail.usericon._attributes && raw.event.detail.usericon._attributes.iconsetpath) {
323
+ feat.properties.icon = raw.event.detail.usericon._attributes.iconsetpath;
324
+ }
325
+
326
+
327
+ if (raw.event.detail.uid && raw.event.detail.uid._attributes && raw.event.detail.uid._attributes.Droid) {
328
+ feat.properties.droid = raw.event.detail.uid._attributes.Droid;
329
+ }
330
+
331
+ if (raw.event.detail.takv && raw.event.detail.takv._attributes) {
332
+ feat.properties.takv = raw.event.detail.takv._attributes;
333
+ }
334
+
335
+ if (raw.event.detail.__group && raw.event.detail.__group._attributes) {
336
+ feat.properties.group = raw.event.detail.__group._attributes;
337
+ }
338
+
339
+ if (raw.event.detail['_flow-tags_'] && raw.event.detail['_flow-tags_']._attributes) {
340
+ feat.properties.flow = raw.event.detail['_flow-tags_']._attributes;
341
+ }
342
+
343
+ if (raw.event.detail.status && raw.event.detail.status._attributes) {
344
+ feat.properties.status = raw.event.detail.status._attributes;
345
+ }
346
+
347
+ if (raw.event.detail.mission && raw.event.detail.mission._attributes) {
348
+ const mission: Static<typeof FeaturePropertyMission> = {
349
+ ...raw.event.detail.mission._attributes
350
+ };
351
+
352
+ if (raw.event.detail.mission && raw.event.detail.mission.MissionChanges) {
353
+ const changes =
354
+ Array.isArray(raw.event.detail.mission.MissionChanges)
355
+ ? raw.event.detail.mission.MissionChanges
356
+ : [ raw.event.detail.mission.MissionChanges ]
357
+
358
+ mission.missionChanges = []
359
+ for (const change of changes) {
360
+ mission.missionChanges.push({
361
+ contentUid: change.MissionChange.contentUid._text,
362
+ creatorUid: change.MissionChange.creatorUid._text,
363
+ isFederatedChange: change.MissionChange.isFederatedChange._text,
364
+ missionName: change.MissionChange.missionName._text,
365
+ timestamp: change.MissionChange.timestamp._text,
366
+ type: change.MissionChange.type._text,
367
+ details: {
368
+ ...change.MissionChange.details._attributes,
369
+ ...change.MissionChange.details.location
370
+ ? change.MissionChange.details.location._attributes
371
+ : {}
372
+ }
373
+ })
374
+ }
375
+ }
376
+
377
+
378
+ if (raw.event.detail.mission && raw.event.detail.mission.missionLayer) {
379
+ const missionLayer: Static<typeof FeaturePropertyMissionLayer> = {};
380
+
381
+ if (raw.event.detail.mission.missionLayer.name && raw.event.detail.mission.missionLayer.name._text) {
382
+ missionLayer.name = raw.event.detail.mission.missionLayer.name._text;
383
+ }
384
+ if (raw.event.detail.mission.missionLayer.parentUid && raw.event.detail.mission.missionLayer.parentUid._text) {
385
+ missionLayer.parentUid = raw.event.detail.mission.missionLayer.parentUid._text;
386
+ }
387
+ if (raw.event.detail.mission.missionLayer.type && raw.event.detail.mission.missionLayer.type._text) {
388
+ missionLayer.type = raw.event.detail.mission.missionLayer.type._text;
389
+ }
390
+ if (raw.event.detail.mission.missionLayer.uid && raw.event.detail.mission.missionLayer.uid._text) {
391
+ missionLayer.uid = raw.event.detail.mission.missionLayer.uid._text;
392
+ }
393
+
394
+ mission.missionLayer = missionLayer;
395
+ }
396
+
397
+ feat.properties.mission = mission;
398
+ }
399
+
400
+ if (raw.event.detail.precisionlocation && raw.event.detail.precisionlocation._attributes) {
401
+ feat.properties.precisionlocation = raw.event.detail.precisionlocation._attributes;
402
+ }
403
+
404
+ // Line or Polygon style types
405
+ if (['u-d-f', 'u-d-r', 'b-m-r', 'u-rb-a'].includes(raw.event._attributes.type) && Array.isArray(raw.event.detail.link)) {
406
+ const coordinates = [];
407
+
408
+ for (const l of raw.event.detail.link) {
409
+ if (!l._attributes.point) continue;
410
+ coordinates.push(l._attributes.point.split(',').map((p: string) => { return Number(p.trim()) }).splice(0, 2).reverse());
411
+ }
412
+
413
+ if (raw.event.detail.strokeColor && raw.event.detail.strokeColor._attributes && raw.event.detail.strokeColor._attributes.value) {
414
+ const stroke = new Color(Number(raw.event.detail.strokeColor._attributes.value));
415
+ feat.properties.stroke = stroke.as_hex();
416
+ feat.properties['stroke-opacity'] = stroke.as_opacity() / 255;
417
+ }
418
+
419
+ if (raw.event.detail.strokeWeight && raw.event.detail.strokeWeight._attributes && raw.event.detail.strokeWeight._attributes.value) {
420
+ feat.properties['stroke-width'] = Number(raw.event.detail.strokeWeight._attributes.value);
421
+ }
422
+
423
+ if (raw.event.detail.strokeStyle && raw.event.detail.strokeStyle._attributes && raw.event.detail.strokeStyle._attributes.value) {
424
+ feat.properties['stroke-style'] = raw.event.detail.strokeStyle._attributes.value;
425
+ }
426
+
427
+ // Range & Bearing Line
428
+ if (raw.event._attributes.type === 'u-rb-a') {
429
+ const detail = cot.detail();
430
+
431
+ if (!detail.range) throw new Error('Range value not provided')
432
+ if (!detail.bearing) throw new Error('Bearing value not provided')
433
+
434
+ // TODO Support inclination
435
+ const dest = destination(
436
+ cot.position(),
437
+ detail.range._attributes.value / 1000,
438
+ detail.bearing._attributes.value
439
+ ).geometry.coordinates;
440
+
441
+ feat.geometry = {
442
+ type: 'LineString',
443
+ coordinates: [cot.position(), dest]
444
+ };
445
+ } else if (raw.event._attributes.type === 'u-d-r' || (coordinates[0][0] === coordinates[coordinates.length -1][0] && coordinates[0][1] === coordinates[coordinates.length -1][1])) {
446
+ if (raw.event._attributes.type === 'u-d-r') {
447
+ // CoT rectangles are only 4 points - GeoJSON needs to be closed
448
+ coordinates.push(coordinates[0])
449
+ }
450
+
451
+ feat.geometry = {
452
+ type: 'Polygon',
453
+ coordinates: [coordinates]
454
+ }
455
+
456
+ if (raw.event.detail.fillColor && raw.event.detail.fillColor._attributes && raw.event.detail.fillColor._attributes.value) {
457
+ const fill = new Color(Number(raw.event.detail.fillColor._attributes.value));
458
+ feat.properties['fill-opacity'] = fill.as_opacity() / 255;
459
+ feat.properties['fill'] = fill.as_hex();
460
+ }
461
+ } else {
462
+ feat.geometry = {
463
+ type: 'LineString',
464
+ coordinates
465
+ }
466
+ }
467
+ } else if (raw.event._attributes.type.startsWith('u-d-c-c')) {
468
+ if (!raw.event.detail.shape) throw new Err(400, null, 'u-d-c-c (Circle) must define shape value')
469
+ if (
470
+ !raw.event.detail.shape.ellipse
471
+ || !raw.event.detail.shape.ellipse._attributes
472
+ ) throw new Err(400, null, 'u-d-c-c (Circle) must define ellipse shape value')
473
+
474
+ const ellipse = {
475
+ major: Number(raw.event.detail.shape.ellipse._attributes.major),
476
+ minor: Number(raw.event.detail.shape.ellipse._attributes.minor),
477
+ angle: Number(raw.event.detail.shape.ellipse._attributes.angle)
478
+ }
479
+
480
+ feat.geometry = Truncate(Ellipse(
481
+ feat.geometry.coordinates as number[],
482
+ Number(ellipse.major) / 1000,
483
+ Number(ellipse.minor) / 1000,
484
+ {
485
+ angle: ellipse.angle
486
+ }
487
+ ), {
488
+ precision: COORDINATE_PRECISION,
489
+ mutate: true
490
+ }).geometry as Static<typeof Polygon>;
491
+
492
+ feat.properties.shape = {};
493
+ feat.properties.shape.ellipse = ellipse;
494
+ } else if (raw.event._attributes.type.startsWith('b-m-p-s-p-i')) {
495
+ // TODO: Currently the "shape" tag is only parsed here - asking ARA for clarification if it is a general use tag
496
+ if (raw.event.detail.shape && raw.event.detail.shape.polyline && raw.event.detail.shape.polyline.vertex) {
497
+ const coordinates = [];
498
+
499
+ const vertices = Array.isArray(raw.event.detail.shape.polyline.vertex) ? raw.event.detail.shape.polyline.vertex : [raw.event.detail.shape.polyline.vertex];
500
+ for (const v of vertices) {
501
+ coordinates.push([Number(v._attributes.lon), Number(v._attributes.lat)]);
502
+ }
503
+
504
+ if (coordinates.length === 1) {
505
+ feat.geometry = { type: 'Point', coordinates: coordinates[0] }
506
+ } else if (raw.event.detail.shape.polyline._attributes && raw.event.detail.shape.polyline._attributes.closed === true) {
507
+ coordinates.push(coordinates[0]);
508
+ feat.geometry = { type: 'Polygon', coordinates: [coordinates] }
509
+ } else {
510
+ feat.geometry = { type: 'LineString', coordinates }
511
+ }
512
+ }
513
+
514
+ if (
515
+ raw.event.detail.shape
516
+ && raw.event.detail.shape.polyline
517
+ && raw.event.detail.shape.polyline._attributes
518
+ && raw.event.detail.shape.polyline._attributes
519
+ ) {
520
+ if (raw.event.detail.shape.polyline._attributes.fillColor) {
521
+ const fill = new Color(Number(raw.event.detail.shape.polyline._attributes.fillColor));
522
+ feat.properties['fill-opacity'] = fill.as_opacity() / 255;
523
+ feat.properties['fill'] = fill.as_hex();
524
+ }
525
+
526
+ if (raw.event.detail.shape.polyline._attributes.color) {
527
+ const stroke = new Color(Number(raw.event.detail.shape.polyline._attributes.color));
528
+ feat.properties.stroke = stroke.as_hex();
529
+ feat.properties['stroke-opacity'] = stroke.as_opacity() / 255;
530
+ }
531
+ }
532
+ }
533
+
534
+ if (raw.event.detail.color && raw.event.detail.color._attributes && raw.event.detail.color._attributes.argb) {
535
+ const color = new Color(Number(raw.event.detail.color._attributes.argb));
536
+ feat.properties['marker-color'] = color.as_hex();
537
+ feat.properties['marker-opacity'] = color.as_opacity() / 255;
538
+ }
539
+
540
+ feat.properties.metadata = cot.metadata;
541
+ feat.path = cot.path;
542
+
543
+ return feat;
544
+ }
545
+
546
+ /**
547
+ * Parse an ATAK compliant Protobuf to a JS Object
548
+ */
549
+ static from_proto(
550
+ raw: Uint8Array,
551
+ version = 1,
552
+ opts: CoTOptions = {}
553
+ ): CoT {
554
+ const ProtoMessage = RootMessage.lookupType(`atakmap.commoncommo.protobuf.v${version}.TakMessage`)
555
+
556
+ // TODO Type this
557
+ const msg: any = ProtoMessage.decode(raw);
558
+
559
+ if (!msg.cotEvent) throw new Err(400, null, 'No cotEvent Data');
560
+
561
+ const detail: Record<string, any> = {};
562
+ const metadata: Record<string, unknown> = {};
563
+ for (const key in msg.cotEvent.detail) {
564
+ if (key === 'xmlDetail') {
565
+ const parsed: any = xml2js(`<detail>${msg.cotEvent.detail.xmlDetail}</detail>`, { compact: true });
566
+ Object.assign(detail, parsed.detail);
567
+
568
+ if (detail.metadata) {
569
+ for (const key in detail.metadata) {
570
+ metadata[key] = detail.metadata[key]._text;
571
+ }
572
+ delete detail.metadata;
573
+ }
574
+ } else if (key === 'group') {
575
+ if (msg.cotEvent.detail[key]) {
576
+ detail.__group = { _attributes: msg.cotEvent.detail[key] };
577
+ }
578
+ } else if (['contact', 'precisionlocation', 'status', 'takv', 'track'].includes(key)) {
579
+ if (msg.cotEvent.detail[key]) {
580
+ detail[key] = { _attributes: msg.cotEvent.detail[key] };
581
+ }
582
+ }
583
+ }
584
+
585
+ const cot = new CoT({
586
+ event: {
587
+ _attributes: {
588
+ version: '2.0',
589
+ uid: msg.cotEvent.uid, type: msg.cotEvent.type, how: msg.cotEvent.how,
590
+ qos: msg.cotEvent.qos, opex: msg.cotEvent.opex, access: msg.cotEvent.access,
591
+ time: new Date(msg.cotEvent.sendTime.toNumber()).toISOString(),
592
+ start: new Date(msg.cotEvent.startTime.toNumber()).toISOString(),
593
+ stale: new Date(msg.cotEvent.staleTime.toNumber()).toISOString(),
594
+ },
595
+ detail,
596
+ point: {
597
+ _attributes: {
598
+ lat: msg.cotEvent.lat,
599
+ lon: msg.cotEvent.lon,
600
+ hae: msg.cotEvent.hae,
601
+ le: msg.cotEvent.le,
602
+ ce: msg.cotEvent.ce,
603
+ },
604
+ }
605
+ }
606
+ }, opts);
607
+
608
+ cot.metadata = metadata;
609
+
610
+ return this.validate(cot);
611
+ }
612
+
613
+ /**
614
+ * Return an CoT Message given a GeoJSON Feature
615
+ *
616
+ * @param {Object} feature GeoJSON Point Feature
617
+ *
618
+ * @return {CoT}
619
+ */
620
+ static from_geojson(
621
+ feature: Static<typeof InputFeature>,
622
+ opts: CoTOptions = {}
623
+ ): CoT {
624
+ checkFeat(feature);
625
+ if (checkFeat.errors) throw new Err(400, null, `${checkFeat.errors[0].message} (${checkFeat.errors[0].instancePath})`);
626
+
627
+ const cot: Static<typeof JSONCoT> = {
628
+ event: {
629
+ _attributes: Util.cot_event_attr(
630
+ feature.properties.type || 'a-f-G',
631
+ feature.properties.how || 'm-g',
632
+ feature.properties.time,
633
+ feature.properties.start,
634
+ feature.properties.stale
635
+ ),
636
+ point: Util.cot_point(),
637
+ detail: Util.cot_event_detail(feature.properties.callsign)
638
+ }
639
+ };
640
+
641
+ if (feature.id) cot.event._attributes.uid = String(feature.id);
642
+ if (feature.properties.callsign && !feature.id) cot.event._attributes.uid = feature.properties.callsign;
643
+ if (!cot.event.detail) cot.event.detail = {};
644
+
645
+ if (feature.properties.droid) {
646
+ cot.event.detail.uid = { _attributes: { Droid: feature.properties.droid } };
647
+ }
648
+
649
+ if (feature.properties.archived) {
650
+ cot.event.detail.archive = { _attributes: { } };
651
+ }
652
+
653
+ if (feature.properties.links) {
654
+ if (!cot.event.detail.link) cot.event.detail.link = [];
655
+ else if (!Array.isArray(cot.event.detail.link)) cot.event.detail.link = [cot.event.detail.link];
656
+
657
+ cot.event.detail.link.push(...feature.properties.links.map((link: Static<typeof LinkAttributes>) => {
658
+ return { _attributes: link };
659
+ }))
660
+ }
661
+
662
+ if (feature.properties.dest) {
663
+ const dest = !Array.isArray(feature.properties.dest) ? [ feature.properties.dest ] : feature.properties.dest;
664
+
665
+ cot.event.detail.marti = {
666
+ dest: dest.map((dest) => {
667
+ return { _attributes: { ...dest } };
668
+ })
669
+ }
670
+ }
671
+
672
+ if (feature.properties.takv) {
673
+ cot.event.detail.takv = { _attributes: { ...feature.properties.takv } };
674
+ }
675
+
676
+ if (feature.properties.creator) {
677
+ cot.event.detail.creator = { _attributes: { ...feature.properties.creator } };
678
+ }
679
+
680
+ if (feature.properties.range !== undefined) {
681
+ cot.event.detail.range = { _attributes: { value: feature.properties.range } }
682
+ }
683
+
684
+ if (feature.properties.bearing !== undefined) {
685
+ cot.event.detail.bearing = { _attributes: { value: feature.properties.bearing } }
686
+ }
687
+
688
+ if (feature.properties.geofence) {
689
+ cot.event.detail.__geofence = { _attributes: { ...feature.properties.geofence } };
690
+ }
691
+
692
+ if (feature.properties.milsym) {
693
+ cot.event.detail.__milsym = { _attributes: { id: feature.properties.milsym.id} };
694
+ }
695
+
696
+ if (feature.properties.sensor) {
697
+ cot.event.detail.sensor = { _attributes: { ...feature.properties.sensor } };
698
+ }
699
+
700
+ if (feature.properties.ackrequest) {
701
+ cot.event.detail.ackrequest = { _attributes: { ...feature.properties.ackrequest } };
702
+ }
703
+
704
+ if (feature.properties.video) {
705
+ if (feature.properties.video.connection) {
706
+ const video = JSON.parse(JSON.stringify(feature.properties.video));
707
+
708
+ const connection = video.connection;
709
+ delete video.connection;
710
+
711
+ cot.event.detail.__video = {
712
+ _attributes: { ...video },
713
+ ConnectionEntry: {
714
+ _attributes: connection
715
+ }
716
+ }
717
+ } else {
718
+ cot.event.detail.__video = { _attributes: { ...feature.properties.video } };
719
+ }
720
+ }
721
+
722
+ if (feature.properties.attachments) {
723
+ cot.event.detail.attachment_list = { _attributes: { hashes: JSON.stringify(feature.properties.attachments) } };
724
+ }
725
+
726
+ if (feature.properties.contact) {
727
+ cot.event.detail.contact = {
728
+ _attributes: {
729
+ ...feature.properties.contact,
730
+ callsign: feature.properties.callsign || 'UNKNOWN',
731
+ }
732
+ };
733
+ }
734
+
735
+ if (feature.properties.fileshare) {
736
+ cot.event.detail.fileshare = { _attributes: { ...feature.properties.fileshare } };
737
+ }
738
+
739
+ if (feature.properties.course !== undefined || feature.properties.speed !== undefined || feature.properties.slope !== undefined) {
740
+ cot.event.detail.track = {
741
+ _attributes: Util.cot_track_attr(feature.properties.course, feature.properties.speed, feature.properties.slope)
742
+ }
743
+ }
744
+
745
+ if (feature.properties.group) {
746
+ cot.event.detail.__group = { _attributes: { ...feature.properties.group } }
747
+ }
748
+
749
+ if (feature.properties.flow) {
750
+ cot.event.detail['_flow-tags_'] = { _attributes: { ...feature.properties.flow } }
751
+ }
752
+
753
+ if (feature.properties.status) {
754
+ cot.event.detail.status = { _attributes: { ...feature.properties.status } }
755
+ }
756
+
757
+ if (feature.properties.precisionlocation) {
758
+ cot.event.detail.precisionlocation = { _attributes: { ...feature.properties.precisionlocation } }
759
+ }
760
+
761
+ if (feature.properties.icon) {
762
+ cot.event.detail.usericon = { _attributes: { iconsetpath: feature.properties.icon } }
763
+ }
764
+
765
+ if (feature.properties.mission) {
766
+ cot.event.detail.mission = {
767
+ _attributes: {
768
+ type: feature.properties.mission.type,
769
+ guid: feature.properties.mission.guid,
770
+ tool: feature.properties.mission.tool,
771
+ name: feature.properties.mission.name,
772
+ authorUid: feature.properties.mission.authorUid,
773
+ }
774
+ }
775
+
776
+ if (feature.properties.mission.missionLayer) {
777
+ cot.event.detail.mission.missionLayer = {};
778
+
779
+ if (feature.properties.mission.missionLayer.name) {
780
+ cot.event.detail.mission.missionLayer.name = { _text: feature.properties.mission.missionLayer.name };
781
+ }
782
+
783
+ if (feature.properties.mission.missionLayer.parentUid) {
784
+ cot.event.detail.mission.missionLayer.parentUid = { _text: feature.properties.mission.missionLayer.parentUid };
785
+ }
786
+
787
+ if (feature.properties.mission.missionLayer.type) {
788
+ cot.event.detail.mission.missionLayer.type = { _text: feature.properties.mission.missionLayer.type };
789
+ }
790
+
791
+ if (feature.properties.mission.missionLayer.uid) {
792
+ cot.event.detail.mission.missionLayer.uid = { _text: feature.properties.mission.missionLayer.uid };
793
+ }
794
+ }
795
+ }
796
+
797
+ cot.event.detail.remarks = { _attributes: { }, _text: feature.properties.remarks || '' };
798
+
799
+ if (!feature.geometry) {
800
+ throw new Err(400, null, 'Must have Geometry');
801
+ } else if (!['Point', 'Polygon', 'LineString'].includes(feature.geometry.type)) {
802
+ throw new Err(400, null, 'Unsupported Geometry Type');
803
+ }
804
+
805
+ if (feature.geometry.type === 'Point') {
806
+ cot.event.point._attributes.lon = feature.geometry.coordinates[0];
807
+ cot.event.point._attributes.lat = feature.geometry.coordinates[1];
808
+ cot.event.point._attributes.hae = feature.geometry.coordinates[2] || 0.0;
809
+
810
+
811
+ if (feature.properties['marker-color']) {
812
+ const color = new Color(feature.properties['marker-color'] || -1761607936);
813
+ color.a = feature.properties['marker-opacity'] !== undefined ? feature.properties['marker-opacity'] * 255 : 128;
814
+ cot.event.detail.color = { _attributes: { argb: color.as_32bit() } };
815
+ }
816
+ } else if (feature.geometry.type === 'Polygon' && feature.properties.type === 'u-d-c-c') {
817
+ if (!feature.properties.shape || !feature.properties.shape.ellipse) {
818
+ throw new Err(400, null, 'u-d-c-c (Circle) must define a feature.properties.shape.ellipse property')
819
+ }
820
+ cot.event.detail.shape = { ellipse: { _attributes: feature.properties.shape.ellipse } }
821
+
822
+ if (feature.properties.center) {
823
+ cot.event.point._attributes.lon = feature.properties.center[0];
824
+ cot.event.point._attributes.lat = feature.properties.center[1];
825
+ } else {
826
+ const centre = PointOnFeature(feature as AllGeoJSON);
827
+ cot.event.point._attributes.lon = centre.geometry.coordinates[0];
828
+ cot.event.point._attributes.lat = centre.geometry.coordinates[1];
829
+ cot.event.point._attributes.hae = 0.0;
830
+ }
831
+ } else if (['Polygon', 'LineString'].includes(feature.geometry.type)) {
832
+ const stroke = new Color(feature.properties.stroke || -1761607936);
833
+ stroke.a = feature.properties['stroke-opacity'] !== undefined ? feature.properties['stroke-opacity'] * 255 : 128;
834
+ cot.event.detail.strokeColor = { _attributes: { value: stroke.as_32bit() } };
835
+
836
+ if (!feature.properties['stroke-width']) feature.properties['stroke-width'] = 3;
837
+ cot.event.detail.strokeWeight = { _attributes: {
838
+ value: feature.properties['stroke-width']
839
+ } };
840
+
841
+ if (!feature.properties['stroke-style']) feature.properties['stroke-style'] = 'solid';
842
+ cot.event.detail.strokeStyle = { _attributes: {
843
+ value: feature.properties['stroke-style']
844
+ } };
845
+
846
+ if (feature.geometry.type === 'LineString' && feature.properties.type === 'b-m-r') {
847
+ cot.event._attributes.type = 'b-m-r';
848
+
849
+ if (!cot.event.detail.link) {
850
+ cot.event.detail.link = [];
851
+ } else if (!Array.isArray(cot.event.detail.link)) {
852
+ cot.event.detail.link = [cot.event.detail.link]
853
+ }
854
+
855
+ cot.event.detail.__routeinfo = {
856
+ __navcues: {
857
+ __navcue: []
858
+ }
859
+ }
860
+
861
+ for (const coord of feature.geometry.coordinates) {
862
+ cot.event.detail.link.push({
863
+ _attributes: {
864
+ type: 'b-m-p-c',
865
+ uid: crypto.randomUUID(),
866
+ callsign: "",
867
+ point: `${coord[1]},${coord[0]}`
868
+ }
869
+ });
870
+ }
871
+ } else if (feature.geometry.type === 'LineString') {
872
+ cot.event._attributes.type = 'u-d-f';
873
+
874
+ if (!cot.event.detail.link) {
875
+ cot.event.detail.link = [];
876
+ } else if (!Array.isArray(cot.event.detail.link)) {
877
+ cot.event.detail.link = [cot.event.detail.link]
878
+ }
879
+
880
+ for (const coord of feature.geometry.coordinates) {
881
+ cot.event.detail.link.push({
882
+ _attributes: { point: `${coord[1]},${coord[0]}` }
883
+ });
884
+ }
885
+ } else if (feature.geometry.type === 'Polygon') {
886
+ cot.event._attributes.type = 'u-d-f';
887
+
888
+ if (!cot.event.detail.link) cot.event.detail.link = [];
889
+ else if (!Array.isArray(cot.event.detail.link)) cot.event.detail.link = [cot.event.detail.link]
890
+
891
+ // Inner rings are not yet supported
892
+ for (const coord of feature.geometry.coordinates[0]) {
893
+ cot.event.detail.link.push({
894
+ _attributes: { point: `${coord[1]},${coord[0]}` }
895
+ });
896
+ }
897
+
898
+ const fill = new Color(feature.properties.fill || -1761607936);
899
+ fill.a = feature.properties['fill-opacity'] !== undefined ? feature.properties['fill-opacity'] * 255 : 128;
900
+ cot.event.detail.fillColor = { _attributes: { value: fill.as_32bit() } };
901
+ }
902
+
903
+ cot.event.detail.labels_on = { _attributes: { value: false } };
904
+ cot.event.detail.tog = { _attributes: { enabled: '0' } };
905
+
906
+ if (feature.properties.center && Array.isArray(feature.properties.center) && feature.properties.center.length >= 2) {
907
+ cot.event.point._attributes.lon = feature.properties.center[0];
908
+ cot.event.point._attributes.lat = feature.properties.center[1];
909
+
910
+ if (feature.properties.center.length >= 3) {
911
+ cot.event.point._attributes.hae = feature.properties.center[2] || 0.0;
912
+ } else {
913
+ cot.event.point._attributes.hae = 0.0;
914
+ }
915
+ } else {
916
+ const centre = PointOnFeature(feature as AllGeoJSON);
917
+ cot.event.point._attributes.lon = centre.geometry.coordinates[0];
918
+ cot.event.point._attributes.lat = centre.geometry.coordinates[1];
919
+ cot.event.point._attributes.hae = 0.0;
920
+ }
921
+ }
922
+
923
+ const newcot = new CoT(cot, opts);
924
+
925
+ if (feature.properties.metadata) {
926
+ newcot.metadata = feature.properties.metadata
927
+ }
928
+
929
+ if (feature.path) {
930
+ newcot.path = feature.path
931
+ }
932
+
933
+ return this.validate(newcot);
934
+ }
935
+ }