@mxtommy/kip 4.7.0-beta.2 → 4.7.0-beta.4

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.
@@ -0,0 +1,1194 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const server_api_1 = require("@signalk/server-api");
37
+ const openapi = __importStar(require("./openApi.json"));
38
+ const history_series_service_1 = require("./history-series.service");
39
+ const sqlite_history_storage_service_1 = require("./sqlite-history-storage.service");
40
+ async function defaultGetSqliteModule() {
41
+ try {
42
+ return await Promise.resolve().then(() => __importStar(require('node:sqlite')));
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ const start = (server) => {
49
+ const mutableOpenApi = JSON.parse(JSON.stringify(openapi.default ?? openapi));
50
+ const API_PATHS = {
51
+ DISPLAYS: `/displays`,
52
+ INSTANCE: `/displays/:displayId`,
53
+ SCREEN_INDEX: `/displays/:displayId/screenIndex`,
54
+ ACTIVATE_SCREEN: `/displays/:displayId/activeScreen`,
55
+ SERIES: '/series',
56
+ SERIES_INSTANCE: '/series/:seriesId',
57
+ SERIES_RECONCILE: '/series/reconcile',
58
+ };
59
+ const PUT_CONTEXT = 'vessels.self';
60
+ const COMMAND_PATHS = {
61
+ SET_DISPLAY: 'kip.remote.setDisplay',
62
+ SET_SCREEN_INDEX: 'kip.remote.setScreenIndex',
63
+ REQUEST_ACTIVE_SCREEN: 'kip.remote.requestActiveScreen'
64
+ };
65
+ const CONFIG_SCHEMA = {
66
+ type: 'object',
67
+ title: 'Remote Control and Data Series',
68
+ 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.',
69
+ properties: {
70
+ nodeSqliteAvailable: {
71
+ type: 'boolean',
72
+ title: 'node:sqlite Available',
73
+ description: 'Indicates if node:sqlite is available in the current runtime (requires Node.js version 22.5.0 or newer). This is set automatically and is read-only.\n\nBefore upgrading Node.js, always verify compatibility with your Signal K server version at https://demo.signalk.org/documentation.',
74
+ readOnly: true
75
+ },
76
+ historySeriesServiceEnabled: {
77
+ type: 'boolean',
78
+ title: 'Enable Automatic Historical Time-Series Capture and Management',
79
+ 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.',
80
+ default: true
81
+ },
82
+ registerAsHistoryApiProvider: {
83
+ type: 'boolean',
84
+ title: 'Enable Query Provider',
85
+ description: 'The built-in History-API query provider is a feature that enables the plugin to respond to History-API requests. If you want to use another History-API provider, disable this option and configure your chosen History-API compatible provider accordingly and KIP will query that provider.',
86
+ default: true
87
+ }
88
+ }
89
+ };
90
+ const historySeries = new history_series_service_1.HistorySeriesService(() => Date.now(), typeof server.selfId === 'string' && server.selfId.trim().length > 0 ? `vessels.${server.selfId.trim()}` : null);
91
+ const storageService = new sqlite_history_storage_service_1.SqliteHistoryStorageService(server.app.getDataDirPath());
92
+ let retentionSweepTimer = null;
93
+ let storageFlushTimer = null;
94
+ let sqliteInitializationPromise = null;
95
+ const SQLITE_INIT_WAIT_TIMEOUT_MS = 5000;
96
+ const MIN_NODE_SQLITE_VERSION = '22.5.0';
97
+ let streamUnsubscribes = [];
98
+ let historyApiProviderRegistered = false;
99
+ let runtimeSqliteUnavailableMessage = null;
100
+ function logRuntimeDependencyVersions() {
101
+ const nodeIdentity = `node@${process.version}`;
102
+ const sqliteAvailability = modeConfig && modeConfig.nodeSqliteAvailable ? 'available' : 'unavailable';
103
+ server.debug(`[KIP][RUNTIME] ${nodeIdentity} node:sqlite=${sqliteAvailability}`);
104
+ }
105
+ async function detectSqliteRuntime() {
106
+ const exportedStart = start;
107
+ const resolveSqliteModule = typeof exportedStart.getSqliteModule === 'function'
108
+ ? exportedStart.getSqliteModule
109
+ : defaultGetSqliteModule;
110
+ const sqliteModule = await resolveSqliteModule();
111
+ if (!sqliteModule) {
112
+ runtimeSqliteUnavailableMessage = `node:sqlite requires Node ${MIN_NODE_SQLITE_VERSION}+`;
113
+ return false;
114
+ }
115
+ if (!sqliteModule.DatabaseSync && !sqliteModule.Database) {
116
+ runtimeSqliteUnavailableMessage = 'node:sqlite module missing required exports';
117
+ return false;
118
+ }
119
+ runtimeSqliteUnavailableMessage = null;
120
+ return true;
121
+ }
122
+ function getSqliteUnavailableMessage() {
123
+ if (!(modeConfig && modeConfig.nodeSqliteAvailable)) {
124
+ return `node:sqlite is not supported in the installed Node.js runtime. Node ${MIN_NODE_SQLITE_VERSION}+ is required.`;
125
+ }
126
+ const details = storageService.getLastInitError();
127
+ return details
128
+ ? `node:sqlite storage unavailable: ${details}`
129
+ : 'node:sqlite storage unavailable';
130
+ }
131
+ function isSqliteUnavailable() {
132
+ return !(modeConfig && modeConfig.nodeSqliteAvailable) || Boolean(storageService.getLastInitError());
133
+ }
134
+ function resolveHistoryModeConfig(settings) {
135
+ const root = (settings && typeof settings === 'object' ? settings : {});
136
+ const historySeriesServiceEnabledSetting = typeof root.historySeriesServiceEnabled === 'boolean'
137
+ ? root.historySeriesServiceEnabled
138
+ : undefined;
139
+ const registerAsHistoryApiProviderSetting = typeof root.registerAsHistoryApiProvider === 'boolean'
140
+ ? root.registerAsHistoryApiProvider
141
+ : undefined;
142
+ const nodeSqliteAvailable = typeof root.nodeSqliteAvailable === 'boolean'
143
+ ? root.nodeSqliteAvailable
144
+ : undefined;
145
+ return {
146
+ historySeriesServiceEnabled: historySeriesServiceEnabledSetting !== false,
147
+ registerAsHistoryApiProvider: registerAsHistoryApiProviderSetting !== false,
148
+ nodeSqliteAvailable: nodeSqliteAvailable !== false
149
+ };
150
+ }
151
+ function slugify(value) {
152
+ return value
153
+ .toLowerCase()
154
+ .replace(/[^a-z0-9]+/g, '-')
155
+ .replace(/^-+|-+$/g, '');
156
+ }
157
+ function resolveBmsBatteryIdsFromSelfPath() {
158
+ const batteriesPath = server.getSelfPath('electrical.batteries');
159
+ const readCandidate = (node) => {
160
+ if (!node || typeof node !== 'object' || Array.isArray(node)) {
161
+ return null;
162
+ }
163
+ const root = node;
164
+ if (Object.prototype.hasOwnProperty.call(root, 'value')) {
165
+ const value = root.value;
166
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
167
+ return value;
168
+ }
169
+ return null;
170
+ }
171
+ return root;
172
+ };
173
+ const candidates = readCandidate(batteriesPath);
174
+ if (!candidates) {
175
+ return [];
176
+ }
177
+ return Object.keys(candidates)
178
+ .filter(id => /^[a-z0-9_-]+$/i.test(id))
179
+ .sort((left, right) => left.localeCompare(right));
180
+ }
181
+ function getExistingConcreteBmsSeries(templateSeries, existingSeries) {
182
+ return existingSeries
183
+ .filter(series => series.ownerWidgetUuid === templateSeries.ownerWidgetUuid)
184
+ .filter(history_series_service_1.isKipConcreteSeriesDefinition)
185
+ .filter(series => series.seriesId !== templateSeries.seriesId)
186
+ .map(series => ({ ...series }));
187
+ }
188
+ function mergeSeriesDefinitions(series) {
189
+ const mergedById = new Map();
190
+ series.forEach(item => {
191
+ mergedById.set(item.seriesId, item);
192
+ });
193
+ return Array.from(mergedById.values());
194
+ }
195
+ function expandTemplateSeriesDefinitions(payload, existingSeries = []) {
196
+ const bmsMetrics = ['capacity.stateOfCharge', 'current'];
197
+ const expandedById = new Map();
198
+ const discoveredBatteryIds = resolveBmsBatteryIdsFromSelfPath();
199
+ payload.forEach(series => {
200
+ if (!(0, history_series_service_1.isKipTemplateSeriesDefinition)(series)) {
201
+ expandedById.set(series.seriesId, series);
202
+ return;
203
+ }
204
+ if (discoveredBatteryIds.length === 0) {
205
+ getExistingConcreteBmsSeries(series, existingSeries).forEach(existing => {
206
+ expandedById.set(existing.seriesId, existing);
207
+ });
208
+ return;
209
+ }
210
+ const allowedBatteryIds = Array.isArray(series.allowedBatteryIds)
211
+ ? series.allowedBatteryIds
212
+ .filter((id) => typeof id === 'string')
213
+ .map(id => id.trim())
214
+ .filter(id => id.length > 0)
215
+ : [];
216
+ const allowedSet = allowedBatteryIds.length > 0 ? new Set(allowedBatteryIds) : null;
217
+ const batteryIds = discoveredBatteryIds.filter(id => !allowedSet || allowedSet.has(id));
218
+ if (batteryIds.length === 0) {
219
+ return;
220
+ }
221
+ const source = series.source ?? 'default';
222
+ const sourceKey = slugify(source || 'default') || 'default';
223
+ batteryIds.forEach(batteryId => {
224
+ bmsMetrics.forEach(metric => {
225
+ const path = `self.electrical.batteries.${batteryId}.${metric}`;
226
+ const seriesId = `${series.ownerWidgetUuid}:bms:${batteryId}:${metric}:${sourceKey}`;
227
+ expandedById.set(seriesId, {
228
+ ...series,
229
+ seriesId,
230
+ datasetUuid: `${series.ownerWidgetUuid}:bms:${batteryId}:${metric}:${sourceKey}`,
231
+ path,
232
+ retentionDurationMs: Number.isFinite(series.retentionDurationMs) ? series.retentionDurationMs : 24 * 60 * 60 * 1000,
233
+ expansionMode: null
234
+ });
235
+ });
236
+ });
237
+ });
238
+ return Array.from(expandedById.values());
239
+ }
240
+ function getDisplaySelfPath(displayId, suffix) {
241
+ const tail = suffix ? `.${suffix}` : '';
242
+ const want = `displays.${displayId}${tail}`;
243
+ const full = server.getSelfPath(want);
244
+ server.debug(`[KIP][SELF_PATH] displayId=${displayId} suffix=${String(suffix ?? '')} requested=${want} resolved=${JSON.stringify(full)}`);
245
+ return typeof full === 'object' && full !== null ? full : undefined;
246
+ }
247
+ function getAvailableDisplays() {
248
+ const fullPath = server.getSelfPath('displays');
249
+ server.debug(`[KIP][DISPLAYS] resolved=${JSON.stringify(fullPath)}`);
250
+ return typeof fullPath === 'object' && fullPath !== null ? fullPath : undefined;
251
+ }
252
+ function sendOk(res, body) {
253
+ if (body === undefined)
254
+ return res.status(204).end();
255
+ return res.status(200).json(body);
256
+ }
257
+ function sendFail(res, statusCode, message) {
258
+ return res.status(statusCode).json({ state: 'FAILED', statusCode, message });
259
+ }
260
+ function getHistorySeriesServiceDisabledMessage() {
261
+ return 'KIP history-series service is disabled by plugin configuration';
262
+ }
263
+ async function waitForSqliteInitialization(timeoutMs = SQLITE_INIT_WAIT_TIMEOUT_MS) {
264
+ if (!sqliteInitializationPromise) {
265
+ return storageService.isSqliteReady();
266
+ }
267
+ try {
268
+ const ready = await Promise.race([
269
+ sqliteInitializationPromise,
270
+ new Promise(resolvePromise => {
271
+ setTimeout(() => resolvePromise(false), timeoutMs);
272
+ })
273
+ ]);
274
+ if (!ready && !storageService.isSqliteReady()) {
275
+ server.error(`[SERIES STORAGE] node:sqlite initialization wait timed out after ${timeoutMs}ms`);
276
+ }
277
+ return ready;
278
+ }
279
+ catch {
280
+ return false;
281
+ }
282
+ }
283
+ function getRouteError(error, fallbackMessage) {
284
+ const message = String(error?.message || fallbackMessage);
285
+ const normalized = message.toLowerCase();
286
+ if (normalized.includes('invalid ')
287
+ || normalized.includes('missing ')
288
+ || normalized.includes('body must')
289
+ || normalized.includes('required')
290
+ || normalized.includes('expected an iso')) {
291
+ return { statusCode: 400, message };
292
+ }
293
+ if (normalized.includes('sqlite')
294
+ || normalized.includes('storage unavailable')
295
+ || normalized.includes('not initialized')
296
+ || isSqliteUnavailable()) {
297
+ return { statusCode: 503, message };
298
+ }
299
+ return { statusCode: 500, message };
300
+ }
301
+ function findSeriesById(seriesId) {
302
+ const current = historySeries.findSeriesById(seriesId);
303
+ return current ? JSON.parse(JSON.stringify(current)) : null;
304
+ }
305
+ function isHistorySeriesServiceEnabled() {
306
+ return !!(modeConfig && modeConfig.historySeriesServiceEnabled && modeConfig.nodeSqliteAvailable);
307
+ }
308
+ function isHistoryApiProviderEnabled() {
309
+ return !!(modeConfig && modeConfig.registerAsHistoryApiProvider && modeConfig.nodeSqliteAvailable);
310
+ }
311
+ function logOperationalMode(stage) {
312
+ server.debug(`[HISTORY MODE] stage=${stage} historySeriesServiceEnabled=${isHistorySeriesServiceEnabled()} historyApiProviderEnabled=${isHistoryApiProviderEnabled()} historyApiProviderRegistered=${historyApiProviderRegistered}`);
313
+ }
314
+ async function ensureSqliteReadyForRequest(res) {
315
+ await waitForSqliteInitialization();
316
+ if (storageService.isSqliteReady()) {
317
+ return true;
318
+ }
319
+ sendFail(res, 503, getSqliteUnavailableMessage());
320
+ return false;
321
+ }
322
+ function ensureHistorySeriesServiceEnabledForRequest(res) {
323
+ if (!(modeConfig && modeConfig.nodeSqliteAvailable)) {
324
+ sendFail(res, 503, getSqliteUnavailableMessage());
325
+ return false;
326
+ }
327
+ if (isHistorySeriesServiceEnabled()) {
328
+ return true;
329
+ }
330
+ sendFail(res, 503, getHistorySeriesServiceDisabledMessage());
331
+ return false;
332
+ }
333
+ function logAuthTrace(req, stage) {
334
+ const hasAuthorizationHeader = typeof req.headers.authorization === 'string' && req.headers.authorization.length > 0;
335
+ const hasCookieHeader = typeof req.headers.cookie === 'string' && req.headers.cookie.length > 0;
336
+ const origin = req.headers.origin ?? null;
337
+ const userAgent = req.headers['user-agent'] ?? null;
338
+ const contentType = req.headers['content-type'] ?? null;
339
+ 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)}`);
340
+ }
341
+ function completed(statusCode, message) {
342
+ return { state: 'COMPLETED', statusCode, message };
343
+ }
344
+ function isValidDisplayId(displayId) {
345
+ return typeof displayId === 'string' && /^[A-Za-z0-9-]+$/.test(displayId);
346
+ }
347
+ function applyDisplayWrite(displayId, suffix, value) {
348
+ const path = suffix ? `displays.${displayId}.${suffix}` : `displays.${displayId}`;
349
+ server.debug(`[KIP][WRITE] applyDisplayWrite path=${path} value=${JSON.stringify(value)}`);
350
+ try {
351
+ server.handleMessage(plugin.id, {
352
+ updates: [
353
+ {
354
+ values: [
355
+ {
356
+ path: path,
357
+ value: value ?? null
358
+ }
359
+ ]
360
+ }
361
+ ]
362
+ }, server_api_1.SKVersion.v1);
363
+ server.debug(`[KIP][WRITE] handleMessage success path=${path}`);
364
+ return completed(200);
365
+ }
366
+ catch (error) {
367
+ const message = error?.message ?? 'Unable to write display path';
368
+ server.error(`[WRITE TRACE] handleMessage failure path=${path} message=${message}`);
369
+ return completed(400, message);
370
+ }
371
+ }
372
+ function handleSetDisplay(value) {
373
+ server.debug(`[KIP][COMMAND] handleSetDisplay payload=${JSON.stringify(value)}`);
374
+ const command = value;
375
+ if (!command || typeof command !== 'object') {
376
+ return completed(400, 'Command payload is required');
377
+ }
378
+ if (!isValidDisplayId(command.displayId)) {
379
+ return completed(400, 'Invalid displayId format');
380
+ }
381
+ const displayValue = command.display ?? null;
382
+ if (displayValue !== null && typeof displayValue !== 'object') {
383
+ return completed(400, 'display must be an object or null');
384
+ }
385
+ return applyDisplayWrite(command.displayId, null, displayValue);
386
+ }
387
+ function handleScreenWrite(value, suffix) {
388
+ server.debug(`[KIP][COMMAND] handleScreenWrite suffix=${suffix} payload=${JSON.stringify(value)}`);
389
+ const command = value;
390
+ if (!command || typeof command !== 'object') {
391
+ return completed(400, 'Command payload is required');
392
+ }
393
+ if (!isValidDisplayId(command.displayId)) {
394
+ return completed(400, 'Invalid displayId format');
395
+ }
396
+ const screenIdxValue = command.screenIdx ?? null;
397
+ if (screenIdxValue !== null && typeof screenIdxValue !== 'number') {
398
+ return completed(400, 'screenIdx must be a number or null');
399
+ }
400
+ return applyDisplayWrite(command.displayId, suffix, screenIdxValue);
401
+ }
402
+ function sendActionAsRest(res, result) {
403
+ server.debug(`[KIP][REST] sendActionAsRest statusCode=${result.statusCode} message=${result.message ?? ''}`);
404
+ if (result.statusCode === 200) {
405
+ return res.status(200).json({ state: 'SUCCESS', statusCode: 200 });
406
+ }
407
+ return sendFail(res, result.statusCode || 400, result.message || 'Command failed');
408
+ }
409
+ function stopSeriesCapture() {
410
+ streamUnsubscribes.forEach(unsub => {
411
+ try {
412
+ unsub();
413
+ }
414
+ catch {
415
+ // ignore unsubscribe failures
416
+ }
417
+ });
418
+ streamUnsubscribes = [];
419
+ }
420
+ function toIsoString(value) {
421
+ if (typeof value === 'string' && value.trim()) {
422
+ return value;
423
+ }
424
+ if (value instanceof Date) {
425
+ return value.toISOString();
426
+ }
427
+ if (value && typeof value === 'object' && typeof value.toString === 'function') {
428
+ const serialized = value.toString();
429
+ return serialized && serialized !== '[object Object]' ? serialized : undefined;
430
+ }
431
+ return undefined;
432
+ }
433
+ function toDurationString(value) {
434
+ if (typeof value === 'number' && Number.isFinite(value)) {
435
+ return value;
436
+ }
437
+ if (typeof value === 'string' && value.trim()) {
438
+ return value;
439
+ }
440
+ if (value && typeof value === 'object' && typeof value.toString === 'function') {
441
+ const serialized = value.toString();
442
+ return serialized && serialized !== '[object Object]' ? serialized : undefined;
443
+ }
444
+ return undefined;
445
+ }
446
+ function normalizeHistoryMethod(method) {
447
+ const raw = String(method ?? 'avg').trim().toLowerCase();
448
+ if (raw === 'average') {
449
+ return 'avg';
450
+ }
451
+ if (raw === 'min' || raw === 'max' || raw === 'sma' || raw === 'ema' || raw === 'avg') {
452
+ return raw;
453
+ }
454
+ return 'avg';
455
+ }
456
+ function normalizeHistoryPath(path) {
457
+ const trimmed = typeof path === 'string' ? path.trim() : '';
458
+ if (!trimmed) {
459
+ return '';
460
+ }
461
+ if (trimmed.startsWith('vessels.self.')) {
462
+ return trimmed.slice('vessels.self.'.length);
463
+ }
464
+ if (trimmed.startsWith('self.')) {
465
+ return trimmed.slice('self.'.length);
466
+ }
467
+ return trimmed;
468
+ }
469
+ function buildPathsFromPathSpecs(pathSpecs) {
470
+ if (!Array.isArray(pathSpecs)) {
471
+ return '';
472
+ }
473
+ const specs = pathSpecs;
474
+ const encoded = specs
475
+ .map(spec => {
476
+ const path = normalizeHistoryPath(spec.path);
477
+ if (!path) {
478
+ return '';
479
+ }
480
+ const method = normalizeHistoryMethod(spec.aggregate);
481
+ const params = Array.isArray(spec.parameter)
482
+ ? spec.parameter.map(item => String(item).trim()).filter(Boolean)
483
+ : [];
484
+ return [path, method, ...params].join(':');
485
+ })
486
+ .filter(Boolean);
487
+ return encoded.join(',');
488
+ }
489
+ function buildHistoryQueryFromValuesRequest(query) {
490
+ const from = toIsoString(query?.from);
491
+ const to = toIsoString(query?.to);
492
+ const duration = toDurationString(query?.duration);
493
+ const paths = buildPathsFromPathSpecs(query?.pathSpecs);
494
+ return {
495
+ paths,
496
+ context: typeof query?.context === 'string' ? query.context : undefined,
497
+ from,
498
+ to,
499
+ duration,
500
+ resolution: typeof query?.resolution === 'number' || typeof query?.resolution === 'string' ? query.resolution : undefined
501
+ };
502
+ }
503
+ function buildHistoryQueryFromRangeRequest(query) {
504
+ return {
505
+ from: toIsoString(query?.from),
506
+ to: toIsoString(query?.to),
507
+ duration: toDurationString(query?.duration)
508
+ };
509
+ }
510
+ async function resolveHistoryPaths(query) {
511
+ await waitForSqliteInitialization();
512
+ if (!storageService.isSqliteReady()) {
513
+ throw new Error(getSqliteUnavailableMessage());
514
+ }
515
+ try {
516
+ await storageService.flush();
517
+ }
518
+ catch (flushError) {
519
+ server.error(`[SERIES STORAGE] pre-paths flush failed: ${String(flushError.message || flushError)}`);
520
+ }
521
+ return storageService.getStoredPaths({
522
+ ...(query ?? {})
523
+ });
524
+ }
525
+ async function resolveHistoryContexts(query) {
526
+ await waitForSqliteInitialization();
527
+ if (!storageService.isSqliteReady()) {
528
+ throw new Error(getSqliteUnavailableMessage());
529
+ }
530
+ try {
531
+ await storageService.flush();
532
+ }
533
+ catch (flushError) {
534
+ server.error(`[SERIES STORAGE] pre-contexts flush failed: ${String(flushError.message || flushError)}`);
535
+ }
536
+ return storageService.getStoredContexts({
537
+ ...(query ?? {})
538
+ });
539
+ }
540
+ async function resolveHistoryValues(query) {
541
+ await waitForSqliteInitialization();
542
+ if (!storageService.isSqliteReady()) {
543
+ throw new Error(getSqliteUnavailableMessage());
544
+ }
545
+ try {
546
+ await storageService.flush();
547
+ }
548
+ catch (flushError) {
549
+ server.error(`[SERIES STORAGE] pre-query flush failed: ${String(flushError.message || flushError)}`);
550
+ }
551
+ const values = await storageService.getValues({
552
+ ...query
553
+ });
554
+ if (!values) {
555
+ throw new Error('node:sqlite storage did not return history values.');
556
+ }
557
+ return values;
558
+ }
559
+ function registerHistoryProvider() {
560
+ historyApiProviderRegistered = false;
561
+ if (!isHistoryApiProviderEnabled()) {
562
+ server.debug('[KIP][HISTORY_PROVIDER] registration skipped reason=config-disabled');
563
+ return;
564
+ }
565
+ const serverWithHistoryApi = server;
566
+ const registerHistoryApiProvider = typeof serverWithHistoryApi.registerHistoryApiProvider === 'function'
567
+ ? serverWithHistoryApi.registerHistoryApiProvider.bind(serverWithHistoryApi)
568
+ : (typeof serverWithHistoryApi.history?.registerHistoryApiProvider === 'function'
569
+ ? serverWithHistoryApi.history.registerHistoryApiProvider.bind(serverWithHistoryApi.history)
570
+ : null);
571
+ // guard when running in SK variants that do not support History API registration
572
+ if (!registerHistoryApiProvider) {
573
+ server.debug('[KIP][HISTORY_PROVIDER] registration skipped reason=api-unavailable');
574
+ return;
575
+ }
576
+ const apiProvider = {
577
+ getValues: async (query) => {
578
+ const resolved = await resolveHistoryValues(buildHistoryQueryFromValuesRequest(query));
579
+ return {
580
+ ...resolved,
581
+ values: resolved.values.map(valueSpec => ({
582
+ path: valueSpec.path,
583
+ method: valueSpec.method === 'avg' ? 'average' : valueSpec.method
584
+ }))
585
+ };
586
+ },
587
+ getPaths: (query) => resolveHistoryPaths(buildHistoryQueryFromRangeRequest(query)),
588
+ getContexts: (query) => resolveHistoryContexts(buildHistoryQueryFromRangeRequest(query))
589
+ };
590
+ registerHistoryApiProvider(apiProvider);
591
+ historyApiProviderRegistered = true;
592
+ server.debug('[KIP][HISTORY_PROVIDER] registration success provider=kip');
593
+ }
594
+ function rebuildSeriesCaptureSubscriptions() {
595
+ stopSeriesCapture();
596
+ if (!isHistorySeriesServiceEnabled()) {
597
+ return;
598
+ }
599
+ const streamBundle = server.streambundle;
600
+ if (!streamBundle || typeof streamBundle.getBus !== 'function') {
601
+ // server.debug('[SERIES CAPTURE] streambundle.getBus not available; capture disabled');
602
+ return;
603
+ }
604
+ const subscriptionCandidates = new Map();
605
+ const addCandidate = (path, allSelfContext) => {
606
+ const normalized = typeof path === 'string' ? path.trim() : '';
607
+ if (!normalized) {
608
+ return;
609
+ }
610
+ const existing = subscriptionCandidates.get(normalized);
611
+ if (!existing) {
612
+ subscriptionCandidates.set(normalized, { allSelfContext });
613
+ return;
614
+ }
615
+ // If any series for this path requires non-self context, force generic bus subscription.
616
+ existing.allSelfContext = existing.allSelfContext && allSelfContext;
617
+ };
618
+ historySeries.listSeries().filter(history_series_service_1.isKipSeriesEnabled).forEach(series => {
619
+ const allSelfContext = (series.context ?? 'vessels.self') === 'vessels.self';
620
+ addCandidate(series.path, allSelfContext);
621
+ // Workaround: subscribe to immediate parent path so object deltas (e.g. navigation.attitude)
622
+ // are captured and flattened into leaf numeric paths (e.g. navigation.attitude.pitch).
623
+ // Remove this fallback when KIP adds first-class object-path capture support.
624
+ const parentIdx = series.path.lastIndexOf('.');
625
+ if (parentIdx > 0) {
626
+ addCandidate(series.path.slice(0, parentIdx), allSelfContext);
627
+ }
628
+ });
629
+ const candidates = Array.from(subscriptionCandidates.entries()).map(([path, meta]) => ({
630
+ path,
631
+ allSelfContext: meta.allSelfContext
632
+ }));
633
+ candidates.forEach(candidate => {
634
+ try {
635
+ const bus = candidate.allSelfContext && typeof streamBundle.getSelfBus === 'function'
636
+ ? streamBundle.getSelfBus(candidate.path)
637
+ : streamBundle.getBus(candidate.path);
638
+ if (!bus || typeof bus.onValue !== 'function') {
639
+ return;
640
+ }
641
+ const unsubscribe = bus.onValue((sample) => {
642
+ try {
643
+ const count = historySeries.recordFromSignalKSample(sample);
644
+ if (count > 0) {
645
+ // server.debug(`[SERIES CAPTURE] path=${candidate.path} recorded=${count}`);
646
+ }
647
+ }
648
+ catch (error) {
649
+ server.error(`[SERIES CAPTURE] failed to record sample path=${candidate.path}: ${String(error.message || error)}`);
650
+ }
651
+ });
652
+ if (typeof unsubscribe === 'function') {
653
+ streamUnsubscribes.push(unsubscribe);
654
+ }
655
+ }
656
+ catch (error) {
657
+ server.error(`[SERIES CAPTURE] failed to subscribe path=${candidate.path}: ${String(error.message || error)}`);
658
+ }
659
+ });
660
+ // server.debug(`[SERIES CAPTURE] activePathSubscriptions=${candidates.length}`);
661
+ }
662
+ function startStorageFlushTimer(intervalMs) {
663
+ if (storageFlushTimer) {
664
+ clearInterval(storageFlushTimer);
665
+ storageFlushTimer = null;
666
+ }
667
+ if (!storageService.isSqliteEnabled()) {
668
+ return;
669
+ }
670
+ storageFlushTimer = setInterval(() => {
671
+ void storageService.flush()
672
+ .catch(error => {
673
+ server.error(`[SERIES STORAGE] flush failed: ${String(error.message || error)}`);
674
+ });
675
+ }, intervalMs);
676
+ storageFlushTimer.unref?.();
677
+ }
678
+ let modeConfig = null;
679
+ const plugin = {
680
+ id: 'kip',
681
+ name: 'KIP',
682
+ description: 'KIP server plugin',
683
+ start: async (settings) => {
684
+ server.debug('[KIP][LIFECYCLE] start');
685
+ modeConfig = resolveHistoryModeConfig(settings);
686
+ // Overwrite runtime-detected properties in modeConfig
687
+ modeConfig.nodeSqliteAvailable = await detectSqliteRuntime();
688
+ if (!modeConfig.nodeSqliteAvailable) {
689
+ server.error(`[KIP][RUNTIME] node:sqlite unavailable. ${runtimeSqliteUnavailableMessage}`);
690
+ }
691
+ storageService.setRuntimeAvailability(modeConfig.nodeSqliteAvailable, runtimeSqliteUnavailableMessage ?? undefined);
692
+ logRuntimeDependencyVersions();
693
+ logOperationalMode('start-configured');
694
+ const needsSqlite = (modeConfig.historySeriesServiceEnabled || modeConfig.registerAsHistoryApiProvider) && modeConfig.nodeSqliteAvailable;
695
+ if (needsSqlite) {
696
+ storageService.setLogger({
697
+ debug: (msg) => server.debug(msg),
698
+ error: (msg) => server.error(msg)
699
+ });
700
+ const storageConfig = storageService.configure();
701
+ server.debug(`[KIP][STORAGE] config engine=${storageConfig.engine} db=${storageConfig.databaseFile} flushMs=${storageConfig.flushIntervalMs}`);
702
+ historySeries.setSampleSink(sample => {
703
+ storageService.enqueueSample(sample);
704
+ });
705
+ sqliteInitializationPromise = storageService.initialize();
706
+ void sqliteInitializationPromise.then((ready) => {
707
+ server.debug(`[KIP][STORAGE] sqliteReady=${ready}`);
708
+ if (ready && storageService.isSqliteEnabled()) {
709
+ if (modeConfig && modeConfig.historySeriesServiceEnabled) {
710
+ void storageService.getSeriesDefinitions()
711
+ .then((storedSeries) => {
712
+ if (storedSeries.length > 0) {
713
+ historySeries.reconcileSeries(storedSeries);
714
+ rebuildSeriesCaptureSubscriptions();
715
+ }
716
+ startStorageFlushTimer(storageConfig.flushIntervalMs);
717
+ logOperationalMode('sqlite-ready');
718
+ server.setPluginStatus(`Providing: Remote Control${historyApiProviderRegistered ? ', History service' : ', No History service'}${storedSeries.length > 0 ? `, ${storedSeries.length} Time-Series` : ', No Time-Series'}.`);
719
+ })
720
+ .catch((loadError) => {
721
+ server.error(`[SERIES STORAGE] failed to load persisted series: ${String(loadError.message || loadError)}`);
722
+ startStorageFlushTimer(storageConfig.flushIntervalMs);
723
+ logOperationalMode('sqlite-ready-series-load-failed');
724
+ server.setPluginStatus(`Providing: Remote Control${historyApiProviderRegistered ? ', History service' : ', No History service'}, No Time-Series.`);
725
+ });
726
+ }
727
+ else {
728
+ historySeries.reconcileSeries([]);
729
+ stopSeriesCapture();
730
+ startStorageFlushTimer(storageConfig.flushIntervalMs);
731
+ logOperationalMode('sqlite-ready-series-disabled');
732
+ server.setPluginStatus(`Providing: Remote Control${historyApiProviderRegistered ? ', History service' : ', No History service'}, No Time-Series.`);
733
+ }
734
+ return;
735
+ }
736
+ if (storageFlushTimer) {
737
+ clearInterval(storageFlushTimer);
738
+ storageFlushTimer = null;
739
+ }
740
+ const initError = storageService.getLastInitError();
741
+ if (initError) {
742
+ server.setPluginError(`node:sqlite unavailable. ${initError}`);
743
+ logOperationalMode('sqlite-unavailable');
744
+ server.setPluginStatus(`Providing: Remote Control${historyApiProviderRegistered ? ', History service' : ', No History service'}, No Time-Series.`);
745
+ }
746
+ });
747
+ if (retentionSweepTimer) {
748
+ clearInterval(retentionSweepTimer);
749
+ }
750
+ retentionSweepTimer = setInterval(() => {
751
+ try {
752
+ if (storageService.isSqliteReady()) {
753
+ const lifecycleToken = storageService.getLifecycleToken();
754
+ void storageService.pruneExpiredSamples(Date.now(), lifecycleToken)
755
+ .then(removedPersistedRows => {
756
+ if (removedPersistedRows > 0) {
757
+ server.debug(`[KIP][RETENTION] pruneExpired removedRows=${removedPersistedRows}`);
758
+ }
759
+ return storageService.pruneOrphanedSamples(lifecycleToken)
760
+ .then(removedOrphanRows => {
761
+ if (removedOrphanRows > 0) {
762
+ server.debug(`[KIP][RETENTION] pruneOrphaned removedRows=${removedOrphanRows}`);
763
+ }
764
+ });
765
+ })
766
+ .catch(error => {
767
+ server.error(`[SERIES RETENTION] node:sqlite Prune failed: ${String(error.message || error)}`);
768
+ });
769
+ }
770
+ }
771
+ catch (error) {
772
+ server.error(`[SERIES RETENTION] node:sqlite sweep failed: ${String(error.message || error)}`);
773
+ }
774
+ }, 60 * 60_000);
775
+ retentionSweepTimer.unref?.();
776
+ rebuildSeriesCaptureSubscriptions();
777
+ }
778
+ else {
779
+ if (modeConfig && !modeConfig.nodeSqliteAvailable && (modeConfig.historySeriesServiceEnabled || modeConfig.registerAsHistoryApiProvider)) {
780
+ server.setPluginStatus(getSqliteUnavailableMessage());
781
+ }
782
+ server.debug('[KIP][STORAGE] sqlite init skipped reason=config-disabled-or-runtime');
783
+ sqliteInitializationPromise = null;
784
+ stopSeriesCapture();
785
+ }
786
+ if (server.registerPutHandler) {
787
+ server.debug(`[KIP][COMMAND] registerPutHandlers context=${PUT_CONTEXT}`);
788
+ server.registerPutHandler(PUT_CONTEXT, COMMAND_PATHS.SET_DISPLAY, (context, path, value) => {
789
+ server.debug(`[KIP][COMMAND] putHandlerHit command=${COMMAND_PATHS.SET_DISPLAY} path=${String(path)} context=${String(context)}`);
790
+ void context;
791
+ void path;
792
+ return handleSetDisplay(value);
793
+ }, plugin.id);
794
+ server.registerPutHandler(PUT_CONTEXT, COMMAND_PATHS.SET_SCREEN_INDEX, (context, path, value) => {
795
+ server.debug(`[KIP][COMMAND] putHandlerHit command=${COMMAND_PATHS.SET_SCREEN_INDEX} path=${String(path)} context=${String(context)}`);
796
+ void context;
797
+ void path;
798
+ return handleScreenWrite(value, 'screenIndex');
799
+ }, plugin.id);
800
+ server.registerPutHandler(PUT_CONTEXT, COMMAND_PATHS.REQUEST_ACTIVE_SCREEN, (context, path, value) => {
801
+ server.debug(`[KIP][COMMAND] putHandlerHit command=${COMMAND_PATHS.REQUEST_ACTIVE_SCREEN} path=${String(path)} context=${String(context)}`);
802
+ void context;
803
+ void path;
804
+ return handleScreenWrite(value, 'activeScreen');
805
+ }, plugin.id);
806
+ }
807
+ registerHistoryProvider();
808
+ logOperationalMode('post-provider-registration');
809
+ server.setPluginStatus(`Starting...`);
810
+ },
811
+ stop: () => {
812
+ server.debug('[KIP][LIFECYCLE] stop');
813
+ stopSeriesCapture();
814
+ if (retentionSweepTimer) {
815
+ clearInterval(retentionSweepTimer);
816
+ retentionSweepTimer = null;
817
+ }
818
+ if (storageFlushTimer) {
819
+ clearInterval(storageFlushTimer);
820
+ storageFlushTimer = null;
821
+ }
822
+ const storageLifecycleToken = storageService.getLifecycleToken();
823
+ void storageService.flush(storageLifecycleToken)
824
+ .catch(() => undefined)
825
+ .then(() => storageService.close(storageLifecycleToken))
826
+ .catch(() => undefined);
827
+ const serverWithHistoryApi = server;
828
+ const unregisterHistoryApiProvider = typeof serverWithHistoryApi.unregisterHistoryApiProvider === 'function'
829
+ ? serverWithHistoryApi.unregisterHistoryApiProvider.bind(serverWithHistoryApi)
830
+ : (typeof serverWithHistoryApi.history?.unregisterHistoryApiProvider === 'function'
831
+ ? serverWithHistoryApi.history.unregisterHistoryApiProvider.bind(serverWithHistoryApi.history)
832
+ : null);
833
+ if (unregisterHistoryApiProvider) {
834
+ unregisterHistoryApiProvider();
835
+ }
836
+ historyApiProviderRegistered = false;
837
+ sqliteInitializationPromise = null;
838
+ const msg = 'Stopped.';
839
+ server.setPluginStatus(msg);
840
+ },
841
+ schema: () => {
842
+ // Return schema with live modeConfig values
843
+ const schema = JSON.parse(JSON.stringify(CONFIG_SCHEMA));
844
+ if (schema && schema.properties && modeConfig) {
845
+ if (typeof modeConfig.nodeSqliteAvailable === 'boolean') {
846
+ schema.properties.nodeSqliteAvailable.default = modeConfig.nodeSqliteAvailable;
847
+ }
848
+ if (typeof modeConfig.historySeriesServiceEnabled === 'boolean') {
849
+ schema.properties.historySeriesServiceEnabled.default = modeConfig.historySeriesServiceEnabled;
850
+ }
851
+ if (typeof modeConfig.registerAsHistoryApiProvider === 'boolean') {
852
+ schema.properties.registerAsHistoryApiProvider.default = modeConfig.registerAsHistoryApiProvider;
853
+ }
854
+ }
855
+ return schema;
856
+ },
857
+ registerWithRouter(router) {
858
+ server.debug(`[KIP][ROUTES] register displays=${API_PATHS.DISPLAYS} instance=${API_PATHS.INSTANCE} screenIndex=${API_PATHS.SCREEN_INDEX} activeScreen=${API_PATHS.ACTIVATE_SCREEN}`);
859
+ // Validate/normalize :displayId where present
860
+ router.param('displayId', (req, res, next, displayId) => {
861
+ logAuthTrace(req, 'router.param:displayId:entry');
862
+ if (displayId == null)
863
+ return sendFail(res, 400, 'Missing displayId parameter');
864
+ try {
865
+ let id = String(displayId);
866
+ // Decode percent-encoding if present
867
+ try {
868
+ id = decodeURIComponent(id);
869
+ }
870
+ catch {
871
+ // ignore decode errors, keep original id
872
+ }
873
+ // If someone sent JSON as the path segment, try to recover {"displayId":"..."}
874
+ if (id.trim().startsWith('{')) {
875
+ try {
876
+ const parsed = JSON.parse(id);
877
+ if (parsed && typeof parsed.displayId === 'string') {
878
+ id = parsed.displayId;
879
+ }
880
+ else {
881
+ return sendFail(res, 400, 'Invalid displayId format in JSON');
882
+ }
883
+ }
884
+ catch {
885
+ return sendFail(res, 400, 'Invalid displayId JSON');
886
+ }
887
+ }
888
+ // Basic safety: allow UUID-like strings (alphanum + dash)
889
+ if (!/^[A-Za-z0-9-]+$/.test(id)) {
890
+ return sendFail(res, 400, 'Invalid displayId format');
891
+ }
892
+ req.displayId = id;
893
+ server.debug(`[KIP][AUTH] displayIdNormalized value=${id}`);
894
+ next();
895
+ }
896
+ catch {
897
+ server.error(`[AUTH TRACE] router.param:displayId:failed rawDisplayId=${String(displayId)}`);
898
+ return sendFail(res, 400, 'Missing or invalid displayId parameter');
899
+ }
900
+ });
901
+ router.put(`${API_PATHS.INSTANCE}`, async (req, res) => {
902
+ logAuthTrace(req, 'route:PUT:INSTANCE:entry');
903
+ server.debug(`[KIP][ROUTE] method=PUT path=${API_PATHS.INSTANCE} params=${JSON.stringify(req.params)} body=${JSON.stringify(req.body)}`);
904
+ try {
905
+ const displayId = req.displayId;
906
+ if (!displayId) {
907
+ return sendFail(res, 400, 'Missing displayId parameter');
908
+ }
909
+ const result = handleSetDisplay({ displayId, display: req.body ?? null });
910
+ return sendActionAsRest(res, result);
911
+ }
912
+ catch (error) {
913
+ const msg = `HandleMessage failed with errors!`;
914
+ server.setPluginError(msg);
915
+ server.error(`Error in HandleMessage: ${error}`);
916
+ return sendFail(res, 400, error.message);
917
+ }
918
+ });
919
+ router.put(`${API_PATHS.SCREEN_INDEX}`, async (req, res) => {
920
+ logAuthTrace(req, 'route:PUT:SCREEN_INDEX:entry');
921
+ server.debug(`[KIP][ROUTE] method=PUT path=${API_PATHS.SCREEN_INDEX} params=${JSON.stringify(req.params)} body=${JSON.stringify(req.body)}`);
922
+ try {
923
+ const displayId = req.displayId;
924
+ if (!displayId) {
925
+ return sendFail(res, 400, 'Missing displayId parameter');
926
+ }
927
+ const result = handleScreenWrite({
928
+ displayId,
929
+ screenIdx: req.body?.screenIdx !== undefined ? req.body.screenIdx : null
930
+ }, 'screenIndex');
931
+ return sendActionAsRest(res, result);
932
+ }
933
+ catch (error) {
934
+ const msg = `HandleMessage failed with errors!`;
935
+ server.setPluginError(msg);
936
+ server.error(`Error in HandleMessage: ${error}`);
937
+ return sendFail(res, 400, error.message);
938
+ }
939
+ });
940
+ router.put(`${API_PATHS.ACTIVATE_SCREEN}`, async (req, res) => {
941
+ logAuthTrace(req, 'route:PUT:ACTIVATE_SCREEN:entry');
942
+ server.debug(`[KIP][ROUTE] method=PUT path=${API_PATHS.ACTIVATE_SCREEN} params=${JSON.stringify(req.params)} body=${JSON.stringify(req.body)}`);
943
+ try {
944
+ const displayId = req.displayId;
945
+ if (!displayId) {
946
+ return sendFail(res, 400, 'Missing displayId parameter');
947
+ }
948
+ const result = handleScreenWrite({
949
+ displayId,
950
+ screenIdx: req.body?.screenIdx !== undefined ? req.body.screenIdx : null
951
+ }, 'activeScreen');
952
+ return sendActionAsRest(res, result);
953
+ }
954
+ catch (error) {
955
+ const msg = `HandleMessage failed with errors!`;
956
+ server.setPluginError(msg);
957
+ server.error(`Error in HandleMessage: ${error}`);
958
+ return sendFail(res, 400, error.message);
959
+ }
960
+ });
961
+ router.get(API_PATHS.DISPLAYS, (req, res) => {
962
+ server.debug(`[KIP][ROUTE] method=GET path=${API_PATHS.DISPLAYS} params=${JSON.stringify(req.params)}`);
963
+ try {
964
+ const displays = getAvailableDisplays();
965
+ const items = displays && typeof displays === 'object'
966
+ ? Object.entries(displays)
967
+ .filter(([, v]) => v && typeof v === 'object')
968
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
969
+ .map(([displayId, v]) => ({
970
+ displayId,
971
+ displayName: v?.value?.displayName ?? null
972
+ }))
973
+ : [];
974
+ server.debug(`[KIP][DISPLAYS] raw=${JSON.stringify(displays)}`);
975
+ server.debug(`[KIP][DISPLAYS] count=${items.length} items=${JSON.stringify(items)}`);
976
+ return res.status(200).json(items);
977
+ }
978
+ catch (error) {
979
+ server.error(`Error reading displays: ${String(error.message || error)}`);
980
+ return sendFail(res, 400, error.message);
981
+ }
982
+ });
983
+ router.get(`${API_PATHS.INSTANCE}`, (req, res) => {
984
+ server.debug(`[KIP][ROUTE] method=GET path=${API_PATHS.INSTANCE} params=${JSON.stringify(req.params)}`);
985
+ try {
986
+ const displayId = req.displayId;
987
+ if (!displayId) {
988
+ return sendFail(res, 400, 'Missing displayId parameter');
989
+ }
990
+ const node = getDisplaySelfPath(displayId);
991
+ if (node === undefined) {
992
+ return sendFail(res, 404, `Display ${displayId} not found`);
993
+ }
994
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
995
+ const screens = node?.value?.screens ?? null;
996
+ return sendOk(res, screens);
997
+ }
998
+ catch (error) {
999
+ server.error(`Error reading display ${req.params?.displayId}: ${String(error.message || error)}`);
1000
+ return sendFail(res, 400, error.message);
1001
+ }
1002
+ });
1003
+ router.get(`${API_PATHS.SCREEN_INDEX}`, (req, res) => {
1004
+ server.debug(`[KIP][ROUTE] method=GET path=${API_PATHS.SCREEN_INDEX} params=${JSON.stringify(req.params)}`);
1005
+ try {
1006
+ const displayId = req.displayId;
1007
+ if (!displayId) {
1008
+ return sendFail(res, 400, 'Missing displayId parameter');
1009
+ }
1010
+ const node = getDisplaySelfPath(displayId, 'screenIndex');
1011
+ if (node === undefined) {
1012
+ return sendFail(res, 404, `Active screen for display Id ${displayId} not found in path`);
1013
+ }
1014
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1015
+ const idx = node?.value ?? null;
1016
+ return sendOk(res, idx);
1017
+ }
1018
+ catch (error) {
1019
+ server.error(`Error reading activeScreen for ${req.params?.displayId}: ${String(error.message || error)}`);
1020
+ return sendFail(res, 400, error.message);
1021
+ }
1022
+ });
1023
+ router.get(`${API_PATHS.ACTIVATE_SCREEN}`, (req, res) => {
1024
+ server.debug(`[KIP][ROUTE] method=GET path=${API_PATHS.ACTIVATE_SCREEN} params=${JSON.stringify(req.params)}`);
1025
+ try {
1026
+ const displayId = req.displayId;
1027
+ if (!displayId) {
1028
+ return sendFail(res, 400, 'Missing displayId parameter');
1029
+ }
1030
+ const node = getDisplaySelfPath(displayId, 'activeScreen');
1031
+ if (node === undefined) {
1032
+ return sendFail(res, 404, `Change display screen Id ${displayId} not found in path`);
1033
+ }
1034
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1035
+ const idx = node?.value ?? null;
1036
+ return sendOk(res, idx);
1037
+ }
1038
+ catch (error) {
1039
+ server.error(`Error reading activeScreen for ${req.params?.displayId}: ${String(error.message || error)}`);
1040
+ return sendFail(res, 400, error.message);
1041
+ }
1042
+ });
1043
+ router.get(API_PATHS.SERIES, async (req, res) => {
1044
+ server.debug(`[KIP][ROUTE] method=GET path=${API_PATHS.SERIES} params=${JSON.stringify(req.params)}`);
1045
+ try {
1046
+ if (!ensureHistorySeriesServiceEnabledForRequest(res)) {
1047
+ return;
1048
+ }
1049
+ if (!(await ensureSqliteReadyForRequest(res))) {
1050
+ return;
1051
+ }
1052
+ return sendOk(res, historySeries.listSeries());
1053
+ }
1054
+ catch (error) {
1055
+ server.error(`Error reading series: ${String(error.message || error)}`);
1056
+ const mapped = getRouteError(error, 'Failed to read series');
1057
+ return sendFail(res, mapped.statusCode, mapped.message);
1058
+ }
1059
+ });
1060
+ router.put(API_PATHS.SERIES_INSTANCE, async (req, res) => {
1061
+ server.debug(`[KIP][ROUTE] method=PUT path=${API_PATHS.SERIES_INSTANCE} params=${JSON.stringify(req.params)} body=${JSON.stringify(req.body)}`);
1062
+ try {
1063
+ if (!ensureHistorySeriesServiceEnabledForRequest(res)) {
1064
+ return;
1065
+ }
1066
+ if (!(await ensureSqliteReadyForRequest(res))) {
1067
+ return;
1068
+ }
1069
+ const seriesId = String(req.params.seriesId ?? '').trim();
1070
+ if (!seriesId) {
1071
+ return sendFail(res, 400, 'Missing seriesId parameter');
1072
+ }
1073
+ const payload = (req.body ?? {});
1074
+ const previous = findSeriesById(seriesId);
1075
+ const next = historySeries.upsertSeries({
1076
+ ...payload,
1077
+ seriesId,
1078
+ datasetUuid: String(payload.datasetUuid ?? seriesId)
1079
+ });
1080
+ try {
1081
+ await storageService.upsertSeriesDefinition(next);
1082
+ }
1083
+ catch (storageError) {
1084
+ if (previous) {
1085
+ historySeries.upsertSeries(previous);
1086
+ }
1087
+ else {
1088
+ historySeries.deleteSeries(seriesId);
1089
+ }
1090
+ throw storageError;
1091
+ }
1092
+ rebuildSeriesCaptureSubscriptions();
1093
+ return sendOk(res, next);
1094
+ }
1095
+ catch (error) {
1096
+ server.error(`Error writing series: ${String(error.message || error)}`);
1097
+ const mapped = getRouteError(error, 'Failed to write series');
1098
+ return sendFail(res, mapped.statusCode, mapped.message);
1099
+ }
1100
+ });
1101
+ router.delete(API_PATHS.SERIES_INSTANCE, async (req, res) => {
1102
+ server.debug(`[KIP][ROUTE] method=DELETE path=${API_PATHS.SERIES_INSTANCE} params=${JSON.stringify(req.params)}`);
1103
+ try {
1104
+ if (!ensureHistorySeriesServiceEnabledForRequest(res)) {
1105
+ return;
1106
+ }
1107
+ if (!(await ensureSqliteReadyForRequest(res))) {
1108
+ return;
1109
+ }
1110
+ const seriesId = String(req.params.seriesId ?? '').trim();
1111
+ if (!seriesId) {
1112
+ return sendFail(res, 400, 'Missing seriesId parameter');
1113
+ }
1114
+ const previous = findSeriesById(seriesId);
1115
+ if (!previous) {
1116
+ return sendFail(res, 404, `Series ${seriesId} not found`);
1117
+ }
1118
+ await storageService.deleteSeriesDefinition(seriesId);
1119
+ historySeries.deleteSeries(seriesId);
1120
+ rebuildSeriesCaptureSubscriptions();
1121
+ return sendOk(res, { state: 'SUCCESS', statusCode: 200 });
1122
+ }
1123
+ catch (error) {
1124
+ server.error(`Error deleting series: ${String(error.message || error)}`);
1125
+ const mapped = getRouteError(error, 'Failed to delete series');
1126
+ return sendFail(res, mapped.statusCode, mapped.message);
1127
+ }
1128
+ });
1129
+ router.post(API_PATHS.SERIES_RECONCILE, async (req, res) => {
1130
+ server.debug(`[KIP][ROUTE] method=POST path=${API_PATHS.SERIES_RECONCILE} body=${JSON.stringify(req.body)}`);
1131
+ try {
1132
+ if (!ensureHistorySeriesServiceEnabledForRequest(res)) {
1133
+ return;
1134
+ }
1135
+ if (!(await ensureSqliteReadyForRequest(res))) {
1136
+ return;
1137
+ }
1138
+ const payload = req.body;
1139
+ if (!Array.isArray(payload)) {
1140
+ return sendFail(res, 400, 'Body must be an array of series definitions');
1141
+ }
1142
+ const simulated = new history_series_service_1.HistorySeriesService(() => Date.now(), typeof server.selfId === 'string' && server.selfId.trim().length > 0 ? `vessels.${server.selfId.trim()}` : null);
1143
+ const currentSeries = mergeSeriesDefinitions([
1144
+ ...(await storageService.getSeriesDefinitions()),
1145
+ ...historySeries.listSeries()
1146
+ ]);
1147
+ currentSeries.forEach(series => {
1148
+ simulated.upsertSeries(series);
1149
+ });
1150
+ const scopedPayload = payload.map(series => ({
1151
+ ...series
1152
+ }));
1153
+ const isBatteryDiscoveryUnavailable = resolveBmsBatteryIdsFromSelfPath().length === 0;
1154
+ const preservedBmsSeries = isBatteryDiscoveryUnavailable
1155
+ ? scopedPayload
1156
+ .filter(history_series_service_1.isKipTemplateSeriesDefinition)
1157
+ .flatMap(series => currentSeries.filter(current => current.ownerWidgetUuid === series.ownerWidgetUuid && (0, history_series_service_1.isKipConcreteSeriesDefinition)(current) && current.seriesId !== series.seriesId))
1158
+ : [];
1159
+ const expandedPayload = mergeSeriesDefinitions([
1160
+ ...expandTemplateSeriesDefinitions(scopedPayload, currentSeries),
1161
+ ...preservedBmsSeries
1162
+ ]);
1163
+ const result = simulated.reconcileSeries(expandedPayload);
1164
+ const nextSeries = simulated.listSeries();
1165
+ await storageService.replaceSeriesDefinitions(nextSeries);
1166
+ const seriesOutsideScope = historySeries.listSeries();
1167
+ historySeries.reconcileSeries([...seriesOutsideScope, ...nextSeries]);
1168
+ server.debug(`[KIP][SERIES_RECONCILE] created=${result.created} updated=${result.updated} deleted=${result.deleted} total=${result.total}`);
1169
+ rebuildSeriesCaptureSubscriptions();
1170
+ return sendOk(res, result);
1171
+ }
1172
+ catch (error) {
1173
+ server.error(`Error reconciling series: ${String(error.message || error)}`);
1174
+ const mapped = getRouteError(error, 'Failed to reconcile series');
1175
+ return sendFail(res, mapped.statusCode, mapped.message);
1176
+ }
1177
+ });
1178
+ // List all registered routes for debugging
1179
+ if (router.stack) {
1180
+ router.stack.forEach((layer) => {
1181
+ if (layer.route && layer.route.path) {
1182
+ server.debug(`[KIP][ROUTES] registered method=${layer.route.stack[0].method.toUpperCase()} path=${layer.route.path}`);
1183
+ }
1184
+ });
1185
+ }
1186
+ server.setPluginStatus(`Providing remote display screen control and history series API`);
1187
+ },
1188
+ getOpenApi: () => mutableOpenApi
1189
+ };
1190
+ return plugin;
1191
+ };
1192
+ const startWithHooks = start;
1193
+ startWithHooks.getSqliteModule = defaultGetSqliteModule;
1194
+ module.exports = start;