UNPKG

@mxtommy/kip

Version:

An advanced and versatile marine instrumentation package to display Signal K data.

635 lines (634 loc) 26.2 kB
"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;