diff --git a/README.md b/README.md index c45c6f36..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. @@ -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 @@ -263,7 +264,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/admin-custom.test.js b/admin-custom.test.js new file mode 100644 index 00000000..e756db31 --- /dev/null +++ b/admin-custom.test.js @@ -0,0 +1,93 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const customConfig = require('./admin/jsonCustom.json'); +const schema = customConfig.items; + +function executeCustom(expression, data, customObj, instanceObj = {}) { + return new Function( + 'data', + 'originalData', + '_system', + 'instanceObj', + 'customObj', + '_socket', + 'arrayIndex', + 'globalData', + '_changed', + `return ${expression}`, + )(data, {}, {}, instanceObj, customObj, {}, 0, {}, false); +} + +describe('custom settings validation', () => { + const source = {_id: '0_userdata.0.energy', common: {unit: 'kWh'}}; + + 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', () => { + 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'); + }); + + 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('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); + 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 5f1b8b2a..1ffa8daf 100644 --- a/admin/i18n/de.json +++ b/admin/i18n/de.json @@ -39,6 +39,12 @@ "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": "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", "Number of weeks": "Anzahl Wochen", "Number of years": "Anzahl Jahre", @@ -119,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 f0e2e634..d2dc1329 100644 --- a/admin/i18n/en.json +++ b/admin/i18n/en.json @@ -39,6 +39,12 @@ "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": "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", "Number of weeks": "Number of weeks", "Number of years": "Number of years", @@ -119,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 bd28dd56..59fd843c 100644 --- a/admin/i18n/es.json +++ b/admin/i18n/es.json @@ -93,7 +93,13 @@ "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": "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", "Number of weeks": "Number of weeks", "Number of years": "Number of years", @@ -119,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 6dae0f9b..f478ef05 100644 --- a/admin/i18n/fr.json +++ b/admin/i18n/fr.json @@ -93,7 +93,13 @@ "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": "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", "Number of weeks": "Number of weeks", "Number of years": "Number of years", @@ -119,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 9f189233..e57669d5 100644 --- a/admin/i18n/it.json +++ b/admin/i18n/it.json @@ -93,7 +93,13 @@ "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": "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", "Number of weeks": "Number of weeks", "Number of years": "Number of years", @@ -119,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 4052c84d..d388f6aa 100644 --- a/admin/i18n/nl.json +++ b/admin/i18n/nl.json @@ -93,7 +93,13 @@ "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": "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", "Number of weeks": "Number of weeks", "Number of years": "Number of years", @@ -119,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 4b802358..b8588d8f 100644 --- a/admin/i18n/pl.json +++ b/admin/i18n/pl.json @@ -93,7 +93,13 @@ "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": "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", "Number of weeks": "Number of weeks", "Number of years": "Number of years", @@ -119,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 bb0f1a3d..a2a70f73 100644 --- a/admin/i18n/pt.json +++ b/admin/i18n/pt.json @@ -93,7 +93,13 @@ "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": "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", "Number of weeks": "Number of weeks", "Number of years": "Number of years", @@ -119,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 60ab65c9..90f35234 100644 --- a/admin/i18n/ru.json +++ b/admin/i18n/ru.json @@ -93,7 +93,13 @@ "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": "Идентификатор недопустим, зарезервирован или уже используется", + "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", @@ -119,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 ce997320..712b51ad 100644 --- a/admin/i18n/uk.json +++ b/admin/i18n/uk.json @@ -93,7 +93,13 @@ "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": "Ідентифікатор недійсний, зарезервований або вже використовується", + "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", @@ -119,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 9f125443..55d73919 100644 --- a/admin/i18n/zh-cn.json +++ b/admin/i18n/zh-cn.json @@ -93,7 +93,13 @@ "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": "该 ID 无效、已保留或已被使用", + "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", @@ -119,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 2268456e..02e80dff 100644 --- a/admin/jsonCustom.json +++ b/admin/jsonCustom.json @@ -11,34 +11,63 @@ }, "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", + "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, + "md": 6, + "lg": 6, + "xl": 6 }, "selectedPrice": { + "newLine": true, "command": "getPriceDefinitions", - "type": "autocompleteSendTo", + "type": "selectSendTo", + "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, - "md": 4, - "lg": 4, - "xl": 4 + "md": 6, + "lg": 6, + "xl": 6 }, "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)", + "validatorNoSaveOnError": true, + "validatorErrorText": "Unit required error", "xs": 12, "sm": 12, - "md": 3, - "lg": 3, - "xl": 3 + "md": 6, + "lg": 6, + "xl": 6 + }, + "_usedOutputIds": { + "type": "text", + "hidden": true, + "defaultSendTo": "getUsedOutputIds" }, "_calcSettings": { "newLine": true, @@ -131,6 +160,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, @@ -143,6 +173,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 e147253b..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 @@ -284,16 +295,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 +323,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}; } /** @@ -522,6 +583,7 @@ module.exports = { initializePeriodStartValues, migrateLegacyVariableCostTotals, normalizeDecimals, + resolveDecimals, normalizePeriodSnapshot, normalizePowerReading, resolveCumulativeReading, 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 4a8b8bbd..ab25fdd2 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.customConfigBackfills = new Map(); } /** @@ -1411,6 +1415,333 @@ 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}`}; + } + + /** + * @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 + * @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 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 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. + 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]: updates}}, + }); + } finally { + this.setTimeout(() => { + if (this.customConfigBackfills.get(sourceId) === marker) this.customConfigBackfills.delete(sourceId); + }, 5000); + } + } + /** * Load state definitions to memory this.activeStates[stateID] * @param {string} stateID ID of state to refresh memory values @@ -1441,14 +1772,45 @@ 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; + 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); + 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; + } + 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; @@ -1521,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', @@ -1552,7 +1914,6 @@ class Sourceanalytix extends utils.Adapter { priceState: selectedPriceConfig.priceState, }, }; - // Extend memory with objects for watt to kWh calculation if (useUnit === 'W') { this.activeStates[stateID].calcValues.previousReadingWatt = null; @@ -1639,7 +2000,7 @@ class Sourceanalytix extends utils.Adapter { common: { name: alias }, - native: {}, + native: {sourceState: stateID, outputIdSchema: 1}, }); await this.initializeCurrentYearPeriodStates(stateID); @@ -1694,6 +2055,15 @@ class Sourceanalytix extends utils.Adapter { */ async onObjectChange(id, obj) { //ToDo : Verify with test-results if debounce on object change must be implemented + 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, customConfigBackfill)) { + this.log.debug(`Ignored SourceAnalytix custom configuration backfill for ${id}`); + return; + } + } if (calcBlock) return; // cancel operation if calculation block is activate try { const stateID = id; @@ -1894,7 +2264,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]`); @@ -2486,9 +2858,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 +2877,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(); @@ -2578,11 +2952,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; @@ -2602,10 +2985,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 +3005,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 +3132,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 +3157,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); } } @@ -2793,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 @@ -2804,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); } /** @@ -3052,6 +3435,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}`); diff --git a/main.test.js b/main.test.js index 22dbfe70..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); @@ -518,32 +530,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, }); }); }); 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" ],