@tak-ps/node-tak 12.31.1 → 12.32.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.
- package/CHANGELOG.md +5 -0
- package/dist/lib/api/query.d.ts +30 -0
- package/dist/lib/api/query.js +84 -2
- package/dist/lib/api/query.js.map +1 -1
- package/dist/test/query.test.d.ts +1 -0
- package/dist/test/query.test.js +126 -0
- package/dist/test/query.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/lib/api/query.ts +93 -2
- package/package.json +1 -1
- package/test/query.test.ts +156 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
### Pending
|
|
14
14
|
|
|
15
|
+
### v12.32.0 - 2026-10-26
|
|
16
|
+
|
|
17
|
+
- :tada: Add `Query.sa` & `Query.saFeats` for the TAK Server `/Marti/api/cot/sa` Situational Awareness query - every CoT visible to the caller within a time window given as `start`/`end` or `secago` (max 24 hours, validated client-side), optionally restricted to a bounding box
|
|
18
|
+
- :rocket: `Query.historyFeats` tolerates an empty / missing `<events>` body instead of throwing
|
|
19
|
+
|
|
15
20
|
### v12.31.1 - 2026-10-05
|
|
16
21
|
|
|
17
22
|
- :arrow_up: Update core deps
|
package/dist/lib/api/query.d.ts
CHANGED
|
@@ -6,11 +6,41 @@ export declare const HistoryOptions: import("@sinclair/typebox").TObject<{
|
|
|
6
6
|
end: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
7
7
|
secago: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
8
8
|
}>;
|
|
9
|
+
/** TAK Server refuses Situational Awareness windows longer than this */
|
|
10
|
+
export declare const SA_MAX_WINDOW_SECONDS: number;
|
|
11
|
+
/**
|
|
12
|
+
* Options for the Situational Awareness query - every CoT the caller's groups
|
|
13
|
+
* can see within a time window of at most 24 hours
|
|
14
|
+
*/
|
|
15
|
+
export declare const SituationalAwarenessOptions: import("@sinclair/typebox").TObject<{
|
|
16
|
+
start: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
17
|
+
end: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
18
|
+
secago: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TInteger, import("@sinclair/typebox").TString]>>;
|
|
19
|
+
bbox: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TTuple<[import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TNumber]>>;
|
|
20
|
+
filtered: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
21
|
+
}>;
|
|
22
|
+
/** The explicit window TAK Server is queried with once the options are resolved */
|
|
23
|
+
export declare const SituationalAwarenessWindow: import("@sinclair/typebox").TObject<{
|
|
24
|
+
start: import("@sinclair/typebox").TString;
|
|
25
|
+
end: import("@sinclair/typebox").TString;
|
|
26
|
+
}>;
|
|
9
27
|
export default class QueryCommands extends Commands {
|
|
10
28
|
schema: {};
|
|
11
29
|
cli(): Promise<object | string>;
|
|
12
30
|
singleFeat(uid: string): Promise<Static<typeof Feature.Feature>>;
|
|
13
31
|
single(uid: string): Promise<string>;
|
|
14
32
|
historyFeats(uid: string, opts?: Static<typeof HistoryOptions>): Promise<Array<Static<typeof Feature.Feature>>>;
|
|
33
|
+
/**
|
|
34
|
+
* Parse an `<events>` document into GeoJSON Features - an empty body
|
|
35
|
+
* (TAK Server answers 404 with no body when nothing matched) yields []
|
|
36
|
+
*/
|
|
37
|
+
private parseEvents;
|
|
38
|
+
saFeats(opts?: Static<typeof SituationalAwarenessOptions>): Promise<Array<Static<typeof Feature.Feature>>>;
|
|
39
|
+
/**
|
|
40
|
+
* Raw XML of every CoT visible to the caller within the time window.
|
|
41
|
+
* TAK Server requires an explicit start & end and refuses inverted or
|
|
42
|
+
* >24 hour windows, so those are resolved and rejected here
|
|
43
|
+
*/
|
|
44
|
+
sa(opts?: Static<typeof SituationalAwarenessOptions>): Promise<string>;
|
|
15
45
|
history(uid: string, opts?: Static<typeof HistoryOptions>): Promise<string>;
|
|
16
46
|
}
|
package/dist/lib/api/query.js
CHANGED
|
@@ -8,6 +8,30 @@ export const HistoryOptions = Type.Object({
|
|
|
8
8
|
end: Type.Optional(Type.String()),
|
|
9
9
|
secago: Type.Optional(Type.String()),
|
|
10
10
|
});
|
|
11
|
+
/** TAK Server refuses Situational Awareness windows longer than this */
|
|
12
|
+
export const SA_MAX_WINDOW_SECONDS = 24 * 60 * 60;
|
|
13
|
+
/**
|
|
14
|
+
* Options for the Situational Awareness query - every CoT the caller's groups
|
|
15
|
+
* can see within a time window of at most 24 hours
|
|
16
|
+
*/
|
|
17
|
+
export const SituationalAwarenessOptions = Type.Object({
|
|
18
|
+
start: Type.Optional(Type.String({ description: 'ISO 8601 start of the window (inclusive). Defaults to `end` minus `secago`' })),
|
|
19
|
+
end: Type.Optional(Type.String({ description: 'ISO 8601 end of the window (inclusive). Defaults to now' })),
|
|
20
|
+
secago: Type.Optional(Type.Union([Type.Integer(), Type.String()], {
|
|
21
|
+
description: 'Window length in seconds counted back from `end`. Default is 3600 when `start` is omitted'
|
|
22
|
+
})),
|
|
23
|
+
bbox: Type.Optional(Type.Tuple([Type.Number(), Type.Number(), Type.Number(), Type.Number()], {
|
|
24
|
+
description: 'Restrict to events within [minLon, minLat, maxLon, maxLat]'
|
|
25
|
+
})),
|
|
26
|
+
filtered: Type.Optional(Type.Boolean({
|
|
27
|
+
description: 'TAK Server default (true) only returns a-f, a-n, a-u, a-h & b-m-r events plus chat; false returns every stored event'
|
|
28
|
+
})),
|
|
29
|
+
});
|
|
30
|
+
/** The explicit window TAK Server is queried with once the options are resolved */
|
|
31
|
+
export const SituationalAwarenessWindow = Type.Object({
|
|
32
|
+
start: Type.String({ description: 'ISO 8601 start of the window (inclusive)' }),
|
|
33
|
+
end: Type.String({ description: 'ISO 8601 end of the window (inclusive)' }),
|
|
34
|
+
});
|
|
11
35
|
export default class QueryCommands extends Commands {
|
|
12
36
|
schema = {};
|
|
13
37
|
async cli() {
|
|
@@ -34,9 +58,18 @@ export default class QueryCommands extends Commands {
|
|
|
34
58
|
return body;
|
|
35
59
|
}
|
|
36
60
|
async historyFeats(uid, opts) {
|
|
61
|
+
return this.parseEvents(await this.history(uid, opts));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Parse an `<events>` document into GeoJSON Features - an empty body
|
|
65
|
+
* (TAK Server answers 404 with no body when nothing matched) yields []
|
|
66
|
+
*/
|
|
67
|
+
async parseEvents(xml) {
|
|
37
68
|
const feats = [];
|
|
38
|
-
|
|
39
|
-
|
|
69
|
+
if (!xml || !xml.trim().length)
|
|
70
|
+
return feats;
|
|
71
|
+
const res = xml2js(xml, { compact: true });
|
|
72
|
+
if (!Object.keys(res).length || !res.events || !Object.keys(res.events).length)
|
|
40
73
|
return feats;
|
|
41
74
|
if (!res.events.event || (Array.isArray(res.events.event) && !res.events.event.length))
|
|
42
75
|
return feats;
|
|
@@ -45,6 +78,55 @@ export default class QueryCommands extends Commands {
|
|
|
45
78
|
}
|
|
46
79
|
return feats;
|
|
47
80
|
}
|
|
81
|
+
async saFeats(opts = {}) {
|
|
82
|
+
return this.parseEvents(await this.sa(opts));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Raw XML of every CoT visible to the caller within the time window.
|
|
86
|
+
* TAK Server requires an explicit start & end and refuses inverted or
|
|
87
|
+
* >24 hour windows, so those are resolved and rejected here
|
|
88
|
+
*/
|
|
89
|
+
async sa(opts = {}) {
|
|
90
|
+
const url = new URL('/Marti/api/cot/sa', this.api.url);
|
|
91
|
+
const end = opts.end ? new Date(opts.end) : new Date();
|
|
92
|
+
if (Number.isNaN(end.getTime()))
|
|
93
|
+
throw new Err(400, null, 'end must be an ISO 8601 date');
|
|
94
|
+
let start;
|
|
95
|
+
if (opts.start) {
|
|
96
|
+
start = new Date(opts.start);
|
|
97
|
+
if (Number.isNaN(start.getTime()))
|
|
98
|
+
throw new Err(400, null, 'start must be an ISO 8601 date');
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const secago = opts.secago === undefined ? 3600 : Number(opts.secago);
|
|
102
|
+
if (!Number.isInteger(secago) || secago < 1)
|
|
103
|
+
throw new Err(400, null, 'secago must be a positive integer');
|
|
104
|
+
start = new Date(end.getTime() - secago * 1000);
|
|
105
|
+
}
|
|
106
|
+
if (start.getTime() > end.getTime())
|
|
107
|
+
throw new Err(400, null, 'start must be before end');
|
|
108
|
+
if (end.getTime() - start.getTime() > SA_MAX_WINDOW_SECONDS * 1000)
|
|
109
|
+
throw new Err(400, null, 'Window must be 24 hours or less');
|
|
110
|
+
const window = {
|
|
111
|
+
start: start.toISOString(),
|
|
112
|
+
end: end.toISOString(),
|
|
113
|
+
};
|
|
114
|
+
url.searchParams.append('start', window.start);
|
|
115
|
+
url.searchParams.append('end', window.end);
|
|
116
|
+
if (opts.bbox) {
|
|
117
|
+
url.searchParams.append('left', String(opts.bbox[0]));
|
|
118
|
+
url.searchParams.append('bottom', String(opts.bbox[1]));
|
|
119
|
+
url.searchParams.append('right', String(opts.bbox[2]));
|
|
120
|
+
url.searchParams.append('top', String(opts.bbox[3]));
|
|
121
|
+
}
|
|
122
|
+
if (opts.filtered !== undefined) {
|
|
123
|
+
url.searchParams.append('isFiltered', String(opts.filtered));
|
|
124
|
+
}
|
|
125
|
+
const res = await this.api.fetch(url, {
|
|
126
|
+
method: 'GET'
|
|
127
|
+
}, true);
|
|
128
|
+
return await res.text();
|
|
129
|
+
}
|
|
48
130
|
async history(uid, opts) {
|
|
49
131
|
const url = new URL(`/Marti/api/cot/xml/${encodeURIComponent(uid)}/all`, this.api.url);
|
|
50
132
|
if (opts) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query.js","sourceRoot":"","sources":["../../../lib/api/query.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,4BAA4B,CAAC;AAC7C,OAAO,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AACxC,OAAO,EAAE,IAAI,EAAU,MAAM,mBAAmB,CAAC;AACjD,OAAO,GAAG,EAAE,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAEtC,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,CAAC,MAAM,CAAC;IACtC,KAAK,EAAE,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;IACnC,GAAG,EAAE,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;IACjC,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;CACvC,CAAC,CAAA;AAEF,MAAM,CAAC,OAAO,OAAO,aAAc,SAAQ,QAAQ;IAC/C,MAAM,GAAG,EAAE,CAAA;IAEX,KAAK,CAAC,GAAG;QACL,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAC;IAC9C,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,GAAW;QACxB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QAEtC,IAAI,CAAC;YACD,OAAO,SAAS,CAAC,UAAU,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAA;QAC3D,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACX,MAAM,IAAI,GAAG,CAAC,GAAG,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,oDAAoD,CAAC,CAAC;QAClI,CAAC;IACL,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACpB,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,sBAAsB,kBAAkB,CAAC,GAAG,CAAC,EAAE,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAEnF,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE;YAClC,MAAM,EAAE,KAAK;SAChB,EAAE,IAAI,CAAC,CAAC;QAET,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAE9B,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC3B,MAAM,IAAI,GAAG,CAAC,GAAG,EAAE,IAAI,EAAE,2BAA2B,CAAC,CAAC;QAC1D,CAAC;QAED,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,GAAW,EAAE,IAAoC;QAChE,MAAM,KAAK,GAAqC,EAAE,CAAC;QAEnD,
|
|
1
|
+
{"version":3,"file":"query.js","sourceRoot":"","sources":["../../../lib/api/query.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { Client } from 'undici';
|
|
5
|
+
import TAKAPI from '../lib/api.js';
|
|
6
|
+
import { APIAuthCertificate } from '../lib/auth.js';
|
|
7
|
+
function mockResponse(statusCode, contentType, body) {
|
|
8
|
+
return {
|
|
9
|
+
statusCode,
|
|
10
|
+
headers: { 'content-type': contentType },
|
|
11
|
+
body: Readable.from([Buffer.from(body)]),
|
|
12
|
+
trailers: {}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const EVENTS = `<?xml version="1.0" encoding="UTF-8"?>
|
|
16
|
+
<events>
|
|
17
|
+
<event version="2.0" uid="sensor-1" type="a-f-G" time="2026-10-26T10:00:00.000Z" start="2026-10-26T10:00:00.000Z" stale="2026-10-26T10:05:00.000Z" how="m-g">
|
|
18
|
+
<point lat="39.7" lon="-104.9" hae="1600.0" ce="10.0" le="10.0"/>
|
|
19
|
+
<detail><contact callsign="SEE_1"/></detail>
|
|
20
|
+
</event>
|
|
21
|
+
<event version="2.0" uid="sensor-2" type="a-f-G" time="2026-10-26T10:01:00.000Z" start="2026-10-26T10:01:00.000Z" stale="2026-10-26T10:06:00.000Z" how="m-g">
|
|
22
|
+
<point lat="39.8" lon="-104.8" hae="1600.0" ce="10.0" le="10.0"/>
|
|
23
|
+
<detail><contact callsign="SEE_2"/></detail>
|
|
24
|
+
</event>
|
|
25
|
+
</events>`;
|
|
26
|
+
test('Query.saFeats builds the cot/sa request and parses every event', async () => {
|
|
27
|
+
const originalRequest = Client.prototype.request;
|
|
28
|
+
let captured;
|
|
29
|
+
Client.prototype.request = async function (opts) {
|
|
30
|
+
captured = opts;
|
|
31
|
+
return mockResponse(200, 'application/xml', EVENTS);
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
|
|
35
|
+
const feats = await api.Query.saFeats({
|
|
36
|
+
start: '2026-10-26T09:00:00.000Z',
|
|
37
|
+
end: '2026-10-26T11:00:00.000Z',
|
|
38
|
+
bbox: [-105, 39, -104, 40],
|
|
39
|
+
filtered: false
|
|
40
|
+
});
|
|
41
|
+
assert.ok(captured);
|
|
42
|
+
assert.equal(captured.method, 'GET');
|
|
43
|
+
assert.equal(captured.path, '/Marti/api/cot/sa?start=2026-10-26T09%3A00%3A00.000Z&end=2026-10-26T11%3A00%3A00.000Z&left=-105&bottom=39&right=-104&top=40&isFiltered=false');
|
|
44
|
+
assert.equal(feats.length, 2);
|
|
45
|
+
assert.equal(feats[0].id, 'sensor-1');
|
|
46
|
+
assert.equal(feats[0].properties.callsign, 'SEE_1');
|
|
47
|
+
assert.equal(feats[0].properties.time, '2026-10-26T10:00:00.000Z');
|
|
48
|
+
assert.deepEqual(feats[0].geometry, { type: 'Point', coordinates: [-104.9, 39.7, 1600] });
|
|
49
|
+
assert.equal(feats[1].id, 'sensor-2');
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
Client.prototype.request = originalRequest;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
test('Query.sa resolves secago to an explicit window and rejects what TAK Server would refuse', async () => {
|
|
56
|
+
const originalRequest = Client.prototype.request;
|
|
57
|
+
let captured;
|
|
58
|
+
Client.prototype.request = async function (opts) {
|
|
59
|
+
captured = opts;
|
|
60
|
+
return mockResponse(404, 'application/xml', '');
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
|
|
64
|
+
await api.Query.sa({ end: '2026-10-26T12:00:00.000Z', secago: '7200' });
|
|
65
|
+
assert.equal(captured?.path, '/Marti/api/cot/sa?start=2026-10-26T10%3A00%3A00.000Z&end=2026-10-26T12%3A00%3A00.000Z');
|
|
66
|
+
const before = Date.now();
|
|
67
|
+
await api.Query.sa();
|
|
68
|
+
const params = new URL(String(captured?.path), 'https://tak.example.com').searchParams;
|
|
69
|
+
const start = Date.parse(String(params.get('start')));
|
|
70
|
+
const end = Date.parse(String(params.get('end')));
|
|
71
|
+
assert.equal(end - start, 3600 * 1000);
|
|
72
|
+
assert.ok(end >= before && end <= Date.now());
|
|
73
|
+
await assert.rejects(api.Query.sa({ start: '2026-10-26T13:00:00.000Z', end: '2026-10-26T12:00:00.000Z' }), /start must be before end/);
|
|
74
|
+
await assert.rejects(api.Query.sa({ start: '2026-10-25T11:59:59.000Z', end: '2026-10-26T12:00:00.000Z' }), /24 hours or less/);
|
|
75
|
+
await assert.rejects(api.Query.sa({ start: 'yesterday' }), /ISO 8601/);
|
|
76
|
+
await assert.rejects(api.Query.sa({ secago: 0 }), /positive integer/);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
Client.prototype.request = originalRequest;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
test('Query.saFeats omits optional params and returns [] for an empty 404 body', async () => {
|
|
83
|
+
const originalRequest = Client.prototype.request;
|
|
84
|
+
let captured;
|
|
85
|
+
Client.prototype.request = async function (opts) {
|
|
86
|
+
captured = opts;
|
|
87
|
+
return mockResponse(404, 'application/xml', '');
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
|
|
91
|
+
const feats = await api.Query.saFeats({
|
|
92
|
+
start: '2026-10-26T09:00:00.000Z',
|
|
93
|
+
end: '2026-10-26T11:00:00.000Z',
|
|
94
|
+
});
|
|
95
|
+
assert.ok(captured);
|
|
96
|
+
assert.equal(captured.path, '/Marti/api/cot/sa?start=2026-10-26T09%3A00%3A00.000Z&end=2026-10-26T11%3A00%3A00.000Z');
|
|
97
|
+
assert.deepEqual(feats, []);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
Client.prototype.request = originalRequest;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
test('Query.historyFeats returns [] for an empty body and parses a single event', async () => {
|
|
104
|
+
const originalRequest = Client.prototype.request;
|
|
105
|
+
let body = '';
|
|
106
|
+
Client.prototype.request = async function () {
|
|
107
|
+
return mockResponse(200, 'application/xml', body);
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
|
|
111
|
+
assert.deepEqual(await api.Query.historyFeats('sensor-1', { secago: '3600' }), []);
|
|
112
|
+
body = `<events>
|
|
113
|
+
<event version="2.0" uid="sensor-1" type="a-f-G" time="2026-10-26T10:00:00.000Z" start="2026-10-26T10:00:00.000Z" stale="2026-10-26T10:05:00.000Z" how="m-g">
|
|
114
|
+
<point lat="39.7" lon="-104.9" hae="1600.0" ce="10.0" le="10.0"/>
|
|
115
|
+
<detail><contact callsign="SEE_1"/></detail>
|
|
116
|
+
</event>
|
|
117
|
+
</events>`;
|
|
118
|
+
const feats = await api.Query.historyFeats('sensor-1', { secago: '3600' });
|
|
119
|
+
assert.equal(feats.length, 1);
|
|
120
|
+
assert.equal(feats[0].id, 'sensor-1');
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
Client.prototype.request = originalRequest;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
//# sourceMappingURL=query.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.test.js","sourceRoot":"","sources":["../../test/query.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["../cli.ts","../index.ts","../test/certificate.test.ts","../test/default.test.ts","../test/findCoT.test.ts","../test/html-error.test.ts","../test/mission-layer.test.ts","../test/mission-property.test.ts","../test/mission.test.ts","../test/pipeline.test.ts","../test/queue.test.ts","../test/uploadPackage.test.ts","../test/user-management.test.ts","../lib/api.ts","../lib/auth.ts","../lib/commands.ts","../lib/fetch.ts","../lib/multipart.ts","../lib/stream.ts","../lib/api/certificate.ts","../lib/api/client.ts","../lib/api/contacts.ts","../lib/api/credentials.ts","../lib/api/export.ts","../lib/api/files.ts","../lib/api/groups.ts","../lib/api/iconsets.ts","../lib/api/injectors.ts","../lib/api/locate.ts","../lib/api/mission-invite.ts","../lib/api/mission-layer.ts","../lib/api/mission-log.ts","../lib/api/mission-property.ts","../lib/api/mission.ts","../lib/api/oauth.ts","../lib/api/package.ts","../lib/api/profile.ts","../lib/api/query.ts","../lib/api/repeater.ts","../lib/api/security.ts","../lib/api/subscriptions.ts","../lib/api/types.ts","../lib/api/user-management.ts","../lib/api/video.ts","../lib/utils/html-error.ts","../lib/utils/queue.ts"],"version":"6.0.3"}
|
|
1
|
+
{"root":["../cli.ts","../index.ts","../test/certificate.test.ts","../test/default.test.ts","../test/findCoT.test.ts","../test/html-error.test.ts","../test/mission-layer.test.ts","../test/mission-property.test.ts","../test/mission.test.ts","../test/pipeline.test.ts","../test/query.test.ts","../test/queue.test.ts","../test/uploadPackage.test.ts","../test/user-management.test.ts","../lib/api.ts","../lib/auth.ts","../lib/commands.ts","../lib/fetch.ts","../lib/multipart.ts","../lib/stream.ts","../lib/api/certificate.ts","../lib/api/client.ts","../lib/api/contacts.ts","../lib/api/credentials.ts","../lib/api/export.ts","../lib/api/files.ts","../lib/api/groups.ts","../lib/api/iconsets.ts","../lib/api/injectors.ts","../lib/api/locate.ts","../lib/api/mission-invite.ts","../lib/api/mission-layer.ts","../lib/api/mission-log.ts","../lib/api/mission-property.ts","../lib/api/mission.ts","../lib/api/oauth.ts","../lib/api/package.ts","../lib/api/profile.ts","../lib/api/query.ts","../lib/api/repeater.ts","../lib/api/security.ts","../lib/api/subscriptions.ts","../lib/api/types.ts","../lib/api/user-management.ts","../lib/api/video.ts","../lib/utils/html-error.ts","../lib/utils/queue.ts"],"version":"6.0.3"}
|
package/lib/api/query.ts
CHANGED
|
@@ -11,6 +11,33 @@ export const HistoryOptions = Type.Object({
|
|
|
11
11
|
secago: Type.Optional(Type.String()),
|
|
12
12
|
})
|
|
13
13
|
|
|
14
|
+
/** TAK Server refuses Situational Awareness windows longer than this */
|
|
15
|
+
export const SA_MAX_WINDOW_SECONDS = 24 * 60 * 60;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options for the Situational Awareness query - every CoT the caller's groups
|
|
19
|
+
* can see within a time window of at most 24 hours
|
|
20
|
+
*/
|
|
21
|
+
export const SituationalAwarenessOptions = Type.Object({
|
|
22
|
+
start: Type.Optional(Type.String({ description: 'ISO 8601 start of the window (inclusive). Defaults to `end` minus `secago`' })),
|
|
23
|
+
end: Type.Optional(Type.String({ description: 'ISO 8601 end of the window (inclusive). Defaults to now' })),
|
|
24
|
+
secago: Type.Optional(Type.Union([Type.Integer(), Type.String()], {
|
|
25
|
+
description: 'Window length in seconds counted back from `end`. Default is 3600 when `start` is omitted'
|
|
26
|
+
})),
|
|
27
|
+
bbox: Type.Optional(Type.Tuple([Type.Number(), Type.Number(), Type.Number(), Type.Number()], {
|
|
28
|
+
description: 'Restrict to events within [minLon, minLat, maxLon, maxLat]'
|
|
29
|
+
})),
|
|
30
|
+
filtered: Type.Optional(Type.Boolean({
|
|
31
|
+
description: 'TAK Server default (true) only returns a-f, a-n, a-u, a-h & b-m-r events plus chat; false returns every stored event'
|
|
32
|
+
})),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/** The explicit window TAK Server is queried with once the options are resolved */
|
|
36
|
+
export const SituationalAwarenessWindow = Type.Object({
|
|
37
|
+
start: Type.String({ description: 'ISO 8601 start of the window (inclusive)' }),
|
|
38
|
+
end: Type.String({ description: 'ISO 8601 end of the window (inclusive)' }),
|
|
39
|
+
})
|
|
40
|
+
|
|
14
41
|
export default class QueryCommands extends Commands {
|
|
15
42
|
schema = {}
|
|
16
43
|
|
|
@@ -45,11 +72,21 @@ export default class QueryCommands extends Commands {
|
|
|
45
72
|
}
|
|
46
73
|
|
|
47
74
|
async historyFeats(uid: string, opts?: Static<typeof HistoryOptions>): Promise<Array<Static<typeof Feature.Feature>>> {
|
|
75
|
+
return this.parseEvents(await this.history(uid, opts));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse an `<events>` document into GeoJSON Features - an empty body
|
|
80
|
+
* (TAK Server answers 404 with no body when nothing matched) yields []
|
|
81
|
+
*/
|
|
82
|
+
private async parseEvents(xml: string): Promise<Array<Static<typeof Feature.Feature>>> {
|
|
48
83
|
const feats: Static<typeof Feature.Feature>[] = [];
|
|
49
84
|
|
|
50
|
-
|
|
85
|
+
if (!xml || !xml.trim().length) return feats;
|
|
51
86
|
|
|
52
|
-
|
|
87
|
+
const res: any = xml2js(xml, { compact: true });
|
|
88
|
+
|
|
89
|
+
if (!Object.keys(res).length || !res.events || !Object.keys(res.events).length) return feats;
|
|
53
90
|
if (!res.events.event || (Array.isArray(res.events.event) && !res.events.event.length)) return feats;
|
|
54
91
|
|
|
55
92
|
for (const event of Array.isArray(res.events.event) ? res.events.event : [res.events.event] ) {
|
|
@@ -59,6 +96,60 @@ export default class QueryCommands extends Commands {
|
|
|
59
96
|
return feats;
|
|
60
97
|
}
|
|
61
98
|
|
|
99
|
+
async saFeats(opts: Static<typeof SituationalAwarenessOptions> = {}): Promise<Array<Static<typeof Feature.Feature>>> {
|
|
100
|
+
return this.parseEvents(await this.sa(opts));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Raw XML of every CoT visible to the caller within the time window.
|
|
105
|
+
* TAK Server requires an explicit start & end and refuses inverted or
|
|
106
|
+
* >24 hour windows, so those are resolved and rejected here
|
|
107
|
+
*/
|
|
108
|
+
async sa(opts: Static<typeof SituationalAwarenessOptions> = {}): Promise<string> {
|
|
109
|
+
const url = new URL('/Marti/api/cot/sa', this.api.url);
|
|
110
|
+
|
|
111
|
+
const end = opts.end ? new Date(opts.end) : new Date();
|
|
112
|
+
if (Number.isNaN(end.getTime())) throw new Err(400, null, 'end must be an ISO 8601 date');
|
|
113
|
+
|
|
114
|
+
let start: Date;
|
|
115
|
+
if (opts.start) {
|
|
116
|
+
start = new Date(opts.start);
|
|
117
|
+
if (Number.isNaN(start.getTime())) throw new Err(400, null, 'start must be an ISO 8601 date');
|
|
118
|
+
} else {
|
|
119
|
+
const secago = opts.secago === undefined ? 3600 : Number(opts.secago);
|
|
120
|
+
if (!Number.isInteger(secago) || secago < 1) throw new Err(400, null, 'secago must be a positive integer');
|
|
121
|
+
start = new Date(end.getTime() - secago * 1000);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (start.getTime() > end.getTime()) throw new Err(400, null, 'start must be before end');
|
|
125
|
+
if (end.getTime() - start.getTime() > SA_MAX_WINDOW_SECONDS * 1000) throw new Err(400, null, 'Window must be 24 hours or less');
|
|
126
|
+
|
|
127
|
+
const window: Static<typeof SituationalAwarenessWindow> = {
|
|
128
|
+
start: start.toISOString(),
|
|
129
|
+
end: end.toISOString(),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
url.searchParams.append('start', window.start);
|
|
133
|
+
url.searchParams.append('end', window.end);
|
|
134
|
+
|
|
135
|
+
if (opts.bbox) {
|
|
136
|
+
url.searchParams.append('left', String(opts.bbox[0]));
|
|
137
|
+
url.searchParams.append('bottom', String(opts.bbox[1]));
|
|
138
|
+
url.searchParams.append('right', String(opts.bbox[2]));
|
|
139
|
+
url.searchParams.append('top', String(opts.bbox[3]));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (opts.filtered !== undefined) {
|
|
143
|
+
url.searchParams.append('isFiltered', String(opts.filtered));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const res = await this.api.fetch(url, {
|
|
147
|
+
method: 'GET'
|
|
148
|
+
}, true);
|
|
149
|
+
|
|
150
|
+
return await res.text();
|
|
151
|
+
}
|
|
152
|
+
|
|
62
153
|
async history(uid: string, opts?: Static<typeof HistoryOptions>): Promise<string> {
|
|
63
154
|
const url = new URL(`/Marti/api/cot/xml/${encodeURIComponent(uid)}/all`, this.api.url);
|
|
64
155
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { Client } from 'undici';
|
|
5
|
+
import type { Dispatcher } from 'undici';
|
|
6
|
+
import TAKAPI from '../lib/api.js';
|
|
7
|
+
import { APIAuthCertificate } from '../lib/auth.js';
|
|
8
|
+
|
|
9
|
+
type RequestArgs = Parameters<Client['request']>[0];
|
|
10
|
+
|
|
11
|
+
function mockResponse(statusCode: number, contentType: string, body: string): Dispatcher.ResponseData {
|
|
12
|
+
return {
|
|
13
|
+
statusCode,
|
|
14
|
+
headers: { 'content-type': contentType },
|
|
15
|
+
body: Readable.from([Buffer.from(body)]),
|
|
16
|
+
trailers: {}
|
|
17
|
+
} as unknown as Dispatcher.ResponseData;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const EVENTS = `<?xml version="1.0" encoding="UTF-8"?>
|
|
21
|
+
<events>
|
|
22
|
+
<event version="2.0" uid="sensor-1" type="a-f-G" time="2026-10-26T10:00:00.000Z" start="2026-10-26T10:00:00.000Z" stale="2026-10-26T10:05:00.000Z" how="m-g">
|
|
23
|
+
<point lat="39.7" lon="-104.9" hae="1600.0" ce="10.0" le="10.0"/>
|
|
24
|
+
<detail><contact callsign="SEE_1"/></detail>
|
|
25
|
+
</event>
|
|
26
|
+
<event version="2.0" uid="sensor-2" type="a-f-G" time="2026-10-26T10:01:00.000Z" start="2026-10-26T10:01:00.000Z" stale="2026-10-26T10:06:00.000Z" how="m-g">
|
|
27
|
+
<point lat="39.8" lon="-104.8" hae="1600.0" ce="10.0" le="10.0"/>
|
|
28
|
+
<detail><contact callsign="SEE_2"/></detail>
|
|
29
|
+
</event>
|
|
30
|
+
</events>`;
|
|
31
|
+
|
|
32
|
+
test('Query.saFeats builds the cot/sa request and parses every event', async () => {
|
|
33
|
+
const originalRequest = Client.prototype.request;
|
|
34
|
+
|
|
35
|
+
let captured: RequestArgs | undefined;
|
|
36
|
+
|
|
37
|
+
Client.prototype.request = async function(opts: RequestArgs): Promise<Dispatcher.ResponseData> {
|
|
38
|
+
captured = opts;
|
|
39
|
+
return mockResponse(200, 'application/xml', EVENTS);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
|
|
44
|
+
|
|
45
|
+
const feats = await api.Query.saFeats({
|
|
46
|
+
start: '2026-10-26T09:00:00.000Z',
|
|
47
|
+
end: '2026-10-26T11:00:00.000Z',
|
|
48
|
+
bbox: [-105, 39, -104, 40],
|
|
49
|
+
filtered: false
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
assert.ok(captured);
|
|
53
|
+
assert.equal(captured.method, 'GET');
|
|
54
|
+
assert.equal(
|
|
55
|
+
captured.path,
|
|
56
|
+
'/Marti/api/cot/sa?start=2026-10-26T09%3A00%3A00.000Z&end=2026-10-26T11%3A00%3A00.000Z&left=-105&bottom=39&right=-104&top=40&isFiltered=false'
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
assert.equal(feats.length, 2);
|
|
60
|
+
assert.equal(feats[0].id, 'sensor-1');
|
|
61
|
+
assert.equal(feats[0].properties.callsign, 'SEE_1');
|
|
62
|
+
assert.equal(feats[0].properties.time, '2026-10-26T10:00:00.000Z');
|
|
63
|
+
assert.deepEqual(feats[0].geometry, { type: 'Point', coordinates: [-104.9, 39.7, 1600] });
|
|
64
|
+
assert.equal(feats[1].id, 'sensor-2');
|
|
65
|
+
} finally {
|
|
66
|
+
Client.prototype.request = originalRequest;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('Query.sa resolves secago to an explicit window and rejects what TAK Server would refuse', async () => {
|
|
71
|
+
const originalRequest = Client.prototype.request;
|
|
72
|
+
|
|
73
|
+
let captured: RequestArgs | undefined;
|
|
74
|
+
|
|
75
|
+
Client.prototype.request = async function(opts: RequestArgs): Promise<Dispatcher.ResponseData> {
|
|
76
|
+
captured = opts;
|
|
77
|
+
return mockResponse(404, 'application/xml', '');
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
|
|
82
|
+
|
|
83
|
+
await api.Query.sa({ end: '2026-10-26T12:00:00.000Z', secago: '7200' });
|
|
84
|
+
assert.equal(captured?.path, '/Marti/api/cot/sa?start=2026-10-26T10%3A00%3A00.000Z&end=2026-10-26T12%3A00%3A00.000Z');
|
|
85
|
+
|
|
86
|
+
const before = Date.now();
|
|
87
|
+
await api.Query.sa();
|
|
88
|
+
const params = new URL(String(captured?.path), 'https://tak.example.com').searchParams;
|
|
89
|
+
const start = Date.parse(String(params.get('start')));
|
|
90
|
+
const end = Date.parse(String(params.get('end')));
|
|
91
|
+
assert.equal(end - start, 3600 * 1000);
|
|
92
|
+
assert.ok(end >= before && end <= Date.now());
|
|
93
|
+
|
|
94
|
+
await assert.rejects(api.Query.sa({ start: '2026-10-26T13:00:00.000Z', end: '2026-10-26T12:00:00.000Z' }), /start must be before end/);
|
|
95
|
+
await assert.rejects(api.Query.sa({ start: '2026-10-25T11:59:59.000Z', end: '2026-10-26T12:00:00.000Z' }), /24 hours or less/);
|
|
96
|
+
await assert.rejects(api.Query.sa({ start: 'yesterday' }), /ISO 8601/);
|
|
97
|
+
await assert.rejects(api.Query.sa({ secago: 0 }), /positive integer/);
|
|
98
|
+
} finally {
|
|
99
|
+
Client.prototype.request = originalRequest;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('Query.saFeats omits optional params and returns [] for an empty 404 body', async () => {
|
|
104
|
+
const originalRequest = Client.prototype.request;
|
|
105
|
+
|
|
106
|
+
let captured: RequestArgs | undefined;
|
|
107
|
+
|
|
108
|
+
Client.prototype.request = async function(opts: RequestArgs): Promise<Dispatcher.ResponseData> {
|
|
109
|
+
captured = opts;
|
|
110
|
+
return mockResponse(404, 'application/xml', '');
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
|
|
115
|
+
|
|
116
|
+
const feats = await api.Query.saFeats({
|
|
117
|
+
start: '2026-10-26T09:00:00.000Z',
|
|
118
|
+
end: '2026-10-26T11:00:00.000Z',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
assert.ok(captured);
|
|
122
|
+
assert.equal(captured.path, '/Marti/api/cot/sa?start=2026-10-26T09%3A00%3A00.000Z&end=2026-10-26T11%3A00%3A00.000Z');
|
|
123
|
+
assert.deepEqual(feats, []);
|
|
124
|
+
} finally {
|
|
125
|
+
Client.prototype.request = originalRequest;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('Query.historyFeats returns [] for an empty body and parses a single event', async () => {
|
|
130
|
+
const originalRequest = Client.prototype.request;
|
|
131
|
+
|
|
132
|
+
let body = '';
|
|
133
|
+
|
|
134
|
+
Client.prototype.request = async function(): Promise<Dispatcher.ResponseData> {
|
|
135
|
+
return mockResponse(200, 'application/xml', body);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
|
|
140
|
+
|
|
141
|
+
assert.deepEqual(await api.Query.historyFeats('sensor-1', { secago: '3600' }), []);
|
|
142
|
+
|
|
143
|
+
body = `<events>
|
|
144
|
+
<event version="2.0" uid="sensor-1" type="a-f-G" time="2026-10-26T10:00:00.000Z" start="2026-10-26T10:00:00.000Z" stale="2026-10-26T10:05:00.000Z" how="m-g">
|
|
145
|
+
<point lat="39.7" lon="-104.9" hae="1600.0" ce="10.0" le="10.0"/>
|
|
146
|
+
<detail><contact callsign="SEE_1"/></detail>
|
|
147
|
+
</event>
|
|
148
|
+
</events>`;
|
|
149
|
+
|
|
150
|
+
const feats = await api.Query.historyFeats('sensor-1', { secago: '3600' });
|
|
151
|
+
assert.equal(feats.length, 1);
|
|
152
|
+
assert.equal(feats[0].id, 'sensor-1');
|
|
153
|
+
} finally {
|
|
154
|
+
Client.prototype.request = originalRequest;
|
|
155
|
+
}
|
|
156
|
+
});
|