@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mxtommy/kip",
3
- "version": "4.7.0-beta.2",
3
+ "version": "4.7.0-beta.4",
4
4
  "description": "An advanced and versatile marine instrumentation package to display Signal K data.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -6,11 +6,14 @@ exports.HistorySeriesService = void 0;
6
6
  */
7
7
  class HistorySeriesService {
8
8
  nowProvider;
9
+ selfContext;
9
10
  seriesById = new Map();
11
+ enabledSeriesKeysByPath = new Map();
10
12
  lastAcceptedTimestampBySeriesKey = new Map();
11
13
  sampleSink = null;
12
- constructor(nowProvider = () => Date.now()) {
14
+ constructor(nowProvider = () => Date.now(), selfContext = null) {
13
15
  this.nowProvider = nowProvider;
16
+ this.selfContext = selfContext;
14
17
  }
15
18
  /**
16
19
  * Returns all configured series sorted by `seriesId`.
@@ -56,6 +59,7 @@ class HistorySeriesService {
56
59
  const normalized = this.normalizeSeries(input);
57
60
  const key = normalized.seriesId;
58
61
  this.seriesById.set(key, normalized);
62
+ this.rebuildEnabledPathIndex();
59
63
  return normalized;
60
64
  }
61
65
  /**
@@ -90,6 +94,7 @@ class HistorySeriesService {
90
94
  this.seriesById.delete(key);
91
95
  this.lastAcceptedTimestampBySeriesKey.delete(key);
92
96
  });
97
+ this.rebuildEnabledPathIndex();
93
98
  return true;
94
99
  }
95
100
  /**
@@ -103,7 +108,7 @@ class HistorySeriesService {
103
108
  * console.log(result.created, result.deleted);
104
109
  */
105
110
  reconcileSeries(desiredSeries) {
106
- const now = Date.now();
111
+ const now = this.nowProvider();
107
112
  const desiredById = new Map();
108
113
  desiredSeries.forEach(entry => {
109
114
  const normalized = this.normalizeSeries(entry);
@@ -122,7 +127,7 @@ class HistorySeriesService {
122
127
  created += 1;
123
128
  return;
124
129
  }
125
- if (JSON.stringify(existing) !== JSON.stringify(desired)) {
130
+ if (!this.areSeriesEquivalent(existing, desired)) {
126
131
  this.seriesById.set(seriesKey, desiredWithReconcile);
127
132
  updated += 1;
128
133
  }
@@ -138,6 +143,7 @@ class HistorySeriesService {
138
143
  deleted += 1;
139
144
  }
140
145
  });
146
+ this.rebuildEnabledPathIndex();
141
147
  return {
142
148
  created,
143
149
  updated,
@@ -157,12 +163,10 @@ class HistorySeriesService {
157
163
  * service.recordSample('abc', 12.4, Date.now());
158
164
  */
159
165
  recordSample(seriesId, value, timestamp) {
160
- const seriesEntry = Array.from(this.seriesById.entries())
161
- .find(([, series]) => series.seriesId === seriesId);
162
- if (!seriesEntry) {
166
+ if (!this.seriesById.has(seriesId)) {
163
167
  return false;
164
168
  }
165
- return this.recordSampleByKey(seriesEntry[0], value, timestamp);
169
+ return this.recordSampleByKey(seriesId, value, timestamp);
166
170
  }
167
171
  recordSampleByKey(seriesKey, value, timestamp) {
168
172
  const series = this.seriesById.get(seriesKey);
@@ -213,8 +217,13 @@ class HistorySeriesService {
213
217
  }
214
218
  let recorded = 0;
215
219
  leafSamples.forEach(leaf => {
216
- this.seriesById.forEach((series, seriesKey) => {
217
- if (series.path !== leaf.path || series.enabled === false) {
220
+ const seriesKeys = this.enabledSeriesKeysByPath.get(leaf.path);
221
+ if (!seriesKeys || seriesKeys.length === 0) {
222
+ return;
223
+ }
224
+ seriesKeys.forEach(seriesKey => {
225
+ const series = this.seriesById.get(seriesKey);
226
+ if (!series) {
218
227
  return;
219
228
  }
220
229
  const seriesContext = series.context ?? 'vessels.self';
@@ -293,6 +302,68 @@ class HistorySeriesService {
293
302
  });
294
303
  return Array.from(contexts).sort();
295
304
  }
305
+ rebuildEnabledPathIndex() {
306
+ this.enabledSeriesKeysByPath.clear();
307
+ this.seriesById.forEach((series, seriesKey) => {
308
+ if (series.enabled === false) {
309
+ return;
310
+ }
311
+ const keys = this.enabledSeriesKeysByPath.get(series.path) ?? [];
312
+ keys.push(seriesKey);
313
+ this.enabledSeriesKeysByPath.set(series.path, keys);
314
+ });
315
+ }
316
+ areSeriesEquivalent(left, right) {
317
+ const leftComparable = this.toComparableSeries(left);
318
+ const rightComparable = this.toComparableSeries(right);
319
+ return leftComparable.seriesId === rightComparable.seriesId
320
+ && leftComparable.datasetUuid === rightComparable.datasetUuid
321
+ && leftComparable.ownerWidgetUuid === rightComparable.ownerWidgetUuid
322
+ && leftComparable.ownerWidgetSelector === rightComparable.ownerWidgetSelector
323
+ && leftComparable.path === rightComparable.path
324
+ && leftComparable.expansionMode === rightComparable.expansionMode
325
+ && this.areStringArraysEquivalent(leftComparable.allowedBatteryIds, rightComparable.allowedBatteryIds)
326
+ && leftComparable.source === rightComparable.source
327
+ && leftComparable.context === rightComparable.context
328
+ && leftComparable.timeScale === rightComparable.timeScale
329
+ && leftComparable.period === rightComparable.period
330
+ && leftComparable.retentionDurationMs === rightComparable.retentionDurationMs
331
+ && leftComparable.sampleTime === rightComparable.sampleTime
332
+ && leftComparable.enabled === rightComparable.enabled
333
+ && this.areStringArraysEquivalent(leftComparable.methods, rightComparable.methods);
334
+ }
335
+ toComparableSeries(series) {
336
+ const { reconcileTs, ...comparable } = series;
337
+ void reconcileTs;
338
+ return {
339
+ ...comparable,
340
+ allowedBatteryIds: this.normalizeComparableStringArray(comparable.allowedBatteryIds),
341
+ methods: this.normalizeComparableStringArray(comparable.methods)
342
+ };
343
+ }
344
+ areStringArraysEquivalent(left, right) {
345
+ const normalizedLeft = this.normalizeComparableStringArray(left) ?? [];
346
+ const normalizedRight = this.normalizeComparableStringArray(right) ?? [];
347
+ if (normalizedLeft.length !== normalizedRight.length) {
348
+ return false;
349
+ }
350
+ return normalizedLeft.every((value, index) => value === normalizedRight[index]);
351
+ }
352
+ normalizeComparableStringArray(values) {
353
+ if (!Array.isArray(values) || values.length === 0) {
354
+ return undefined;
355
+ }
356
+ return [...values]
357
+ .filter((value) => typeof value === 'string')
358
+ .sort((left, right) => left.localeCompare(right));
359
+ }
360
+ isChartWidget(ownerWidgetSelector, ownerWidgetUuid) {
361
+ if (ownerWidgetSelector === 'widget-data-chart' || ownerWidgetSelector === 'widget-windtrends-chart') {
362
+ return true;
363
+ }
364
+ return ownerWidgetUuid?.startsWith('widget-windtrends-chart') === true
365
+ || ownerWidgetUuid?.startsWith('widget-data-chart') === true;
366
+ }
296
367
  normalizeSeries(input) {
297
368
  const seriesId = (input.seriesId || input.datasetUuid || '').trim();
298
369
  if (!seriesId) {
@@ -310,8 +381,8 @@ class HistorySeriesService {
310
381
  if (!path) {
311
382
  throw new Error('path is required');
312
383
  }
313
- // Determine if this is a chart type widget
314
- const isDataWidget = ownerWidgetUuid?.startsWith('widget-windtrends-chart') || ownerWidgetUuid?.startsWith('widget-data-chart');
384
+ const ownerWidgetSelector = typeof input.ownerWidgetSelector === 'string' ? input.ownerWidgetSelector.trim() : undefined;
385
+ const isDataWidget = this.isChartWidget(ownerWidgetSelector, ownerWidgetUuid);
315
386
  const retentionMs = this.resolveRetentionMs(input);
316
387
  let sampleTime;
317
388
  if (isDataWidget) {
@@ -329,6 +400,7 @@ class HistorySeriesService {
329
400
  seriesId,
330
401
  datasetUuid,
331
402
  ownerWidgetUuid,
403
+ ownerWidgetSelector,
332
404
  path,
333
405
  source: input.source ?? 'default',
334
406
  context: input.context ?? 'vessels.self',
@@ -408,11 +480,17 @@ class HistorySeriesService {
408
480
  if (seriesContext === sampleContext) {
409
481
  return true;
410
482
  }
411
- if (seriesContext === 'vessels.self') {
412
- return sampleContext === 'vessels.self' || sampleContext.startsWith('vessels.');
483
+ if (this.isSelfContext(seriesContext) && this.isSelfContext(sampleContext)) {
484
+ return true;
413
485
  }
414
486
  return false;
415
487
  }
488
+ isSelfContext(context) {
489
+ if (context === 'vessels.self') {
490
+ return true;
491
+ }
492
+ return !!this.selfContext && context === this.selfContext;
493
+ }
416
494
  isSourceMatch(seriesSource, sampleSource) {
417
495
  if (seriesSource === '*' || seriesSource === 'any') {
418
496
  return true;
package/plugin/index.js CHANGED
@@ -87,8 +87,8 @@ const start = (server) => {
87
87
  }
88
88
  }
89
89
  };
90
- const historySeries = new history_series_service_1.HistorySeriesService(() => Date.now());
91
- const storageService = new sqlite_history_storage_service_1.SqliteHistoryStorageService();
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
92
  let retentionSweepTimer = null;
93
93
  let storageFlushTimer = null;
94
94
  let sqliteInitializationPromise = null;
@@ -148,6 +148,97 @@ const start = (server) => {
148
148
  nodeSqliteAvailable: nodeSqliteAvailable !== false
149
149
  };
150
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(series => series.expansionMode == null)
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
+ const isBmsTemplate = series.expansionMode === 'bms-battery-tree' && series.ownerWidgetSelector === 'widget-bms';
201
+ if (!isBmsTemplate) {
202
+ expandedById.set(series.seriesId, series);
203
+ return;
204
+ }
205
+ if (discoveredBatteryIds.length === 0) {
206
+ getExistingConcreteBmsSeries(series, existingSeries).forEach(existing => {
207
+ expandedById.set(existing.seriesId, existing);
208
+ });
209
+ return;
210
+ }
211
+ const allowedBatteryIds = Array.isArray(series.allowedBatteryIds)
212
+ ? series.allowedBatteryIds
213
+ .filter((id) => typeof id === 'string')
214
+ .map(id => id.trim())
215
+ .filter(id => id.length > 0)
216
+ : [];
217
+ const allowedSet = allowedBatteryIds.length > 0 ? new Set(allowedBatteryIds) : null;
218
+ const batteryIds = discoveredBatteryIds.filter(id => !allowedSet || allowedSet.has(id));
219
+ if (batteryIds.length === 0) {
220
+ return;
221
+ }
222
+ const source = series.source ?? 'default';
223
+ const sourceKey = slugify(source || 'default') || 'default';
224
+ batteryIds.forEach(batteryId => {
225
+ bmsMetrics.forEach(metric => {
226
+ const path = `self.electrical.batteries.${batteryId}.${metric}`;
227
+ const seriesId = `${series.ownerWidgetUuid}:bms:${batteryId}:${metric}:${sourceKey}`;
228
+ expandedById.set(seriesId, {
229
+ ...series,
230
+ seriesId,
231
+ datasetUuid: `${series.ownerWidgetUuid}:bms:${batteryId}:${metric}:${sourceKey}`,
232
+ path,
233
+ retentionDurationMs: Number.isFinite(series.retentionDurationMs) ? series.retentionDurationMs : 24 * 60 * 60 * 1000,
234
+ expansionMode: null,
235
+ allowedBatteryIds: null
236
+ });
237
+ });
238
+ });
239
+ });
240
+ return Array.from(expandedById.values());
241
+ }
151
242
  function getDisplaySelfPath(displayId, suffix) {
152
243
  const tail = suffix ? `.${suffix}` : '';
153
244
  const want = `displays.${displayId}${tail}`;
@@ -599,9 +690,6 @@ const start = (server) => {
599
690
  if (!modeConfig.nodeSqliteAvailable) {
600
691
  server.error(`[KIP][RUNTIME] node:sqlite unavailable. ${runtimeSqliteUnavailableMessage}`);
601
692
  }
602
- const serverWithApp = server;
603
- const dataDirPath = serverWithApp.app?.getDataDirPath?.();
604
- storageService.setDataDirPath(typeof dataDirPath === 'string' ? dataDirPath : null);
605
693
  storageService.setRuntimeAvailability(modeConfig.nodeSqliteAvailable, runtimeSqliteUnavailableMessage ?? undefined);
606
694
  logRuntimeDependencyVersions();
607
695
  logOperationalMode('start-configured');
@@ -1053,14 +1141,28 @@ const start = (server) => {
1053
1141
  if (!Array.isArray(payload)) {
1054
1142
  return sendFail(res, 400, 'Body must be an array of series definitions');
1055
1143
  }
1056
- const simulated = new history_series_service_1.HistorySeriesService(() => Date.now());
1057
- historySeries.listSeries().forEach(series => {
1144
+ const simulated = new history_series_service_1.HistorySeriesService(() => Date.now(), typeof server.selfId === 'string' && server.selfId.trim().length > 0 ? `vessels.${server.selfId.trim()}` : null);
1145
+ const currentSeries = mergeSeriesDefinitions([
1146
+ ...(await storageService.getSeriesDefinitions()),
1147
+ ...historySeries.listSeries()
1148
+ ]);
1149
+ currentSeries.forEach(series => {
1058
1150
  simulated.upsertSeries(series);
1059
1151
  });
1060
1152
  const scopedPayload = payload.map(series => ({
1061
1153
  ...series
1062
1154
  }));
1063
- const result = simulated.reconcileSeries(scopedPayload);
1155
+ const isBatteryDiscoveryUnavailable = resolveBmsBatteryIdsFromSelfPath().length === 0;
1156
+ const preservedBmsSeries = isBatteryDiscoveryUnavailable
1157
+ ? scopedPayload
1158
+ .filter(series => series.expansionMode === 'bms-battery-tree' && series.ownerWidgetSelector === 'widget-bms')
1159
+ .flatMap(series => currentSeries.filter(current => current.ownerWidgetUuid === series.ownerWidgetUuid && current.expansionMode == null && current.seriesId !== series.seriesId))
1160
+ : [];
1161
+ const expandedPayload = mergeSeriesDefinitions([
1162
+ ...expandTemplateSeriesDefinitions(scopedPayload, currentSeries),
1163
+ ...preservedBmsSeries
1164
+ ]);
1165
+ const result = simulated.reconcileSeries(expandedPayload);
1064
1166
  const nextSeries = simulated.listSeries();
1065
1167
  await storageService.replaceSeriesDefinitions(nextSeries);
1066
1168
  const seriesOutsideScope = historySeries.listSeries();