@mxtommy/kip 4.5.0-beta.1 → 4.5.1

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