From 91a71fc7567a001290a993aa9804f91624b1aa50 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 5 Aug 2026 09:45:07 +0200 Subject: [PATCH 1/9] fix: keep calculation state stable during updates --- main.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/main.js b/main.js index 4a8b8bbd..a380a919 100644 --- a/main.js +++ b/main.js @@ -2486,9 +2486,11 @@ class Sourceanalytix extends utils.Adapter { * @param {object} [stateVal] - object with current value (val) and timestamp (ts) */ async calculationHandler(stateID, stateVal) { + let activeState; try { this.log.debug(`[calculationHandler] Calculation for ${stateID} with values : ${JSON.stringify(stateVal)}`); - this.log.debug(`[calculationHandler] Configuration : ${JSON.stringify(this.activeStates[stateID])}`); + activeState = stateID ? this.activeStates[stateID] : undefined; + this.log.debug(`[calculationHandler] Configuration : ${JSON.stringify(activeState)}`); // Verify if received value is null or undefined if (!stateVal){ @@ -2503,15 +2505,15 @@ class Sourceanalytix extends utils.Adapter { } // Check if for some reason calculation handler ist called for an object not initialised - if (!this.activeStates[stateID]){ + if (!activeState){ this.errorHandling(`calculationHandler`, `Called for non-initialised state ${stateID}`); return; } - const calcValues = this.activeStates[stateID].calcValues; - const stateDetails = this.activeStates[stateID].stateDetails; - const statePrices = this.activeStates[stateID].prices; + const calcValues = activeState.calcValues; + const stateDetails = activeState.stateDetails; + const statePrices = activeState.prices; const currentCath = this.unitPriceDef.unitConfig[stateDetails.stateUnit].category; const targetCath = this.unitPriceDef.unitConfig[stateDetails.useUnit].category; const date = new Date(); @@ -2602,10 +2604,10 @@ class Sourceanalytix extends utils.Adapter { } } - if (this.activeStates[stateID].firstActivation) { + if (activeState.firstActivation) { const initializedStarts = calculation.initializePeriodStartValues(calcValues, reading, true); Object.assign(calcValues, initializedStarts); - this.activeStates[stateID].firstActivation = false; + activeState.firstActivation = false; await this.extendForeignObject(stateID, { common: { custom: { @@ -2622,9 +2624,9 @@ class Sourceanalytix extends utils.Adapter { this.log.debug(`[calculationHandler] ${stateID} set cumulated value ${reading}`); // Update current value to memory - this.activeStates[stateID]['calcValues'].cumulativeValue = reading; + activeState.calcValues.cumulativeValue = reading; // this.visWidgetJson[stateID].cumulativeValue = reading; - this.log.debug(`[calculationHandler] ActiveStatesArray ${JSON.stringify(this.activeStates[stateID])})`); + this.log.debug(`[calculationHandler] ActiveStatesArray ${JSON.stringify(activeState)})`); // Write current reading at device root await this.setStateChangedAsync(`${stateDetails.deviceName}.cumulativeReading`, { @@ -2749,8 +2751,8 @@ class Sourceanalytix extends utils.Adapter { }; if (this.usesHistoricalCostCalculation(stateID)) { const dynamicCalculationRounded = await this.calculateDynamicCostsForState(stateID, reading, readingTimestamp); - if (dynamicCalculationRounded && this.activeStates[stateID].dynamicCosts) { - variableCosts = this.activeStates[stateID].dynamicCosts.totals; + if (dynamicCalculationRounded && activeState.dynamicCosts) { + variableCosts = activeState.dynamicCosts.totals; } } if (stateDetails.costs) Object.assign(calculationRounded, await this.addBasicPriceTotals(stateID, variableCosts, date)); @@ -2774,7 +2776,7 @@ class Sourceanalytix extends utils.Adapter { } catch (error) { - this.errorHandling(`[calculationHandler] ${stateID} with config ${JSON.stringify(this.activeStates[stateID])}`, error); + this.errorHandling(`[calculationHandler] ${stateID} with config ${JSON.stringify(activeState)}`, error); } } From 6eb4f4d2410e63692ccc2863f23e65c88ef6d1b0 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 5 Aug 2026 10:02:05 +0200 Subject: [PATCH 2/9] fix: confirm cumulative meter resets --- README.md | 2 +- lib/calculation.js | 62 +++++++++++++++++++++++++++++++++++++++++----- main.js | 9 +++++++ main.test.js | 56 +++++++++++++++++++++++++++++++++-------- 4 files changed, 112 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c45c6f36..4ce10faf 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ The state is rebuilt from existing statistics when the adapter starts and its wr ## Meter Resets And Corrections -With reset detection enabled, a decrease larger than **Threshold** is treated as a real meter reset or replacement. SourceAnalytix stores an offset and continues its cumulative reading without losing earlier consumption. A smaller backwards change is treated as jitter and ignored. A threshold of `0` treats every decrease as a reset. +With reset detection enabled, a decrease larger than **Threshold** starts reset confirmation. SourceAnalytix keeps the last accepted total until another reading remains in the lower range and confirms the reset or replacement. It then stores an offset and continues its cumulative reading without losing earlier consumption. If the source returns to its previous range instead, the candidate is discarded as a temporary invalid reading. A smaller backwards change is treated as jitter and ignored. A threshold of `0` treats every decrease as a possible reset that still requires confirmation. If reset detection is disabled, decreasing source readings are accepted and can reduce calculated totals. This mode is intended only for sources where that behavior is expected. diff --git a/lib/calculation.js b/lib/calculation.js index e147253b..5ad470cf 100644 --- a/lib/calculation.js +++ b/lib/calculation.js @@ -284,16 +284,25 @@ function classifyCumulativeReading(reading, previousReading, resetDetectionEnabl * @param {number} previousReading - Last accepted cumulative reading * @param {boolean} resetDetectionEnabled - Whether device resets should be detected * @param {number} threshold - Maximum backwards jitter in the target unit - * @returns {{type: 'normal' | 'jitter' | 'reset' | 'decrease' | 'invalid', reading: number, resetOffset: number, decrease: number}} Resolved cumulative reading and offset + * @param {{rawReading: number, previousRawReading: number, previousReading: number} | null} [pendingReset] - Unconfirmed reset candidate + * @returns {{type: 'normal' | 'jitter' | 'resetPending' | 'resetRejected' | 'reset' | 'decrease' | 'invalid', reading: number, resetOffset: number, decrease: number, pendingReset: {rawReading: number, previousRawReading: number, previousReading: number} | null}} Resolved cumulative reading and offset */ -function resolveCumulativeReading(reading, resetOffset, previousReading, resetDetectionEnabled, threshold) { +function resolveCumulativeReading(reading, resetOffset, previousReading, resetDetectionEnabled, threshold, pendingReset = null) { const normalizedOffset = Number.isFinite(resetOffset) ? resetOffset : 0; + const normalizedThreshold = Number.isFinite(threshold) && threshold >= 0 ? threshold : 0; + const validPendingReset = pendingReset + && Number.isFinite(pendingReset.rawReading) + && Number.isFinite(pendingReset.previousRawReading) + && Number.isFinite(pendingReset.previousReading) + ? pendingReset + : null; if (!Number.isFinite(reading)) { return { type: 'invalid', decrease: 0, reading: Number.isFinite(previousReading) ? previousReading : 0, resetOffset: normalizedOffset, + pendingReset: validPendingReset, }; } const cumulativeReading = reading + normalizedOffset; @@ -303,18 +312,59 @@ function resolveCumulativeReading(reading, resetOffset, previousReading, resetDe decrease: 0, reading: Number.isFinite(previousReading) ? previousReading : 0, resetOffset: normalizedOffset, + pendingReset: validPendingReset, + }; + } + + if (resetDetectionEnabled && validPendingReset) { + if (reading + normalizedThreshold >= validPendingReset.previousRawReading) { + const classification = classifyCumulativeReading(cumulativeReading, previousReading, true, normalizedThreshold); + return { + type: 'resetRejected', + decrease: classification.decrease, + reading: classification.type === 'jitter' ? previousReading : cumulativeReading, + resetOffset: normalizedOffset, + pendingReset: null, + }; + } + if (reading >= validPendingReset.rawReading) { + const nextOffset = validPendingReset.previousReading - validPendingReset.rawReading; + return { + type: 'reset', + decrease: validPendingReset.previousRawReading - validPendingReset.rawReading, + reading: reading + nextOffset, + resetOffset: nextOffset, + pendingReset: null, + }; + } + return { + type: 'resetPending', + decrease: validPendingReset.previousRawReading - reading, + reading: validPendingReset.previousReading, + resetOffset: normalizedOffset, + pendingReset: {...validPendingReset, rawReading: reading}, }; } + const classification = classifyCumulativeReading(cumulativeReading, previousReading, resetDetectionEnabled, threshold); if (classification.type === 'jitter') { - return {...classification, reading: previousReading, resetOffset: normalizedOffset}; + return {...classification, reading: previousReading, resetOffset: normalizedOffset, pendingReset: null}; } if (classification.type === 'reset') { - const nextOffset = previousReading - reading; - return {...classification, reading: previousReading, resetOffset: nextOffset}; + return { + type: 'resetPending', + decrease: classification.decrease, + reading: previousReading, + resetOffset: normalizedOffset, + pendingReset: { + rawReading: reading, + previousRawReading: previousReading - normalizedOffset, + previousReading, + }, + }; } - return {...classification, reading: cumulativeReading, resetOffset: normalizedOffset}; + return {...classification, reading: cumulativeReading, resetOffset: normalizedOffset, pendingReset: null}; } /** diff --git a/main.js b/main.js index a380a919..24c3d9f5 100644 --- a/main.js +++ b/main.js @@ -2580,11 +2580,20 @@ class Sourceanalytix extends utils.Adapter { this.getNumberOrDefault(calcValues.cumulativeValue, reading), stateDetails.deviceResetLogicEnabled, this.getNumberOrDefault(stateDetails.threshold, 0), + this.activeStates[stateID].pendingDeviceReset, ); if (resolvedReading.type === 'invalid') { this.log.warn(`[calculationHandler] Ignoring non-finite cumulative reading for ${stateID}`); return; } + this.activeStates[stateID].pendingDeviceReset = resolvedReading.pendingReset; + if (resolvedReading.type === 'resetPending') { + this.log.info(`[calculationHandler] Waiting for another reading before confirming a possible device reset for ${stateID}`); + return; + } + if (resolvedReading.type === 'resetRejected') { + this.log.info(`[calculationHandler] Rejected a possible device reset for ${stateID} after the reading returned to its previous range`); + } if (resolvedReading.type === 'jitter') { this.log.debug(`[calculationHandler] Ignoring cumulative reading jitter of ${resolvedReading.decrease} for ${stateID}`); return; diff --git a/main.test.js b/main.test.js index 22dbfe70..d8fa6ca6 100644 --- a/main.test.js +++ b/main.test.js @@ -518,32 +518,68 @@ describe('period and cumulative calculations', () => { }); describe('resolveCumulativeReading', () => { - it('anchors a reset at the previous cumulative value and continues from there', () => { - assert.deepEqual(resolveCumulativeReading(0, 0, 102, true, 1), { - type: 'reset', decrease: 102, reading: 102, resetOffset: 102, + it('confirms a reset only after another reading continues from the lower range', () => { + const pending = resolveCumulativeReading(0, 0, 102, true, 1); + assert.deepEqual(pending, { + type: 'resetPending', + decrease: 102, + reading: 102, + resetOffset: 0, + pendingReset: {rawReading: 0, previousRawReading: 102, previousReading: 102}, }); - assert.deepEqual(resolveCumulativeReading(1, 102, 102, true, 1), { - type: 'normal', decrease: 0, reading: 103, resetOffset: 102, + assert.deepEqual(resolveCumulativeReading(1, 0, 102, true, 1, pending.pendingReset), { + type: 'reset', decrease: 102, reading: 103, resetOffset: 102, pendingReset: null, }); }); it('supports replacement meters which start above zero', () => { - assert.equal(resolveCumulativeReading(50, 0, 102, true, 1).resetOffset, 52); - assert.equal(resolveCumulativeReading(51, 52, 102, true, 1).reading, 103); + const pending = resolveCumulativeReading(50, 0, 102, true, 1); + const confirmed = resolveCumulativeReading(51, 0, 102, true, 1, pending.pendingReset); + assert.equal(confirmed.resetOffset, 52); + assert.equal(confirmed.reading, 103); + }); + + it('rejects a temporary zero when the meter returns to its previous range', () => { + const pending = resolveCumulativeReading(0, 0, 877.2, true, 1); + assert.deepEqual(resolveCumulativeReading(878.9, 0, 877.2, true, 1, pending.pendingReset), { + type: 'resetRejected', decrease: 0, reading: 878.9, resetOffset: 0, pendingReset: null, + }); + }); + + it('confirms another reset after an earlier persisted reset offset', () => { + const pending = resolveCumulativeReading(0, 100, 102, true, 1); + assert.deepEqual(resolveCumulativeReading(0.25, 100, 102, true, 1, pending.pendingReset), { + type: 'reset', decrease: 2, reading: 102.25, resetOffset: 102, pendingReset: null, + }); + }); + + it('moves a reset candidate down before confirming the new baseline', () => { + const firstCandidate = resolveCumulativeReading(50, 0, 100, true, 1); + const lowerCandidate = resolveCumulativeReading(0, 0, 100, true, 1, firstCandidate.pendingReset); + assert.equal(lowerCandidate.type, 'resetPending'); + assert.ok(lowerCandidate.pendingReset); + assert.equal(lowerCandidate.pendingReset.rawReading, 0); + assert.equal(resolveCumulativeReading(1, 0, 100, true, 1, lowerCandidate.pendingReset).reading, 101); + }); + + it('never creates a reset candidate while reset detection is disabled', () => { + assert.deepEqual(resolveCumulativeReading(50, 0, 100, false, 1), { + type: 'decrease', decrease: 50, reading: 50, resetOffset: 0, pendingReset: null, + }); }); it('keeps small backwards jitter at the accepted high-water mark', () => { assert.deepEqual(resolveCumulativeReading(99.9, 0, 100, true, 0.2), { - type: 'jitter', decrease: 0.09999999999999432, reading: 100, resetOffset: 0, + type: 'jitter', decrease: 0.09999999999999432, reading: 100, resetOffset: 0, pendingReset: null, }); }); it('keeps the last valid reading when the device reports a non-finite value', () => { assert.deepEqual(resolveCumulativeReading(Number.NaN, 12, 100, true, 1), { - type: 'invalid', decrease: 0, reading: 100, resetOffset: 12, + type: 'invalid', decrease: 0, reading: 100, resetOffset: 12, pendingReset: null, }); assert.deepEqual(resolveCumulativeReading(Number.POSITIVE_INFINITY, 12, 100, true, 1), { - type: 'invalid', decrease: 0, reading: 100, resetOffset: 12, + type: 'invalid', decrease: 0, reading: 100, resetOffset: 12, pendingReset: null, }); }); }); From 5fb2b88159fa8bcdd956baf8fdf1d9aec3ae0d78 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 3 Aug 2026 14:13:47 +0200 Subject: [PATCH 3/9] feat: allow custom output IDs --- README.md | 5 +- admin/i18n/de.json | 4 + admin/i18n/en.json | 4 + admin/i18n/es.json | 6 +- admin/i18n/fr.json | 6 +- admin/i18n/it.json | 6 +- admin/i18n/nl.json | 6 +- admin/i18n/pl.json | 6 +- admin/i18n/pt.json | 6 +- admin/i18n/ru.json | 6 +- admin/i18n/uk.json | 6 +- admin/i18n/zh-cn.json | 6 +- admin/jsonCustom.json | 35 +++-- lib/output-id.js | 125 ++++++++++++++++ main.js | 340 +++++++++++++++++++++++++++++++++++++++++- output-id.test.js | 72 +++++++++ package.json | 1 + 17 files changed, 613 insertions(+), 27 deletions(-) create mode 100644 lib/output-id.js create mode 100644 output-id.test.js diff --git a/README.md b/README.md index 4ce10faf..fcf6f9c0 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,8 @@ SourceAnalytix is configured through the ioBroker custom settings of each source | Setting | Description | | --- | --- | | Enabled | Activates this source for the selected SourceAnalytix instance. | -| Alias | Optional display name for the generated device. It does not change the generated state ID. | +| Name | Optional display name for the generated device. | +| Output ID | Technical device ID below `sourceanalytix.`. It is initialized with the backward-compatible source-derived ID and can be shortened. | | Select price definition | Mandatory category from the adapter's price definitions. | | Select Unit | Source unit. Leave on automatic detection when the source object has a correct supported unit. | | Calculate costs | Creates and updates cost or earnings states. | @@ -140,7 +141,7 @@ SourceAnalytix is configured through the ioBroker custom settings of each source | Device value reset detection | Continues a cumulative total after a meter reset or replacement. | | Threshold | Largest backwards fluctuation ignored as measurement jitter, expressed in the target unit. | -The source state ID is converted to the generated SourceAnalytix device ID by replacing dots with double underscores. +For existing and newly activated sources, the initial output ID is derived from the source state ID by replacing dots with double underscores. It may be changed to a shorter unique ID containing letters, numbers, underscores and hyphens. SourceAnalytix copies and verifies its complete generated object tree before deleting the old tree. Existing scripts, visualizations, aliases and external history queries which refer to the old ID must be updated manually. ## Source Values And Units diff --git a/admin/i18n/de.json b/admin/i18n/de.json index 5f1b8b2a..c6f1a06a 100644 --- a/admin/i18n/de.json +++ b/admin/i18n/de.json @@ -39,6 +39,10 @@ "Month": "Monat", "Months": "Monate", "Name": "Name", + "Name help": "Lesbarer Anzeigename des erzeugten SourceAnalytix-Geräts", + "Output ID": "Ausgabe-ID", + "Output ID help": "Technische ID unter sourceanalytix.0. Beim Ändern einer bestehenden ID werden deren Werte migriert; Skripte, Visualisierungen und externe Historienverweise müssen angepasst werden.", + "Output ID format error": "Erlaubt sind 1 bis 128 Buchstaben, Zahlen, Unterstriche oder Bindestriche", "Number of quarters": "Anzahl Quartale", "Number of weeks": "Anzahl Wochen", "Number of years": "Anzahl Jahre", diff --git a/admin/i18n/en.json b/admin/i18n/en.json index f0e2e634..9ef0a9ab 100644 --- a/admin/i18n/en.json +++ b/admin/i18n/en.json @@ -39,6 +39,10 @@ "Month": "Month", "Months": "Months", "Name": "Name", + "Name help": "Readable display name of the generated SourceAnalytix device", + "Output ID": "Output ID", + "Output ID help": "Technical ID below sourceanalytix.0. Changing an existing ID migrates its values, but scripts, visualizations and external history references must be adjusted.", + "Output ID format error": "Use 1 to 128 letters, numbers, underscores or hyphens", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/es.json b/admin/i18n/es.json index bd28dd56..d635a878 100644 --- a/admin/i18n/es.json +++ b/admin/i18n/es.json @@ -93,7 +93,11 @@ "ID": "ID", "Invert selection": "Invert selection", "Last changed": "Last changed", - "Name": "Name", + "Name": "Nombre", + "Name help": "Nombre visible del dispositivo SourceAnalytix generado", + "Output ID": "ID de salida", + "Output ID help": "ID técnica debajo de sourceanalytix.0. Al cambiar una ID existente se migran sus valores; deben adaptarse los scripts, las visualizaciones y las referencias de historial externas.", + "Output ID format error": "Utilice entre 1 y 128 letras, números, guiones bajos o guiones", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/fr.json b/admin/i18n/fr.json index 6dae0f9b..ba2345eb 100644 --- a/admin/i18n/fr.json +++ b/admin/i18n/fr.json @@ -93,7 +93,11 @@ "ID": "ID", "Invert selection": "Invert selection", "Last changed": "Last changed", - "Name": "Name", + "Name": "Nom", + "Name help": "Nom d'affichage lisible du périphérique SourceAnalytix créé", + "Output ID": "ID de sortie", + "Output ID help": "ID technique sous sourceanalytix.0. La modification d'une ID existante migre ses valeurs ; les scripts, visualisations et références d'historique externes doivent être adaptés.", + "Output ID format error": "Utilisez 1 à 128 lettres, chiffres, traits de soulignement ou traits d'union", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/it.json b/admin/i18n/it.json index 9f189233..e9016148 100644 --- a/admin/i18n/it.json +++ b/admin/i18n/it.json @@ -93,7 +93,11 @@ "ID": "ID", "Invert selection": "Invert selection", "Last changed": "Last changed", - "Name": "Name", + "Name": "Nome", + "Name help": "Nome visualizzato del dispositivo SourceAnalytix generato", + "Output ID": "ID di output", + "Output ID help": "ID tecnica sotto sourceanalytix.0. Modificando un'ID esistente vengono migrati i valori; script, visualizzazioni e riferimenti alla cronologia esterna devono essere aggiornati.", + "Output ID format error": "Utilizzare da 1 a 128 lettere, numeri, trattini bassi o trattini", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/nl.json b/admin/i18n/nl.json index 4052c84d..a2d2e7e0 100644 --- a/admin/i18n/nl.json +++ b/admin/i18n/nl.json @@ -93,7 +93,11 @@ "ID": "ID", "Invert selection": "Invert selection", "Last changed": "Last changed", - "Name": "Name", + "Name": "Naam", + "Name help": "Leesbare weergavenaam van het aangemaakte SourceAnalytix-apparaat", + "Output ID": "Uitvoer-ID", + "Output ID help": "Technische ID onder sourceanalytix.0. Bij wijziging van een bestaande ID worden de waarden gemigreerd; scripts, visualisaties en externe historieverwijzingen moeten worden aangepast.", + "Output ID format error": "Gebruik 1 tot 128 letters, cijfers, onderstrepingstekens of koppeltekens", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/pl.json b/admin/i18n/pl.json index 4b802358..c6a3f4a7 100644 --- a/admin/i18n/pl.json +++ b/admin/i18n/pl.json @@ -93,7 +93,11 @@ "ID": "ID", "Invert selection": "Invert selection", "Last changed": "Last changed", - "Name": "Name", + "Name": "Nazwa", + "Name help": "Czytelna nazwa wyświetlana utworzonego urządzenia SourceAnalytix", + "Output ID": "Identyfikator wyjściowy", + "Output ID help": "Identyfikator techniczny w sourceanalytix.0. Zmiana istniejącego identyfikatora przenosi jego wartości; skrypty, wizualizacje i zewnętrzne odwołania do historii wymagają dostosowania.", + "Output ID format error": "Użyj od 1 do 128 liter, cyfr, podkreśleń lub łączników", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/pt.json b/admin/i18n/pt.json index bb0f1a3d..466db7a8 100644 --- a/admin/i18n/pt.json +++ b/admin/i18n/pt.json @@ -93,7 +93,11 @@ "ID": "ID", "Invert selection": "Invert selection", "Last changed": "Last changed", - "Name": "Name", + "Name": "Nome", + "Name help": "Nome de apresentação legível do dispositivo SourceAnalytix criado", + "Output ID": "ID de saída", + "Output ID help": "ID técnica abaixo de sourceanalytix.0. Alterar uma ID existente migra os respetivos valores; scripts, visualizações e referências externas do histórico têm de ser adaptados.", + "Output ID format error": "Utilize entre 1 e 128 letras, números, sublinhados ou hífenes", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/ru.json b/admin/i18n/ru.json index 60ab65c9..200abc8d 100644 --- a/admin/i18n/ru.json +++ b/admin/i18n/ru.json @@ -93,7 +93,11 @@ "ID": "ID", "Invert selection": "Invert selection", "Last changed": "Last changed", - "Name": "Name", + "Name": "Имя", + "Name help": "Понятное отображаемое имя созданного устройства SourceAnalytix", + "Output ID": "ID выхода", + "Output ID help": "Технический ID в sourceanalytix.0. При изменении существующего ID его значения переносятся; скрипты, визуализации и внешние ссылки на историю необходимо изменить.", + "Output ID format error": "Используйте от 1 до 128 букв, цифр, символов подчёркивания или дефисов", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/uk.json b/admin/i18n/uk.json index ce997320..f916801b 100644 --- a/admin/i18n/uk.json +++ b/admin/i18n/uk.json @@ -93,7 +93,11 @@ "ID": "ID", "Invert selection": "Invert selection", "Last changed": "Last changed", - "Name": "Name", + "Name": "Ім'я", + "Name help": "Зрозуміле відображуване ім'я створеного пристрою SourceAnalytix", + "Output ID": "Ідентифікатор виходу", + "Output ID help": "Технічний ідентифікатор у sourceanalytix.0. Під час зміни наявного ідентифікатора його значення переносяться; скрипти, візуалізації та зовнішні посилання на історію потрібно змінити.", + "Output ID format error": "Використовуйте від 1 до 128 літер, цифр, символів підкреслення або дефісів", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/zh-cn.json b/admin/i18n/zh-cn.json index 9f125443..f991c46a 100644 --- a/admin/i18n/zh-cn.json +++ b/admin/i18n/zh-cn.json @@ -93,7 +93,11 @@ "ID": "ID", "Invert selection": "Invert selection", "Last changed": "Last changed", - "Name": "Name", + "Name": "名称", + "Name help": "生成的 SourceAnalytix 设备的易读显示名称", + "Output ID": "输出 ID", + "Output ID help": "sourceanalytix.0 下的技术 ID。更改现有 ID 时会迁移其值,但必须调整脚本、可视化和外部历史记录引用。", + "Output ID format error": "请使用 1 到 128 个字母、数字、下划线或连字符", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/jsonCustom.json b/admin/jsonCustom.json index 2268456e..48b663ae 100644 --- a/admin/jsonCustom.json +++ b/admin/jsonCustom.json @@ -11,23 +11,36 @@ }, "alias": { "type": "text", - "label": "Alias", - "help": "Alias changes only the display name; the generated state ID remains unchanged", + "label": "Name", + "tooltip": "Name help", "xs": 12, "sm": 12, - "md": 5, - "lg": 5, - "xl": 5 + "md": 6, + "lg": 6, + "xl": 6 + }, + "outputId": { + "type": "text", + "label": "Output ID", + "tooltip": "Output ID help", + "validator": "!data.outputId || /^[A-Za-z0-9_-]{1,128}$/.test(data.outputId)", + "validatorErrorText": "Output ID format error", + "xs": 12, + "sm": 12, + "md": 6, + "lg": 6, + "xl": 6 }, "selectedPrice": { + "newLine": true, "command": "getPriceDefinitions", "type": "autocompleteSendTo", "label": "Select price definition", "xs": 12, "sm": 12, - "md": 4, - "lg": 4, - "xl": 4 + "md": 6, + "lg": 6, + "xl": 6 }, "selectedUnit": { "command": "getUnits", @@ -36,9 +49,9 @@ "default": "Detect automatically", "xs": 12, "sm": 12, - "md": 3, - "lg": 3, - "xl": 3 + "md": 6, + "lg": 6, + "xl": 6 }, "_calcSettings": { "newLine": true, diff --git a/lib/output-id.js b/lib/output-id.js new file mode 100644 index 00000000..db323b87 --- /dev/null +++ b/lib/output-id.js @@ -0,0 +1,125 @@ +'use strict'; + +const RESERVED_OUTPUT_IDS = new Set([ + 'info', + 'priceDefinitions', + 'priceHistory', + 'basicPriceHistory', +]); + +/** + * @param {string} sourceId - Full source state ID + * @returns {string} Legacy SourceAnalytix device ID + */ +function getLegacyOutputId(sourceId) { + return String(sourceId || '').split('.').join('__'); +} + +/** + * @param {unknown} configuredOutputId - User-configured output ID + * @param {string} sourceId - Full source state ID + * @returns {string} Configured ID or the backward-compatible legacy ID + */ +function resolveOutputId(configuredOutputId, sourceId) { + if (typeof configuredOutputId === 'string' && configuredOutputId.trim()) return configuredOutputId.trim(); + return getLegacyOutputId(sourceId); +} + +/** + * @param {unknown} outputId - Candidate output ID + * @returns {{valid: boolean, reason: string|null}} Validation result + */ +function validateOutputId(outputId) { + if (typeof outputId !== 'string' || outputId.length === 0) { + return {valid: false, reason: 'must not be empty'}; + } + if (outputId.length > 128) { + return {valid: false, reason: 'must not exceed 128 characters'}; + } + if (!/^[A-Za-z0-9_-]+$/.test(outputId)) { + return {valid: false, reason: 'may contain only letters, numbers, underscores and hyphens'}; + } + if (RESERVED_OUTPUT_IDS.has(outputId)) { + return {valid: false, reason: 'is reserved by SourceAnalytix'}; + } + return {valid: true, reason: null}; +} + +/** + * Validate a configured ID while preserving source-derived IDs created by older versions. + * @param {unknown} configuredOutputId - User-configured output ID + * @param {string} sourceId - Full source state ID + * @returns {{valid: boolean, reason: string|null}} Validation result + */ +function validateResolvedOutputId(configuredOutputId, sourceId) { + const effectiveOutputId = resolveOutputId(configuredOutputId, sourceId); + const validation = validateOutputId(effectiveOutputId); + const explicitlyConfigured = typeof configuredOutputId === 'string' && !!configuredOutputId.trim(); + if (!validation.valid && !explicitlyConfigured + && effectiveOutputId === getLegacyOutputId(sourceId) + && validation.reason !== 'is reserved by SourceAnalytix') { + return {valid: true, reason: null}; + } + return validation; +} + +/** + * @param {string} objectId - Full object ID below the old root + * @param {string} oldRoot - Full old root ID + * @param {string} newRoot - Full new root ID + * @returns {string|null} Mapped object ID, or null when it is outside the tree + */ +function mapOutputTreeId(objectId, oldRoot, newRoot) { + if (objectId === oldRoot) return newRoot; + if (!objectId.startsWith(`${oldRoot}.`)) return null; + return `${newRoot}${objectId.slice(oldRoot.length)}`; +} + +/** + * @param {object} object - ioBroker object + * @param {boolean} root - Whether the object is the output root + * @returns {object} Stable object content used for migration verification + */ +function getComparableObject(object, root) { + const native = JSON.parse(JSON.stringify(object.native || {})); + if (root) { + delete native.sourceState; + delete native.outputIdSchema; + delete native.outputMigration; + } + return { + type: object.type, + common: object.common || {}, + native, + acl: object.acl || null, + }; +} + +/** + * @param {Array<{id: string, object: object}>} sourceObjects - Objects below the old root + * @param {Array<{id: string, object: object}>} targetObjects - Objects below the new root + * @param {string} oldRoot - Full old root ID + * @param {string} newRoot - Full new root ID + * @returns {boolean} Whether IDs and persisted object content match + */ +function verifyMappedObjects(sourceObjects, targetObjects, oldRoot, newRoot) { + if (sourceObjects.length !== targetObjects.length) return false; + const targets = new Map(targetObjects.map(entry => [entry.id, entry.object])); + return sourceObjects.every(entry => { + const targetId = mapOutputTreeId(entry.id, oldRoot, newRoot); + const target = targetId ? targets.get(targetId) : null; + if (!target) return false; + const isRoot = entry.id === oldRoot; + return JSON.stringify(getComparableObject(entry.object, isRoot)) + === JSON.stringify(getComparableObject(target, isRoot)); + }); +} + +module.exports = { + getLegacyOutputId, + mapOutputTreeId, + resolveOutputId, + validateResolvedOutputId, + validateOutputId, + verifyMappedObjects, +}; diff --git a/main.js b/main.js index 24c3d9f5..d0bf284a 100644 --- a/main.js +++ b/main.js @@ -9,8 +9,10 @@ const utils = require('@iobroker/adapter-core'); const adapterHelpers = require('iobroker-adapter-helpers'); // Lib used for Unit calculations const schedule = require('cron').CronJob; // Cron Scheduler +const {isDeepStrictEqual} = require('node:util'); const calculation = require('./lib/calculation'); const dynamicPricing = require('./lib/dynamic-pricing'); +const outputId = require('./lib/output-id'); const statisticsJson = require('./lib/statistics-json'); // Sentry error reporting, disable when testing alpha source code locally! @@ -65,6 +67,8 @@ class Sourceanalytix extends utils.Adapter { this.statisticsJsonSnapshots = {}; this.statisticsJsonTimers = {}; this.statisticsJsonLastValues = {}; + this.migratingStates = new Set(); + this.outputIdBackfills = new Map(); } /** @@ -1411,6 +1415,306 @@ class Sourceanalytix extends utils.Adapter { } + /** + * @param {string} deviceName - Local output device ID + * @returns {string} Full ioBroker object ID + */ + getFullOutputRoot(deviceName) { + return `${this.namespace}.${deviceName}`; + } + + /** + * @param {string} deviceName - Local output device ID + * @returns {Promise>} Objects below the output root + */ + async getOutputTreeObjects(deviceName) { + const fullRoot = this.getFullOutputRoot(deviceName); + const result = await this.getObjectListAsync({startkey: fullRoot, endkey: `${fullRoot}\u9999`}); + const objects = []; + for (const row of result && Array.isArray(result.rows) ? result.rows : []) { + if (row.id !== fullRoot && !row.id.startsWith(`${fullRoot}.`)) continue; + const object = await this.getForeignObjectAsync(row.id); + if (object) objects.push({id: row.id, object}); + } + return objects.sort((a, b) => a.id.length - b.id.length || a.id.localeCompare(b.id)); + } + + /** + * @param {string} deviceName - Local output device ID + * @returns {Promise>} States below the output root + */ + async getOutputTreeStates(deviceName) { + const states = await this.getForeignStatesAsync(`${this.getFullOutputRoot(deviceName)}.*`); + return Object.fromEntries(Object.entries(states || {}).filter(([, state]) => !!state)); + } + + /** + * @param {string} deviceName - Local output device ID + */ + async deleteOutputTree(deviceName) { + const states = await this.getOutputTreeStates(deviceName); + for (const stateId of Object.keys(states)) await this.delForeignStateAsync(stateId); + + const objects = await this.getOutputTreeObjects(deviceName); + for (const entry of objects.sort((a, b) => b.id.length - a.id.length || b.id.localeCompare(a.id))) { + await this.delForeignObjectAsync(entry.id); + } + } + + /** + * @param {string} sourceId - Source state ID + * @param {string} desiredOutputId - Configured output ID + * @returns {Promise} Existing output ID owned by the source + */ + async findCurrentOutputId(sourceId, desiredOutputId) { + const activeOutputId = this.activeStates[sourceId] + && this.activeStates[sourceId].stateDetails + && this.activeStates[sourceId].stateDetails.deviceName; + if (activeOutputId) return activeOutputId; + + const desiredObject = await this.getObjectAsync(desiredOutputId); + if (desiredObject && desiredObject.native && desiredObject.native.sourceState === sourceId) return desiredOutputId; + + const devices = await this.getObjectViewAsync('system', 'device', { + startkey: `${this.namespace}.`, + endkey: `${this.namespace}.\u9999`, + }); + for (const row of devices && Array.isArray(devices.rows) ? devices.rows : []) { + const object = await this.getForeignObjectAsync(row.id); + if (object && object.native && object.native.sourceState === sourceId) { + return row.id.slice(`${this.namespace}.`.length); + } + } + + const legacyOutputId = outputId.getLegacyOutputId(sourceId); + return await this.getObjectAsync(legacyOutputId) ? legacyOutputId : null; + } + + /** + * Resume or roll back a migration interrupted by an adapter restart. + * @param {string} sourceId - Source state ID + * @param {string} desiredOutputId - Configured output ID + */ + async recoverOutputMigration(sourceId, desiredOutputId) { + const targetObject = await this.getObjectAsync(desiredOutputId); + const migration = targetObject && targetObject.native && Reflect.get(targetObject.native, 'outputMigration'); + if (!migration || typeof migration.from !== 'string') return; + + if (migration.status === 'copying') { + const oldObjects = await this.getOutputTreeObjects(migration.from); + if (oldObjects.length === 0) { + throw new Error(`Incomplete output migration for ${sourceId} has no intact source tree ${migration.from}`); + } + await this.deleteOutputTree(desiredOutputId); + this.log.warn(`Removed incomplete output migration for ${sourceId}; migration from ${migration.from} will be retried`); + return; + } + + if (migration.status === 'verified') { + await this.deleteOutputTree(migration.from); + const cleanTargetObject = await this.getObjectAsync(desiredOutputId); + if (cleanTargetObject) { + const copiedObject = this.createMigratedObject(cleanTargetObject, false, sourceId, migration.from); + if (copiedObject.native) Reflect.deleteProperty(copiedObject.native, 'outputMigration'); + await this.setObjectAsync(desiredOutputId, copiedObject); + } + this.log.info(`Completed pending output migration cleanup for ${sourceId}`); + } + } + + /** + * @param {string} sourceId - Source state ID + * @param {string} desiredOutputId - Configured output ID + * @param {string|null} currentOutputId - Current output ID + * @returns {Promise<{valid: boolean, reason: string|null}>} Availability result + */ + async validateOutputIdAvailability(sourceId, desiredOutputId, currentOutputId) { + for (const [otherSourceId, activeState] of Object.entries(this.activeStates)) { + if (otherSourceId !== sourceId && activeState && activeState.stateDetails + && activeState.stateDetails.deviceName === desiredOutputId) { + return {valid: false, reason: `is already used by ${otherSourceId}`}; + } + } + if (desiredOutputId === currentOutputId) return {valid: true, reason: null}; + + const existingObject = await this.getObjectAsync(desiredOutputId); + if (!existingObject) return {valid: true, reason: null}; + if (existingObject.native && existingObject.native.sourceState === sourceId) return {valid: true, reason: null}; + return {valid: false, reason: `already exists below ${this.namespace}`}; + } + + /** + * @param {ioBroker.Object} object - Object to copy + * @param {boolean} isRoot - Whether this is the output device root + * @param {string} sourceId - Source state ID + * @param {string} oldOutputId - Previous local output ID + * @returns {ioBroker.SettableObject} Copy suitable for setForeignObjectAsync + */ + createMigratedObject(object, isRoot, sourceId, oldOutputId) { + const copiedObject = JSON.parse(JSON.stringify(object)); + delete copiedObject._id; + delete copiedObject.from; + delete copiedObject.ts; + delete copiedObject.user; + if (isRoot) { + copiedObject.native = { + ...(copiedObject.native || {}), + sourceState: sourceId, + outputIdSchema: 1, + outputMigration: {from: oldOutputId, status: 'copying'}, + }; + } + return copiedObject; + } + + /** + * @param {ioBroker.State} state - State to copy + * @returns {ioBroker.SettableState} State fields supported when writing + */ + createMigratedState(state) { + return { + val: state.val, + ack: state.ack, + q: state.q, + ...(Number.isFinite(state.ts) ? {ts: state.ts} : {}), + ...(typeof state.c === 'string' ? {c: state.c} : {}), + ...(Number.isFinite(state.expire) ? {expire: state.expire} : {}), + }; + } + + /** + * @param {Record} sourceStates - States below the old root + * @param {Record} targetStates - States below the new root + * @param {string} oldRoot - Full old root ID + * @param {string} newRoot - Full new root ID + * @returns {boolean} Whether all persisted values match + */ + verifyMigratedStates(sourceStates, targetStates, oldRoot, newRoot) { + const sourceIds = Object.keys(sourceStates); + if (sourceIds.length !== Object.keys(targetStates).length) return false; + return sourceIds.every(sourceStateId => { + const targetStateId = outputId.mapOutputTreeId(sourceStateId, oldRoot, newRoot); + const sourceState = sourceStates[sourceStateId]; + const targetState = targetStateId ? targetStates[targetStateId] : null; + return !!targetState + && JSON.stringify(targetState.val) === JSON.stringify(sourceState.val) + && targetState.ack === sourceState.ack + && (targetState.q || 0) === (sourceState.q || 0); + }); + } + + /** + * Copy and verify a complete SourceAnalytix device tree before deleting it. + * @param {string} sourceId - Source state ID + * @param {string} oldOutputId - Previous local output ID + * @param {string} newOutputId - Requested local output ID + * @returns {Promise} Whether migration succeeded + */ + async migrateOutputTree(sourceId, oldOutputId, newOutputId) { + if (oldOutputId === newOutputId) return true; + const sourceObjects = await this.getOutputTreeObjects(oldOutputId); + if (sourceObjects.length === 0) return true; + + this.migratingStates.add(sourceId); + let verified = false; + try { + const existingTargetObjects = await this.getOutputTreeObjects(newOutputId); + if (existingTargetObjects.length > 0) { + const targetRoot = existingTargetObjects.find(entry => entry.id === this.getFullOutputRoot(newOutputId)); + const migration = targetRoot && targetRoot.object.native && targetRoot.object.native.outputMigration; + if (!migration || migration.from !== oldOutputId) { + throw new Error(`target ${newOutputId} is not an incomplete migration from ${oldOutputId}`); + } + await this.deleteOutputTree(newOutputId); + } + + const oldRoot = this.getFullOutputRoot(oldOutputId); + const newRoot = this.getFullOutputRoot(newOutputId); + const sourceStates = await this.getOutputTreeStates(oldOutputId); + for (const entry of sourceObjects) { + const targetId = outputId.mapOutputTreeId(entry.id, oldRoot, newRoot); + if (!targetId) throw new Error(`cannot map object ${entry.id}`); + await this.setForeignObjectAsync( + targetId, + this.createMigratedObject(entry.object, entry.id === oldRoot, sourceId, oldOutputId), + ); + } + for (const [stateId, state] of Object.entries(sourceStates)) { + const targetId = outputId.mapOutputTreeId(stateId, oldRoot, newRoot); + if (!targetId) throw new Error(`cannot map state ${stateId}`); + await this.setForeignStateAsync(targetId, this.createMigratedState(state)); + } + + const targetObjects = await this.getOutputTreeObjects(newOutputId); + const targetStates = await this.getOutputTreeStates(newOutputId); + if (!outputId.verifyMappedObjects(sourceObjects, targetObjects, oldRoot, newRoot) + || !this.verifyMigratedStates(sourceStates, targetStates, oldRoot, newRoot)) { + throw new Error(`verification failed (${sourceObjects.length} objects, ${Object.keys(sourceStates).length} states)`); + } + verified = true; + await this.extendObjectAsync(newOutputId, { + native: {sourceState: sourceId, outputIdSchema: 1, outputMigration: {from: oldOutputId, status: 'verified'}}, + }); + + try { + await this.deleteOutputTree(oldOutputId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log.warn(`Output ID migration for ${sourceId} was verified, but cleanup of ${oldOutputId} must be retried: ${message}`); + return true; + } + const targetRootObject = await this.getObjectAsync(newOutputId); + if (targetRootObject) { + const cleanRootObject = JSON.parse(JSON.stringify(targetRootObject)); + delete cleanRootObject._id; + delete cleanRootObject.from; + delete cleanRootObject.ts; + delete cleanRootObject.user; + if (cleanRootObject.native) delete cleanRootObject.native.outputMigration; + await this.setObjectAsync(newOutputId, cleanRootObject); + } + this.log.info(`Migrated SourceAnalytix output for ${sourceId} from ${oldOutputId} to ${newOutputId}`); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!verified) { + try { + await this.deleteOutputTree(newOutputId); + } catch (cleanupError) { + const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + this.log.warn(`Cannot remove incomplete output tree ${newOutputId}: ${cleanupMessage}`); + } + } + this.log.error(`Cannot migrate output ID for ${sourceId} from ${oldOutputId} to ${newOutputId}: ${message}`); + return false; + } finally { + this.migratingStates.delete(sourceId); + } + } + + /** + * Store the effective output ID in the source custom configuration. + * @param {string} sourceId - Source state ID + * @param {object} customData - Existing SourceAnalytix custom configuration + * @param {string} effectiveOutputId - Effective local output ID + */ + async persistDefaultOutputId(sourceId, customData, effectiveOutputId) { + if (typeof customData.outputId === 'string' && customData.outputId.trim()) return; + // Legacy IDs may contain characters which are no longer offered for custom IDs. + if (!outputId.validateOutputId(effectiveOutputId).valid) return; + const marker = {...customData, outputId: effectiveOutputId}; + this.outputIdBackfills.set(sourceId, marker); + try { + await this.extendForeignObjectAsync(sourceId, { + common: {custom: {[this.namespace]: {outputId: effectiveOutputId}}}, + }); + } finally { + this.setTimeout(() => { + if (this.outputIdBackfills.get(sourceId) === marker) this.outputIdBackfills.delete(sourceId); + }, 5000); + } + } + /** * Load state definitions to memory this.activeStates[stateID] * @param {string} stateID ID of state to refresh memory values @@ -1441,14 +1745,28 @@ class Sourceanalytix extends utils.Adapter { return false; } - // Replace not allowed characters for state name - const newDeviceName = stateID.split('.').join('__'); - // Check if configuration for SourceAnalytix is present, trow error in case of issue in configuration if (stateInfo && stateInfo.common && stateInfo.common.custom && stateInfo.common.custom[this.namespace]) { const customData = stateInfo.common.custom[this.namespace]; const commonData = stateInfo.common; this.log.debug(`[buildStateDetailsArray] commonData ${JSON.stringify(commonData)}`); + const newDeviceName = outputId.resolveOutputId(customData.outputId, stateID); + const idValidation = outputId.validateResolvedOutputId(customData.outputId, stateID); + if (!idValidation.valid) { + this.log.error(`Output ID ${JSON.stringify(newDeviceName)} for ${stateID} ${idValidation.reason}`); + return false; + } + await this.recoverOutputMigration(stateID, newDeviceName); + const currentOutputId = await this.findCurrentOutputId(stateID, newDeviceName); + const availability = await this.validateOutputIdAvailability(stateID, newDeviceName, currentOutputId); + if (!availability.valid) { + this.log.error(`Output ID ${JSON.stringify(newDeviceName)} for ${stateID} ${availability.reason}`); + return false; + } + if (currentOutputId && currentOutputId !== newDeviceName + && !await this.migrateOutputTree(stateID, currentOutputId, newDeviceName)) { + return false; + } // Load start value from config to memory (avoid wrong calculations at meter reset, set to 0 if empty) const valueAtDeviceReset = (customData.valueAtDeviceReset || customData.valueAtDeviceReset === 0) ? customData.valueAtDeviceReset : null; @@ -1552,6 +1870,7 @@ class Sourceanalytix extends utils.Adapter { priceState: selectedPriceConfig.priceState, }, }; + await this.persistDefaultOutputId(stateID, customData, newDeviceName); // Extend memory with objects for watt to kWh calculation if (useUnit === 'W') { @@ -1639,7 +1958,7 @@ class Sourceanalytix extends utils.Adapter { common: { name: alias }, - native: {}, + native: {sourceState: stateID, outputIdSchema: 1}, }); await this.initializeCurrentYearPeriodStates(stateID); @@ -1694,6 +2013,15 @@ class Sourceanalytix extends utils.Adapter { */ async onObjectChange(id, obj) { //ToDo : Verify with test-results if debounce on object change must be implemented + const outputIdBackfill = this.outputIdBackfills.get(id); + if (outputIdBackfill) { + this.outputIdBackfills.delete(id); + const changedCustomData = obj && obj.common && obj.common.custom && obj.common.custom[this.namespace]; + if (isDeepStrictEqual(changedCustomData, outputIdBackfill)) { + this.log.debug(`Ignored SourceAnalytix output ID backfill for ${id}`); + return; + } + } if (calcBlock) return; // cancel operation if calculation block is activate try { const stateID = id; @@ -1894,7 +2222,9 @@ class Sourceanalytix extends utils.Adapter { // Handle calculation for state // Check if for some reason calculation handler ist called for an object not initialised - if (this.activeStates[id]){ + if (this.migratingStates.has(id)) { + this.log.debug(`[onStateChange] calculation for ${id} paused during output ID migration`); + } else if (this.activeStates[id]){ await this.calculationHandler(id, state); } else if (!isDynamicPriceState) { this.log.debug(`[onStateChange] state not initialised, calculation cancelled]`); diff --git a/output-id.test.js b/output-id.test.js new file mode 100644 index 00000000..c16fb2e3 --- /dev/null +++ b/output-id.test.js @@ -0,0 +1,72 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { + getLegacyOutputId, + mapOutputTreeId, + resolveOutputId, + validateResolvedOutputId, + validateOutputId, + verifyMappedObjects, +} = require('./lib/output-id'); + +describe('custom output IDs', () => { + it('keeps the legacy source-derived ID as the default', () => { + assert.equal(getLegacyOutputId('alias.0.Kitchen.Energy'), 'alias__0__Kitchen__Energy'); + assert.equal(resolveOutputId('', 'alias.0.Kitchen.Energy'), 'alias__0__Kitchen__Energy'); + assert.equal(resolveOutputId(undefined, 'alias.0.Kitchen.Energy'), 'alias__0__Kitchen__Energy'); + }); + + it('trims and accepts an explicitly configured ID', () => { + assert.equal(resolveOutputId(' Kitchen_Energy ', 'alias.0.Kitchen.Energy'), 'Kitchen_Energy'); + }); + + it('accepts safe single-segment IDs', () => { + for (const value of ['Kitchen_Energy', 'meter-2', 'A', '123']) { + assert.deepEqual(validateOutputId(value), {valid: true, reason: null}); + } + }); + + it('rejects paths, unsafe characters, excessive length and reserved roots', () => { + for (const value of ['', 'Kitchen.Energy', 'Kitchen Energy', 'Küche', 'info', 'priceHistory', 'a'.repeat(129)]) { + assert.equal(validateOutputId(value).valid, false, value); + } + }); + + it('keeps unusual legacy IDs compatible until the user chooses a custom ID', () => { + const longSourceId = `adapter.0.${'long-segment-'.repeat(12)}legacy value`; + assert.deepEqual(validateResolvedOutputId(undefined, longSourceId), {valid: true, reason: null}); + assert.equal(validateResolvedOutputId('legacy value', longSourceId).valid, false); + assert.equal(validateResolvedOutputId(undefined, 'info').valid, false); + }); + + it('maps every child while rejecting objects outside the source tree', () => { + const oldRoot = 'sourceanalytix.0.alias__0__Kitchen__Energy'; + const newRoot = 'sourceanalytix.0.Kitchen_Energy'; + assert.equal(mapOutputTreeId(oldRoot, oldRoot, newRoot), newRoot); + assert.equal( + mapOutputTreeId(`${oldRoot}.currentYear.consumed.01_currentDay`, oldRoot, newRoot), + `${newRoot}.currentYear.consumed.01_currentDay`, + ); + assert.equal(mapOutputTreeId('sourceanalytix.0.other.currentYear', oldRoot, newRoot), null); + }); + + it('verifies copied object definitions while allowing root migration metadata', () => { + const oldRoot = 'sourceanalytix.0.old'; + const newRoot = 'sourceanalytix.0.new'; + const source = [ + {id: oldRoot, object: {type: 'device', common: {name: 'Meter'}, native: {existing: true}}}, + {id: `${oldRoot}.value`, object: {type: 'state', common: {name: 'Value', unit: 'kWh'}, native: {}}}, + ]; + const target = [ + {id: newRoot, object: {type: 'device', common: {name: 'Meter'}, native: { + existing: true, sourceState: 'alias.0.meter', outputIdSchema: 1, outputMigration: {status: 'copying'}, + }}}, + {id: `${newRoot}.value`, object: {type: 'state', common: {name: 'Value', unit: 'kWh'}, native: {}}}, + ]; + + assert.equal(verifyMappedObjects(source, target, oldRoot, newRoot), true); + target[1].object.common.unit = 'Wh'; + assert.equal(verifyMappedObjects(source, target, oldRoot, newRoot), false); + }); +}); diff --git a/package.json b/package.json index d55c441e..1fb699b7 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "io-package.json", "lib/calculation.js", "lib/dynamic-pricing.js", + "lib/output-id.js", "lib/statistics-json.js", "main.js" ], From 5dd64c184987de9de1daf167cd986143973e8148 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 3 Aug 2026 14:22:40 +0200 Subject: [PATCH 4/9] fix: validate required custom settings --- admin-custom.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ admin/i18n/de.json | 2 ++ admin/i18n/en.json | 2 ++ admin/i18n/es.json | 2 ++ admin/i18n/fr.json | 2 ++ admin/i18n/it.json | 2 ++ admin/i18n/nl.json | 2 ++ admin/i18n/pl.json | 2 ++ admin/i18n/pt.json | 2 ++ admin/i18n/ru.json | 2 ++ admin/i18n/uk.json | 2 ++ admin/i18n/zh-cn.json | 2 ++ admin/jsonCustom.json | 10 +++++++++- main.js | 3 +-- 14 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 admin-custom.test.js diff --git a/admin-custom.test.js b/admin-custom.test.js new file mode 100644 index 00000000..92c499bd --- /dev/null +++ b/admin-custom.test.js @@ -0,0 +1,40 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const schema = require('./admin/jsonCustom.json').items; + +function executeCustom(expression, data, customObj) { + return new Function( + 'data', + 'originalData', + '_system', + 'instanceObj', + 'customObj', + '_socket', + 'arrayIndex', + 'globalData', + '_changed', + `return ${expression}`, + )(data, {}, {}, {}, customObj, {}, 0, {}, false); +} + +describe('custom settings validation', () => { + const source = {_id: '0_userdata.0.energy', common: {unit: 'kWh'}}; + + it('pre-fills the backward-compatible output ID', () => { + assert.equal(executeCustom(schema.outputId.defaultFunc, {}, source), '0_userdata__0__energy'); + }); + + it('requires an output ID and price definition when enabled', () => { + assert.equal(executeCustom(schema.outputId.validator, {enabled: true, outputId: ''}, source), false); + assert.equal(executeCustom(schema.outputId.validator, {enabled: true, outputId: 'Kitchen'}, source), true); + assert.equal(executeCustom(schema.selectedPrice.validator, {enabled: true}, source), false); + assert.equal(executeCustom(schema.selectedPrice.validator, {enabled: true, selectedPrice: 'Electricity'}, source), true); + }); + + it('accepts detected or manually selected supported units', () => { + assert.equal(executeCustom(schema.selectedUnit.validator, {enabled: true, selectedUnit: 'Detect automatically'}, source), true); + assert.equal(executeCustom(schema.selectedUnit.validator, {enabled: true, selectedUnit: 'kW'}, {_id: source._id, common: {}}), true); + assert.equal(executeCustom(schema.selectedUnit.validator, {enabled: true, selectedUnit: 'Detect automatically'}, {_id: source._id, common: {}}), false); + }); +}); diff --git a/admin/i18n/de.json b/admin/i18n/de.json index c6f1a06a..68372d46 100644 --- a/admin/i18n/de.json +++ b/admin/i18n/de.json @@ -43,6 +43,8 @@ "Output ID": "Ausgabe-ID", "Output ID help": "Technische ID unter sourceanalytix.0. Beim Ändern einer bestehenden ID werden deren Werte migriert; Skripte, Visualisierungen und externe Historienverweise müssen angepasst werden.", "Output ID format error": "Erlaubt sind 1 bis 128 Buchstaben, Zahlen, Unterstriche oder Bindestriche", + "Price definition required error": "Bitte eine Preisdefinition auswählen", + "Unit required error": "Keine passende Einheit erkannt. Bitte eine Einheit auswählen", "Number of quarters": "Anzahl Quartale", "Number of weeks": "Anzahl Wochen", "Number of years": "Anzahl Jahre", diff --git a/admin/i18n/en.json b/admin/i18n/en.json index 9ef0a9ab..e4c79254 100644 --- a/admin/i18n/en.json +++ b/admin/i18n/en.json @@ -43,6 +43,8 @@ "Output ID": "Output ID", "Output ID help": "Technical ID below sourceanalytix.0. Changing an existing ID migrates its values, but scripts, visualizations and external history references must be adjusted.", "Output ID format error": "Use 1 to 128 letters, numbers, underscores or hyphens", + "Price definition required error": "Please select a price definition", + "Unit required error": "No supported unit was detected. Please select a unit", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/es.json b/admin/i18n/es.json index d635a878..8b4e6e21 100644 --- a/admin/i18n/es.json +++ b/admin/i18n/es.json @@ -98,6 +98,8 @@ "Output ID": "ID de salida", "Output ID help": "ID técnica debajo de sourceanalytix.0. Al cambiar una ID existente se migran sus valores; deben adaptarse los scripts, las visualizaciones y las referencias de historial externas.", "Output ID format error": "Utilice entre 1 y 128 letras, números, guiones bajos o guiones", + "Price definition required error": "Seleccione una definición de precio", + "Unit required error": "No se detectó ninguna unidad compatible. Seleccione una unidad", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/fr.json b/admin/i18n/fr.json index ba2345eb..9f0700ec 100644 --- a/admin/i18n/fr.json +++ b/admin/i18n/fr.json @@ -98,6 +98,8 @@ "Output ID": "ID de sortie", "Output ID help": "ID technique sous sourceanalytix.0. La modification d'une ID existante migre ses valeurs ; les scripts, visualisations et références d'historique externes doivent être adaptés.", "Output ID format error": "Utilisez 1 à 128 lettres, chiffres, traits de soulignement ou traits d'union", + "Price definition required error": "Veuillez sélectionner une définition de prix", + "Unit required error": "Aucune unité compatible n'a été détectée. Veuillez sélectionner une unité", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/it.json b/admin/i18n/it.json index e9016148..13de8491 100644 --- a/admin/i18n/it.json +++ b/admin/i18n/it.json @@ -98,6 +98,8 @@ "Output ID": "ID di output", "Output ID help": "ID tecnica sotto sourceanalytix.0. Modificando un'ID esistente vengono migrati i valori; script, visualizzazioni e riferimenti alla cronologia esterna devono essere aggiornati.", "Output ID format error": "Utilizzare da 1 a 128 lettere, numeri, trattini bassi o trattini", + "Price definition required error": "Selezionare una definizione del prezzo", + "Unit required error": "Non è stata rilevata un'unità supportata. Selezionare un'unità", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/nl.json b/admin/i18n/nl.json index a2d2e7e0..1f6121b7 100644 --- a/admin/i18n/nl.json +++ b/admin/i18n/nl.json @@ -98,6 +98,8 @@ "Output ID": "Uitvoer-ID", "Output ID help": "Technische ID onder sourceanalytix.0. Bij wijziging van een bestaande ID worden de waarden gemigreerd; scripts, visualisaties en externe historieverwijzingen moeten worden aangepast.", "Output ID format error": "Gebruik 1 tot 128 letters, cijfers, onderstrepingstekens of koppeltekens", + "Price definition required error": "Selecteer een prijsdefinitie", + "Unit required error": "Er is geen ondersteunde eenheid herkend. Selecteer een eenheid", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/pl.json b/admin/i18n/pl.json index c6a3f4a7..64492e26 100644 --- a/admin/i18n/pl.json +++ b/admin/i18n/pl.json @@ -98,6 +98,8 @@ "Output ID": "Identyfikator wyjściowy", "Output ID help": "Identyfikator techniczny w sourceanalytix.0. Zmiana istniejącego identyfikatora przenosi jego wartości; skrypty, wizualizacje i zewnętrzne odwołania do historii wymagają dostosowania.", "Output ID format error": "Użyj od 1 do 128 liter, cyfr, podkreśleń lub łączników", + "Price definition required error": "Wybierz definicję ceny", + "Unit required error": "Nie wykryto obsługiwanej jednostki. Wybierz jednostkę", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/pt.json b/admin/i18n/pt.json index 466db7a8..dfa7bae8 100644 --- a/admin/i18n/pt.json +++ b/admin/i18n/pt.json @@ -98,6 +98,8 @@ "Output ID": "ID de saída", "Output ID help": "ID técnica abaixo de sourceanalytix.0. Alterar uma ID existente migra os respetivos valores; scripts, visualizações e referências externas do histórico têm de ser adaptados.", "Output ID format error": "Utilize entre 1 e 128 letras, números, sublinhados ou hífenes", + "Price definition required error": "Selecione uma definição de preço", + "Unit required error": "Não foi detetada uma unidade suportada. Selecione uma unidade", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/ru.json b/admin/i18n/ru.json index 200abc8d..a5bd96b4 100644 --- a/admin/i18n/ru.json +++ b/admin/i18n/ru.json @@ -98,6 +98,8 @@ "Output ID": "ID выхода", "Output ID help": "Технический ID в sourceanalytix.0. При изменении существующего ID его значения переносятся; скрипты, визуализации и внешние ссылки на историю необходимо изменить.", "Output ID format error": "Используйте от 1 до 128 букв, цифр, символов подчёркивания или дефисов", + "Price definition required error": "Выберите определение цены", + "Unit required error": "Поддерживаемая единица не обнаружена. Выберите единицу вручную", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/uk.json b/admin/i18n/uk.json index f916801b..0aa13337 100644 --- a/admin/i18n/uk.json +++ b/admin/i18n/uk.json @@ -98,6 +98,8 @@ "Output ID": "Ідентифікатор виходу", "Output ID help": "Технічний ідентифікатор у sourceanalytix.0. Під час зміни наявного ідентифікатора його значення переносяться; скрипти, візуалізації та зовнішні посилання на історію потрібно змінити.", "Output ID format error": "Використовуйте від 1 до 128 літер, цифр, символів підкреслення або дефісів", + "Price definition required error": "Виберіть визначення ціни", + "Unit required error": "Підтримувану одиницю не виявлено. Виберіть одиницю вручну", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/i18n/zh-cn.json b/admin/i18n/zh-cn.json index f991c46a..c031bc40 100644 --- a/admin/i18n/zh-cn.json +++ b/admin/i18n/zh-cn.json @@ -98,6 +98,8 @@ "Output ID": "输出 ID", "Output ID help": "sourceanalytix.0 下的技术 ID。更改现有 ID 时会迁移其值,但必须调整脚本、可视化和外部历史记录引用。", "Output ID format error": "请使用 1 到 128 个字母、数字、下划线或连字符", + "Price definition required error": "请选择价格定义", + "Unit required error": "未检测到支持的单位。请选择一个单位", "Number of quarters": "Number of quarters", "Number of weeks": "Number of weeks", "Number of years": "Number of years", diff --git a/admin/jsonCustom.json b/admin/jsonCustom.json index 48b663ae..45d0c7e3 100644 --- a/admin/jsonCustom.json +++ b/admin/jsonCustom.json @@ -23,8 +23,10 @@ "type": "text", "label": "Output ID", "tooltip": "Output ID help", - "validator": "!data.outputId || /^[A-Za-z0-9_-]{1,128}$/.test(data.outputId)", + "defaultFunc": "customObj && customObj._id ? customObj._id.split('.').join('__') : ''", + "validator": "!data.enabled || (!!data.outputId && !['info', 'priceDefinitions', 'priceHistory', 'basicPriceHistory'].includes(data.outputId) && (/^[A-Za-z0-9_-]{1,128}$/.test(data.outputId) || (customObj && data.outputId === customObj._id.split('.').join('__'))))", "validatorErrorText": "Output ID format error", + "validatorNoSaveOnError": true, "xs": 12, "sm": 12, "md": 6, @@ -36,6 +38,9 @@ "command": "getPriceDefinitions", "type": "autocompleteSendTo", "label": "Select price definition", + "validator": "!data.enabled || (!!data.selectedPrice && data.selectedPrice !== 'Choose')", + "validatorErrorText": "Price definition required error", + "validatorNoSaveOnError": true, "xs": 12, "sm": 12, "md": 6, @@ -47,6 +52,9 @@ "type": "autocompleteSendTo", "label": "Select Unit", "default": "Detect automatically", + "validator": "!data.enabled || ['GW', 'GWh', 'MW', 'MWh', 'W', 'Wh', 'cl', 'cm', 'cm³', 'dl', 'dm', 'dm³', 'g', 'hl', 'kW', 'kWh', 'kg', 'km', 'km³', 'l', 'm', 'mW', 'mWh', 'ml', 'mm', 'mm³', 'm³', 'nm', 't', 'µm'].includes(data.selectedUnit && data.selectedUnit !== 'Detect automatically' ? data.selectedUnit : customObj && customObj.common && customObj.common.unit)", + "validatorErrorText": "Unit required error", + "validatorNoSaveOnError": true, "xs": 12, "sm": 12, "md": 6, diff --git a/main.js b/main.js index d0bf284a..d527b625 100644 --- a/main.js +++ b/main.js @@ -1767,6 +1767,7 @@ class Sourceanalytix extends utils.Adapter { && !await this.migrateOutputTree(stateID, currentOutputId, newDeviceName)) { return false; } + await this.persistDefaultOutputId(stateID, customData, newDeviceName); // Load start value from config to memory (avoid wrong calculations at meter reset, set to 0 if empty) const valueAtDeviceReset = (customData.valueAtDeviceReset || customData.valueAtDeviceReset === 0) ? customData.valueAtDeviceReset : null; @@ -1870,8 +1871,6 @@ class Sourceanalytix extends utils.Adapter { priceState: selectedPriceConfig.priceState, }, }; - await this.persistDefaultOutputId(stateID, customData, newDeviceName); - // Extend memory with objects for watt to kWh calculation if (useUnit === 'W') { this.activeStates[stateID].calcValues.previousReadingWatt = null; From 950cb90217b595797495178d40ed60fa085cd4c2 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 3 Aug 2026 14:36:39 +0200 Subject: [PATCH 5/9] fix: validate custom settings inline --- admin-custom.test.js | 21 +++++++++++++++++++++ admin/i18n/de.json | 2 +- admin/i18n/en.json | 2 +- admin/i18n/es.json | 2 +- admin/i18n/fr.json | 2 +- admin/i18n/it.json | 2 +- admin/i18n/nl.json | 2 +- admin/i18n/pl.json | 2 +- admin/i18n/pt.json | 2 +- admin/i18n/ru.json | 2 +- admin/i18n/uk.json | 2 +- admin/i18n/zh-cn.json | 2 +- admin/jsonCustom.json | 11 +++++++---- main.js | 25 +++++++++++++++++++++++++ 14 files changed, 64 insertions(+), 15 deletions(-) diff --git a/admin-custom.test.js b/admin-custom.test.js index 92c499bd..261f07fb 100644 --- a/admin-custom.test.js +++ b/admin-custom.test.js @@ -32,6 +32,27 @@ describe('custom settings validation', () => { assert.equal(executeCustom(schema.selectedPrice.validator, {enabled: true, selectedPrice: 'Electricity'}, source), true); }); + it('rejects an output ID owned by another source', () => { + assert.equal(executeCustom(schema.outputId.validator, { + enabled: true, + outputId: 'Kitchen', + _usedOutputIds: {Kitchen: 'alias.0.other'}, + }, source), false); + assert.equal(executeCustom(schema.outputId.validator, { + enabled: true, + outputId: 'Kitchen', + _usedOutputIds: {Kitchen: source._id}, + }, source), true); + }); + + it('handles output ID validation without a single source object', () => { + assert.equal(executeCustom(schema.outputId.validator, { + enabled: true, + outputId: 'Kitchen', + _usedOutputIds: {Kitchen: 'alias.0.other'}, + }, {common: {custom: {}}, native: {}}), false); + }); + it('accepts detected or manually selected supported units', () => { assert.equal(executeCustom(schema.selectedUnit.validator, {enabled: true, selectedUnit: 'Detect automatically'}, source), true); assert.equal(executeCustom(schema.selectedUnit.validator, {enabled: true, selectedUnit: 'kW'}, {_id: source._id, common: {}}), true); diff --git a/admin/i18n/de.json b/admin/i18n/de.json index 68372d46..1b954857 100644 --- a/admin/i18n/de.json +++ b/admin/i18n/de.json @@ -42,7 +42,7 @@ "Name help": "Lesbarer Anzeigename des erzeugten SourceAnalytix-Geräts", "Output ID": "Ausgabe-ID", "Output ID help": "Technische ID unter sourceanalytix.0. Beim Ändern einer bestehenden ID werden deren Werte migriert; Skripte, Visualisierungen und externe Historienverweise müssen angepasst werden.", - "Output ID format error": "Erlaubt sind 1 bis 128 Buchstaben, Zahlen, Unterstriche oder Bindestriche", + "Output ID format error": "Die ID ist ungültig, reserviert oder bereits vergeben", "Price definition required error": "Bitte eine Preisdefinition auswählen", "Unit required error": "Keine passende Einheit erkannt. Bitte eine Einheit auswählen", "Number of quarters": "Anzahl Quartale", diff --git a/admin/i18n/en.json b/admin/i18n/en.json index e4c79254..12e8cf9d 100644 --- a/admin/i18n/en.json +++ b/admin/i18n/en.json @@ -42,7 +42,7 @@ "Name help": "Readable display name of the generated SourceAnalytix device", "Output ID": "Output ID", "Output ID help": "Technical ID below sourceanalytix.0. Changing an existing ID migrates its values, but scripts, visualizations and external history references must be adjusted.", - "Output ID format error": "Use 1 to 128 letters, numbers, underscores or hyphens", + "Output ID format error": "The ID is invalid, reserved or already in use", "Price definition required error": "Please select a price definition", "Unit required error": "No supported unit was detected. Please select a unit", "Number of quarters": "Number of quarters", diff --git a/admin/i18n/es.json b/admin/i18n/es.json index 8b4e6e21..a875216f 100644 --- a/admin/i18n/es.json +++ b/admin/i18n/es.json @@ -97,7 +97,7 @@ "Name help": "Nombre visible del dispositivo SourceAnalytix generado", "Output ID": "ID de salida", "Output ID help": "ID técnica debajo de sourceanalytix.0. Al cambiar una ID existente se migran sus valores; deben adaptarse los scripts, las visualizaciones y las referencias de historial externas.", - "Output ID format error": "Utilice entre 1 y 128 letras, números, guiones bajos o guiones", + "Output ID format error": "La ID no es válida, está reservada o ya está en uso", "Price definition required error": "Seleccione una definición de precio", "Unit required error": "No se detectó ninguna unidad compatible. Seleccione una unidad", "Number of quarters": "Number of quarters", diff --git a/admin/i18n/fr.json b/admin/i18n/fr.json index 9f0700ec..d3170388 100644 --- a/admin/i18n/fr.json +++ b/admin/i18n/fr.json @@ -97,7 +97,7 @@ "Name help": "Nom d'affichage lisible du périphérique SourceAnalytix créé", "Output ID": "ID de sortie", "Output ID help": "ID technique sous sourceanalytix.0. La modification d'une ID existante migre ses valeurs ; les scripts, visualisations et références d'historique externes doivent être adaptés.", - "Output ID format error": "Utilisez 1 à 128 lettres, chiffres, traits de soulignement ou traits d'union", + "Output ID format error": "L'ID est invalide, réservée ou déjà utilisée", "Price definition required error": "Veuillez sélectionner une définition de prix", "Unit required error": "Aucune unité compatible n'a été détectée. Veuillez sélectionner une unité", "Number of quarters": "Number of quarters", diff --git a/admin/i18n/it.json b/admin/i18n/it.json index 13de8491..d3ff4d2d 100644 --- a/admin/i18n/it.json +++ b/admin/i18n/it.json @@ -97,7 +97,7 @@ "Name help": "Nome visualizzato del dispositivo SourceAnalytix generato", "Output ID": "ID di output", "Output ID help": "ID tecnica sotto sourceanalytix.0. Modificando un'ID esistente vengono migrati i valori; script, visualizzazioni e riferimenti alla cronologia esterna devono essere aggiornati.", - "Output ID format error": "Utilizzare da 1 a 128 lettere, numeri, trattini bassi o trattini", + "Output ID format error": "L'ID non è valida, è riservata o è già in uso", "Price definition required error": "Selezionare una definizione del prezzo", "Unit required error": "Non è stata rilevata un'unità supportata. Selezionare un'unità", "Number of quarters": "Number of quarters", diff --git a/admin/i18n/nl.json b/admin/i18n/nl.json index 1f6121b7..640e2dce 100644 --- a/admin/i18n/nl.json +++ b/admin/i18n/nl.json @@ -97,7 +97,7 @@ "Name help": "Leesbare weergavenaam van het aangemaakte SourceAnalytix-apparaat", "Output ID": "Uitvoer-ID", "Output ID help": "Technische ID onder sourceanalytix.0. Bij wijziging van een bestaande ID worden de waarden gemigreerd; scripts, visualisaties en externe historieverwijzingen moeten worden aangepast.", - "Output ID format error": "Gebruik 1 tot 128 letters, cijfers, onderstrepingstekens of koppeltekens", + "Output ID format error": "De ID is ongeldig, gereserveerd of al in gebruik", "Price definition required error": "Selecteer een prijsdefinitie", "Unit required error": "Er is geen ondersteunde eenheid herkend. Selecteer een eenheid", "Number of quarters": "Number of quarters", diff --git a/admin/i18n/pl.json b/admin/i18n/pl.json index 64492e26..70ea2b0e 100644 --- a/admin/i18n/pl.json +++ b/admin/i18n/pl.json @@ -97,7 +97,7 @@ "Name help": "Czytelna nazwa wyświetlana utworzonego urządzenia SourceAnalytix", "Output ID": "Identyfikator wyjściowy", "Output ID help": "Identyfikator techniczny w sourceanalytix.0. Zmiana istniejącego identyfikatora przenosi jego wartości; skrypty, wizualizacje i zewnętrzne odwołania do historii wymagają dostosowania.", - "Output ID format error": "Użyj od 1 do 128 liter, cyfr, podkreśleń lub łączników", + "Output ID format error": "Identyfikator jest nieprawidłowy, zarezerwowany lub już używany", "Price definition required error": "Wybierz definicję ceny", "Unit required error": "Nie wykryto obsługiwanej jednostki. Wybierz jednostkę", "Number of quarters": "Number of quarters", diff --git a/admin/i18n/pt.json b/admin/i18n/pt.json index dfa7bae8..d5fba312 100644 --- a/admin/i18n/pt.json +++ b/admin/i18n/pt.json @@ -97,7 +97,7 @@ "Name help": "Nome de apresentação legível do dispositivo SourceAnalytix criado", "Output ID": "ID de saída", "Output ID help": "ID técnica abaixo de sourceanalytix.0. Alterar uma ID existente migra os respetivos valores; scripts, visualizações e referências externas do histórico têm de ser adaptados.", - "Output ID format error": "Utilize entre 1 e 128 letras, números, sublinhados ou hífenes", + "Output ID format error": "O ID é inválido, reservado ou já está a ser utilizado", "Price definition required error": "Selecione uma definição de preço", "Unit required error": "Não foi detetada uma unidade suportada. Selecione uma unidade", "Number of quarters": "Number of quarters", diff --git a/admin/i18n/ru.json b/admin/i18n/ru.json index a5bd96b4..d3c87ccc 100644 --- a/admin/i18n/ru.json +++ b/admin/i18n/ru.json @@ -97,7 +97,7 @@ "Name help": "Понятное отображаемое имя созданного устройства SourceAnalytix", "Output ID": "ID выхода", "Output ID help": "Технический ID в sourceanalytix.0. При изменении существующего ID его значения переносятся; скрипты, визуализации и внешние ссылки на историю необходимо изменить.", - "Output ID format error": "Используйте от 1 до 128 букв, цифр, символов подчёркивания или дефисов", + "Output ID format error": "Идентификатор недопустим, зарезервирован или уже используется", "Price definition required error": "Выберите определение цены", "Unit required error": "Поддерживаемая единица не обнаружена. Выберите единицу вручную", "Number of quarters": "Number of quarters", diff --git a/admin/i18n/uk.json b/admin/i18n/uk.json index 0aa13337..5b00fda2 100644 --- a/admin/i18n/uk.json +++ b/admin/i18n/uk.json @@ -97,7 +97,7 @@ "Name help": "Зрозуміле відображуване ім'я створеного пристрою SourceAnalytix", "Output ID": "Ідентифікатор виходу", "Output ID help": "Технічний ідентифікатор у sourceanalytix.0. Під час зміни наявного ідентифікатора його значення переносяться; скрипти, візуалізації та зовнішні посилання на історію потрібно змінити.", - "Output ID format error": "Використовуйте від 1 до 128 літер, цифр, символів підкреслення або дефісів", + "Output ID format error": "Ідентифікатор недійсний, зарезервований або вже використовується", "Price definition required error": "Виберіть визначення ціни", "Unit required error": "Підтримувану одиницю не виявлено. Виберіть одиницю вручну", "Number of quarters": "Number of quarters", diff --git a/admin/i18n/zh-cn.json b/admin/i18n/zh-cn.json index c031bc40..aeb061ce 100644 --- a/admin/i18n/zh-cn.json +++ b/admin/i18n/zh-cn.json @@ -97,7 +97,7 @@ "Name help": "生成的 SourceAnalytix 设备的易读显示名称", "Output ID": "输出 ID", "Output ID help": "sourceanalytix.0 下的技术 ID。更改现有 ID 时会迁移其值,但必须调整脚本、可视化和外部历史记录引用。", - "Output ID format error": "请使用 1 到 128 个字母、数字、下划线或连字符", + "Output ID format error": "该 ID 无效、已保留或已被使用", "Price definition required error": "请选择价格定义", "Unit required error": "未检测到支持的单位。请选择一个单位", "Number of quarters": "Number of quarters", diff --git a/admin/jsonCustom.json b/admin/jsonCustom.json index 45d0c7e3..a99899d4 100644 --- a/admin/jsonCustom.json +++ b/admin/jsonCustom.json @@ -23,10 +23,10 @@ "type": "text", "label": "Output ID", "tooltip": "Output ID help", + "noMultiEdit": true, "defaultFunc": "customObj && customObj._id ? customObj._id.split('.').join('__') : ''", - "validator": "!data.enabled || (!!data.outputId && !['info', 'priceDefinitions', 'priceHistory', 'basicPriceHistory'].includes(data.outputId) && (/^[A-Za-z0-9_-]{1,128}$/.test(data.outputId) || (customObj && data.outputId === customObj._id.split('.').join('__'))))", + "validator": "!data.enabled || (!!data.outputId && !['info', 'priceDefinitions', 'priceHistory', 'basicPriceHistory'].includes(data.outputId) && (/^[A-Za-z0-9_-]{1,128}$/.test(data.outputId) || !!(customObj && customObj._id && data.outputId === customObj._id.split('.').join('__'))) && (!data._usedOutputIds || data._usedOutputIds[data.outputId] === undefined || !!(customObj && customObj._id && (data._usedOutputIds[data.outputId] === customObj._id || data.outputId === customObj._id.split('.').join('__')))))", "validatorErrorText": "Output ID format error", - "validatorNoSaveOnError": true, "xs": 12, "sm": 12, "md": 6, @@ -40,7 +40,6 @@ "label": "Select price definition", "validator": "!data.enabled || (!!data.selectedPrice && data.selectedPrice !== 'Choose')", "validatorErrorText": "Price definition required error", - "validatorNoSaveOnError": true, "xs": 12, "sm": 12, "md": 6, @@ -54,13 +53,17 @@ "default": "Detect automatically", "validator": "!data.enabled || ['GW', 'GWh', 'MW', 'MWh', 'W', 'Wh', 'cl', 'cm', 'cm³', 'dl', 'dm', 'dm³', 'g', 'hl', 'kW', 'kWh', 'kg', 'km', 'km³', 'l', 'm', 'mW', 'mWh', 'ml', 'mm', 'mm³', 'm³', 'nm', 't', 'µm'].includes(data.selectedUnit && data.selectedUnit !== 'Detect automatically' ? data.selectedUnit : customObj && customObj.common && customObj.common.unit)", "validatorErrorText": "Unit required error", - "validatorNoSaveOnError": true, "xs": 12, "sm": 12, "md": 6, "lg": 6, "xl": 6 }, + "_usedOutputIds": { + "type": "text", + "hidden": true, + "defaultSendTo": "getUsedOutputIds" + }, "_calcSettings": { "newLine": true, "type": "header", diff --git a/main.js b/main.js index d527b625..cfbef051 100644 --- a/main.js +++ b/main.js @@ -1543,6 +1543,25 @@ class Sourceanalytix extends utils.Adapter { return {valid: false, reason: `already exists below ${this.namespace}`}; } + /** + * @returns {Promise>} Output IDs and their owning source states + */ + async getUsedOutputIds() { + const usedOutputIds = Object.fromEntries([]); + const devices = await this.getObjectViewAsync('system', 'device', { + startkey: `${this.namespace}.`, + endkey: `${this.namespace}.\u9999`, + }); + for (const row of devices && Array.isArray(devices.rows) ? devices.rows : []) { + const object = await this.getForeignObjectAsync(row.id); + const localId = row.id.slice(`${this.namespace}.`.length); + usedOutputIds[localId] = object && object.native && typeof object.native.sourceState === 'string' + ? object.native.sourceState + : null; + } + return usedOutputIds; + } + /** * @param {ioBroker.Object} object - Object to copy * @param {boolean} isRoot - Whether this is the output device root @@ -3392,6 +3411,12 @@ class Sourceanalytix extends utils.Adapter { } break; + case 'getUsedOutputIds': + if (obj.callback) { + this.sendTo(obj.from, obj.command, await this.getUsedOutputIds(), obj.callback); + } + break; + case 'recoverPeriods': { this.log.info(`Period rollover requested by ${obj.from}`); const recovered = await this.runPeriodRollovers(`message from ${obj.from}`); From 5846be48991a8c88e29d5798741ed87b5f2d1164 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 3 Aug 2026 22:18:35 +0200 Subject: [PATCH 6/9] fix: opt into compatible blocking validation --- admin-custom.test.js | 10 +++++++++- admin/jsonCustom.json | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/admin-custom.test.js b/admin-custom.test.js index 261f07fb..e1508196 100644 --- a/admin-custom.test.js +++ b/admin-custom.test.js @@ -1,7 +1,8 @@ 'use strict'; const assert = require('node:assert/strict'); -const schema = require('./admin/jsonCustom.json').items; +const customConfig = require('./admin/jsonCustom.json'); +const schema = customConfig.items; function executeCustom(expression, data, customObj) { return new Function( @@ -21,6 +22,13 @@ function executeCustom(expression, data, customObj) { describe('custom settings validation', () => { const source = {_id: '0_userdata.0.energy', common: {unit: 'kWh'}}; + it('opts into blocking validation without triggering legacy field dialogs', () => { + assert.equal(customConfig.validatorNoSaveOnError, true); + assert.equal(Object.hasOwn(schema.outputId, 'validatorNoSaveOnError'), false); + assert.equal(Object.hasOwn(schema.selectedPrice, 'validatorNoSaveOnError'), false); + assert.equal(Object.hasOwn(schema.selectedUnit, 'validatorNoSaveOnError'), false); + }); + it('pre-fills the backward-compatible output ID', () => { assert.equal(executeCustom(schema.outputId.defaultFunc, {}, source), '0_userdata__0__energy'); }); diff --git a/admin/jsonCustom.json b/admin/jsonCustom.json index a99899d4..78b95d33 100644 --- a/admin/jsonCustom.json +++ b/admin/jsonCustom.json @@ -1,6 +1,7 @@ { "i18n": true, "type": "panel", + "validatorNoSaveOnError": true, "items": { "_basicSettings": { "newLine": true, From 87e2a9f91c6a0ff66e9b9ac5be4e96b3f1253b5d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 5 Aug 2026 10:29:47 +0200 Subject: [PATCH 7/9] fix: store explicit rounding settings --- README.md | 6 ++--- admin-custom.test.js | 21 +++++++++++++-- admin/i18n/de.json | 4 +-- admin/i18n/en.json | 4 +-- admin/i18n/es.json | 4 +-- admin/i18n/fr.json | 4 +-- admin/i18n/it.json | 4 +-- admin/i18n/nl.json | 4 +-- admin/i18n/pl.json | 4 +-- admin/i18n/pt.json | 4 +-- admin/i18n/ru.json | 4 +-- admin/i18n/uk.json | 4 +-- admin/i18n/zh-cn.json | 4 +-- admin/jsonCustom.json | 2 ++ lib/calculation.js | 12 +++++++++ main.js | 62 ++++++++++++++++++++++++++++++------------- main.test.js | 12 +++++++++ 17 files changed, 113 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index fcf6f9c0..a374e289 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,10 @@ The **General settings** tab controls which detailed statistics are created. Dis | Current year: Weekday | Stores the current week's values by weekday. | | Current year: Weeks / Months / Quarters | Stores values for each period below `.currentYear`. | | Current year: Previous period | Stores the completed day, week, month, quarter and year, plus the previous week's weekday values. | -| Rounding: Decimals for consumption values | Decimals for calculated quantities and meter readings, `3` by default. | -| Rounding: Decimals for cost values | Decimals for calculated costs and earnings, `2` by default. | +| Rounding: Decimals for consumption values | Initial decimals copied into newly configured sources, `3` by default. | +| Rounding: Decimals for cost values | Initial decimals copied into newly configured sources, `2` by default. | -Both rounding settings accept `-1` to store the exact calculated value without rounding. A single source can deviate from them: its **Decimals for consumption values** and **Decimals for cost values** fields override the global setting and use it whenever they are left empty. Rounding only affects the values written to states; internal calculations, the cumulative reading and the persisted memories always keep full precision, so no accuracy is lost over time. +Both rounding settings accept `-1` to store the exact calculated value without rounding. Every source stores its own explicit **Decimals for consumption values** and **Decimals for cost values**. New sources are pre-filled from the instance settings above. Existing sources without these fields receive their previously effective instance values once during migration, so later changes to the instance defaults do not alter their results. Rounding only affects the values written to states; internal calculations, the cumulative reading and the persisted memories always keep full precision, so no accuracy is lost over time. SourceAnalytix remembers the last successfully processed calendar periods. If the adapter or ioBroker is not running at midnight, missed day, week, month, quarter and year changes are processed once at the next start. diff --git a/admin-custom.test.js b/admin-custom.test.js index e1508196..5f2c135a 100644 --- a/admin-custom.test.js +++ b/admin-custom.test.js @@ -4,7 +4,7 @@ const assert = require('node:assert/strict'); const customConfig = require('./admin/jsonCustom.json'); const schema = customConfig.items; -function executeCustom(expression, data, customObj) { +function executeCustom(expression, data, customObj, instanceObj = {}) { return new Function( 'data', 'originalData', @@ -16,7 +16,7 @@ function executeCustom(expression, data, customObj) { 'globalData', '_changed', `return ${expression}`, - )(data, {}, {}, {}, customObj, {}, 0, {}, false); + )(data, {}, {}, instanceObj, customObj, {}, 0, {}, false); } describe('custom settings validation', () => { @@ -66,4 +66,21 @@ describe('custom settings validation', () => { assert.equal(executeCustom(schema.selectedUnit.validator, {enabled: true, selectedUnit: 'kW'}, {_id: source._id, common: {}}), true); assert.equal(executeCustom(schema.selectedUnit.validator, {enabled: true, selectedUnit: 'Detect automatically'}, {_id: source._id, common: {}}), false); }); + + it('pre-fills explicit rounding values from the selected instance', () => { + const instance = {native: {decimalsQuantity: 5, decimalsCosts: 4}}; + assert.equal(executeCustom(schema.decimalsQuantity.defaultFunc, {}, source, instance), 5); + assert.equal(executeCustom(schema.decimalsCosts.defaultFunc, {}, source, instance), 4); + assert.equal(executeCustom(schema.decimalsQuantity.defaultFunc, {}, source), 3); + assert.equal(executeCustom(schema.decimalsCosts.defaultFunc, {}, source), 2); + }); + + it('normalizes unusual instance templates before pre-filling a source', () => { + assert.equal(executeCustom(schema.decimalsQuantity.defaultFunc, {}, source, {native: {decimalsQuantity: 0}}), 0); + assert.equal(executeCustom(schema.decimalsCosts.defaultFunc, {}, source, {native: {decimalsCosts: -1}}), -1); + assert.equal(executeCustom(schema.decimalsQuantity.defaultFunc, {}, source, {native: {decimalsQuantity: ''}}), 3); + assert.equal(executeCustom(schema.decimalsCosts.defaultFunc, {}, source, {native: {decimalsCosts: 'invalid'}}), 2); + assert.equal(executeCustom(schema.decimalsQuantity.defaultFunc, {}, source, {native: {decimalsQuantity: 99}}), 15); + assert.equal(executeCustom(schema.decimalsCosts.defaultFunc, {}, source, {native: {decimalsCosts: -5}}), -1); + }); }); diff --git a/admin/i18n/de.json b/admin/i18n/de.json index 1b954857..1ffa8daf 100644 --- a/admin/i18n/de.json +++ b/admin/i18n/de.json @@ -125,6 +125,6 @@ "Rounding": "Rundung", "Decimals for consumption values": "Nachkommastellen für Verbrauchswerte", "Decimals for cost values": "Nachkommastellen für Kostenwerte", - "Rounding decimals help": "Anzahl der Nachkommastellen für berechnete Werte. Mit -1 wird der exakte Wert ohne Rundung gespeichert.", - "Individual rounding decimals help": "Überschreibt die globale Einstellung für diese Quelle. Leer lassen, um die globale Einstellung zu verwenden; -1 speichert den exakten Wert." + "Rounding decimals help": "Startwert, der in neu konfigurierte Quellen übernommen wird. Bestehende Quellen behalten ihren eigenen Wert. Mit -1 wird der exakte Wert ohne Rundung gespeichert.", + "Individual rounding decimals help": "Mit -1 wird der exakte Wert ohne Rundung gespeichert." } diff --git a/admin/i18n/en.json b/admin/i18n/en.json index 12e8cf9d..d2dc1329 100644 --- a/admin/i18n/en.json +++ b/admin/i18n/en.json @@ -125,6 +125,6 @@ "Rounding": "Rounding", "Decimals for consumption values": "Decimals for consumption values", "Decimals for cost values": "Decimals for cost values", - "Rounding decimals help": "Number of decimals for calculated values. Use -1 to store the exact value without rounding.", - "Individual rounding decimals help": "Overrides the global setting for this source. Leave empty to use the global setting, -1 stores the exact value." + "Rounding decimals help": "Initial value copied into newly configured sources. Existing sources keep their own value. Use -1 to store the exact value without rounding.", + "Individual rounding decimals help": "Use -1 to store the exact value without rounding." } diff --git a/admin/i18n/es.json b/admin/i18n/es.json index a875216f..59fd843c 100644 --- a/admin/i18n/es.json +++ b/admin/i18n/es.json @@ -125,6 +125,6 @@ "Rounding": "Redondeo", "Decimals for consumption values": "Decimales para los valores de consumo", "Decimals for cost values": "Decimales para los valores de coste", - "Rounding decimals help": "Número de decimales para los valores calculados. Use -1 para guardar el valor exacto sin redondeo.", - "Individual rounding decimals help": "Sustituye el ajuste global para esta fuente. Déjelo vacío para usar el ajuste global; -1 guarda el valor exacto." + "Rounding decimals help": "Valor inicial que se copia en las fuentes recién configuradas. Las fuentes existentes conservan su propio valor. Use -1 para guardar el valor exacto sin redondeo.", + "Individual rounding decimals help": "Use -1 para guardar el valor exacto sin redondeo." } diff --git a/admin/i18n/fr.json b/admin/i18n/fr.json index d3170388..f478ef05 100644 --- a/admin/i18n/fr.json +++ b/admin/i18n/fr.json @@ -125,6 +125,6 @@ "Rounding": "Arrondi", "Decimals for consumption values": "Décimales pour les valeurs de consommation", "Decimals for cost values": "Décimales pour les valeurs de coût", - "Rounding decimals help": "Nombre de décimales pour les valeurs calculées. Utilisez -1 pour enregistrer la valeur exacte sans arrondi.", - "Individual rounding decimals help": "Remplace le réglage global pour cette source. Laissez vide pour utiliser le réglage global ; -1 enregistre la valeur exacte." + "Rounding decimals help": "Valeur initiale copiée dans les nouvelles sources configurées. Les sources existantes conservent leur propre valeur. Utilisez -1 pour enregistrer la valeur exacte sans arrondi.", + "Individual rounding decimals help": "Utilisez -1 pour enregistrer la valeur exacte sans arrondi." } diff --git a/admin/i18n/it.json b/admin/i18n/it.json index d3ff4d2d..e57669d5 100644 --- a/admin/i18n/it.json +++ b/admin/i18n/it.json @@ -125,6 +125,6 @@ "Rounding": "Arrotondamento", "Decimals for consumption values": "Decimali per i valori di consumo", "Decimals for cost values": "Decimali per i valori di costo", - "Rounding decimals help": "Numero di decimali per i valori calcolati. Usare -1 per memorizzare il valore esatto senza arrotondamento.", - "Individual rounding decimals help": "Sostituisce l'impostazione globale per questa sorgente. Lasciare vuoto per usare l'impostazione globale; -1 memorizza il valore esatto." + "Rounding decimals help": "Valore iniziale copiato nelle nuove sorgenti configurate. Le sorgenti esistenti mantengono il proprio valore. Usare -1 per memorizzare il valore esatto senza arrotondamento.", + "Individual rounding decimals help": "Usare -1 per memorizzare il valore esatto senza arrotondamento." } diff --git a/admin/i18n/nl.json b/admin/i18n/nl.json index 640e2dce..d388f6aa 100644 --- a/admin/i18n/nl.json +++ b/admin/i18n/nl.json @@ -125,6 +125,6 @@ "Rounding": "Afronding", "Decimals for consumption values": "Decimalen voor verbruikswaarden", "Decimals for cost values": "Decimalen voor kostenwaarden", - "Rounding decimals help": "Aantal decimalen voor berekende waarden. Gebruik -1 om de exacte waarde zonder afronding op te slaan.", - "Individual rounding decimals help": "Overschrijft de globale instelling voor deze bron. Laat leeg om de globale instelling te gebruiken; -1 slaat de exacte waarde op." + "Rounding decimals help": "Beginwaarde die naar nieuw geconfigureerde bronnen wordt gekopieerd. Bestaande bronnen behouden hun eigen waarde. Gebruik -1 om de exacte waarde zonder afronding op te slaan.", + "Individual rounding decimals help": "Gebruik -1 om de exacte waarde zonder afronding op te slaan." } diff --git a/admin/i18n/pl.json b/admin/i18n/pl.json index 70ea2b0e..b8588d8f 100644 --- a/admin/i18n/pl.json +++ b/admin/i18n/pl.json @@ -125,6 +125,6 @@ "Rounding": "Zaokrąglanie", "Decimals for consumption values": "Miejsca dziesiętne dla wartości zużycia", "Decimals for cost values": "Miejsca dziesiętne dla wartości kosztów", - "Rounding decimals help": "Liczba miejsc dziesiętnych dla obliczonych wartości. Wartość -1 zapisuje dokładną wartość bez zaokrąglania.", - "Individual rounding decimals help": "Zastępuje ustawienie globalne dla tego źródła. Pozostaw puste, aby użyć ustawienia globalnego; -1 zapisuje dokładną wartość." + "Rounding decimals help": "Wartość początkowa kopiowana do nowo konfigurowanych źródeł. Istniejące źródła zachowują własną wartość. Wartość -1 zapisuje dokładną wartość bez zaokrąglania.", + "Individual rounding decimals help": "Wartość -1 zapisuje dokładną wartość bez zaokrąglania." } diff --git a/admin/i18n/pt.json b/admin/i18n/pt.json index d5fba312..a2a70f73 100644 --- a/admin/i18n/pt.json +++ b/admin/i18n/pt.json @@ -125,6 +125,6 @@ "Rounding": "Arredondamento", "Decimals for consumption values": "Casas decimais para valores de consumo", "Decimals for cost values": "Casas decimais para valores de custo", - "Rounding decimals help": "Número de casas decimais para os valores calculados. Use -1 para guardar o valor exato sem arredondamento.", - "Individual rounding decimals help": "Substitui a definição global para esta fonte. Deixe vazio para usar a definição global; -1 guarda o valor exato." + "Rounding decimals help": "Valor inicial copiado para fontes recém-configuradas. As fontes existentes mantêm o seu próprio valor. Use -1 para guardar o valor exato sem arredondamento.", + "Individual rounding decimals help": "Use -1 para guardar o valor exato sem arredondamento." } diff --git a/admin/i18n/ru.json b/admin/i18n/ru.json index d3c87ccc..90f35234 100644 --- a/admin/i18n/ru.json +++ b/admin/i18n/ru.json @@ -125,6 +125,6 @@ "Rounding": "Округление", "Decimals for consumption values": "Знаков после запятой для значений потребления", "Decimals for cost values": "Знаков после запятой для значений затрат", - "Rounding decimals help": "Количество знаков после запятой для рассчитанных значений. Значение -1 сохраняет точное значение без округления.", - "Individual rounding decimals help": "Переопределяет глобальную настройку для этого источника. Оставьте пустым, чтобы использовать глобальную настройку; -1 сохраняет точное значение." + "Rounding decimals help": "Начальное значение, копируемое в новые настроенные источники. Существующие источники сохраняют собственное значение. Значение -1 сохраняет точное значение без округления.", + "Individual rounding decimals help": "Значение -1 сохраняет точное значение без округления." } diff --git a/admin/i18n/uk.json b/admin/i18n/uk.json index 5b00fda2..712b51ad 100644 --- a/admin/i18n/uk.json +++ b/admin/i18n/uk.json @@ -125,6 +125,6 @@ "Rounding": "Округлення", "Decimals for consumption values": "Знаків після коми для значень споживання", "Decimals for cost values": "Знаків після коми для значень витрат", - "Rounding decimals help": "Кількість знаків після коми для обчислених значень. Значення -1 зберігає точне значення без округлення.", - "Individual rounding decimals help": "Перевизначає глобальне налаштування для цього джерела. Залиште порожнім, щоб використовувати глобальне налаштування; -1 зберігає точне значення." + "Rounding decimals help": "Початкове значення, яке копіюється до нових налаштованих джерел. Наявні джерела зберігають власне значення. Значення -1 зберігає точне значення без округлення.", + "Individual rounding decimals help": "Значення -1 зберігає точне значення без округлення." } diff --git a/admin/i18n/zh-cn.json b/admin/i18n/zh-cn.json index aeb061ce..55d73919 100644 --- a/admin/i18n/zh-cn.json +++ b/admin/i18n/zh-cn.json @@ -125,6 +125,6 @@ "Rounding": "四舍五入", "Decimals for consumption values": "用量值的小数位数", "Decimals for cost values": "费用值的小数位数", - "Rounding decimals help": "计算值保留的小数位数。设为 -1 则保存精确值,不进行舍入。", - "Individual rounding decimals help": "覆盖此数据源的全局设置。留空则使用全局设置;设为 -1 保存精确值。" + "Rounding decimals help": "复制到新配置数据源的初始值。现有数据源保留各自的值。设为 -1 则保存精确值,不进行舍入。", + "Individual rounding decimals help": "设为 -1 则保存精确值,不进行舍入。" } diff --git a/admin/jsonCustom.json b/admin/jsonCustom.json index 78b95d33..04189453 100644 --- a/admin/jsonCustom.json +++ b/admin/jsonCustom.json @@ -156,6 +156,7 @@ "type": "number", "label": "Decimals for consumption values", "help": "Individual rounding decimals help", + "defaultFunc": "(() => { const value = instanceObj && instanceObj.native ? instanceObj.native.decimalsQuantity : undefined; if (value === '' || value === null || value === undefined || !Number.isFinite(Number(value))) return 3; return Math.max(-1, Math.min(15, Math.trunc(Number(value)))); })()", "min": -1, "max": 15, "xs": 12, @@ -168,6 +169,7 @@ "type": "number", "label": "Decimals for cost values", "help": "Individual rounding decimals help", + "defaultFunc": "(() => { const value = instanceObj && instanceObj.native ? instanceObj.native.decimalsCosts : undefined; if (value === '' || value === null || value === undefined || !Number.isFinite(Number(value))) return 2; return Math.max(-1, Math.min(15, Math.trunc(Number(value)))); })()", "min": -1, "max": 15, "xs": 12, diff --git a/lib/calculation.js b/lib/calculation.js index 5ad470cf..89a67ef6 100644 --- a/lib/calculation.js +++ b/lib/calculation.js @@ -97,6 +97,17 @@ function normalizeDecimals(value, fallback) { return Math.min(decimals, 15); } +/** + * Resolve the explicit decimals stored for a source from its configured template. + * @param {unknown} value - Source-specific decimals + * @param {unknown} configuredDefault - Instance template for sources without a value + * @param {number} factoryDefault - Factory fallback when the template is unusable + * @returns {number} Effective decimals to store for the source + */ +function resolveDecimals(value, configuredDefault, factoryDefault) { + return normalizeDecimals(value, normalizeDecimals(configuredDefault, factoryDefault)); +} + /** * Round a calculated value to the configured number of decimals. * @param {unknown} value - Value to round @@ -572,6 +583,7 @@ module.exports = { initializePeriodStartValues, migrateLegacyVariableCostTotals, normalizeDecimals, + resolveDecimals, normalizePeriodSnapshot, normalizePowerReading, resolveCumulativeReading, diff --git a/main.js b/main.js index cfbef051..ab25fdd2 100644 --- a/main.js +++ b/main.js @@ -68,7 +68,7 @@ class Sourceanalytix extends utils.Adapter { this.statisticsJsonTimers = {}; this.statisticsJsonLastValues = {}; this.migratingStates = new Set(); - this.outputIdBackfills = new Map(); + this.customConfigBackfills = new Map(); } /** @@ -1712,24 +1712,32 @@ class Sourceanalytix extends utils.Adapter { } /** - * Store the effective output ID in the source custom configuration. + * Store defaults which were missing from the source custom configuration. * @param {string} sourceId - Source state ID * @param {object} customData - Existing SourceAnalytix custom configuration * @param {string} effectiveOutputId - Effective local output ID + * @param {number} decimalsQuantity - Effective consumption decimals + * @param {number} decimalsCosts - Effective cost decimals */ - async persistDefaultOutputId(sourceId, customData, effectiveOutputId) { - if (typeof customData.outputId === 'string' && customData.outputId.trim()) return; + async persistCustomConfigDefaults(sourceId, customData, effectiveOutputId, decimalsQuantity, decimalsCosts) { + const updates = {}; + if (customData.decimalsQuantity !== decimalsQuantity) updates.decimalsQuantity = decimalsQuantity; + if (customData.decimalsCosts !== decimalsCosts) updates.decimalsCosts = decimalsCosts; // Legacy IDs may contain characters which are no longer offered for custom IDs. - if (!outputId.validateOutputId(effectiveOutputId).valid) return; - const marker = {...customData, outputId: effectiveOutputId}; - this.outputIdBackfills.set(sourceId, marker); + const hasOutputId = typeof customData.outputId === 'string' && customData.outputId.trim(); + if (!hasOutputId && outputId.validateOutputId(effectiveOutputId).valid) { + updates.outputId = effectiveOutputId; + } + if (!Object.keys(updates).length) return; + const marker = {...customData, ...updates}; + this.customConfigBackfills.set(sourceId, marker); try { await this.extendForeignObjectAsync(sourceId, { - common: {custom: {[this.namespace]: {outputId: effectiveOutputId}}}, + common: {custom: {[this.namespace]: updates}}, }); } finally { this.setTimeout(() => { - if (this.outputIdBackfills.get(sourceId) === marker) this.outputIdBackfills.delete(sourceId); + if (this.customConfigBackfills.get(sourceId) === marker) this.customConfigBackfills.delete(sourceId); }, 5000); } } @@ -1768,6 +1776,16 @@ class Sourceanalytix extends utils.Adapter { if (stateInfo && stateInfo.common && stateInfo.common.custom && stateInfo.common.custom[this.namespace]) { const customData = stateInfo.common.custom[this.namespace]; const commonData = stateInfo.common; + const decimalsQuantity = calculation.resolveDecimals( + customData.decimalsQuantity, + this.config.decimalsQuantity, + 3, + ); + const decimalsCosts = calculation.resolveDecimals( + customData.decimalsCosts, + this.config.decimalsCosts, + 2, + ); this.log.debug(`[buildStateDetailsArray] commonData ${JSON.stringify(commonData)}`); const newDeviceName = outputId.resolveOutputId(customData.outputId, stateID); const idValidation = outputId.validateResolvedOutputId(customData.outputId, stateID); @@ -1786,7 +1804,13 @@ class Sourceanalytix extends utils.Adapter { && !await this.migrateOutputTree(stateID, currentOutputId, newDeviceName)) { return false; } - await this.persistDefaultOutputId(stateID, customData, newDeviceName); + await this.persistCustomConfigDefaults( + stateID, + customData, + newDeviceName, + decimalsQuantity, + decimalsCosts, + ); // Load start value from config to memory (avoid wrong calculations at meter reset, set to 0 if empty) const valueAtDeviceReset = (customData.valueAtDeviceReset || customData.valueAtDeviceReset === 0) ? customData.valueAtDeviceReset : null; @@ -1859,8 +1883,8 @@ class Sourceanalytix extends utils.Adapter { basicRate: customData.basicRate === true, consumption: customData.consumption, costs: customData.costs, - decimalsCosts: customData.decimalsCosts, - decimalsQuantity: customData.decimalsQuantity, + decimalsCosts, + decimalsQuantity, deviceName: newDeviceName.toString(), financialCategory: stateType, headCategory: stateType === 'earnings' ? 'delivered' : 'consumed', @@ -2031,12 +2055,12 @@ class Sourceanalytix extends utils.Adapter { */ async onObjectChange(id, obj) { //ToDo : Verify with test-results if debounce on object change must be implemented - const outputIdBackfill = this.outputIdBackfills.get(id); - if (outputIdBackfill) { - this.outputIdBackfills.delete(id); + const customConfigBackfill = this.customConfigBackfills.get(id); + if (customConfigBackfill) { + this.customConfigBackfills.delete(id); const changedCustomData = obj && obj.common && obj.common.custom && obj.common.custom[this.namespace]; - if (isDeepStrictEqual(changedCustomData, outputIdBackfill)) { - this.log.debug(`Ignored SourceAnalytix output ID backfill for ${id}`); + if (isDeepStrictEqual(changedCustomData, customConfigBackfill)) { + this.log.debug(`Ignored SourceAnalytix custom configuration backfill for ${id}`); return; } } @@ -3152,7 +3176,7 @@ class Sourceanalytix extends utils.Adapter { // } /** - * Decimals to apply for a source, preferring its own setting over the global one. + * Decimals to apply for a source. Active sources always contain an explicit setting. * @param {string | undefined} stateID - Source state ID * @param {'quantity' | 'costs'} kind - Type of value to round * @returns {number} Decimals to apply, or -1 to keep the exact value @@ -3163,7 +3187,7 @@ class Sourceanalytix extends utils.Adapter { const stateDetails = stateID && this.activeStates[stateID] ? this.activeStates[stateID].stateDetails : null; if (!stateDetails) return globalDecimals; const sourceSetting = kind === 'costs' ? stateDetails.decimalsCosts : stateDetails.decimalsQuantity; - return calculation.normalizeDecimals(sourceSetting, globalDecimals); + return calculation.normalizeDecimals(sourceSetting, kind === 'costs' ? 2 : 3); } /** diff --git a/main.test.js b/main.test.js index d8fa6ca6..3cf29f64 100644 --- a/main.test.js +++ b/main.test.js @@ -17,6 +17,7 @@ const { migrateLegacyVariableCostTotals, normalizeDecimals, normalizePeriodSnapshot, + resolveDecimals, resolveCumulativeReading, roundValue, } = require('./lib/calculation'); @@ -360,6 +361,17 @@ describe('period and cumulative calculations', () => { assert.equal(roundValue(0.000123456, 6), 0.000123); }); + it('copies the configured instance template into sources without a value', () => { + assert.equal(resolveDecimals(undefined, 5, 3), 5); + assert.equal(resolveDecimals('', 4, 2), 4); + assert.equal(resolveDecimals(undefined, undefined, 3), 3); + assert.equal(resolveDecimals(6, 5, 3), 6); + assert.equal(resolveDecimals(undefined, 0, 3), 0); + assert.equal(resolveDecimals(undefined, -1, 2), -1); + assert.equal(resolveDecimals(undefined, 99, 3), 15); + assert.equal(resolveDecimals(undefined, 'invalid', 2), 2); + }); + it('keeps the exact value when rounding is disabled', () => { assert.equal(normalizeDecimals(-1, 3), -1); assert.equal(normalizeDecimals(-5, 3), -1); From a970c6a4cba99bb3ec7839b753e94cd8f7bea9fb Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 7 Aug 2026 13:07:49 +0200 Subject: [PATCH 8/9] fix: support custom settings with Admin 8 --- admin-custom.test.js | 7 +++++++ admin/jsonCustom.json | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/admin-custom.test.js b/admin-custom.test.js index 5f2c135a..ca29b366 100644 --- a/admin-custom.test.js +++ b/admin-custom.test.js @@ -29,6 +29,13 @@ describe('custom settings validation', () => { assert.equal(Object.hasOwn(schema.selectedUnit, 'validatorNoSaveOnError'), false); }); + it('restricts prices and units to the options provided by the adapter', () => { + assert.equal(schema.selectedPrice.type, 'selectSendTo'); + assert.equal(schema.selectedPrice.manual, false); + assert.equal(schema.selectedUnit.type, 'selectSendTo'); + assert.equal(schema.selectedUnit.manual, false); + }); + it('pre-fills the backward-compatible output ID', () => { assert.equal(executeCustom(schema.outputId.defaultFunc, {}, source), '0_userdata__0__energy'); }); diff --git a/admin/jsonCustom.json b/admin/jsonCustom.json index 04189453..e5fa5385 100644 --- a/admin/jsonCustom.json +++ b/admin/jsonCustom.json @@ -37,7 +37,8 @@ "selectedPrice": { "newLine": true, "command": "getPriceDefinitions", - "type": "autocompleteSendTo", + "type": "selectSendTo", + "manual": false, "label": "Select price definition", "validator": "!data.enabled || (!!data.selectedPrice && data.selectedPrice !== 'Choose')", "validatorErrorText": "Price definition required error", @@ -49,7 +50,8 @@ }, "selectedUnit": { "command": "getUnits", - "type": "autocompleteSendTo", + "type": "selectSendTo", + "manual": false, "label": "Select Unit", "default": "Detect automatically", "validator": "!data.enabled || ['GW', 'GWh', 'MW', 'MWh', 'W', 'Wh', 'cl', 'cm', 'cm³', 'dl', 'dm', 'dm³', 'g', 'hl', 'kW', 'kWh', 'kg', 'km', 'km³', 'l', 'm', 'mW', 'mWh', 'ml', 'mm', 'mm³', 'm³', 'nm', 't', 'µm'].includes(data.selectedUnit && data.selectedUnit !== 'Detect automatically' ? data.selectedUnit : customObj && customObj.common && customObj.common.unit)", From e961826b9d635aedfc5a164e4147d15025b52da9 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 8 Aug 2026 21:11:22 +0200 Subject: [PATCH 9/9] fix: block saving invalid source settings --- admin-custom.test.js | 10 +++++----- admin/jsonCustom.json | 4 +++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/admin-custom.test.js b/admin-custom.test.js index ca29b366..e756db31 100644 --- a/admin-custom.test.js +++ b/admin-custom.test.js @@ -22,11 +22,11 @@ function executeCustom(expression, data, customObj, instanceObj = {}) { describe('custom settings validation', () => { const source = {_id: '0_userdata.0.energy', common: {unit: 'kWh'}}; - it('opts into blocking validation without triggering legacy field dialogs', () => { - assert.equal(customConfig.validatorNoSaveOnError, true); - assert.equal(Object.hasOwn(schema.outputId, 'validatorNoSaveOnError'), false); - assert.equal(Object.hasOwn(schema.selectedPrice, 'validatorNoSaveOnError'), false); - assert.equal(Object.hasOwn(schema.selectedUnit, 'validatorNoSaveOnError'), false); + it('blocks saving while required source settings are invalid', () => { + assert.equal(Object.hasOwn(customConfig, 'validatorNoSaveOnError'), false); + assert.equal(schema.outputId.validatorNoSaveOnError, true); + assert.equal(schema.selectedPrice.validatorNoSaveOnError, true); + assert.equal(schema.selectedUnit.validatorNoSaveOnError, true); }); it('restricts prices and units to the options provided by the adapter', () => { diff --git a/admin/jsonCustom.json b/admin/jsonCustom.json index e5fa5385..02e80dff 100644 --- a/admin/jsonCustom.json +++ b/admin/jsonCustom.json @@ -1,7 +1,6 @@ { "i18n": true, "type": "panel", - "validatorNoSaveOnError": true, "items": { "_basicSettings": { "newLine": true, @@ -27,6 +26,7 @@ "noMultiEdit": true, "defaultFunc": "customObj && customObj._id ? customObj._id.split('.').join('__') : ''", "validator": "!data.enabled || (!!data.outputId && !['info', 'priceDefinitions', 'priceHistory', 'basicPriceHistory'].includes(data.outputId) && (/^[A-Za-z0-9_-]{1,128}$/.test(data.outputId) || !!(customObj && customObj._id && data.outputId === customObj._id.split('.').join('__'))) && (!data._usedOutputIds || data._usedOutputIds[data.outputId] === undefined || !!(customObj && customObj._id && (data._usedOutputIds[data.outputId] === customObj._id || data.outputId === customObj._id.split('.').join('__')))))", + "validatorNoSaveOnError": true, "validatorErrorText": "Output ID format error", "xs": 12, "sm": 12, @@ -41,6 +41,7 @@ "manual": false, "label": "Select price definition", "validator": "!data.enabled || (!!data.selectedPrice && data.selectedPrice !== 'Choose')", + "validatorNoSaveOnError": true, "validatorErrorText": "Price definition required error", "xs": 12, "sm": 12, @@ -55,6 +56,7 @@ "label": "Select Unit", "default": "Detect automatically", "validator": "!data.enabled || ['GW', 'GWh', 'MW', 'MWh', 'W', 'Wh', 'cl', 'cm', 'cm³', 'dl', 'dm', 'dm³', 'g', 'hl', 'kW', 'kWh', 'kg', 'km', 'km³', 'l', 'm', 'mW', 'mWh', 'ml', 'mm', 'mm³', 'm³', 'nm', 't', 'µm'].includes(data.selectedUnit && data.selectedUnit !== 'Detect automatically' ? data.selectedUnit : customObj && customObj.common && customObj.common.unit)", + "validatorNoSaveOnError": true, "validatorErrorText": "Unit required error", "xs": 12, "sm": 12,