camstreamerlib 4.0.16 → 4.0.17-dev-milestone.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.
@@ -107,6 +107,15 @@ export declare class PlaneTrackerAPI<Client extends IClient<TResponse, any>> ext
107
107
  protocol: "https" | "http" | "https_insecure";
108
108
  sourceKey: string;
109
109
  };
110
+ milestone: {
111
+ ip: string;
112
+ enabled: boolean;
113
+ port: number;
114
+ cameraList: string[];
115
+ pass: string;
116
+ user: string;
117
+ protocol: "https" | "http" | "https_insecure";
118
+ };
110
119
  camstreamerIntegration: {
111
120
  adPlacementEnabled: boolean;
112
121
  adMinIntervalSec: number;
@@ -0,0 +1,14 @@
1
+ import { MilestoneAgentOptions, TBookmark, TMilestoneCamera } from '../../types/MilestoneAgent';
2
+ export declare class MilestoneAgent {
3
+ private settings;
4
+ private protocol;
5
+ private sender;
6
+ private token?;
7
+ constructor(options?: MilestoneAgentOptions);
8
+ checkConnection(): Promise<void>;
9
+ getAllCameras(): Promise<TMilestoneCamera[]>;
10
+ sendBookmark(cameraId: string, bookmark: TBookmark): Promise<void>;
11
+ private getCamerasPage;
12
+ private getToken;
13
+ private getRequestOptions;
14
+ }
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MilestoneAgent = void 0;
4
+ const errors_1 = require("../../errors/errors");
5
+ const HttpRequestSender_1 = require("../HttpRequestSender");
6
+ const MilestoneAgent_1 = require("../../types/MilestoneAgent");
7
+ const CLIENT_ID = 'GrantValidatorClient';
8
+ const PAGE_SIZE = 100;
9
+ const TOKEN_EXPIRY_MARGIN_SEC = 60;
10
+ class MilestoneAgent {
11
+ settings;
12
+ protocol;
13
+ sender;
14
+ token;
15
+ constructor(options = {}) {
16
+ this.settings = {
17
+ protocol: options.protocol ?? 'https_insecure',
18
+ ip: options.ip ?? '127.0.0.1',
19
+ port: options.port ?? 443,
20
+ user: options.user ?? '',
21
+ pass: options.pass ?? '',
22
+ timeout: options.timeout ?? 10000,
23
+ };
24
+ this.protocol = this.settings.protocol === 'http' ? 'http:' : 'https:';
25
+ const tlsInsecure = this.settings.protocol === 'https_insecure';
26
+ this.sender = new HttpRequestSender_1.HttpRequestSender({ rejectUnaurhorized: !tlsInsecure });
27
+ }
28
+ async checkConnection() {
29
+ await this.getToken(true);
30
+ await this.getCamerasPage(1, 1);
31
+ }
32
+ async getAllCameras() {
33
+ const cameras = [];
34
+ let page = 1;
35
+ for (;;) {
36
+ const pageCameras = await this.getCamerasPage(page, PAGE_SIZE);
37
+ cameras.push(...pageCameras);
38
+ if (pageCameras.length < PAGE_SIZE) {
39
+ break;
40
+ }
41
+ page += 1;
42
+ }
43
+ return cameras;
44
+ }
45
+ async sendBookmark(cameraId, bookmark) {
46
+ const token = await this.getToken();
47
+ const body = JSON.stringify({
48
+ ...bookmark,
49
+ devicePath: { type: 'cameras', id: cameraId },
50
+ });
51
+ const res = await this.sender.sendRequest(this.getRequestOptions('POST', '/api/rest/v1/bookmarks', token, {
52
+ 'Content-Type': 'application/json',
53
+ }), body);
54
+ if (!res.ok) {
55
+ throw new errors_1.ErrorWithResponse(res);
56
+ }
57
+ }
58
+ async getCamerasPage(page, itemsPerPage) {
59
+ const token = await this.getToken();
60
+ const res = await this.sender.sendRequest(this.getRequestOptions('GET', `/api/rest/v1/cameras?page=${page}&itemsPerPage=${itemsPerPage}`, token));
61
+ if (!res.ok) {
62
+ throw new errors_1.ErrorWithResponse(res);
63
+ }
64
+ const responseBody = await res.text();
65
+ const result = await MilestoneAgent_1.camerasResponseSchema.safeParseAsync(JSON.parse(responseBody));
66
+ if (!result.success) {
67
+ throw new Error('Milestone get cameras failed: ' + JSON.stringify(result.error.issues) + '\n' + responseBody);
68
+ }
69
+ return result.data.array;
70
+ }
71
+ async getToken(forceRefresh = false) {
72
+ const nowSec = Date.now() / 1000;
73
+ if (!forceRefresh && this.token !== undefined && this.token.expiresAt > nowSec) {
74
+ return this.token.value;
75
+ }
76
+ const body = new URLSearchParams({
77
+ grant_type: 'password',
78
+ username: this.settings.user,
79
+ password: this.settings.pass,
80
+ client_id: CLIENT_ID,
81
+ }).toString();
82
+ const res = await this.sender.sendRequest({
83
+ method: 'POST',
84
+ protocol: this.protocol,
85
+ host: this.settings.ip,
86
+ port: this.settings.port,
87
+ path: '/idp/connect/token',
88
+ timeout: this.settings.timeout,
89
+ headers: {
90
+ 'Content-Type': 'application/x-www-form-urlencoded',
91
+ 'Accept': 'application/json',
92
+ },
93
+ }, body);
94
+ if (!res.ok) {
95
+ throw new errors_1.ErrorWithResponse(res);
96
+ }
97
+ const responseBody = await res.text();
98
+ const result = await MilestoneAgent_1.tokenResponseSchema.safeParseAsync(JSON.parse(responseBody));
99
+ if (!result.success) {
100
+ throw new Error('Milestone authorization failed: ' + JSON.stringify(result.error.issues) + '\n' + responseBody);
101
+ }
102
+ this.token = {
103
+ value: result.data.access_token,
104
+ expiresAt: nowSec + result.data.expires_in - TOKEN_EXPIRY_MARGIN_SEC,
105
+ };
106
+ return this.token.value;
107
+ }
108
+ getRequestOptions(method, path, token, extraHeaders) {
109
+ return {
110
+ method,
111
+ protocol: this.protocol,
112
+ host: this.settings.ip,
113
+ port: this.settings.port,
114
+ path,
115
+ timeout: this.settings.timeout,
116
+ headers: {
117
+ Authorization: `Bearer ${token}`,
118
+ Accept: 'application/json',
119
+ ...extraHeaders,
120
+ },
121
+ };
122
+ }
123
+ }
124
+ exports.MilestoneAgent = MilestoneAgent;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const globals_1 = require("@jest/globals");
4
+ const HttpRequestSender_1 = require("../HttpRequestSender");
5
+ const MilestoneAgent_1 = require("./MilestoneAgent");
6
+ globals_1.jest.mock('../HttpRequestSender');
7
+ function jsonResponse(body, status = 200) {
8
+ return {
9
+ ok: status >= 200 && status < 300,
10
+ status,
11
+ statusText: 'OK',
12
+ text: () => Promise.resolve(JSON.stringify(body)),
13
+ };
14
+ }
15
+ const TOKEN_BODY = { access_token: 'abc123', expires_in: 3600, token_type: 'Bearer', scope: 'managementserver' };
16
+ (0, globals_1.describe)('MilestoneAgent', () => {
17
+ const MockedSender = globals_1.jest.mocked(HttpRequestSender_1.HttpRequestSender);
18
+ let sendRequest;
19
+ (0, globals_1.beforeEach)(() => {
20
+ sendRequest = globals_1.jest.fn();
21
+ MockedSender.mockImplementation(() => ({ sendRequest }));
22
+ });
23
+ function callsTo(prefix) {
24
+ return sendRequest.mock.calls.filter((c) => c[0].path.startsWith(prefix));
25
+ }
26
+ function routeByPath(handlers) {
27
+ sendRequest.mockImplementation((...args) => {
28
+ const options = args[0];
29
+ if (options.path === '/idp/connect/token') {
30
+ return Promise.resolve(jsonResponse(TOKEN_BODY));
31
+ }
32
+ for (const [prefix, handler] of Object.entries(handlers)) {
33
+ if (options.path.startsWith(prefix)) {
34
+ if (typeof handler === 'function') {
35
+ const page = Number(new URLSearchParams(options.path.split('?')[1]).get('page'));
36
+ return Promise.resolve(handler(page));
37
+ }
38
+ return Promise.resolve(handler);
39
+ }
40
+ }
41
+ throw new Error(`Unexpected path ${options.path}`);
42
+ });
43
+ }
44
+ (0, globals_1.test)('fetches a bearer token before listing cameras and sends it in the header', async () => {
45
+ routeByPath({ '/api/rest/v1/cameras': jsonResponse({ array: [{ id: 'cam-1', displayName: 'Cam 1' }] }) });
46
+ const agent = new MilestoneAgent_1.MilestoneAgent({ ip: '1.2.3.4' });
47
+ const cameras = await agent.getAllCameras();
48
+ (0, globals_1.expect)(cameras).toEqual([{ id: 'cam-1', displayName: 'Cam 1' }]);
49
+ const tokenCalls = callsTo('/idp/connect/token');
50
+ (0, globals_1.expect)(tokenCalls.length).toBe(1);
51
+ (0, globals_1.expect)(tokenCalls[0][0].method).toBe('POST');
52
+ const camerasCall = callsTo('/api/rest/v1/cameras')[0];
53
+ (0, globals_1.expect)(camerasCall[0].headers.Authorization).toBe('Bearer abc123');
54
+ });
55
+ (0, globals_1.test)('reuses the cached token across calls', async () => {
56
+ routeByPath({ '/api/rest/v1/cameras': jsonResponse({ array: [] }) });
57
+ const agent = new MilestoneAgent_1.MilestoneAgent({ ip: '1.2.3.4' });
58
+ await agent.getAllCameras();
59
+ await agent.getAllCameras();
60
+ (0, globals_1.expect)(callsTo('/idp/connect/token').length).toBe(1);
61
+ });
62
+ (0, globals_1.test)('pages through cameras until a non-full page is returned', async () => {
63
+ const fullPage = (page) => jsonResponse({ array: Array.from({ length: 100 }, (_, i) => ({ id: `p${page}-${i}` })) });
64
+ routeByPath({
65
+ '/api/rest/v1/cameras': (page) => page < 3 ? fullPage(page) : jsonResponse({ array: [{ id: 'last' }] }),
66
+ });
67
+ const agent = new MilestoneAgent_1.MilestoneAgent({ ip: '1.2.3.4' });
68
+ const cameras = await agent.getAllCameras();
69
+ (0, globals_1.expect)(cameras.length).toBe(201);
70
+ (0, globals_1.expect)(cameras[cameras.length - 1]).toEqual({ id: 'last' });
71
+ });
72
+ (0, globals_1.test)('sends a bookmark with the camera devicePath', async () => {
73
+ routeByPath({ '/api/rest/v1/bookmarks': jsonResponse({ result: {} }, 201) });
74
+ const agent = new MilestoneAgent_1.MilestoneAgent({ ip: '1.2.3.4' });
75
+ await agent.sendBookmark('cam-guid', {
76
+ header: 'Airbus A320',
77
+ description: 'ICAO BC4AA',
78
+ timeBegin: '2026-04-16T10:25:00.000Z',
79
+ timeEnd: '2026-04-16T10:25:00.000Z',
80
+ timeTriggered: '2026-04-16T10:25:00.000Z',
81
+ reference: 'BC4AA',
82
+ });
83
+ const call = callsTo('/api/rest/v1/bookmarks')[0];
84
+ (0, globals_1.expect)(call).toBeDefined();
85
+ const body = JSON.parse(call[1]);
86
+ (0, globals_1.expect)(body.devicePath).toEqual({ type: 'cameras', id: 'cam-guid' });
87
+ (0, globals_1.expect)(body.header).toBe('Airbus A320');
88
+ (0, globals_1.expect)(body.description).toBe('ICAO BC4AA');
89
+ });
90
+ (0, globals_1.test)('throws on a non-ok bookmark response', async () => {
91
+ routeByPath({ '/api/rest/v1/bookmarks': jsonResponse({ error: 'nope' }, 500) });
92
+ const agent = new MilestoneAgent_1.MilestoneAgent({ ip: '1.2.3.4' });
93
+ await (0, globals_1.expect)(agent.sendBookmark('cam-guid', {
94
+ header: 'h',
95
+ description: 'd',
96
+ timeBegin: 't',
97
+ timeEnd: 't',
98
+ timeTriggered: 't',
99
+ reference: 'r',
100
+ })).rejects.toThrow();
101
+ });
102
+ });
@@ -6,6 +6,8 @@ export * from './events/AxisCameraStationEvents';
6
6
  export * from './TimeZoneDaemon';
7
7
  export * from './events/GenetecAgent';
8
8
  export * from '../types/GenetecAgent';
9
+ export * from './events/MilestoneAgent';
10
+ export * from '../types/MilestoneAgent';
9
11
  export { ResourceManager } from './CamOverlayPainter/ResourceManager';
10
12
  export { Painter } from './CamOverlayPainter/Painter';
11
13
  export { Frame } from './CamOverlayPainter/Frame';
package/cjs/node/index.js CHANGED
@@ -23,6 +23,8 @@ __exportStar(require("./events/AxisCameraStationEvents"), exports);
23
23
  __exportStar(require("./TimeZoneDaemon"), exports);
24
24
  __exportStar(require("./events/GenetecAgent"), exports);
25
25
  __exportStar(require("../types/GenetecAgent"), exports);
26
+ __exportStar(require("./events/MilestoneAgent"), exports);
27
+ __exportStar(require("../types/MilestoneAgent"), exports);
26
28
  var ResourceManager_1 = require("./CamOverlayPainter/ResourceManager");
27
29
  Object.defineProperty(exports, "ResourceManager", { enumerable: true, get: function () { return ResourceManager_1.ResourceManager; } });
28
30
  var Painter_1 = require("./CamOverlayPainter/Painter");
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod';
2
+ export type MilestoneAgentOptions = {
3
+ protocol?: 'http' | 'https' | 'https_insecure';
4
+ ip?: string;
5
+ port?: number;
6
+ user?: string;
7
+ pass?: string;
8
+ timeout?: number;
9
+ };
10
+ export declare const tokenResponseSchema: z.ZodObject<{
11
+ access_token: z.ZodString;
12
+ expires_in: z.ZodNumber;
13
+ token_type: z.ZodString;
14
+ }, "strip", z.ZodTypeAny, {
15
+ access_token: string;
16
+ expires_in: number;
17
+ token_type: string;
18
+ }, {
19
+ access_token: string;
20
+ expires_in: number;
21
+ token_type: string;
22
+ }>;
23
+ export type TTokenResponse = z.infer<typeof tokenResponseSchema>;
24
+ export declare const milestoneCameraSchema: z.ZodObject<{
25
+ id: z.ZodString;
26
+ name: z.ZodOptional<z.ZodString>;
27
+ displayName: z.ZodOptional<z.ZodString>;
28
+ enabled: z.ZodOptional<z.ZodBoolean>;
29
+ }, "strip", z.ZodTypeAny, {
30
+ id: string;
31
+ name?: string | undefined;
32
+ enabled?: boolean | undefined;
33
+ displayName?: string | undefined;
34
+ }, {
35
+ id: string;
36
+ name?: string | undefined;
37
+ enabled?: boolean | undefined;
38
+ displayName?: string | undefined;
39
+ }>;
40
+ export type TMilestoneCamera = z.infer<typeof milestoneCameraSchema>;
41
+ export declare const camerasResponseSchema: z.ZodObject<{
42
+ array: z.ZodArray<z.ZodObject<{
43
+ id: z.ZodString;
44
+ name: z.ZodOptional<z.ZodString>;
45
+ displayName: z.ZodOptional<z.ZodString>;
46
+ enabled: z.ZodOptional<z.ZodBoolean>;
47
+ }, "strip", z.ZodTypeAny, {
48
+ id: string;
49
+ name?: string | undefined;
50
+ enabled?: boolean | undefined;
51
+ displayName?: string | undefined;
52
+ }, {
53
+ id: string;
54
+ name?: string | undefined;
55
+ enabled?: boolean | undefined;
56
+ displayName?: string | undefined;
57
+ }>, "many">;
58
+ }, "strip", z.ZodTypeAny, {
59
+ array: {
60
+ id: string;
61
+ name?: string | undefined;
62
+ enabled?: boolean | undefined;
63
+ displayName?: string | undefined;
64
+ }[];
65
+ }, {
66
+ array: {
67
+ id: string;
68
+ name?: string | undefined;
69
+ enabled?: boolean | undefined;
70
+ displayName?: string | undefined;
71
+ }[];
72
+ }>;
73
+ export type TCamerasResponse = z.infer<typeof camerasResponseSchema>;
74
+ export type TBookmark = {
75
+ header: string;
76
+ description: string;
77
+ timeBegin: string;
78
+ timeEnd: string;
79
+ timeTriggered: string;
80
+ reference: string;
81
+ };
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.camerasResponseSchema = exports.milestoneCameraSchema = exports.tokenResponseSchema = void 0;
4
+ const zod_1 = require("zod");
5
+ exports.tokenResponseSchema = zod_1.z.object({
6
+ access_token: zod_1.z.string(),
7
+ expires_in: zod_1.z.number(),
8
+ token_type: zod_1.z.string(),
9
+ });
10
+ exports.milestoneCameraSchema = zod_1.z.object({
11
+ id: zod_1.z.string(),
12
+ name: zod_1.z.string().optional(),
13
+ displayName: zod_1.z.string().optional(),
14
+ enabled: zod_1.z.boolean().optional(),
15
+ });
16
+ exports.camerasResponseSchema = zod_1.z.object({
17
+ array: zod_1.z.array(exports.milestoneCameraSchema),
18
+ });
@@ -358,6 +358,32 @@ export declare const cameraSettingsSchema: z.ZodObject<{
358
358
  protocol: "https" | "http" | "https_insecure";
359
359
  sourceKey: string;
360
360
  }>>;
361
+ milestone: z.ZodDefault<z.ZodObject<{
362
+ protocol: z.ZodUnion<[z.ZodLiteral<"http">, z.ZodLiteral<"https">, z.ZodLiteral<"https_insecure">]>;
363
+ ip: z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>;
364
+ port: z.ZodNumber;
365
+ user: z.ZodString;
366
+ pass: z.ZodString;
367
+ } & {
368
+ enabled: z.ZodBoolean;
369
+ cameraList: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
370
+ }, "strip", z.ZodTypeAny, {
371
+ ip: string;
372
+ enabled: boolean;
373
+ port: number;
374
+ cameraList: string[];
375
+ pass: string;
376
+ user: string;
377
+ protocol: "https" | "http" | "https_insecure";
378
+ }, {
379
+ ip: string;
380
+ enabled: boolean;
381
+ port: number;
382
+ pass: string;
383
+ user: string;
384
+ protocol: "https" | "http" | "https_insecure";
385
+ cameraList?: string[] | undefined;
386
+ }>>;
361
387
  camstreamerIntegration: z.ZodDefault<z.ZodObject<{
362
388
  adPlacementEnabled: z.ZodBoolean;
363
389
  adMinIntervalSec: z.ZodNumber;
@@ -463,6 +489,15 @@ export declare const cameraSettingsSchema: z.ZodObject<{
463
489
  protocol: "https" | "http" | "https_insecure";
464
490
  sourceKey: string;
465
491
  };
492
+ milestone: {
493
+ ip: string;
494
+ enabled: boolean;
495
+ port: number;
496
+ cameraList: string[];
497
+ pass: string;
498
+ user: string;
499
+ protocol: "https" | "http" | "https_insecure";
500
+ };
466
501
  camstreamerIntegration: {
467
502
  adPlacementEnabled: boolean;
468
503
  adMinIntervalSec: number;
@@ -606,6 +641,15 @@ export declare const cameraSettingsSchema: z.ZodObject<{
606
641
  protocol: "https" | "http" | "https_insecure";
607
642
  sourceKey: string;
608
643
  } | undefined;
644
+ milestone?: {
645
+ ip: string;
646
+ enabled: boolean;
647
+ port: number;
648
+ pass: string;
649
+ user: string;
650
+ protocol: "https" | "http" | "https_insecure";
651
+ cameraList?: string[] | undefined;
652
+ } | undefined;
609
653
  camstreamerIntegration?: {
610
654
  adPlacementEnabled: boolean;
611
655
  adMinIntervalSec: number;
@@ -202,6 +202,20 @@ exports.cameraSettingsSchema = zod_1.z.object({
202
202
  pass: '',
203
203
  sourceKey: '',
204
204
  }),
205
+ milestone: exports.connectionSchema
206
+ .extend({
207
+ enabled: zod_1.z.boolean(),
208
+ cameraList: zod_1.z.string().array().default([]),
209
+ })
210
+ .default({
211
+ enabled: false,
212
+ protocol: 'https_insecure',
213
+ ip: '',
214
+ port: 443,
215
+ user: '',
216
+ pass: '',
217
+ cameraList: [],
218
+ }),
205
219
  camstreamerIntegration: zod_1.z
206
220
  .object({
207
221
  adPlacementEnabled: zod_1.z.boolean(),
@@ -0,0 +1,120 @@
1
+ import { ErrorWithResponse } from '../../errors/errors';
2
+ import { HttpRequestSender } from '../HttpRequestSender';
3
+ import { camerasResponseSchema, tokenResponseSchema, } from '../../types/MilestoneAgent';
4
+ const CLIENT_ID = 'GrantValidatorClient';
5
+ const PAGE_SIZE = 100;
6
+ const TOKEN_EXPIRY_MARGIN_SEC = 60;
7
+ export class MilestoneAgent {
8
+ settings;
9
+ protocol;
10
+ sender;
11
+ token;
12
+ constructor(options = {}) {
13
+ this.settings = {
14
+ protocol: options.protocol ?? 'https_insecure',
15
+ ip: options.ip ?? '127.0.0.1',
16
+ port: options.port ?? 443,
17
+ user: options.user ?? '',
18
+ pass: options.pass ?? '',
19
+ timeout: options.timeout ?? 10000,
20
+ };
21
+ this.protocol = this.settings.protocol === 'http' ? 'http:' : 'https:';
22
+ const tlsInsecure = this.settings.protocol === 'https_insecure';
23
+ this.sender = new HttpRequestSender({ rejectUnaurhorized: !tlsInsecure });
24
+ }
25
+ async checkConnection() {
26
+ await this.getToken(true);
27
+ await this.getCamerasPage(1, 1);
28
+ }
29
+ async getAllCameras() {
30
+ const cameras = [];
31
+ let page = 1;
32
+ for (;;) {
33
+ const pageCameras = await this.getCamerasPage(page, PAGE_SIZE);
34
+ cameras.push(...pageCameras);
35
+ if (pageCameras.length < PAGE_SIZE) {
36
+ break;
37
+ }
38
+ page += 1;
39
+ }
40
+ return cameras;
41
+ }
42
+ async sendBookmark(cameraId, bookmark) {
43
+ const token = await this.getToken();
44
+ const body = JSON.stringify({
45
+ ...bookmark,
46
+ devicePath: { type: 'cameras', id: cameraId },
47
+ });
48
+ const res = await this.sender.sendRequest(this.getRequestOptions('POST', '/api/rest/v1/bookmarks', token, {
49
+ 'Content-Type': 'application/json',
50
+ }), body);
51
+ if (!res.ok) {
52
+ throw new ErrorWithResponse(res);
53
+ }
54
+ }
55
+ async getCamerasPage(page, itemsPerPage) {
56
+ const token = await this.getToken();
57
+ const res = await this.sender.sendRequest(this.getRequestOptions('GET', `/api/rest/v1/cameras?page=${page}&itemsPerPage=${itemsPerPage}`, token));
58
+ if (!res.ok) {
59
+ throw new ErrorWithResponse(res);
60
+ }
61
+ const responseBody = await res.text();
62
+ const result = await camerasResponseSchema.safeParseAsync(JSON.parse(responseBody));
63
+ if (!result.success) {
64
+ throw new Error('Milestone get cameras failed: ' + JSON.stringify(result.error.issues) + '\n' + responseBody);
65
+ }
66
+ return result.data.array;
67
+ }
68
+ async getToken(forceRefresh = false) {
69
+ const nowSec = Date.now() / 1000;
70
+ if (!forceRefresh && this.token !== undefined && this.token.expiresAt > nowSec) {
71
+ return this.token.value;
72
+ }
73
+ const body = new URLSearchParams({
74
+ grant_type: 'password',
75
+ username: this.settings.user,
76
+ password: this.settings.pass,
77
+ client_id: CLIENT_ID,
78
+ }).toString();
79
+ const res = await this.sender.sendRequest({
80
+ method: 'POST',
81
+ protocol: this.protocol,
82
+ host: this.settings.ip,
83
+ port: this.settings.port,
84
+ path: '/idp/connect/token',
85
+ timeout: this.settings.timeout,
86
+ headers: {
87
+ 'Content-Type': 'application/x-www-form-urlencoded',
88
+ 'Accept': 'application/json',
89
+ },
90
+ }, body);
91
+ if (!res.ok) {
92
+ throw new ErrorWithResponse(res);
93
+ }
94
+ const responseBody = await res.text();
95
+ const result = await tokenResponseSchema.safeParseAsync(JSON.parse(responseBody));
96
+ if (!result.success) {
97
+ throw new Error('Milestone authorization failed: ' + JSON.stringify(result.error.issues) + '\n' + responseBody);
98
+ }
99
+ this.token = {
100
+ value: result.data.access_token,
101
+ expiresAt: nowSec + result.data.expires_in - TOKEN_EXPIRY_MARGIN_SEC,
102
+ };
103
+ return this.token.value;
104
+ }
105
+ getRequestOptions(method, path, token, extraHeaders) {
106
+ return {
107
+ method,
108
+ protocol: this.protocol,
109
+ host: this.settings.ip,
110
+ port: this.settings.port,
111
+ path,
112
+ timeout: this.settings.timeout,
113
+ headers: {
114
+ Authorization: `Bearer ${token}`,
115
+ Accept: 'application/json',
116
+ ...extraHeaders,
117
+ },
118
+ };
119
+ }
120
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, test, expect, beforeEach, jest } from '@jest/globals';
2
+ import { HttpRequestSender } from '../HttpRequestSender';
3
+ import { MilestoneAgent } from './MilestoneAgent';
4
+ jest.mock('../HttpRequestSender');
5
+ function jsonResponse(body, status = 200) {
6
+ return {
7
+ ok: status >= 200 && status < 300,
8
+ status,
9
+ statusText: 'OK',
10
+ text: () => Promise.resolve(JSON.stringify(body)),
11
+ };
12
+ }
13
+ const TOKEN_BODY = { access_token: 'abc123', expires_in: 3600, token_type: 'Bearer', scope: 'managementserver' };
14
+ describe('MilestoneAgent', () => {
15
+ const MockedSender = jest.mocked(HttpRequestSender);
16
+ let sendRequest;
17
+ beforeEach(() => {
18
+ sendRequest = jest.fn();
19
+ MockedSender.mockImplementation(() => ({ sendRequest }));
20
+ });
21
+ function callsTo(prefix) {
22
+ return sendRequest.mock.calls.filter((c) => c[0].path.startsWith(prefix));
23
+ }
24
+ function routeByPath(handlers) {
25
+ sendRequest.mockImplementation((...args) => {
26
+ const options = args[0];
27
+ if (options.path === '/idp/connect/token') {
28
+ return Promise.resolve(jsonResponse(TOKEN_BODY));
29
+ }
30
+ for (const [prefix, handler] of Object.entries(handlers)) {
31
+ if (options.path.startsWith(prefix)) {
32
+ if (typeof handler === 'function') {
33
+ const page = Number(new URLSearchParams(options.path.split('?')[1]).get('page'));
34
+ return Promise.resolve(handler(page));
35
+ }
36
+ return Promise.resolve(handler);
37
+ }
38
+ }
39
+ throw new Error(`Unexpected path ${options.path}`);
40
+ });
41
+ }
42
+ test('fetches a bearer token before listing cameras and sends it in the header', async () => {
43
+ routeByPath({ '/api/rest/v1/cameras': jsonResponse({ array: [{ id: 'cam-1', displayName: 'Cam 1' }] }) });
44
+ const agent = new MilestoneAgent({ ip: '1.2.3.4' });
45
+ const cameras = await agent.getAllCameras();
46
+ expect(cameras).toEqual([{ id: 'cam-1', displayName: 'Cam 1' }]);
47
+ const tokenCalls = callsTo('/idp/connect/token');
48
+ expect(tokenCalls.length).toBe(1);
49
+ expect(tokenCalls[0][0].method).toBe('POST');
50
+ const camerasCall = callsTo('/api/rest/v1/cameras')[0];
51
+ expect(camerasCall[0].headers.Authorization).toBe('Bearer abc123');
52
+ });
53
+ test('reuses the cached token across calls', async () => {
54
+ routeByPath({ '/api/rest/v1/cameras': jsonResponse({ array: [] }) });
55
+ const agent = new MilestoneAgent({ ip: '1.2.3.4' });
56
+ await agent.getAllCameras();
57
+ await agent.getAllCameras();
58
+ expect(callsTo('/idp/connect/token').length).toBe(1);
59
+ });
60
+ test('pages through cameras until a non-full page is returned', async () => {
61
+ const fullPage = (page) => jsonResponse({ array: Array.from({ length: 100 }, (_, i) => ({ id: `p${page}-${i}` })) });
62
+ routeByPath({
63
+ '/api/rest/v1/cameras': (page) => page < 3 ? fullPage(page) : jsonResponse({ array: [{ id: 'last' }] }),
64
+ });
65
+ const agent = new MilestoneAgent({ ip: '1.2.3.4' });
66
+ const cameras = await agent.getAllCameras();
67
+ expect(cameras.length).toBe(201);
68
+ expect(cameras[cameras.length - 1]).toEqual({ id: 'last' });
69
+ });
70
+ test('sends a bookmark with the camera devicePath', async () => {
71
+ routeByPath({ '/api/rest/v1/bookmarks': jsonResponse({ result: {} }, 201) });
72
+ const agent = new MilestoneAgent({ ip: '1.2.3.4' });
73
+ await agent.sendBookmark('cam-guid', {
74
+ header: 'Airbus A320',
75
+ description: 'ICAO BC4AA',
76
+ timeBegin: '2026-04-16T10:25:00.000Z',
77
+ timeEnd: '2026-04-16T10:25:00.000Z',
78
+ timeTriggered: '2026-04-16T10:25:00.000Z',
79
+ reference: 'BC4AA',
80
+ });
81
+ const call = callsTo('/api/rest/v1/bookmarks')[0];
82
+ expect(call).toBeDefined();
83
+ const body = JSON.parse(call[1]);
84
+ expect(body.devicePath).toEqual({ type: 'cameras', id: 'cam-guid' });
85
+ expect(body.header).toBe('Airbus A320');
86
+ expect(body.description).toBe('ICAO BC4AA');
87
+ });
88
+ test('throws on a non-ok bookmark response', async () => {
89
+ routeByPath({ '/api/rest/v1/bookmarks': jsonResponse({ error: 'nope' }, 500) });
90
+ const agent = new MilestoneAgent({ ip: '1.2.3.4' });
91
+ await expect(agent.sendBookmark('cam-guid', {
92
+ header: 'h',
93
+ description: 'd',
94
+ timeBegin: 't',
95
+ timeEnd: 't',
96
+ timeTriggered: 't',
97
+ reference: 'r',
98
+ })).rejects.toThrow();
99
+ });
100
+ });
package/esm/node/index.js CHANGED
@@ -6,6 +6,8 @@ export * from './events/AxisCameraStationEvents';
6
6
  export * from './TimeZoneDaemon';
7
7
  export * from './events/GenetecAgent';
8
8
  export * from '../types/GenetecAgent';
9
+ export * from './events/MilestoneAgent';
10
+ export * from '../types/MilestoneAgent';
9
11
  export { ResourceManager } from './CamOverlayPainter/ResourceManager';
10
12
  export { Painter } from './CamOverlayPainter/Painter';
11
13
  export { Frame } from './CamOverlayPainter/Frame';
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+ export const tokenResponseSchema = z.object({
3
+ access_token: z.string(),
4
+ expires_in: z.number(),
5
+ token_type: z.string(),
6
+ });
7
+ export const milestoneCameraSchema = z.object({
8
+ id: z.string(),
9
+ name: z.string().optional(),
10
+ displayName: z.string().optional(),
11
+ enabled: z.boolean().optional(),
12
+ });
13
+ export const camerasResponseSchema = z.object({
14
+ array: z.array(milestoneCameraSchema),
15
+ });
@@ -199,6 +199,20 @@ export const cameraSettingsSchema = z.object({
199
199
  pass: '',
200
200
  sourceKey: '',
201
201
  }),
202
+ milestone: connectionSchema
203
+ .extend({
204
+ enabled: z.boolean(),
205
+ cameraList: z.string().array().default([]),
206
+ })
207
+ .default({
208
+ enabled: false,
209
+ protocol: 'https_insecure',
210
+ ip: '',
211
+ port: 443,
212
+ user: '',
213
+ pass: '',
214
+ cameraList: [],
215
+ }),
202
216
  camstreamerIntegration: z
203
217
  .object({
204
218
  adPlacementEnabled: z.boolean(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camstreamerlib",
3
- "version": "4.0.16",
3
+ "version": "4.0.17-dev-milestone.0",
4
4
  "description": "Helper library for CamStreamer ACAP applications.",
5
5
  "prettier": "@camstreamer/prettier-config",
6
6
  "engine": {
@@ -107,6 +107,15 @@ export declare class PlaneTrackerAPI<Client extends IClient<TResponse, any>> ext
107
107
  protocol: "https" | "http" | "https_insecure";
108
108
  sourceKey: string;
109
109
  };
110
+ milestone: {
111
+ ip: string;
112
+ enabled: boolean;
113
+ port: number;
114
+ cameraList: string[];
115
+ pass: string;
116
+ user: string;
117
+ protocol: "https" | "http" | "https_insecure";
118
+ };
110
119
  camstreamerIntegration: {
111
120
  adPlacementEnabled: boolean;
112
121
  adMinIntervalSec: number;
@@ -0,0 +1,14 @@
1
+ import { MilestoneAgentOptions, TBookmark, TMilestoneCamera } from '../../types/MilestoneAgent';
2
+ export declare class MilestoneAgent {
3
+ private settings;
4
+ private protocol;
5
+ private sender;
6
+ private token?;
7
+ constructor(options?: MilestoneAgentOptions);
8
+ checkConnection(): Promise<void>;
9
+ getAllCameras(): Promise<TMilestoneCamera[]>;
10
+ sendBookmark(cameraId: string, bookmark: TBookmark): Promise<void>;
11
+ private getCamerasPage;
12
+ private getToken;
13
+ private getRequestOptions;
14
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -6,6 +6,8 @@ export * from './events/AxisCameraStationEvents';
6
6
  export * from './TimeZoneDaemon';
7
7
  export * from './events/GenetecAgent';
8
8
  export * from '../types/GenetecAgent';
9
+ export * from './events/MilestoneAgent';
10
+ export * from '../types/MilestoneAgent';
9
11
  export { ResourceManager } from './CamOverlayPainter/ResourceManager';
10
12
  export { Painter } from './CamOverlayPainter/Painter';
11
13
  export { Frame } from './CamOverlayPainter/Frame';
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod';
2
+ export type MilestoneAgentOptions = {
3
+ protocol?: 'http' | 'https' | 'https_insecure';
4
+ ip?: string;
5
+ port?: number;
6
+ user?: string;
7
+ pass?: string;
8
+ timeout?: number;
9
+ };
10
+ export declare const tokenResponseSchema: z.ZodObject<{
11
+ access_token: z.ZodString;
12
+ expires_in: z.ZodNumber;
13
+ token_type: z.ZodString;
14
+ }, "strip", z.ZodTypeAny, {
15
+ access_token: string;
16
+ expires_in: number;
17
+ token_type: string;
18
+ }, {
19
+ access_token: string;
20
+ expires_in: number;
21
+ token_type: string;
22
+ }>;
23
+ export type TTokenResponse = z.infer<typeof tokenResponseSchema>;
24
+ export declare const milestoneCameraSchema: z.ZodObject<{
25
+ id: z.ZodString;
26
+ name: z.ZodOptional<z.ZodString>;
27
+ displayName: z.ZodOptional<z.ZodString>;
28
+ enabled: z.ZodOptional<z.ZodBoolean>;
29
+ }, "strip", z.ZodTypeAny, {
30
+ id: string;
31
+ name?: string | undefined;
32
+ enabled?: boolean | undefined;
33
+ displayName?: string | undefined;
34
+ }, {
35
+ id: string;
36
+ name?: string | undefined;
37
+ enabled?: boolean | undefined;
38
+ displayName?: string | undefined;
39
+ }>;
40
+ export type TMilestoneCamera = z.infer<typeof milestoneCameraSchema>;
41
+ export declare const camerasResponseSchema: z.ZodObject<{
42
+ array: z.ZodArray<z.ZodObject<{
43
+ id: z.ZodString;
44
+ name: z.ZodOptional<z.ZodString>;
45
+ displayName: z.ZodOptional<z.ZodString>;
46
+ enabled: z.ZodOptional<z.ZodBoolean>;
47
+ }, "strip", z.ZodTypeAny, {
48
+ id: string;
49
+ name?: string | undefined;
50
+ enabled?: boolean | undefined;
51
+ displayName?: string | undefined;
52
+ }, {
53
+ id: string;
54
+ name?: string | undefined;
55
+ enabled?: boolean | undefined;
56
+ displayName?: string | undefined;
57
+ }>, "many">;
58
+ }, "strip", z.ZodTypeAny, {
59
+ array: {
60
+ id: string;
61
+ name?: string | undefined;
62
+ enabled?: boolean | undefined;
63
+ displayName?: string | undefined;
64
+ }[];
65
+ }, {
66
+ array: {
67
+ id: string;
68
+ name?: string | undefined;
69
+ enabled?: boolean | undefined;
70
+ displayName?: string | undefined;
71
+ }[];
72
+ }>;
73
+ export type TCamerasResponse = z.infer<typeof camerasResponseSchema>;
74
+ export type TBookmark = {
75
+ header: string;
76
+ description: string;
77
+ timeBegin: string;
78
+ timeEnd: string;
79
+ timeTriggered: string;
80
+ reference: string;
81
+ };
@@ -358,6 +358,32 @@ export declare const cameraSettingsSchema: z.ZodObject<{
358
358
  protocol: "https" | "http" | "https_insecure";
359
359
  sourceKey: string;
360
360
  }>>;
361
+ milestone: z.ZodDefault<z.ZodObject<{
362
+ protocol: z.ZodUnion<[z.ZodLiteral<"http">, z.ZodLiteral<"https">, z.ZodLiteral<"https_insecure">]>;
363
+ ip: z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>;
364
+ port: z.ZodNumber;
365
+ user: z.ZodString;
366
+ pass: z.ZodString;
367
+ } & {
368
+ enabled: z.ZodBoolean;
369
+ cameraList: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
370
+ }, "strip", z.ZodTypeAny, {
371
+ ip: string;
372
+ enabled: boolean;
373
+ port: number;
374
+ cameraList: string[];
375
+ pass: string;
376
+ user: string;
377
+ protocol: "https" | "http" | "https_insecure";
378
+ }, {
379
+ ip: string;
380
+ enabled: boolean;
381
+ port: number;
382
+ pass: string;
383
+ user: string;
384
+ protocol: "https" | "http" | "https_insecure";
385
+ cameraList?: string[] | undefined;
386
+ }>>;
361
387
  camstreamerIntegration: z.ZodDefault<z.ZodObject<{
362
388
  adPlacementEnabled: z.ZodBoolean;
363
389
  adMinIntervalSec: z.ZodNumber;
@@ -463,6 +489,15 @@ export declare const cameraSettingsSchema: z.ZodObject<{
463
489
  protocol: "https" | "http" | "https_insecure";
464
490
  sourceKey: string;
465
491
  };
492
+ milestone: {
493
+ ip: string;
494
+ enabled: boolean;
495
+ port: number;
496
+ cameraList: string[];
497
+ pass: string;
498
+ user: string;
499
+ protocol: "https" | "http" | "https_insecure";
500
+ };
466
501
  camstreamerIntegration: {
467
502
  adPlacementEnabled: boolean;
468
503
  adMinIntervalSec: number;
@@ -606,6 +641,15 @@ export declare const cameraSettingsSchema: z.ZodObject<{
606
641
  protocol: "https" | "http" | "https_insecure";
607
642
  sourceKey: string;
608
643
  } | undefined;
644
+ milestone?: {
645
+ ip: string;
646
+ enabled: boolean;
647
+ port: number;
648
+ pass: string;
649
+ user: string;
650
+ protocol: "https" | "http" | "https_insecure";
651
+ cameraList?: string[] | undefined;
652
+ } | undefined;
609
653
  camstreamerIntegration?: {
610
654
  adPlacementEnabled: boolean;
611
655
  adMinIntervalSec: number;