@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.
- package/CHANGELOG.md +18 -0
- package/README.md +11 -7
- package/package.json +21 -8
- package/plugin/duckdb-parquet-storage.service.js +1182 -0
- package/plugin/history-series.service.js +439 -0
- package/plugin/index.js +705 -30
- package/plugin/openApi.json +253 -3
- package/plugin/plugin-auth.service.js +75 -0
- package/public/assets/help-docs/chartplotter.md +5 -18
- package/public/assets/help-docs/community.md +0 -3
- package/public/assets/help-docs/configuration.md +1 -1
- package/public/assets/help-docs/contact-us.md +0 -4
- package/public/assets/help-docs/dashboards.md +20 -18
- package/public/assets/help-docs/datainspector.md +7 -5
- package/public/assets/help-docs/history-api.md +116 -0
- package/public/assets/help-docs/menu.json +18 -6
- package/public/assets/help-docs/nodered-control-flows.md +125 -0
- package/public/assets/help-docs/putcontrols.md +101 -60
- package/public/assets/help-docs/welcome.md +6 -7
- package/public/assets/help-docs/widget-historical-series.md +66 -0
- package/public/assets/help-docs/zones.md +5 -10
- package/public/chunk-A6DQJFP4.js +16 -0
- package/public/chunk-B75MT7ND.js +1 -0
- package/public/{chunk-T6TFVZVM.js → chunk-CEB42O2C.js} +1 -1
- package/public/chunk-CHGXAEKT.js +2 -0
- package/public/chunk-D7VDX7ZF.js +5 -0
- package/public/{chunk-ZQER6AIQ.js → chunk-DEGYRCMI.js} +1 -1
- package/public/{chunk-M2B5OYGO.js → chunk-DEM56G4S.js} +1 -1
- package/public/chunk-DYTBBUMI.js +4 -0
- package/public/chunk-EQ2N7KDA.js +3 -0
- package/public/chunk-FNF7M3AE.js +1 -0
- package/public/chunk-IHURI4IH.js +5 -0
- package/public/{chunk-YIYYVDFO.js → chunk-IYRLINL7.js} +2 -2
- package/public/{chunk-5FEX27I4.js → chunk-JB4YVVNW.js} +1 -1
- package/public/chunk-JGGMFMY5.js +1 -0
- package/public/chunk-KPHICV76.js +5 -0
- package/public/{chunk-QZKCRH3H.js → chunk-KZ5DUKAX.js} +1 -1
- package/public/{chunk-HMOOTAEA.js → chunk-LQDSU4WS.js} +3 -3
- package/public/{chunk-IXQ7KIFY.js → chunk-MGPPVLZ7.js} +1 -1
- package/public/{chunk-QVCLOCEC.js → chunk-R7RQHWKJ.js} +1 -1
- package/public/chunk-RONXIZ2U.js +9 -0
- package/public/chunk-S72JTJPN.js +6 -0
- package/public/{chunk-KFFAA7DL.js → chunk-VCY32MWT.js} +8 -8
- package/public/chunk-YCEXTKGG.js +1 -0
- package/public/chunk-YKJKIWXO.js +6 -0
- package/public/chunk-ZV7IYYEQ.js +50 -0
- package/public/index.html +1 -1
- package/public/main-FQESQQV6.js +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -84
- package/.github/ISSUE_TEMPLATE/config.yml +0 -5
- package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -35
- package/.github/copilot-instructions.md +0 -205
- package/.github/instructions/angular.instructions.md +0 -123
- package/.github/instructions/best-practices.instructions.md +0 -59
- package/.github/instructions/project.instructions.md +0 -432
- package/.github/workflows/ci.yml +0 -37
- package/docs/widget-schematic.md +0 -102
- package/images/ActionSidenav.png +0 -0
- package/images/ChartplotterMode.png +0 -0
- package/images/KIPDemo.png +0 -0
- package/images/KipBrightness-1024.png +0 -0
- package/images/KipConfig-Units-1024.png +0 -0
- package/images/KipConfig-display-1024x488.png +0 -0
- package/images/KipFreeboard-SK-1024.png +0 -0
- package/images/KipGaugeSample1-1024x545.png +0 -0
- package/images/KipGaugeSample2-1024x488.png +0 -0
- package/images/KipGaugeSample3-1024x508.png +0 -0
- package/images/KipNightMode-1024.png +0 -0
- package/images/KipWidgetConfig-layout-1024.png +0 -0
- package/images/KipWidgetConfig-paths-1024x488.png +0 -0
- package/images/Options.png +0 -0
- package/images/exterior_user_installs.png +0 -0
- package/images/formfactor.png +0 -0
- package/public/assets/help-docs/datasets.md +0 -95
- package/public/chunk-2OB7ZJBR.js +0 -3
- package/public/chunk-6GGJZDRE.js +0 -1
- package/public/chunk-6V4GGGXE.js +0 -2
- package/public/chunk-A5BW6BUM.js +0 -1
- package/public/chunk-DGE5YFPU.js +0 -5
- package/public/chunk-G6M3Z3BY.js +0 -53
- package/public/chunk-GMGZLXY7.js +0 -4
- package/public/chunk-GUZ3BDVZ.js +0 -2
- package/public/chunk-ICDGHQFP.js +0 -6
- package/public/chunk-JCNE4QHQ.js +0 -15
- package/public/chunk-K6XYUNG4.js +0 -8
- package/public/chunk-LGCQEN7V.js +0 -4
- package/public/chunk-O3JH7UTR.js +0 -1
- package/public/chunk-Q3USFT4F.js +0 -2
- package/public/chunk-VIKU7BH7.js +0 -1
- package/public/chunk-XMQPXXLW.js +0 -8
- package/public/main-4URMGBQS.js +0 -1
- package/rm-npmjs-beta.sh +0 -50
- package/tools/schematics/collection.json +0 -9
- package/tools/schematics/create-host2-widget/files/readme/README.md.template +0 -109
- package/tools/schematics/create-host2-widget/files/spec/widget-__name@dasherize__.component.spec.ts +0 -38
- package/tools/schematics/create-host2-widget/files/widget/widget-__name@dasherize__.component.html +0 -6
- package/tools/schematics/create-host2-widget/files/widget/widget-__name@dasherize__.component.scss +0 -5
- package/tools/schematics/create-host2-widget/files/widget/widget-__name@dasherize__.component.ts.template +0 -94
- package/tools/schematics/create-host2-widget/index.js +0 -138
- package/tools/schematics/create-host2-widget/schema.json +0 -89
- package/tools/schematics/create-host2-widget/test/create-host2-widget.spec.ts +0 -70
- 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
|
-
|
|
55
|
-
type: '
|
|
56
|
-
title: '
|
|
57
|
-
description: '
|
|
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(`
|
|
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(`
|
|
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(
|
|
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(
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(`
|
|
287
|
-
server.debug(`
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(`
|
|
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
|
};
|