@mxtommy/kip 4.5.0-beta.1 → 4.5.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 (94) hide show
  1. package/.github/copilot-instructions.md +19 -6
  2. package/.github/instructions/project.instructions.md +42 -6
  3. package/CHANGELOG.md +13 -0
  4. package/README.md +11 -7
  5. package/package.json +8 -5
  6. package/plugin/duckdb-parquet-storage.service.js +1206 -0
  7. package/plugin/history-series.service.js +439 -0
  8. package/plugin/index.js +670 -15
  9. package/plugin/openApi.json +253 -3
  10. package/plugin/plugin-auth.service.js +75 -0
  11. package/plugin-config-data/kip/historicalData/kip-history.duckdb +0 -0
  12. package/plugin-config-data/kip/historicalData/parquet/chart-1/1772344583976-1772344583976.parquet +0 -0
  13. package/plugin-config-data/kip/historicalData/parquet/live-1/1771408800000-1771408890000.parquet +0 -0
  14. package/plugin-config-data/kip/historicalData/parquet/live-1/1771412400000-1771412490000.parquet +0 -0
  15. package/plugin-config-data/kip/historicalData/parquet/live-1/1771419600000-1771419650000.parquet +0 -0
  16. package/plugin-config-data/kip/historicalData/parquet/live-1/1772344584154-1772344584154.parquet +0 -0
  17. package/plugin-config-data/kip/historicalData/parquet/live-1/1772344584191-1772344584191.parquet +0 -0
  18. package/plugin-config-data/kip/historicalData/parquet/live-1/1772344584268-1772344584268.parquet +0 -0
  19. package/plugin-config-data/kip/historicalData/parquet/live-2/1771502400000-1771502400000.parquet +0 -0
  20. package/plugin-config-data/kip/historicalData/parquet/live-3/1771408800000-1771408890000.parquet +0 -0
  21. package/plugin-config-data/kip/historicalData/parquet/live-3/1771412400000-1771412490000.parquet +0 -0
  22. package/plugin-config-data/kip/historicalData/parquet/live-3/1771419600000-1771419650000.parquet +0 -0
  23. package/plugin-config-data/kip/historicalData/parquet/live-3/1772344584268-1772344584268.parquet +0 -0
  24. package/plugin-config-data/kip/historicalData/parquet/live-4/1771408800000-1771408890000.parquet +0 -0
  25. package/plugin-config-data/kip/historicalData/parquet/live-4/1771412400000-1771412490000.parquet +0 -0
  26. package/plugin-config-data/kip/historicalData/parquet/live-4/1771419600000-1771419650000.parquet +0 -0
  27. package/plugin-config-data/kip/historicalData/parquet/live-5/1771412400000-1771412490000.parquet +0 -0
  28. package/plugin-config-data/kip/historicalData/parquet/live-5/1771419600000-1771419650000.parquet +0 -0
  29. package/plugin-config-data/kip/historicalData/parquet/live-6/1771419600000-1771419650000.parquet +0 -0
  30. package/plugin-config-data/kip/historicalData/parquet/live-prefixed-1/1771408800000-1771408890000.parquet +0 -0
  31. package/plugin-config-data/kip/historicalData/parquet/live-prefixed-1/1771412400000-1771412490000.parquet +0 -0
  32. package/plugin-config-data/kip/historicalData/parquet/live-prefixed-1/1771419600000-1771419650000.parquet +0 -0
  33. package/plugin-config-data/kip/historicalData/parquet/live-prefixed-1/1772344584191-1772344584191.parquet +0 -0
  34. package/plugin-config-data/kip/historicalData/parquet/live-prefixed-1/1772344584268-1772344584268.parquet +0 -0
  35. package/public/assets/help-docs/chartplotter.md +5 -18
  36. package/public/assets/help-docs/community.md +0 -3
  37. package/public/assets/help-docs/configuration.md +1 -1
  38. package/public/assets/help-docs/contact-us.md +0 -4
  39. package/public/assets/help-docs/dashboards.md +20 -18
  40. package/public/assets/help-docs/datainspector.md +7 -5
  41. package/public/assets/help-docs/history-api.md +116 -0
  42. package/public/assets/help-docs/menu.json +18 -6
  43. package/public/assets/help-docs/nodered-control-flows.md +125 -0
  44. package/public/assets/help-docs/putcontrols.md +101 -60
  45. package/public/assets/help-docs/welcome.md +6 -7
  46. package/public/assets/help-docs/widget-historical-series.md +66 -0
  47. package/public/assets/help-docs/zones.md +5 -10
  48. package/public/{chunk-KFFAA7DL.js → chunk-2ICAVOT2.js} +8 -8
  49. package/public/chunk-6XFWUUDD.js +3 -0
  50. package/public/chunk-A6DQJFP4.js +16 -0
  51. package/public/chunk-B75MT7ND.js +1 -0
  52. package/public/{chunk-T6TFVZVM.js → chunk-CEB42O2C.js} +1 -1
  53. package/public/chunk-CHGXAEKT.js +2 -0
  54. package/public/chunk-D7VDX7ZF.js +5 -0
  55. package/public/chunk-DD4F6F4S.js +9 -0
  56. package/public/{chunk-ZQER6AIQ.js → chunk-DEGYRCMI.js} +1 -1
  57. package/public/{chunk-M2B5OYGO.js → chunk-DEM56G4S.js} +1 -1
  58. package/public/chunk-DYTBBUMI.js +4 -0
  59. package/public/{chunk-YIYYVDFO.js → chunk-EDNYYQIZ.js} +2 -2
  60. package/public/chunk-FNF7M3AE.js +1 -0
  61. package/public/chunk-IHURI4IH.js +5 -0
  62. package/public/chunk-J3LDKVIS.js +50 -0
  63. package/public/{chunk-5FEX27I4.js → chunk-JB4YVVNW.js} +1 -1
  64. package/public/chunk-KPHICV76.js +5 -0
  65. package/public/{chunk-QZKCRH3H.js → chunk-KZ5DUKAX.js} +1 -1
  66. package/public/{chunk-HMOOTAEA.js → chunk-LQDSU4WS.js} +3 -3
  67. package/public/{chunk-IXQ7KIFY.js → chunk-MGPPVLZ7.js} +1 -1
  68. package/public/{chunk-QVCLOCEC.js → chunk-R7RQHWKJ.js} +1 -1
  69. package/public/chunk-S72JTJPN.js +6 -0
  70. package/public/chunk-UYIJND2R.js +1 -0
  71. package/public/chunk-YCEXTKGG.js +1 -0
  72. package/public/chunk-YKJKIWXO.js +6 -0
  73. package/public/index.html +1 -1
  74. package/public/main-EG2WF4EO.js +1 -0
  75. package/tools/schematics/create-host2-widget/files/readme/README.md.template +1 -1
  76. package/public/assets/help-docs/datasets.md +0 -95
  77. package/public/chunk-2OB7ZJBR.js +0 -3
  78. package/public/chunk-6GGJZDRE.js +0 -1
  79. package/public/chunk-6V4GGGXE.js +0 -2
  80. package/public/chunk-A5BW6BUM.js +0 -1
  81. package/public/chunk-DGE5YFPU.js +0 -5
  82. package/public/chunk-G6M3Z3BY.js +0 -53
  83. package/public/chunk-GMGZLXY7.js +0 -4
  84. package/public/chunk-GUZ3BDVZ.js +0 -2
  85. package/public/chunk-ICDGHQFP.js +0 -6
  86. package/public/chunk-JCNE4QHQ.js +0 -15
  87. package/public/chunk-K6XYUNG4.js +0 -8
  88. package/public/chunk-LGCQEN7V.js +0 -4
  89. package/public/chunk-O3JH7UTR.js +0 -1
  90. package/public/chunk-Q3USFT4F.js +0 -2
  91. package/public/chunk-VIKU7BH7.js +0 -1
  92. package/public/chunk-XMQPXXLW.js +0 -8
  93. package/public/main-4URMGBQS.js +0 -1
  94. package/rm-npmjs-beta.sh +0 -50
package/plugin/index.js CHANGED
@@ -35,13 +35,18 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  const server_api_1 = require("@signalk/server-api");
37
37
  const openapi = __importStar(require("./openApi.json"));
38
+ const history_series_service_1 = require("./history-series.service");
39
+ const duckdb_parquet_storage_service_1 = require("./duckdb-parquet-storage.service");
38
40
  const start = (server) => {
39
41
  const mutableOpenApi = JSON.parse(JSON.stringify(openapi.default ?? openapi));
40
42
  const API_PATHS = {
41
43
  DISPLAYS: `/displays`,
42
44
  INSTANCE: `/displays/:displayId`,
43
45
  SCREEN_INDEX: `/displays/:displayId/screenIndex`,
44
- ACTIVATE_SCREEN: `/displays/:displayId/activeScreen`
46
+ ACTIVATE_SCREEN: `/displays/:displayId/activeScreen`,
47
+ SERIES: '/series',
48
+ SERIES_INSTANCE: '/series/:seriesId',
49
+ SERIES_RECONCILE: '/series/reconcile',
45
50
  };
46
51
  const PUT_CONTEXT = 'vessels.self';
47
52
  const COMMAND_PATHS = {
@@ -50,14 +55,48 @@ const start = (server) => {
50
55
  REQUEST_ACTIVE_SCREEN: 'kip.remote.requestActiveScreen'
51
56
  };
52
57
  const CONFIG_SCHEMA = {
58
+ type: 'object',
59
+ title: 'Remote Control and Data Series',
60
+ description: 'NOTE: All plugin settings are also managed from within KIP\'s Display Options panel. Changes made here will be overridden when KIP applies settings from the Display Options.',
53
61
  properties: {
54
- notifications: {
55
- type: 'object',
56
- title: 'Remote Control',
57
- description: 'This plugin requires no configuration.'
62
+ historySeriesServiceEnabled: {
63
+ type: 'boolean',
64
+ title: 'Enable Automatic Historical Time-Series Capture and Management',
65
+ description: 'Historical Time-Series are data captures that supply the widget historical data and seed the Data Chart and Wind Trends widgets. If disabled, data capture must be configured in your chosen history provider plugin.',
66
+ default: true
67
+ },
68
+ registerAsHistoryApiProvider: {
69
+ type: 'boolean',
70
+ title: 'Enable Query Provider',
71
+ description: 'The built-in History-API provider is the querying engine that uses the Historical Time-Series data to seed the widget historical panel, the Data Chart and Wind Trends widgets. If you want to use another History-API provider, turn this off and configure your chosen History-API compatible provider accordingly and KIP will query that provider.',
72
+ default: true
58
73
  }
59
74
  }
60
75
  };
76
+ const historySeries = new history_series_service_1.HistorySeriesService(() => Date.now(), false);
77
+ const storageService = new duckdb_parquet_storage_service_1.DuckDbParquetStorageService();
78
+ let retentionSweepTimer = null;
79
+ let storageFlushTimer = null;
80
+ let duckDbInitializationPromise = null;
81
+ const DUCKDB_INIT_WAIT_TIMEOUT_MS = 5000;
82
+ let streamUnsubscribes = [];
83
+ let historyApiRegistry = null;
84
+ let historySeriesServiceEnabled = true;
85
+ let registerAsHistoryApiProvider = true;
86
+ let historyApiProviderRegistered = false;
87
+ function resolveHistoryModeConfig(settings) {
88
+ const root = (settings && typeof settings === 'object' ? settings : {});
89
+ const historySeriesServiceEnabledSetting = typeof root.historySeriesServiceEnabled === 'boolean'
90
+ ? root.historySeriesServiceEnabled
91
+ : undefined;
92
+ const registerAsHistoryApiProviderSetting = typeof root.registerAsHistoryApiProvider === 'boolean'
93
+ ? root.registerAsHistoryApiProvider
94
+ : undefined;
95
+ return {
96
+ historySeriesServiceEnabled: historySeriesServiceEnabledSetting !== false,
97
+ registerAsHistoryApiProvider: registerAsHistoryApiProviderSetting !== false
98
+ };
99
+ }
61
100
  // Helpers
62
101
  function getDisplaySelfPath(displayId, suffix) {
63
102
  const tail = suffix ? `.${suffix}` : '';
@@ -79,6 +118,92 @@ const start = (server) => {
79
118
  function sendFail(res, statusCode, message) {
80
119
  return res.status(statusCode).json({ state: 'FAILED', statusCode, message });
81
120
  }
121
+ function getHistorySeriesServiceDisabledMessage() {
122
+ return 'KIP history-series service is disabled by plugin configuration';
123
+ }
124
+ function getDuckDbUnavailableMessage() {
125
+ const details = storageService.getLastInitError();
126
+ return details
127
+ ? `DuckDB storage unavailable: ${details}`
128
+ : 'DuckDB storage unavailable';
129
+ }
130
+ function isDuckDbUnavailable() {
131
+ return Boolean(storageService.getLastInitError());
132
+ }
133
+ async function waitForDuckDbInitialization(timeoutMs = DUCKDB_INIT_WAIT_TIMEOUT_MS) {
134
+ if (!duckDbInitializationPromise) {
135
+ return storageService.isDuckDbParquetReady();
136
+ }
137
+ try {
138
+ const ready = await Promise.race([
139
+ duckDbInitializationPromise,
140
+ new Promise(resolvePromise => {
141
+ setTimeout(() => resolvePromise(false), timeoutMs);
142
+ })
143
+ ]);
144
+ if (!ready && !storageService.isDuckDbParquetReady()) {
145
+ server.error(`[SERIES STORAGE] DuckDB initialization wait timed out after ${timeoutMs}ms`);
146
+ }
147
+ return ready;
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ function getRouteError(error, fallbackMessage) {
154
+ const message = String(error?.message || fallbackMessage);
155
+ const normalized = message.toLowerCase();
156
+ if (normalized.includes('invalid ')
157
+ || normalized.includes('missing ')
158
+ || normalized.includes('body must')
159
+ || normalized.includes('required')
160
+ || normalized.includes('expected an iso')) {
161
+ return { statusCode: 400, message };
162
+ }
163
+ if (normalized.includes('duckdb')
164
+ || normalized.includes('storage unavailable')
165
+ || normalized.includes('not initialized')
166
+ || isDuckDbUnavailable()) {
167
+ return { statusCode: 503, message };
168
+ }
169
+ return { statusCode: 500, message };
170
+ }
171
+ function findSeriesById(seriesId) {
172
+ const current = historySeries.findSeriesById(seriesId);
173
+ return current ? JSON.parse(JSON.stringify(current)) : null;
174
+ }
175
+ function isHistorySeriesServiceEnabled() {
176
+ return historySeriesServiceEnabled;
177
+ }
178
+ function isHistoryApiProviderEnabled() {
179
+ return registerAsHistoryApiProvider;
180
+ }
181
+ function logOperationalMode(stage) {
182
+ server.debug(`[HISTORY MODE] stage=${stage} historySeriesServiceEnabled=${isHistorySeriesServiceEnabled()} historyApiProviderEnabled=${isHistoryApiProviderEnabled()} historyApiProviderRegistered=${historyApiProviderRegistered}`);
183
+ }
184
+ async function ensureDuckDbReadyForRequest(res) {
185
+ await waitForDuckDbInitialization();
186
+ if (storageService.isDuckDbParquetReady()) {
187
+ return true;
188
+ }
189
+ sendFail(res, 503, getDuckDbUnavailableMessage());
190
+ return false;
191
+ }
192
+ function ensureHistorySeriesServiceEnabledForRequest(res) {
193
+ if (isHistorySeriesServiceEnabled()) {
194
+ return true;
195
+ }
196
+ sendFail(res, 503, getHistorySeriesServiceDisabledMessage());
197
+ return false;
198
+ }
199
+ function logAuthTrace(req, stage) {
200
+ const hasAuthorizationHeader = typeof req.headers.authorization === 'string' && req.headers.authorization.length > 0;
201
+ const hasCookieHeader = typeof req.headers.cookie === 'string' && req.headers.cookie.length > 0;
202
+ const origin = req.headers.origin ?? null;
203
+ const userAgent = req.headers['user-agent'] ?? null;
204
+ const contentType = req.headers['content-type'] ?? null;
205
+ server.debug(`[AUTH TRACE] stage=${stage} method=${req.method} path=${req.path} ip=${req.ip} origin=${String(origin)} authHeader=${hasAuthorizationHeader} cookieHeader=${hasCookieHeader} contentType=${String(contentType)} userAgent=${String(userAgent)}`);
206
+ }
82
207
  function completed(statusCode, message) {
83
208
  return { state: 'COMPLETED', statusCode, message };
84
209
  }
@@ -87,6 +212,7 @@ const start = (server) => {
87
212
  }
88
213
  function applyDisplayWrite(displayId, suffix, value) {
89
214
  const path = suffix ? `displays.${displayId}.${suffix}` : `displays.${displayId}`;
215
+ server.debug(`[WRITE TRACE] applyDisplayWrite path=${path} value=${JSON.stringify(value)}`);
90
216
  try {
91
217
  server.handleMessage(plugin.id, {
92
218
  updates: [
@@ -100,14 +226,17 @@ const start = (server) => {
100
226
  }
101
227
  ]
102
228
  }, server_api_1.SKVersion.v1);
229
+ server.debug(`[WRITE TRACE] handleMessage success path=${path}`);
103
230
  return completed(200);
104
231
  }
105
232
  catch (error) {
106
233
  const message = error?.message ?? 'Unable to write display path';
234
+ server.error(`[WRITE TRACE] handleMessage failure path=${path} message=${message}`);
107
235
  return completed(400, message);
108
236
  }
109
237
  }
110
238
  function handleSetDisplay(value) {
239
+ server.debug(`[COMMAND TRACE] handleSetDisplay payload=${JSON.stringify(value)}`);
111
240
  const command = value;
112
241
  if (!command || typeof command !== 'object') {
113
242
  return completed(400, 'Command payload is required');
@@ -122,6 +251,7 @@ const start = (server) => {
122
251
  return applyDisplayWrite(command.displayId, null, displayValue);
123
252
  }
124
253
  function handleScreenWrite(value, suffix) {
254
+ server.debug(`[COMMAND TRACE] handleScreenWrite suffix=${suffix} payload=${JSON.stringify(value)}`);
125
255
  const command = value;
126
256
  if (!command || typeof command !== 'object') {
127
257
  return completed(400, 'Command payload is required');
@@ -136,38 +266,436 @@ const start = (server) => {
136
266
  return applyDisplayWrite(command.displayId, suffix, screenIdxValue);
137
267
  }
138
268
  function sendActionAsRest(res, result) {
269
+ server.debug(`[REST TRACE] sendActionAsRest statusCode=${result.statusCode} message=${result.message ?? ''}`);
139
270
  if (result.statusCode === 200) {
140
271
  return res.status(200).json({ state: 'SUCCESS', statusCode: 200 });
141
272
  }
142
273
  return sendFail(res, result.statusCode || 400, result.message || 'Command failed');
143
274
  }
275
+ function stopSeriesCapture() {
276
+ streamUnsubscribes.forEach(unsub => {
277
+ try {
278
+ unsub();
279
+ }
280
+ catch {
281
+ // ignore unsubscribe failures
282
+ }
283
+ });
284
+ streamUnsubscribes = [];
285
+ }
286
+ function toIsoString(value) {
287
+ if (typeof value === 'string' && value.trim()) {
288
+ return value;
289
+ }
290
+ if (value instanceof Date) {
291
+ return value.toISOString();
292
+ }
293
+ if (value && typeof value === 'object' && typeof value.toString === 'function') {
294
+ const serialized = value.toString();
295
+ return serialized && serialized !== '[object Object]' ? serialized : undefined;
296
+ }
297
+ return undefined;
298
+ }
299
+ function toDurationString(value) {
300
+ if (typeof value === 'number' && Number.isFinite(value)) {
301
+ return value;
302
+ }
303
+ if (typeof value === 'string' && value.trim()) {
304
+ return value;
305
+ }
306
+ if (value && typeof value === 'object' && typeof value.toString === 'function') {
307
+ const serialized = value.toString();
308
+ return serialized && serialized !== '[object Object]' ? serialized : undefined;
309
+ }
310
+ return undefined;
311
+ }
312
+ function normalizeHistoryMethod(method) {
313
+ const raw = String(method ?? 'avg').trim().toLowerCase();
314
+ if (raw === 'average') {
315
+ return 'avg';
316
+ }
317
+ if (raw === 'min' || raw === 'max' || raw === 'sma' || raw === 'ema' || raw === 'avg') {
318
+ return raw;
319
+ }
320
+ return 'avg';
321
+ }
322
+ function normalizeHistoryPath(path) {
323
+ const trimmed = typeof path === 'string' ? path.trim() : '';
324
+ if (!trimmed) {
325
+ return '';
326
+ }
327
+ if (trimmed.startsWith('vessels.self.')) {
328
+ return trimmed.slice('vessels.self.'.length);
329
+ }
330
+ if (trimmed.startsWith('self.')) {
331
+ return trimmed.slice('self.'.length);
332
+ }
333
+ return trimmed;
334
+ }
335
+ function buildPathsFromPathSpecs(pathSpecs) {
336
+ if (!Array.isArray(pathSpecs)) {
337
+ return '';
338
+ }
339
+ const specs = pathSpecs;
340
+ const encoded = specs
341
+ .map(spec => {
342
+ const path = normalizeHistoryPath(spec.path);
343
+ if (!path) {
344
+ return '';
345
+ }
346
+ const method = normalizeHistoryMethod(spec.aggregate);
347
+ const params = Array.isArray(spec.parameter)
348
+ ? spec.parameter.map(item => String(item).trim()).filter(Boolean)
349
+ : [];
350
+ return [path, method, ...params].join(':');
351
+ })
352
+ .filter(Boolean);
353
+ return encoded.join(',');
354
+ }
355
+ function buildHistoryQueryFromValuesRequest(query) {
356
+ const from = toIsoString(query?.from);
357
+ const to = toIsoString(query?.to);
358
+ const duration = toDurationString(query?.duration);
359
+ const paths = buildPathsFromPathSpecs(query?.pathSpecs);
360
+ return {
361
+ paths,
362
+ context: typeof query?.context === 'string' ? query.context : undefined,
363
+ from,
364
+ to,
365
+ duration,
366
+ resolution: typeof query?.resolution === 'number' || typeof query?.resolution === 'string' ? query.resolution : undefined
367
+ };
368
+ }
369
+ function buildHistoryQueryFromRangeRequest(query) {
370
+ return {
371
+ from: toIsoString(query?.from),
372
+ to: toIsoString(query?.to),
373
+ duration: toDurationString(query?.duration)
374
+ };
375
+ }
376
+ async function resolveHistoryPaths(query) {
377
+ await waitForDuckDbInitialization();
378
+ if (!storageService.isDuckDbParquetReady()) {
379
+ throw new Error(getDuckDbUnavailableMessage());
380
+ }
381
+ try {
382
+ await storageService.flush();
383
+ }
384
+ catch (flushError) {
385
+ server.error(`[SERIES STORAGE] pre-paths flush failed: ${String(flushError.message || flushError)}`);
386
+ }
387
+ return storageService.getStoredPaths({
388
+ ...(query ?? {})
389
+ });
390
+ }
391
+ async function resolveHistoryContexts(query) {
392
+ await waitForDuckDbInitialization();
393
+ if (!storageService.isDuckDbParquetReady()) {
394
+ throw new Error(getDuckDbUnavailableMessage());
395
+ }
396
+ try {
397
+ await storageService.flush();
398
+ }
399
+ catch (flushError) {
400
+ server.error(`[SERIES STORAGE] pre-contexts flush failed: ${String(flushError.message || flushError)}`);
401
+ }
402
+ return storageService.getStoredContexts({
403
+ ...(query ?? {})
404
+ });
405
+ }
406
+ async function resolveHistoryValues(query) {
407
+ await waitForDuckDbInitialization();
408
+ if (!storageService.isDuckDbParquetReady()) {
409
+ throw new Error(getDuckDbUnavailableMessage());
410
+ }
411
+ try {
412
+ await storageService.flush();
413
+ }
414
+ catch (flushError) {
415
+ server.error(`[SERIES STORAGE] pre-query flush failed: ${String(flushError.message || flushError)}`);
416
+ }
417
+ const values = await storageService.getValues({
418
+ ...query
419
+ });
420
+ if (!values) {
421
+ throw new Error('DuckDB storage did not return history values.');
422
+ }
423
+ return values;
424
+ }
425
+ function registerHistoryProvider() {
426
+ historyApiProviderRegistered = false;
427
+ if (!isHistoryApiProviderEnabled()) {
428
+ server.debug('[HISTORY PROVIDER] Registration disabled by plugin configuration');
429
+ return;
430
+ }
431
+ const host = server;
432
+ const apiProvider = {
433
+ getValues: async (query) => {
434
+ const resolved = await resolveHistoryValues(buildHistoryQueryFromValuesRequest(query));
435
+ return {
436
+ ...resolved,
437
+ values: resolved.values.map(valueSpec => ({
438
+ path: valueSpec.path,
439
+ method: valueSpec.method === 'avg' ? 'average' : valueSpec.method
440
+ }))
441
+ };
442
+ },
443
+ getPaths: (query) => resolveHistoryPaths(buildHistoryQueryFromRangeRequest(query)),
444
+ getContexts: (query) => resolveHistoryContexts(buildHistoryQueryFromRangeRequest(query))
445
+ };
446
+ const registry = host.history && typeof host.history.registerHistoryApiProvider === 'function'
447
+ ? host.history
448
+ : (typeof host.registerHistoryApiProvider === 'function'
449
+ ? {
450
+ registerHistoryApiProvider: host.registerHistoryApiProvider.bind(host),
451
+ unregisterHistoryApiProvider: typeof host.unregisterHistoryApiProvider === 'function'
452
+ ? host.unregisterHistoryApiProvider.bind(host)
453
+ : undefined
454
+ }
455
+ : null);
456
+ if (registry && typeof registry.registerHistoryApiProvider === 'function') {
457
+ registry.registerHistoryApiProvider(apiProvider);
458
+ historyApiProviderRegistered = true;
459
+ if (typeof registry.unregisterHistoryApiProvider === 'function') {
460
+ historyApiRegistry = { unregisterHistoryApiProvider: registry.unregisterHistoryApiProvider.bind(registry) };
461
+ }
462
+ server.debug('[HISTORY PROVIDER] Registered KIP as History API provider');
463
+ return;
464
+ }
465
+ server.debug('[HISTORY PROVIDER] Registration requested but no compatible registration API was found on server host');
466
+ }
467
+ function rebuildSeriesCaptureSubscriptions() {
468
+ stopSeriesCapture();
469
+ if (!isHistorySeriesServiceEnabled()) {
470
+ return;
471
+ }
472
+ const streamBundle = server.streambundle;
473
+ if (!streamBundle || typeof streamBundle.getBus !== 'function') {
474
+ // server.debug('[SERIES CAPTURE] streambundle.getBus not available; capture disabled');
475
+ return;
476
+ }
477
+ const subscriptionCandidates = new Map();
478
+ const addCandidate = (path, allSelfContext) => {
479
+ const normalized = typeof path === 'string' ? path.trim() : '';
480
+ if (!normalized) {
481
+ return;
482
+ }
483
+ const existing = subscriptionCandidates.get(normalized);
484
+ if (!existing) {
485
+ subscriptionCandidates.set(normalized, { allSelfContext });
486
+ return;
487
+ }
488
+ // If any series for this path requires non-self context, force generic bus subscription.
489
+ existing.allSelfContext = existing.allSelfContext && allSelfContext;
490
+ };
491
+ historySeries.listSeries().filter(series => series.enabled !== false).forEach(series => {
492
+ const allSelfContext = (series.context ?? 'vessels.self') === 'vessels.self';
493
+ addCandidate(series.path, allSelfContext);
494
+ // Workaround: subscribe to immediate parent path so object deltas (e.g. navigation.attitude)
495
+ // are captured and flattened into leaf numeric paths (e.g. navigation.attitude.pitch).
496
+ // Remove this fallback when KIP adds first-class object-path capture support.
497
+ const parentIdx = series.path.lastIndexOf('.');
498
+ if (parentIdx > 0) {
499
+ addCandidate(series.path.slice(0, parentIdx), allSelfContext);
500
+ }
501
+ });
502
+ const candidates = Array.from(subscriptionCandidates.entries()).map(([path, meta]) => ({
503
+ path,
504
+ allSelfContext: meta.allSelfContext
505
+ }));
506
+ candidates.forEach(candidate => {
507
+ try {
508
+ const bus = candidate.allSelfContext && typeof streamBundle.getSelfBus === 'function'
509
+ ? streamBundle.getSelfBus(candidate.path)
510
+ : streamBundle.getBus(candidate.path);
511
+ if (!bus || typeof bus.onValue !== 'function') {
512
+ return;
513
+ }
514
+ const unsubscribe = bus.onValue((sample) => {
515
+ try {
516
+ const count = historySeries.recordFromSignalKSample(sample);
517
+ if (count > 0) {
518
+ // server.debug(`[SERIES CAPTURE] path=${candidate.path} recorded=${count}`);
519
+ }
520
+ }
521
+ catch (error) {
522
+ server.error(`[SERIES CAPTURE] failed to record sample path=${candidate.path}: ${String(error.message || error)}`);
523
+ }
524
+ });
525
+ if (typeof unsubscribe === 'function') {
526
+ streamUnsubscribes.push(unsubscribe);
527
+ }
528
+ }
529
+ catch (error) {
530
+ server.error(`[SERIES CAPTURE] failed to subscribe path=${candidate.path}: ${String(error.message || error)}`);
531
+ }
532
+ });
533
+ // server.debug(`[SERIES CAPTURE] activePathSubscriptions=${candidates.length}`);
534
+ }
535
+ function startStorageFlushTimer(intervalMs) {
536
+ if (storageFlushTimer) {
537
+ clearInterval(storageFlushTimer);
538
+ storageFlushTimer = null;
539
+ }
540
+ if (!storageService.isDuckDbParquetEnabled()) {
541
+ return;
542
+ }
543
+ storageFlushTimer = setInterval(() => {
544
+ void storageService.flush()
545
+ .then(result => {
546
+ if (result.inserted > 0 || result.exported > 0) {
547
+ server.debug(`[SERIES STORAGE] flushed inserted=${result.inserted} exported=${result.exported}`);
548
+ }
549
+ })
550
+ .catch(error => {
551
+ server.error(`[SERIES STORAGE] flush failed: ${String(error.message || error)}`);
552
+ });
553
+ }, intervalMs);
554
+ storageFlushTimer.unref?.();
555
+ }
144
556
  const plugin = {
145
557
  id: 'kip',
146
558
  name: 'KIP',
147
559
  description: 'KIP server plugin',
148
560
  start: (settings) => {
149
561
  server.debug(`Starting plugin with settings: ${JSON.stringify(settings)}`);
562
+ const modeConfig = resolveHistoryModeConfig(settings);
563
+ historySeriesServiceEnabled = modeConfig.historySeriesServiceEnabled;
564
+ registerAsHistoryApiProvider = modeConfig.registerAsHistoryApiProvider;
565
+ logOperationalMode('start-configured');
566
+ storageService.setLogger({
567
+ debug: (msg) => server.debug(msg),
568
+ error: (msg) => server.error(msg)
569
+ });
570
+ const storageConfig = storageService.configure(settings);
571
+ server.debug(`[SERIES STORAGE] engine=${storageConfig.engine} db=${storageConfig.databaseFile} parquetDir=${storageConfig.parquetDirectory} flushMs=${storageConfig.flushIntervalMs}`);
572
+ historySeries.setSampleSink(sample => {
573
+ storageService.enqueueSample(sample);
574
+ });
575
+ duckDbInitializationPromise = storageService.initialize();
576
+ void duckDbInitializationPromise.then((ready) => {
577
+ server.debug(`[SERIES STORAGE] duckdbReady=${ready}`);
578
+ if (ready && storageService.isDuckDbParquetEnabled()) {
579
+ if (isHistorySeriesServiceEnabled()) {
580
+ void storageService.getSeriesDefinitions()
581
+ .then((storedSeries) => {
582
+ if (storedSeries.length > 0) {
583
+ historySeries.reconcileSeries(storedSeries);
584
+ rebuildSeriesCaptureSubscriptions();
585
+ }
586
+ startStorageFlushTimer(storageConfig.flushIntervalMs);
587
+ logOperationalMode('duckdb-ready');
588
+ server.setPluginStatus(`KIP plugin started with DuckDB/Parquet history storage. Loaded ${storedSeries.length} persisted series. historySeriesServiceEnabled=${isHistorySeriesServiceEnabled()} historyApiProviderEnabled=${isHistoryApiProviderEnabled()} historyApiProviderRegistered=${historyApiProviderRegistered}`);
589
+ })
590
+ .catch((loadError) => {
591
+ server.error(`[SERIES STORAGE] failed to load persisted series: ${String(loadError.message || loadError)}`);
592
+ startStorageFlushTimer(storageConfig.flushIntervalMs);
593
+ logOperationalMode('duckdb-ready-series-load-failed');
594
+ server.setPluginStatus(`KIP plugin started with DuckDB/Parquet history storage. historySeriesServiceEnabled=${isHistorySeriesServiceEnabled()} historyApiProviderEnabled=${isHistoryApiProviderEnabled()} historyApiProviderRegistered=${historyApiProviderRegistered}`);
595
+ });
596
+ }
597
+ else {
598
+ historySeries.reconcileSeries([]);
599
+ stopSeriesCapture();
600
+ startStorageFlushTimer(storageConfig.flushIntervalMs);
601
+ logOperationalMode('duckdb-ready-series-disabled');
602
+ server.setPluginStatus(`KIP plugin started with history-series service disabled. historyApiProviderEnabled=${isHistoryApiProviderEnabled()} historyApiProviderRegistered=${historyApiProviderRegistered}`);
603
+ }
604
+ return;
605
+ }
606
+ if (storageFlushTimer) {
607
+ clearInterval(storageFlushTimer);
608
+ storageFlushTimer = null;
609
+ }
610
+ const initError = storageService.getLastInitError();
611
+ if (initError) {
612
+ server.setPluginError(`DuckDB unavailable. ${initError}`);
613
+ logOperationalMode('duckdb-unavailable');
614
+ server.setPluginStatus(`KIP plugin started with DuckDB unavailable. historySeriesServiceEnabled=${isHistorySeriesServiceEnabled()} historyApiProviderEnabled=${isHistoryApiProviderEnabled()} historyApiProviderRegistered=${historyApiProviderRegistered}`);
615
+ }
616
+ });
617
+ if (retentionSweepTimer) {
618
+ clearInterval(retentionSweepTimer);
619
+ }
620
+ retentionSweepTimer = setInterval(() => {
621
+ try {
622
+ if (storageService.isDuckDbParquetReady()) {
623
+ const lifecycleToken = storageService.getLifecycleToken();
624
+ void storageService.pruneExpiredSamples(Date.now(), lifecycleToken)
625
+ .then(removedPersistedRows => {
626
+ if (removedPersistedRows > 0) {
627
+ server.debug(`[SERIES RETENTION] duckdbPrune removedRows=${removedPersistedRows}`);
628
+ }
629
+ return storageService.pruneOrphanedSamples(lifecycleToken)
630
+ .then(removedOrphanRows => {
631
+ if (removedOrphanRows > 0) {
632
+ server.debug(`[SERIES RETENTION] duckdbOrphanPrune removedRows=${removedOrphanRows}`);
633
+ }
634
+ });
635
+ })
636
+ .catch(error => {
637
+ server.error(`[SERIES RETENTION] duckdbPrune failed: ${String(error.message || error)}`);
638
+ });
639
+ }
640
+ }
641
+ catch (error) {
642
+ server.error(`[SERIES RETENTION] sweep failed: ${String(error.message || error)}`);
643
+ }
644
+ }, 60 * 60_000);
645
+ retentionSweepTimer.unref?.();
646
+ rebuildSeriesCaptureSubscriptions();
150
647
  if (server.registerPutHandler) {
648
+ server.debug(`[COMMAND TRACE] Registering PUT handlers under context=${PUT_CONTEXT}`);
151
649
  server.registerPutHandler(PUT_CONTEXT, COMMAND_PATHS.SET_DISPLAY, (context, path, value) => {
650
+ server.debug(`[COMMAND TRACE] PUT handler hit path=${String(path)} context=${String(context)} command=${COMMAND_PATHS.SET_DISPLAY}`);
152
651
  void context;
153
652
  void path;
154
653
  return handleSetDisplay(value);
155
654
  }, plugin.id);
156
655
  server.registerPutHandler(PUT_CONTEXT, COMMAND_PATHS.SET_SCREEN_INDEX, (context, path, value) => {
656
+ server.debug(`[COMMAND TRACE] PUT handler hit path=${String(path)} context=${String(context)} command=${COMMAND_PATHS.SET_SCREEN_INDEX}`);
157
657
  void context;
158
658
  void path;
159
659
  return handleScreenWrite(value, 'screenIndex');
160
660
  }, plugin.id);
161
661
  server.registerPutHandler(PUT_CONTEXT, COMMAND_PATHS.REQUEST_ACTIVE_SCREEN, (context, path, value) => {
662
+ server.debug(`[COMMAND TRACE] PUT handler hit path=${String(path)} context=${String(context)} command=${COMMAND_PATHS.REQUEST_ACTIVE_SCREEN}`);
162
663
  void context;
163
664
  void path;
164
665
  return handleScreenWrite(value, 'activeScreen');
165
666
  }, plugin.id);
166
667
  }
668
+ registerHistoryProvider();
669
+ logOperationalMode('post-provider-registration');
167
670
  server.setPluginStatus(`Starting...`);
168
671
  },
169
672
  stop: () => {
170
673
  server.debug(`Stopping plugin`);
674
+ stopSeriesCapture();
675
+ if (retentionSweepTimer) {
676
+ clearInterval(retentionSweepTimer);
677
+ retentionSweepTimer = null;
678
+ }
679
+ if (storageFlushTimer) {
680
+ clearInterval(storageFlushTimer);
681
+ storageFlushTimer = null;
682
+ }
683
+ const storageLifecycleToken = storageService.getLifecycleToken();
684
+ void storageService.flush(storageLifecycleToken)
685
+ .catch(() => undefined)
686
+ .then(() => storageService.close(storageLifecycleToken))
687
+ .catch(() => undefined);
688
+ if (historyApiRegistry) {
689
+ try {
690
+ historyApiRegistry.unregisterHistoryApiProvider();
691
+ server.debug('[HISTORY PROVIDER] Unregistered KIP History API provider');
692
+ }
693
+ catch (error) {
694
+ server.error(`[HISTORY PROVIDER] unregister failed: ${String(error.message || error)}`);
695
+ }
696
+ historyApiRegistry = null;
697
+ }
698
+ duckDbInitializationPromise = null;
171
699
  const msg = 'Stopped.';
172
700
  server.setPluginStatus(msg);
173
701
  },
@@ -176,6 +704,7 @@ const start = (server) => {
176
704
  server.debug(`Registering plugin routes: ${API_PATHS.DISPLAYS}, ${API_PATHS.INSTANCE}, ${API_PATHS.SCREEN_INDEX}, ${API_PATHS.ACTIVATE_SCREEN}`);
177
705
  // Validate/normalize :displayId where present
178
706
  router.param('displayId', (req, res, next, displayId) => {
707
+ logAuthTrace(req, 'router.param:displayId:entry');
179
708
  if (displayId == null)
180
709
  return sendFail(res, 400, 'Missing displayId parameter');
181
710
  try {
@@ -207,13 +736,16 @@ const start = (server) => {
207
736
  return sendFail(res, 400, 'Invalid displayId format');
208
737
  }
209
738
  req.displayId = id;
739
+ server.debug(`[AUTH TRACE] router.param:displayId:normalized displayId=${id}`);
210
740
  next();
211
741
  }
212
742
  catch {
743
+ server.error(`[AUTH TRACE] router.param:displayId:failed rawDisplayId=${String(displayId)}`);
213
744
  return sendFail(res, 400, 'Missing or invalid displayId parameter');
214
745
  }
215
746
  });
216
747
  router.put(`${API_PATHS.INSTANCE}`, async (req, res) => {
748
+ logAuthTrace(req, 'route:PUT:INSTANCE:entry');
217
749
  server.debug(`** PUT ${API_PATHS.INSTANCE}. Params: ${JSON.stringify(req.params)} Body: ${JSON.stringify(req.body)}`);
218
750
  try {
219
751
  const displayId = req.displayId;
@@ -231,6 +763,7 @@ const start = (server) => {
231
763
  }
232
764
  });
233
765
  router.put(`${API_PATHS.SCREEN_INDEX}`, async (req, res) => {
766
+ logAuthTrace(req, 'route:PUT:SCREEN_INDEX:entry');
234
767
  server.debug(`** PUT ${API_PATHS.SCREEN_INDEX}. Params: ${JSON.stringify(req.params)} Body: ${JSON.stringify(req.body)}`);
235
768
  try {
236
769
  const displayId = req.displayId;
@@ -251,6 +784,7 @@ const start = (server) => {
251
784
  }
252
785
  });
253
786
  router.put(`${API_PATHS.ACTIVATE_SCREEN}`, async (req, res) => {
787
+ logAuthTrace(req, 'route:PUT:ACTIVATE_SCREEN:entry');
254
788
  server.debug(`** PUT ${API_PATHS.ACTIVATE_SCREEN}. Params: ${JSON.stringify(req.params)} Body: ${JSON.stringify(req.body)}`);
255
789
  try {
256
790
  const displayId = req.displayId;
@@ -300,11 +834,11 @@ const start = (server) => {
300
834
  return sendFail(res, 400, 'Missing displayId parameter');
301
835
  }
302
836
  const node = getDisplaySelfPath(displayId);
303
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
304
- const screens = node?.value?.screens ?? null;
305
- if (screens === undefined) {
837
+ if (node === undefined) {
306
838
  return sendFail(res, 404, `Display ${displayId} not found`);
307
839
  }
840
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
841
+ const screens = node?.value?.screens ?? null;
308
842
  return sendOk(res, screens);
309
843
  }
310
844
  catch (error) {
@@ -320,11 +854,11 @@ const start = (server) => {
320
854
  return sendFail(res, 400, 'Missing displayId parameter');
321
855
  }
322
856
  const node = getDisplaySelfPath(displayId, 'screenIndex');
323
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
- const idx = node?.value ?? null;
325
- if (idx === undefined) {
857
+ if (node === undefined) {
326
858
  return sendFail(res, 404, `Active screen for display Id ${displayId} not found in path`);
327
859
  }
860
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
861
+ const idx = node?.value ?? null;
328
862
  return sendOk(res, idx);
329
863
  }
330
864
  catch (error) {
@@ -340,11 +874,11 @@ const start = (server) => {
340
874
  return sendFail(res, 400, 'Missing displayId parameter');
341
875
  }
342
876
  const node = getDisplaySelfPath(displayId, 'activeScreen');
343
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
344
- const idx = node?.value ?? null;
345
- if (idx === undefined) {
877
+ if (node === undefined) {
346
878
  return sendFail(res, 404, `Change display screen Id ${displayId} not found in path`);
347
879
  }
880
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
881
+ const idx = node?.value ?? null;
348
882
  return sendOk(res, idx);
349
883
  }
350
884
  catch (error) {
@@ -352,6 +886,127 @@ const start = (server) => {
352
886
  return sendFail(res, 400, error.message);
353
887
  }
354
888
  });
889
+ router.get(API_PATHS.SERIES, async (req, res) => {
890
+ server.debug(`*** GET SERIES ${API_PATHS.SERIES}. Params: ${JSON.stringify(req.params)}`);
891
+ try {
892
+ if (!ensureHistorySeriesServiceEnabledForRequest(res)) {
893
+ return;
894
+ }
895
+ if (!(await ensureDuckDbReadyForRequest(res))) {
896
+ return;
897
+ }
898
+ return sendOk(res, historySeries.listSeries());
899
+ }
900
+ catch (error) {
901
+ server.error(`Error reading series: ${String(error.message || error)}`);
902
+ const mapped = getRouteError(error, 'Failed to read series');
903
+ return sendFail(res, mapped.statusCode, mapped.message);
904
+ }
905
+ });
906
+ router.put(API_PATHS.SERIES_INSTANCE, async (req, res) => {
907
+ server.debug(`** PUT ${API_PATHS.SERIES_INSTANCE}. Params: ${JSON.stringify(req.params)} Body: ${JSON.stringify(req.body)}`);
908
+ try {
909
+ if (!ensureHistorySeriesServiceEnabledForRequest(res)) {
910
+ return;
911
+ }
912
+ if (!(await ensureDuckDbReadyForRequest(res))) {
913
+ return;
914
+ }
915
+ const seriesId = String(req.params.seriesId ?? '').trim();
916
+ if (!seriesId) {
917
+ return sendFail(res, 400, 'Missing seriesId parameter');
918
+ }
919
+ const payload = (req.body ?? {});
920
+ const previous = findSeriesById(seriesId);
921
+ const next = historySeries.upsertSeries({
922
+ ...payload,
923
+ seriesId,
924
+ datasetUuid: String(payload.datasetUuid ?? seriesId)
925
+ });
926
+ try {
927
+ await storageService.upsertSeriesDefinition(next);
928
+ }
929
+ catch (storageError) {
930
+ if (previous) {
931
+ historySeries.upsertSeries(previous);
932
+ }
933
+ else {
934
+ historySeries.deleteSeries(seriesId);
935
+ }
936
+ throw storageError;
937
+ }
938
+ rebuildSeriesCaptureSubscriptions();
939
+ return sendOk(res, next);
940
+ }
941
+ catch (error) {
942
+ server.error(`Error writing series: ${String(error.message || error)}`);
943
+ const mapped = getRouteError(error, 'Failed to write series');
944
+ return sendFail(res, mapped.statusCode, mapped.message);
945
+ }
946
+ });
947
+ router.delete(API_PATHS.SERIES_INSTANCE, async (req, res) => {
948
+ server.debug(`** DELETE ${API_PATHS.SERIES_INSTANCE}. Params: ${JSON.stringify(req.params)}`);
949
+ try {
950
+ if (!ensureHistorySeriesServiceEnabledForRequest(res)) {
951
+ return;
952
+ }
953
+ if (!(await ensureDuckDbReadyForRequest(res))) {
954
+ return;
955
+ }
956
+ const seriesId = String(req.params.seriesId ?? '').trim();
957
+ if (!seriesId) {
958
+ return sendFail(res, 400, 'Missing seriesId parameter');
959
+ }
960
+ const previous = findSeriesById(seriesId);
961
+ if (!previous) {
962
+ return sendFail(res, 404, `Series ${seriesId} not found`);
963
+ }
964
+ await storageService.deleteSeriesDefinition(seriesId);
965
+ historySeries.deleteSeries(seriesId);
966
+ rebuildSeriesCaptureSubscriptions();
967
+ return sendOk(res, { state: 'SUCCESS', statusCode: 200 });
968
+ }
969
+ catch (error) {
970
+ server.error(`Error deleting series: ${String(error.message || error)}`);
971
+ const mapped = getRouteError(error, 'Failed to delete series');
972
+ return sendFail(res, mapped.statusCode, mapped.message);
973
+ }
974
+ });
975
+ router.post(API_PATHS.SERIES_RECONCILE, async (req, res) => {
976
+ server.debug(`** POST ${API_PATHS.SERIES_RECONCILE}. Body: ${JSON.stringify(req.body)}`);
977
+ try {
978
+ if (!ensureHistorySeriesServiceEnabledForRequest(res)) {
979
+ return;
980
+ }
981
+ if (!(await ensureDuckDbReadyForRequest(res))) {
982
+ return;
983
+ }
984
+ const payload = req.body;
985
+ if (!Array.isArray(payload)) {
986
+ return sendFail(res, 400, 'Body must be an array of series definitions');
987
+ }
988
+ const simulated = new history_series_service_1.HistorySeriesService(() => Date.now(), false);
989
+ historySeries.listSeries().forEach(series => {
990
+ simulated.upsertSeries(series);
991
+ });
992
+ const scopedPayload = payload.map(series => ({
993
+ ...series
994
+ }));
995
+ const result = simulated.reconcileSeries(scopedPayload);
996
+ const nextSeries = simulated.listSeries();
997
+ await storageService.replaceSeriesDefinitions(nextSeries);
998
+ const seriesOutsideScope = historySeries.listSeries();
999
+ historySeries.reconcileSeries([...seriesOutsideScope, ...nextSeries]);
1000
+ server.debug(`[SERIES RECONCILE] created=${result.created} updated=${result.updated} deleted=${result.deleted} total=${result.total}`);
1001
+ rebuildSeriesCaptureSubscriptions();
1002
+ return sendOk(res, result);
1003
+ }
1004
+ catch (error) {
1005
+ server.error(`Error reconciling series: ${String(error.message || error)}`);
1006
+ const mapped = getRouteError(error, 'Failed to reconcile series');
1007
+ return sendFail(res, mapped.statusCode, mapped.message);
1008
+ }
1009
+ });
355
1010
  // List all registered routes for debugging
356
1011
  if (router.stack) {
357
1012
  router.stack.forEach((layer) => {
@@ -360,7 +1015,7 @@ const start = (server) => {
360
1015
  }
361
1016
  });
362
1017
  }
363
- server.setPluginStatus(`Providing remote display screen control`);
1018
+ server.setPluginStatus(`Providing remote display screen control and history series API`);
364
1019
  },
365
1020
  getOpenApi: () => mutableOpenApi
366
1021
  };