@mxtommy/kip
Version:
An advanced and versatile marine instrumentation package to display Signal K data.
635 lines (634 loc) • 26.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.HistorySeriesService = exports.isKipTemplateSeriesDefinition = exports.isKipSolarTemplateSeriesDefinition = exports.isKipSeriesEnabled = exports.isKipElectricalTemplateSeriesDefinition = exports.isKipConcreteSeriesDefinition = exports.isKipBmsTemplateSeriesDefinition = void 0;
const kip_series_contract_1 = require("./kip-series-contract");
Object.defineProperty(exports, "isKipElectricalTemplateSeriesDefinition", { enumerable: true, get: function () { return kip_series_contract_1.isKipElectricalTemplateSeriesDefinition; } });
Object.defineProperty(exports, "isKipBmsTemplateSeriesDefinition", { enumerable: true, get: function () { return kip_series_contract_1.isKipBmsTemplateSeriesDefinition; } });
Object.defineProperty(exports, "isKipConcreteSeriesDefinition", { enumerable: true, get: function () { return kip_series_contract_1.isKipConcreteSeriesDefinition; } });
Object.defineProperty(exports, "isKipSeriesEnabled", { enumerable: true, get: function () { return kip_series_contract_1.isKipSeriesEnabled; } });
Object.defineProperty(exports, "isKipSolarTemplateSeriesDefinition", { enumerable: true, get: function () { return kip_series_contract_1.isKipSolarTemplateSeriesDefinition; } });
Object.defineProperty(exports, "isKipTemplateSeriesDefinition", { enumerable: true, get: function () { return kip_series_contract_1.isKipTemplateSeriesDefinition; } });
/**
* Manages history capture series definitions and serves History API-compatible query results.
*/
class HistorySeriesService {
nowProvider;
selfContext;
seriesById = new Map();
enabledSeriesKeysByPath = new Map();
lastAcceptedTimestampBySeriesKey = new Map();
sampleSink = null;
constructor(nowProvider = () => Date.now(), selfContext = null) {
this.nowProvider = nowProvider;
this.selfContext = selfContext;
}
/**
* Returns all configured series sorted by `seriesId`.
*
* @returns Ordered list of series definitions.
*
* @example
* const list = service.listSeries();
* console.log(list.length);
*/
listSeries() {
return Array.from(this.seriesById.values()).sort((left, right) => {
return left.seriesId.localeCompare(right.seriesId);
});
}
/**
* Finds a series by identifier.
*
* @param {string} seriesId Series identifier.
* @returns {ISeriesDefinition | null} Matching series or null.
*
* @example
* const row = service.findSeriesById('series-1');
*/
findSeriesById(seriesId) {
return this.seriesById.get(seriesId) ?? null;
}
/**
* Creates or updates a series definition.
*
* @param {ISeriesDefinition} input The incoming series definition payload.
* @returns {ISeriesDefinition} The normalized and stored series definition.
*
* @example
* service.upsertSeries({
* seriesId: 'abc',
* datasetUuid: 'abc',
* ownerWidgetUuid: 'widget-1',
* path: 'navigation.speedOverGround'
* });
*/
upsertSeries(input) {
const normalized = this.normalizeSeries(input);
const key = normalized.seriesId;
this.seriesById.set(key, normalized);
this.rebuildEnabledPathIndex();
return normalized;
}
/**
* Registers a callback invoked for every accepted sample.
*
* @param {(sample: IRecordedSeriesSample) => void | null} sink Callback used to forward samples to persistent storage.
* @returns {void}
*
* @example
* service.setSampleSink(sample => console.log(sample.seriesId));
*/
setSampleSink(sink) {
this.sampleSink = sink;
}
/**
* Deletes an existing series definition and all captured samples for the series.
*
* @param {string} seriesId Unique series identifier.
* @returns {boolean} True when a series existed and was deleted.
*
* @example
* const deleted = service.deleteSeries('abc');
*/
deleteSeries(seriesId) {
const keysToDelete = Array.from(this.seriesById.entries())
.filter(([, series]) => series.seriesId === seriesId)
.map(([key]) => key);
if (keysToDelete.length === 0) {
return false;
}
keysToDelete.forEach(key => {
this.seriesById.delete(key);
this.lastAcceptedTimestampBySeriesKey.delete(key);
});
this.rebuildEnabledPathIndex();
return true;
}
/**
* Reconciles the entire desired series set against the current state.
*
* @param {ISeriesDefinition[]} desiredSeries Full desired series payload list.
* @returns {{ created: number; updated: number; deleted: number; total: number }} Reconciliation summary.
*
* @example
* const result = service.reconcileSeries([{ seriesId: 's1', datasetUuid: 's1', ownerWidgetUuid: 'w1', path: 'p' }]);
* console.log(result.created, result.deleted);
*/
reconcileSeries(desiredSeries) {
const now = this.nowProvider();
const desiredById = new Map();
desiredSeries.forEach(entry => {
const normalized = this.normalizeSeries(entry);
const key = normalized.seriesId;
desiredById.set(key, normalized);
});
let created = 0;
let updated = 0;
let deleted = 0;
desiredById.forEach((desired, seriesKey) => {
const existing = this.seriesById.get(seriesKey);
// Always update reconcile_ts on reconcile
const desiredWithReconcile = { ...desired, reconcileTs: now };
if (!existing) {
this.seriesById.set(seriesKey, desiredWithReconcile);
created += 1;
return;
}
if (!this.areSeriesEquivalent(existing, desired)) {
this.seriesById.set(seriesKey, desiredWithReconcile);
updated += 1;
}
else {
// Even if not updated, update reconcileTs
this.seriesById.set(seriesKey, { ...existing, reconcileTs: now });
}
});
Array.from(this.seriesById.keys()).forEach(seriesKey => {
if (!desiredById.has(seriesKey)) {
this.seriesById.delete(seriesKey);
this.lastAcceptedTimestampBySeriesKey.delete(seriesKey);
deleted += 1;
}
});
this.rebuildEnabledPathIndex();
return {
created,
updated,
deleted,
total: this.seriesById.size
};
}
/**
* Records a single numeric sample for a configured series.
*
* @param {string} seriesId Unique series identifier.
* @param {number} value Numeric sample value.
* @param {number} timestamp Sample timestamp in milliseconds.
* @returns {boolean} True when sample was accepted.
*
* @example
* service.recordSample('abc', 12.4, Date.now());
*/
recordSample(seriesId, value, timestamp) {
if (!this.seriesById.has(seriesId)) {
return false;
}
return this.recordSampleByKey(seriesId, value, timestamp);
}
recordSampleByKey(seriesKey, value, timestamp) {
const series = this.seriesById.get(seriesKey);
if (!series || series.enabled === false || !Number.isFinite(value) || !Number.isFinite(timestamp)) {
return false;
}
const previousTimestamp = this.lastAcceptedTimestampBySeriesKey.get(seriesKey);
// Enforces a minimum of 1 second to prevent excessive sampling on short retention durations
const minSampleTime = Math.max(Number(series.sampleTime) || 0, 1000);
if (previousTimestamp !== undefined && (timestamp - previousTimestamp) < minSampleTime) {
return false;
}
const context = series.context ?? 'vessels.self';
const source = series.source ?? 'default';
this.sampleSink?.({
seriesId: series.seriesId,
datasetUuid: series.datasetUuid,
ownerWidgetUuid: series.ownerWidgetUuid,
path: series.path,
context,
source,
timestamp,
value
});
this.lastAcceptedTimestampBySeriesKey.set(seriesKey, timestamp);
return true;
}
/**
* Records a sample based on a Signal K stream value by matching path/context/source against configured series.
*
* @param {{ path?: unknown; value?: unknown; timestamp?: unknown; context?: unknown; source?: unknown; $source?: unknown }} sample Signal K normalized value entry.
* @returns {number} Number of configured series that accepted the sample.
*
* @example
* const count = service.recordFromSignalKSample({ path: 'navigation.speedOverGround', value: 6.2, timestamp: new Date().toISOString() });
*/
recordFromSignalKSample(sample) {
const path = this.normalizePathIdentifier(typeof sample.path === 'string' ? sample.path : '');
if (!path) {
return 0;
}
const ts = this.resolveTimestamp(sample.timestamp);
const context = typeof sample.context === 'string' && sample.context ? sample.context : 'vessels.self';
const source = this.resolveSource(sample);
const leafSamples = this.extractNumericLeafSamples(path, sample.value);
if (leafSamples.length === 0) {
return 0;
}
let recorded = 0;
leafSamples.forEach(leaf => {
const seriesKeys = this.enabledSeriesKeysByPath.get(leaf.path);
if (!seriesKeys || seriesKeys.length === 0) {
return;
}
const hasSpecificSourceMatch = seriesKeys.some(seriesKey => {
const series = this.seriesById.get(seriesKey);
if (!series) {
return false;
}
const seriesContext = series.context ?? 'vessels.self';
if (!this.isContextMatch(seriesContext, context)) {
return false;
}
const seriesSource = series.source ?? 'default';
return seriesSource !== 'default' && seriesSource === source;
});
seriesKeys.forEach(seriesKey => {
const series = this.seriesById.get(seriesKey);
if (!series) {
return;
}
const seriesContext = series.context ?? 'vessels.self';
if (!this.isContextMatch(seriesContext, context)) {
return;
}
const seriesSource = series.source ?? 'default';
if (!this.isSourceMatch(seriesSource, source)) {
return;
}
if (seriesSource === 'default' && source !== 'default' && hasSpecificSourceMatch) {
return;
}
if (this.recordSampleByKey(seriesKey, leaf.value, ts)) {
recorded += 1;
}
});
});
return recorded;
}
extractNumericLeafSamples(basePath, value) {
const samples = [];
const addNumeric = (samplePath, sampleValue) => {
const normalizedPath = this.normalizePathIdentifier(samplePath);
if (!normalizedPath) {
return;
}
const numericValue = Number(sampleValue);
if (!Number.isFinite(numericValue)) {
return;
}
samples.push({ path: normalizedPath, value: numericValue });
};
const walk = (currentPath, currentValue) => {
if (currentValue && typeof currentValue === 'object') {
if (Array.isArray(currentValue)) {
currentValue.forEach((entry, index) => {
walk(`${currentPath}.${index}`, entry);
});
return;
}
Object.entries(currentValue).forEach(([key, child]) => {
walk(`${currentPath}.${key}`, child);
});
return;
}
addNumeric(currentPath, currentValue);
};
walk(basePath, value);
return samples;
}
/**
* Returns all known history paths.
*
* @returns {string[]} Ordered unique path list.
*
* @example
* const paths = service.getPaths();
*/
getPaths() {
const paths = new Set();
this.seriesById.forEach(series => {
paths.add(series.path);
});
return Array.from(paths).sort();
}
/**
* Returns all known history contexts.
*
* @returns {string[]} Ordered unique context list.
*
* @example
* const contexts = service.getContexts();
*/
getContexts() {
const contexts = new Set();
this.seriesById.forEach(series => {
contexts.add(series.context ?? 'vessels.self');
});
return Array.from(contexts).sort();
}
rebuildEnabledPathIndex() {
this.enabledSeriesKeysByPath.clear();
this.seriesById.forEach((series, seriesKey) => {
if (series.enabled === false) {
return;
}
const keys = this.enabledSeriesKeysByPath.get(series.path) ?? [];
keys.push(seriesKey);
this.enabledSeriesKeysByPath.set(series.path, keys);
});
}
areSeriesEquivalent(left, right) {
const leftComparable = this.toComparableSeries(left);
const rightComparable = this.toComparableSeries(right);
return leftComparable.seriesId === rightComparable.seriesId
&& leftComparable.datasetUuid === rightComparable.datasetUuid
&& leftComparable.ownerWidgetUuid === rightComparable.ownerWidgetUuid
&& leftComparable.ownerWidgetSelector === rightComparable.ownerWidgetSelector
&& leftComparable.path === rightComparable.path
&& leftComparable.expansionMode === rightComparable.expansionMode
&& leftComparable.familyKey === rightComparable.familyKey
&& this.areStringArraysEquivalent(leftComparable.allowedIds, rightComparable.allowedIds)
&& this.areTrackedDevicesEquivalent(leftComparable.trackedDevices, rightComparable.trackedDevices)
&& leftComparable.source === rightComparable.source
&& leftComparable.context === rightComparable.context
&& leftComparable.timeScale === rightComparable.timeScale
&& leftComparable.period === rightComparable.period
&& leftComparable.retentionDurationMs === rightComparable.retentionDurationMs
&& leftComparable.sampleTime === rightComparable.sampleTime
&& leftComparable.enabled === rightComparable.enabled
&& this.areStringArraysEquivalent(leftComparable.methods, rightComparable.methods);
}
toComparableSeries(series) {
const { reconcileTs, ...comparable } = series;
void reconcileTs;
return {
...comparable,
allowedIds: this.normalizeComparableStringArray(comparable.allowedIds),
trackedDevices: this.normalizeComparableTrackedDevices(comparable.trackedDevices),
methods: this.normalizeComparableStringArray(comparable.methods)
};
}
areTrackedDevicesEquivalent(left, right) {
const normalizedLeft = this.normalizeComparableTrackedDevices(left) ?? [];
const normalizedRight = this.normalizeComparableTrackedDevices(right) ?? [];
if (normalizedLeft.length !== normalizedRight.length) {
return false;
}
return normalizedLeft.every((value, index) => {
const candidate = normalizedRight[index];
return value.id === candidate?.id && value.source === candidate?.source;
});
}
areStringArraysEquivalent(left, right) {
const normalizedLeft = this.normalizeComparableStringArray(left) ?? [];
const normalizedRight = this.normalizeComparableStringArray(right) ?? [];
if (normalizedLeft.length !== normalizedRight.length) {
return false;
}
return normalizedLeft.every((value, index) => value === normalizedRight[index]);
}
normalizeComparableStringArray(values) {
if (!Array.isArray(values) || values.length === 0) {
return undefined;
}
return [...values]
.filter((value) => typeof value === 'string')
.sort((left, right) => left.localeCompare(right));
}
normalizeComparableTrackedDevices(values) {
if (!Array.isArray(values) || values.length === 0) {
return undefined;
}
const normalizedByKey = new Map();
values.forEach(value => {
if (!value || typeof value !== 'object') {
return;
}
const id = typeof value.id === 'string' ? value.id.trim() : '';
const sourceText = typeof value.source === 'string' ? value.source.trim() : '';
const source = sourceText.length > 0 ? sourceText : 'default';
if (!id) {
return;
}
normalizedByKey.set(`${id}||${source}`, { id, source });
});
if (normalizedByKey.size === 0) {
return undefined;
}
return Array.from(normalizedByKey.values()).sort((left, right) => {
const idCompare = left.id.localeCompare(right.id);
return idCompare !== 0 ? idCompare : left.source.localeCompare(right.source);
});
}
isChartWidget(ownerWidgetSelector, ownerWidgetUuid) {
if (ownerWidgetSelector === 'widget-data-chart' || ownerWidgetSelector === 'widget-windtrends-chart') {
return true;
}
return ownerWidgetUuid?.startsWith('widget-windtrends-chart') === true
|| ownerWidgetUuid?.startsWith('widget-data-chart') === true;
}
normalizeSeries(input) {
const seriesId = (input.seriesId || input.datasetUuid || '').trim();
if (!seriesId) {
throw new Error('seriesId is required');
}
const datasetUuid = (input.datasetUuid || seriesId).trim();
if (!datasetUuid) {
throw new Error('datasetUuid is required');
}
const ownerWidgetUuid = (input.ownerWidgetUuid || '').trim();
if (!ownerWidgetUuid) {
throw new Error('ownerWidgetUuid is required');
}
const path = this.normalizePathIdentifier(input.path || '');
if (!path) {
throw new Error('path is required');
}
const ownerWidgetSelector = typeof input.ownerWidgetSelector === 'string' ? input.ownerWidgetSelector.trim() : null;
const expansionMode = input.expansionMode ?? null;
const familyKey = expansionMode ? this.expansionModeToFamilyKey(expansionMode) : null;
let normalizedTemplateSelector = null;
if (expansionMode) {
const selectorByMode = {
'bms-battery-tree': 'widget-bms',
'solar-tree': 'widget-solar-charger',
'charger-tree': 'widget-charger',
'inverter-tree': 'widget-inverter',
'alternator-tree': 'widget-alternator',
'ac-tree': 'widget-ac'
};
const requiredSelector = selectorByMode[expansionMode];
if (ownerWidgetSelector !== requiredSelector) {
throw new Error(`Template series mode "${expansionMode}" must use ownerWidgetSelector "${requiredSelector}"`);
}
normalizedTemplateSelector = requiredSelector;
}
const normalizedMethods = this.normalizeComparableStringArray(input.methods);
const normalizedAllowedIds = expansionMode
? this.normalizeComparableStringArray(input.allowedIds)
: undefined;
const normalizedTrackedDevices = expansionMode
? this.normalizeComparableTrackedDevices(input.trackedDevices)
: undefined;
const isDataWidget = this.isChartWidget(ownerWidgetSelector, ownerWidgetUuid);
const retentionMs = this.resolveRetentionMs(input);
let sampleTime;
if (isDataWidget) {
// For chart type widgets we use retention duration to dynamically calculate sampleTime to
// aims for around 120 samples.
sampleTime = retentionMs ? Math.max(1000, Math.round(retentionMs / 120)) : 1000;
}
else {
// Non chart type widgets, ie historical Time-Series, have a fixed sampleTime of 15 sec that is
// a good median amount of samples for the dynamically queryable chart display range (15 min up to 24h).
sampleTime = 15000; // ms
}
const normalizedBase = {
seriesId,
datasetUuid,
ownerWidgetUuid,
ownerWidgetSelector,
path,
familyKey,
source: input.source ?? 'default',
context: input.context ?? 'vessels.self',
timeScale: input.timeScale ?? null,
period: Number.isFinite(input.period) ? input.period ?? null : null,
enabled: input.enabled !== false,
retentionDurationMs: retentionMs,
sampleTime,
methods: normalizedMethods,
reconcileTs: input.reconcileTs
};
if (expansionMode) {
const templateSeries = {
...normalizedBase,
ownerWidgetSelector: normalizedTemplateSelector,
expansionMode,
familyKey,
allowedIds: normalizedAllowedIds ?? null,
trackedDevices: normalizedTrackedDevices ?? null
};
return templateSeries;
}
const concreteSeries = {
...normalizedBase,
expansionMode: null,
familyKey: null,
allowedIds: null,
trackedDevices: null
};
return concreteSeries;
}
expansionModeToFamilyKey(mode) {
switch (mode) {
case 'bms-battery-tree':
return 'batteries';
case 'solar-tree':
return 'solar';
case 'charger-tree':
return 'chargers';
case 'inverter-tree':
return 'inverters';
case 'alternator-tree':
return 'alternators';
case 'ac-tree':
return 'ac';
default:
throw new Error(`Unsupported expansion mode: ${String(mode)}`);
}
}
resolveRetentionMs(series) {
if (Number.isFinite(series.retentionDurationMs) && series.retentionDurationMs > 0) {
return series.retentionDurationMs;
}
const period = Number(series.period ?? 0);
const scale = String(series.timeScale ?? '').toLowerCase();
if (scale === 'last minute')
return 60_000;
if (scale === 'last 5 minutes')
return 5 * 60_000;
if (scale === 'last 30 minutes')
return 30 * 60_000;
if (scale === 'second')
return Math.max(0, period) * 1_000;
if (scale === 'minute')
return Math.max(0, period) * 60_000;
if (scale === 'hour')
return Math.max(0, period) * 60 * 60_000;
if (scale === 'day')
return Math.max(0, period) * 24 * 60 * 60_000;
return 24 * 60 * 60_000;
}
normalizePathIdentifier(path) {
const trimmed = String(path).trim();
if (!trimmed) {
return '';
}
if (trimmed.startsWith('vessels.self.')) {
return trimmed.slice('vessels.self.'.length);
}
if (trimmed.startsWith('self.')) {
return trimmed.slice('self.'.length);
}
return trimmed;
}
resolveTimestamp(timestamp) {
if (typeof timestamp === 'number' && Number.isFinite(timestamp)) {
return timestamp;
}
if (typeof timestamp === 'string') {
const parsed = Date.parse(timestamp);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return this.nowProvider();
}
resolveSource(sample) {
if (typeof sample.$source === 'string' && sample.$source.trim().length > 0) {
return sample.$source;
}
if (typeof sample.source === 'string' && sample.source.trim().length > 0) {
return sample.source;
}
if (sample.source && typeof sample.source === 'object') {
const source = sample.source;
const label = typeof source.label === 'string' ? source.label : '';
const src = typeof source.src === 'string' || typeof source.src === 'number' ? String(source.src) : '';
if (label && src) {
return `${label}.${src}`;
}
if (label) {
return label;
}
}
return 'default';
}
isContextMatch(seriesContext, sampleContext) {
if (seriesContext === sampleContext) {
return true;
}
if (this.isSelfContext(seriesContext) && this.isSelfContext(sampleContext)) {
return true;
}
return false;
}
isSelfContext(context) {
if (context === 'vessels.self') {
return true;
}
return !!this.selfContext && context === this.selfContext;
}
isSourceMatch(seriesSource, sampleSource) {
if (seriesSource === '*' || seriesSource === 'any') {
return true;
}
if (seriesSource === 'default') {
return true;
}
if (!sampleSource) {
return false;
}
return seriesSource === sampleSource;
}
}
exports.HistorySeriesService = HistorySeriesService;