diff --git a/index.js b/index.js index 61789ea72..244b7b962 100644 --- a/index.js +++ b/index.js @@ -129,6 +129,13 @@ async function start() { settings.reRead(); + // gc + { + const settingsMigration = require('./dist/util/settingsMigration'); + + settingsMigration.migrateIfNecessary(); + } + const errors = settings.validate(); if (errors.length > 0) { diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index b652c5d4d..fc467cab8 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -663,7 +663,7 @@ export default class Bridge extends Extension { permit_join_timeout: this.zigbee.getPermitJoinTimeout(), restart_required: this.restartRequired, config, - config_schema: settings.schema, + config_schema: settings.schemaJson, }; await this.mqtt.publish('bridge/info', stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true); diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts index cf30a94c9..e0630732c 100644 --- a/lib/types/types.d.ts +++ b/lib/types/types.d.ts @@ -107,6 +107,7 @@ declare global { // Settings interface Settings { + version?: number; homeassistant?: { discovery_topic: string; status_topic: string; @@ -222,7 +223,6 @@ declare global { filtered_optimistic?: string[]; icon?: string; homeassistant?: KeyValue; - legacy?: boolean; friendly_name: string; description?: string; qos?: 0 | 1 | 2; diff --git a/lib/util/settings.schema.json b/lib/util/settings.schema.json index d4bd23f63..aee261a5f 100644 --- a/lib/util/settings.schema.json +++ b/lib/util/settings.schema.json @@ -725,71 +725,8 @@ "enum": ["attribute_and_json", "attribute", "json"], "title": "MQTT output type", "description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)" - }, - "homeassistant_discovery_topic": { - "type": "string", - "title": "Homeassistant discovery topic", - "description": "Home Assistant discovery topic", - "requiresRestart": true, - "examples": ["homeassistant"] - }, - "homeassistant_status_topic": { - "type": "string", - "title": "Home Assistant status topic", - "description": "Home Assistant status topic", - "requiresRestart": true, - "examples": ["homeassistant/status"] - }, - "baudrate": { - "type": "number", - "title": "Baudrate (deprecated)", - "requiresRestart": true, - "description": "Baud rate speed for serial port, this can be anything firmware support but default is 115200 for Z-Stack and EZSP, 38400 for Deconz, however note that some EZSP firmware need 57600", - "examples": [38400, 57600, 115200] - }, - "rtscts": { - "type": "boolean", - "title": "RTS / CTS (deprecated)", - "requiresRestart": true, - "description": "RTS / CTS Hardware Flow Control for serial port" } } - }, - "experimental": { - "type": "object", - "title": "Experimental (deprecated)", - "properties": { - "transmit_power": { - "type": ["number", "null"], - "title": "Transmit power", - "requiresRestart": true, - "description": "Transmit power of adapter, only available for Z-Stack (CC253*/CC2652/CC1352) adapters, CC2652 = 5dbm, CC1352 max is = 20dbm (5dbm default)" - }, - "output": { - "type": "string", - "enum": ["attribute_and_json", "attribute", "json"], - "title": "MQTT output type", - "description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)" - } - } - }, - "whitelist": { - "readOnly": true, - "type": "array", - "requiresRestart": true, - "title": "Whitelist (deprecated, use passlist)", - "items": { - "type": "string" - } - }, - "ban": { - "readOnly": true, - "type": "array", - "requiresRestart": true, - "title": "Ban (deprecated, use blocklist)", - "items": { - "type": "string" - } } }, "required": ["mqtt"], diff --git a/lib/util/settings.ts b/lib/util/settings.ts index b4b85e41c..9f98539f9 100644 --- a/lib/util/settings.ts +++ b/lib/util/settings.ts @@ -8,28 +8,13 @@ import schemaJson from './settings.schema.json'; import utils from './utils'; import yaml, {YAMLFileException} from './yaml'; -export let schema: KeyValue = schemaJson; - -schema = {}; -objectAssignDeep(schema, schemaJson); - -// Remove legacy settings from schema -{ - delete schema.properties.advanced.properties.homeassistant_discovery_topic; - delete schema.properties.advanced.properties.homeassistant_status_topic; - delete schema.properties.advanced.properties.baudrate; - delete schema.properties.advanced.properties.rtscts; - delete schema.properties.experimental; - delete (schemaJson as KeyValue).properties.whitelist; - delete (schemaJson as KeyValue).properties.ban; -} - +export {schemaJson}; +export const CURRENT_VERSION = 2; /** NOTE: by order of priority, lower index is lower level (more important) */ export const LOG_LEVELS: readonly string[] = ['error', 'warning', 'info', 'debug'] as const; export type LogLevel = (typeof LOG_LEVELS)[number]; -// DEPRECATED ZIGBEE2MQTT_CONFIG: https://github.com/Koenkk/zigbee2mqtt/issues/4697 -const file = process.env.ZIGBEE2MQTT_CONFIG ?? data.joinPath('configuration.yaml'); +const CONFIG_FILE_PATH = data.joinPath('configuration.yaml'); const NULLABLE_SETTINGS = ['homeassistant']; const ajvSetting = new Ajv({allErrors: true}).addKeyword('requiresRestart').compile(schemaJson); const ajvRestartRequired = new Ajv({allErrors: true}).addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}).compile(schemaJson); @@ -114,6 +99,7 @@ function loadSettingsWithDefaults(): void { if (!_settings) { _settings = read(); } + _settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings; if (!_settingsWithDefaults.devices) { @@ -131,6 +117,7 @@ function loadSettingsWithDefaults(): void { experimental_event_entities: false, }; const sLegacy = {}; + if (_settingsWithDefaults.advanced) { for (const key of ['homeassistant_discovery_topic', 'homeassistant_status_topic']) { // @ts-expect-error ignore typing @@ -144,6 +131,7 @@ function loadSettingsWithDefaults(): void { const s = typeof _settingsWithDefaults.homeassistant === 'object' ? _settingsWithDefaults.homeassistant : {}; // @ts-expect-error ignore typing _settingsWithDefaults.homeassistant = {}; + // @ts-expect-error ignore typing objectAssignDeep(_settingsWithDefaults.homeassistant, defaults, sLegacy, s); } @@ -153,6 +141,7 @@ function loadSettingsWithDefaults(): void { const s = typeof _settingsWithDefaults.availability === 'object' ? _settingsWithDefaults.availability : {}; // @ts-expect-error ignore typing _settingsWithDefaults.availability = {}; + // @ts-expect-error ignore typing objectAssignDeep(_settingsWithDefaults.availability, defaults, s); } @@ -162,49 +151,10 @@ function loadSettingsWithDefaults(): void { const s = typeof _settingsWithDefaults.frontend === 'object' ? _settingsWithDefaults.frontend : {}; // @ts-expect-error ignore typing _settingsWithDefaults.frontend = {}; + // @ts-expect-error ignore typing objectAssignDeep(_settingsWithDefaults.frontend, defaults, s); } - - // @ts-expect-error ignore typing - if (_settings.advanced?.baudrate !== undefined && _settings.serial?.baudrate == null) { - // @ts-expect-error ignore typing - _settingsWithDefaults.serial.baudrate = _settings.advanced.baudrate; - } - - // @ts-expect-error ignore typing - if (_settings.advanced?.rtscts !== undefined && _settings.serial?.rtscts == null) { - // @ts-expect-error ignore typing - _settingsWithDefaults.serial.rtscts = _settings.advanced.rtscts; - } - - // @ts-expect-error ignore typing - if (_settings.experimental?.transmit_power !== undefined && _settings.advanced?.transmit_power == null) { - // @ts-expect-error ignore typing - _settingsWithDefaults.advanced.transmit_power = _settings.experimental.transmit_power; - } - - // @ts-expect-error ignore typing - if (_settings.experimental?.output !== undefined && _settings.advanced?.output == null) { - // @ts-expect-error ignore typing - _settingsWithDefaults.advanced.output = _settings.experimental.output; - } - - if (_settings.advanced?.log_level === 'warn') { - _settingsWithDefaults.advanced.log_level = 'warning'; - } - - // @ts-expect-error ignore typing - if (_settingsWithDefaults.ban) { - // @ts-expect-error ignore typing - _settingsWithDefaults.blocklist.push(..._settingsWithDefaults.ban); - } - - // @ts-expect-error ignore typing - if (_settingsWithDefaults.whitelist) { - // @ts-expect-error ignore typing - _settingsWithDefaults.passlist.push(..._settingsWithDefaults.whitelist); - } } function parseValueRef(text: string): {filename: string; key: string} | null { @@ -226,7 +176,7 @@ function write(): void { const toWrite: KeyValue = objectAssignDeep({}, settings); // Read settings to check if we have to split devices/groups into separate file. - const actual = yaml.read(file); + const actual = yaml.read(CONFIG_FILE_PATH); // In case the setting is defined in a separate file (e.g. !secret network_key) update it there. for (const path of [ @@ -268,10 +218,10 @@ function write(): void { writeDevicesOrGroups('devices'); writeDevicesOrGroups('groups'); - - yaml.writeIfChanged(file, toWrite); + yaml.writeIfChanged(CONFIG_FILE_PATH, toWrite); _settings = read(); + loadSettingsWithDefaults(); } @@ -332,6 +282,7 @@ export function validate(): string[] { }; const settingsWithDefaults = get(); + Object.values(settingsWithDefaults.devices).forEach((d) => check(d)); Object.values(settingsWithDefaults.groups).forEach((g) => check(g)); @@ -347,7 +298,7 @@ export function validate(): string[] { } function read(): Settings { - const s = yaml.read(file) as Settings; + const s = yaml.read(CONFIG_FILE_PATH) as Settings; applyEnvironmentVariables(s); // Read !secret MQTT username and password if set @@ -458,7 +409,7 @@ function applyEnvironmentVariables(settings: Partial): void { iterate(schemaJson.properties, []); } -function getInternalSettings(): Partial { +export function getInternalSettings(): Partial { if (!_settings) { _settings = read(); } diff --git a/lib/util/settingsMigration.ts b/lib/util/settingsMigration.ts new file mode 100644 index 000000000..8b56650b2 --- /dev/null +++ b/lib/util/settingsMigration.ts @@ -0,0 +1,485 @@ +import {copyFileSync, writeFileSync} from 'fs'; + +import data from './data'; +import * as settings from './settings'; + +interface SettingsMigration { + path: string[]; + note: string; + noteIf?: (previousValue: unknown) => boolean; +} + +interface SettingsAdd extends Omit { + value: unknown; +} + +type SettingsRemove = SettingsMigration; + +interface SettingsChange extends SettingsMigration { + previousValueAnyOf?: unknown[]; + newValue: unknown; +} + +interface SettingsTransfer extends SettingsMigration { + newPath: string[]; +} + +const SUPPORTED_VERSIONS: Settings['version'][] = [undefined, settings.CURRENT_VERSION]; + +function backupSettings(version: number): void { + const filePath = data.joinPath('configuration.yaml'); + + copyFileSync(filePath, filePath.replace('.yaml', `_backup_v${version}.yaml`)); +} + +/** + * Set the given path in given settings to given value. If requested, create path. + * + * @param currentSettings + * @param path + * @param value + * @param createPathIfNotExist + * @returns Returns true if value was set, false if not. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function setValue(currentSettings: any, path: string[], value: unknown, createPathIfNotExist: boolean = false): boolean { + for (let i = 0; i < path.length; i++) { + const key = path[i]; + + if (i === path.length - 1) { + currentSettings[key] = value; + } else { + if (!currentSettings[key]) { + /* istanbul ignore else */ + if (createPathIfNotExist) { + currentSettings[key] = {}; + } else { + // invalid path + // ignored in test since currently call is always guarded by get-validated path, so this is never reached + return false; + } + } + + currentSettings = currentSettings[key]; + } + } + + return true; +} + +/** + * Get the value at the given path in given settings. + * + * @param currentSettings + * @param path + * @returns + * - true if path was valid + * - the value at path + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getValue(currentSettings: any, path: string[]): [validPath: boolean, value: unknown] { + for (let i = 0; i < path.length; i++) { + const key = path[i]; + + if (i === path.length - 1) { + return [true, currentSettings[key]]; + } else { + if (!currentSettings[key]) { + // invalid path + break; + } + + currentSettings = currentSettings[key]; + } + } + + return [false, undefined]; +} + +/** + * Add a value at given path, path is created as needed. + * @param currentSettings + * @param addition + */ +function addValue(currentSettings: Partial, addition: SettingsAdd): void { + setValue(currentSettings, addition.path, addition.value, true); +} + +/** + * Remove value at given path, if path is valid. + * Value is actually set to undefined, which triggers removal when `settings.apply` is called. + * @param currentSettings + * @param removal + * @returns + */ +function removeValue(currentSettings: Partial, removal: SettingsRemove): [validPath: boolean, previousValue: unknown] { + const [validPath, previousValue] = getValue(currentSettings, removal.path); + + if (validPath && previousValue != undefined) { + setValue(currentSettings, removal.path, undefined); + } + + return [validPath, previousValue]; +} + +/** + * Change value at given path, if path is valid, and value matched one of the defined values (if any). + * @param currentSettings + * @param change + * @returns + */ +function changeValue(currentSettings: Partial, change: SettingsChange): [validPath: boolean, previousValue: unknown, changed: boolean] { + const [validPath, previousValue] = getValue(currentSettings, change.path); + let changed: boolean = false; + + if (validPath && previousValue !== change.newValue) { + if (!change.previousValueAnyOf || change.previousValueAnyOf.includes(previousValue)) { + setValue(currentSettings, change.path, change.newValue); + + changed = true; + } + } + + return [validPath, previousValue, changed]; +} + +/** + * Transfer value at given path, to new path. + * Given path must be valid. + * New path must not be valid or new path value must be nullish, otherwise given path is removed only. + * Value at given path is actually set to undefined, which triggers removal when `settings.apply` is called. + * New path is created as needed. + * @param currentSettings + * @param transfer + * @returns + */ +function transferValue( + currentSettings: Partial, + transfer: SettingsTransfer, +): [validPath: boolean, previousValue: unknown, transfered: boolean] { + const [validPath, previousValue] = getValue(currentSettings, transfer.path); + const [destValidPath, destValue] = getValue(currentSettings, transfer.newPath); + const transfered = validPath && previousValue != undefined && (!destValidPath || destValue == undefined || Array.isArray(destValue)); + + // no point in set if already undefined + if (validPath && previousValue != undefined) { + setValue(currentSettings, transfer.path, undefined); + } + + if (transfered) { + if (Array.isArray(previousValue) && Array.isArray(destValue)) { + setValue(currentSettings, transfer.newPath, [...previousValue, ...destValue], true); + } else { + setValue(currentSettings, transfer.newPath, previousValue, true); + } + } + + return [validPath, previousValue, transfered]; +} + +const noteIfWasTrue = (previousValue: unknown): boolean => previousValue === true; +const noteIfWasDefined = (previousValue: unknown): boolean => previousValue != undefined; +const noteIfWasNonEmptyArray = (previousValue: unknown): boolean => Array.isArray(previousValue) && previousValue.length > 0; + +function migrateToTwo( + currentSettings: Partial, + transfers: SettingsTransfer[], + changes: SettingsChange[], + additions: SettingsAdd[], + removals: SettingsRemove[], +): void { + transfers.push( + { + path: ['advanced', 'homeassistant_discovery_topic'], + note: `HA discovery_topic was moved from advanced.homeassistant_discovery_topic to homeassistant.discovery_topic.`, + noteIf: noteIfWasDefined, + newPath: ['homeassistant', 'discovery_topic'], + }, + { + path: ['advanced', 'homeassistant_status_topic'], + note: `HA status_topic was moved from advanced.homeassistant_status_topic to homeassistant.status_topic.`, + noteIf: noteIfWasDefined, + newPath: ['homeassistant', 'status_topic'], + }, + { + path: ['advanced', 'baudrate'], + note: `Baudrate was moved from advanced.baudrate to serial.baudrate.`, + noteIf: noteIfWasDefined, + newPath: ['serial', 'baudrate'], + }, + { + path: ['advanced', 'rtscts'], + note: `RTSCTS was moved from advanced.rtscts to serial.rtscts.`, + noteIf: noteIfWasDefined, + newPath: ['serial', 'rtscts'], + }, + { + path: ['experimental', 'transmit_power'], + note: `Transmit power was moved from experimental.transmit_power to advanced.transmit_power.`, + noteIf: noteIfWasDefined, + newPath: ['advanced', 'transmit_power'], + }, + { + path: ['experimental', 'output'], + note: `Output was moved from experimental.output to advanced.output.`, + noteIf: noteIfWasDefined, + newPath: ['advanced', 'output'], + }, + { + path: ['ban'], + note: `ban was renamed to passlist.`, + noteIf: noteIfWasDefined, + newPath: ['blocklist'], + }, + { + path: ['whitelist'], + note: `whitelist was renamed to passlist.`, + noteIf: noteIfWasDefined, + newPath: ['passlist'], + }, + ); + + changes.push({ + path: ['advanced', 'log_level'], + note: `Log level 'warn' has been renamed to 'warning'.`, + noteIf: (previousValue): boolean => previousValue === 'warn', + previousValueAnyOf: ['warn'], + newValue: 'warning', + }); + + additions.push({ + path: ['version'], + note: `Migrated settings to version 2`, + value: 2, + }); + + const haLegacyTriggers: SettingsRemove = { + path: ['homeassistant', 'legacy_triggers'], + note: `Action and click sensors have been removed (homeassistant.legacy_triggers setting). This means all sensor.*_action and sensor.*_click entities are removed. Use the MQTT device trigger instead.`, + noteIf: noteIfWasTrue, + }; + const haLegacyEntityAttrs: SettingsRemove = { + path: ['homeassistant', 'legacy_entity_attributes'], + note: `Entity attributes (homeassistant.legacy_entity_attributes setting) has been removed. This means that entities discovered by Zigbee2MQTT will no longer have entity attributes (Home Assistant entity attributes are accessed via e.g. states.binary_sensor.my_sensor.attributes).`, + noteIf: noteIfWasTrue, + }; + const otaIkeaUseTestUrl: SettingsRemove = { + path: ['ota', 'ikea_ota_use_test_url'], + note: `Due to the OTA rework, the ota.ikea_ota_use_test_url option has been removed.`, + noteIf: noteIfWasTrue, + }; + + removals.push( + haLegacyTriggers, + haLegacyEntityAttrs, + { + path: ['advanced', 'homeassistant_legacy_triggers'], + note: haLegacyTriggers.note, + noteIf: haLegacyTriggers.noteIf, + }, + { + path: ['advanced', 'homeassistant_legacy_entity_attributes'], + note: haLegacyEntityAttrs.note, + noteIf: haLegacyEntityAttrs.noteIf, + }, + { + path: ['permit_join'], + note: `The permit_join setting has been removed, use the frontend or MQTT to permit joining.`, + noteIf: noteIfWasTrue, + }, + otaIkeaUseTestUrl, + { + path: ['advanced', 'ikea_ota_use_test_url'], + note: otaIkeaUseTestUrl.note, + noteIf: otaIkeaUseTestUrl.noteIf, + }, + { + path: ['advanced', 'legacy_api'], + note: `The MQTT legacy API has been removed (advanced.legacy_api setting). See link below for affected topics.`, + noteIf: noteIfWasTrue, + }, + { + path: ['advanced', 'legacy_availability_payload'], + note: `Due to the removal of advanced.legacy_availability_payload, zigbee2mqtt/bridge/state will now always be a JSON object ({"state":"online"} or {"state":"offline"})`, + noteIf: noteIfWasTrue, + }, + { + path: ['advanced', 'soft_reset_timeout'], + note: `Removed deprecated: Soft reset feature (advanced.soft_reset_timeout setting)`, + noteIf: noteIfWasDefined, + }, + { + path: ['advanced', 'report'], + note: `Removed deprecated: Report feature (advanced.report setting)`, + noteIf: noteIfWasTrue, + }, + { + path: ['advanced', 'availability_timeout'], + note: `Removed deprecated: advanced.availability_timeout availability settings`, + noteIf: noteIfWasDefined, + }, + { + path: ['advanced', 'availability_blocklist'], + note: `Removed deprecated: advanced.availability_blocklist availability settings`, + noteIf: noteIfWasNonEmptyArray, + }, + { + path: ['advanced', 'availability_passlist'], + note: `Removed deprecated: advanced.availability_passlist availability settings`, + noteIf: noteIfWasNonEmptyArray, + }, + { + path: ['advanced', 'availability_blacklist'], + note: `Removed deprecated: advanced.availability_blacklist availability settings`, + noteIf: noteIfWasNonEmptyArray, + }, + { + path: ['advanced', 'availability_whitelist'], + note: `Removed deprecated: advanced.availability_whitelist availability settings`, + noteIf: noteIfWasNonEmptyArray, + }, + { + path: ['device_options', 'legacy'], + note: `Removed everything that was enabled through device_options.legacy. See link below for affected devices.`, + noteIf: noteIfWasTrue, + }, + { + path: ['experimental'], + note: `The entire experimental section was removed.`, + noteIf: noteIfWasDefined, + }, + { + path: ['external_converters'], + note: `External converters are now automatically loaded from the 'data/external_converters' directory without requiring settings to be set. Make sure your external converters are still needed (might be supported out-of-the-box now), and if so, move them to that directory.`, + noteIf: noteIfWasNonEmptyArray, + }, + ); + + // note only once + const noteEntityOptionsRetrieveState = `Retrieve state option ((devices|groups).xyz.retrieve_state setting)`; + + for (const deviceKey in currentSettings.devices) { + removals.push({ + path: ['devices', deviceKey, 'retrieve_state'], + note: noteEntityOptionsRetrieveState, + noteIf: noteIfWasTrue, + }); + } + + for (const groupKey in currentSettings.groups) { + removals.push({ + path: ['groups', groupKey, 'retrieve_state'], + note: noteEntityOptionsRetrieveState, + noteIf: noteIfWasTrue, + }); + removals.push({ + path: ['groups', groupKey, 'devices'], + note: `Removed configuring group members through configuration.yaml (groups.xyz.devices setting). This will not impact current group members; however, you will no longer be able to add or remove devices from a group through the configuration.yaml.`, + noteIf: noteIfWasDefined, + }); + } +} + +// Future +// function migrateFromTwoToThree(currentSettings: Settings, transfers: SettingsTransfer[], changes: SettingsChange[], additions: SettingsAdd[], removals: SettingsRemove[]): void { +// transfers.push(); +// changes.push( +// { +// path: ['version'], +// note: `Migrated settings to version 3`, +// newValue: 3, +// } +// ); +// additions.push(); +// removals.push(); +// } + +/** + * Order of execution: + * - Transfer + * - Change + * - Add + * - Remove + * + * Should allow the most flexibility whenever combination of migrations is necessary (e.g. Transfer + Change) + */ +export function migrateIfNecessary(): void { + const currentSettings = settings.getInternalSettings(); + + if (!SUPPORTED_VERSIONS.includes(currentSettings.version)) { + throw new Error( + `Your configuration.yaml has an unsupported version ${currentSettings.version}, expected one of ${SUPPORTED_VERSIONS.map((v) => String(v)).join(',')}.`, + ); + } + + // when same version as current, nothing left to do + while (currentSettings.version !== settings.CURRENT_VERSION) { + let migrationNotesFileName: string | undefined; + // don't duplicate outputs + const migrationNotes: Set = new Set(); + const transfers: SettingsTransfer[] = []; + const changes: SettingsChange[] = []; + const additions: SettingsAdd[] = []; + const removals: SettingsRemove[] = []; + + backupSettings(currentSettings.version || 1); + + // each version should only bump to the next version so as to gradually migrate if necessary + /* istanbul ignore else */ + if (currentSettings.version == undefined) { + // migrating from 1.x.x (`version` did not exist) to 2.0.0 + migrationNotesFileName = 'migration-1.x.x-to-2.0.0.log'; + + migrateToTwo(currentSettings, transfers, changes, additions, removals); + } /* else if (currentSettings.version === 2) { + // Future + // migrating from 2.x.x to 3.0.0 + migrationNotesFileName = 'migration-2.x.x-to-3.0.0.log'; + + migrateFromTwoToThree(currentSettings, transfers, changes, additions, removals); + }*/ + + for (const transfer of transfers) { + const [validPath, previousValue, transfered] = transferValue(currentSettings, transfer); + + if (validPath && (!transfer.noteIf || transfer.noteIf(previousValue))) { + migrationNotes.add(`[${transfered ? 'TRANSFER' : 'REMOVAL'}] ${transfer.note}`); + } + } + + for (const change of changes) { + const [validPath, previousValue, changed] = changeValue(currentSettings, change); + + if (validPath && changed && (!change.noteIf || change.noteIf(previousValue))) { + migrationNotes.add(`[CHANGE] ${change.note}`); + } + } + + for (const addition of additions) { + addValue(currentSettings, addition); + + migrationNotes.add(`[ADDITION] ${addition.note}`); + } + + for (const removal of removals) { + const [validPath, previousValue] = removeValue(currentSettings, removal); + + if (validPath && (!removal.noteIf || removal.noteIf(previousValue))) { + migrationNotes.add(`[REMOVAL] ${removal.note}`); + } + } + + /* istanbul ignore else */ + if (migrationNotesFileName && migrationNotes.size > 0) { + migrationNotes.add(`For more details, see https://github.com/Koenkk/zigbee2mqtt/discussions/24198`); + const migrationNotesFilePath = data.joinPath(migrationNotesFileName); + + writeFileSync(migrationNotesFilePath, Array.from(migrationNotes).join(`\r\n\r\n`), 'utf8'); + + console.log(`Migration notes written in ${migrationNotesFilePath}`); + } + + settings.apply(currentSettings as unknown as Record); + settings.reRead(); + } +} diff --git a/test/controller.test.ts b/test/controller.test.ts index e1eea4099..2f511a312 100644 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -636,7 +636,7 @@ describe('Controller', () => { it('Publish entity state attribute output', async () => { await controller.start(); - settings.set(['experimental', 'output'], 'attribute'); + settings.set(['advanced', 'output'], 'attribute'); mockMQTT.publishAsync.mockClear(); // @ts-expect-error private const device = controller.zigbee.resolveEntity('bulb')!; @@ -662,7 +662,7 @@ describe('Controller', () => { it('Publish entity state attribute_json output', async () => { await controller.start(); - settings.set(['experimental', 'output'], 'attribute_and_json'); + settings.set(['advanced', 'output'], 'attribute_and_json'); mockMQTT.publishAsync.mockClear(); // @ts-expect-error private const device = controller.zigbee.resolveEntity('bulb')!; @@ -682,7 +682,7 @@ describe('Controller', () => { it('Publish entity state attribute_json output filtered', async () => { await controller.start(); - settings.set(['experimental', 'output'], 'attribute_and_json'); + settings.set(['advanced', 'output'], 'attribute_and_json'); settings.set(['devices', devices.bulb.ieeeAddr, 'filtered_attributes'], ['color_temp', 'linkquality']); mockMQTT.publishAsync.mockClear(); // @ts-expect-error private @@ -697,7 +697,7 @@ describe('Controller', () => { it('Publish entity state attribute_json output filtered (device_options)', async () => { await controller.start(); - settings.set(['experimental', 'output'], 'attribute_and_json'); + settings.set(['advanced', 'output'], 'attribute_and_json'); settings.set(['device_options', 'filtered_attributes'], ['color_temp', 'linkquality']); mockMQTT.publishAsync.mockClear(); // @ts-expect-error private diff --git a/test/extensions/bridge.test.ts b/test/extensions/bridge.test.ts index 1977faf62..8be3c3f9a 100644 --- a/test/extensions/bridge.test.ts +++ b/test/extensions/bridge.test.ts @@ -218,7 +218,7 @@ describe('Extension: Bridge', () => { passlist: [], serial: {disable_led: false, port: '/dev/dummy'}, }, - config_schema: settings.schema, + config_schema: settings.schemaJson, coordinator: {ieee_address: '0x00124b00120144ae', meta: {revision: 20190425, version: 1}, type: 'z-Stack'}, log_level: 'info', network: {channel: 15, extended_pan_id: 0x001122, pan_id: 5674}, diff --git a/test/extensions/homeassistant.test.ts b/test/extensions/homeassistant.test.ts index 03debad75..f3de62ff9 100644 --- a/test/extensions/homeassistant.test.ts +++ b/test/extensions/homeassistant.test.ts @@ -1033,7 +1033,7 @@ describe('Extension: HomeAssistant', () => { }); it('Should throw error when starting with attributes output', async () => { - settings.set(['experimental', 'output'], 'attribute'); + settings.set(['advanced', 'output'], 'attribute'); settings.set(['homeassistant'], true); expect(() => { new Controller(jest.fn(), jest.fn()); diff --git a/test/mocks/data.ts b/test/mocks/data.ts index fee037b83..92d2b8040 100644 --- a/test/mocks/data.ts +++ b/test/mocks/data.ts @@ -9,8 +9,8 @@ import yaml from '../../lib/util/yaml'; export const mockDir: string = tmp.dirSync().name; const stateFile = path.join(mockDir, 'state.json'); -export function writeDefaultConfiguration(): void { - const config = { +export function writeDefaultConfiguration(config: unknown = undefined): void { + config = config || { homeassistant: false, mqtt: { base_topic: 'zigbee2mqtt', diff --git a/test/settings.test.ts b/test/settings.test.ts index 0e8601d5a..62a5f79d6 100644 --- a/test/settings.test.ts +++ b/test/settings.test.ts @@ -1,3 +1,6 @@ +// side-effect ensures using mock paths +import './mocks/data'; + import fs from 'fs'; import yaml from 'js-yaml'; @@ -908,63 +911,4 @@ describe('Settings', () => { settings.reRead(); expect(settings.get().frontend).toStrictEqual({port: 8080, auth_token: null, base_url: '/'}); }); - - it('Baudrate config', () => { - write(configurationFile, {...minimalConfig, advanced: {baudrate: 20}}); - - settings.reRead(); - expect(settings.get().serial.baudrate).toStrictEqual(20); - }); - - it('transmit_power config', () => { - write(configurationFile, {...minimalConfig, experimental: {transmit_power: 1337}}); - - settings.reRead(); - expect(settings.get().advanced.transmit_power).toStrictEqual(1337); - }); - - it('output config', () => { - write(configurationFile, {...minimalConfig, experimental: {output: 'json'}}); - - settings.reRead(); - expect(settings.get().advanced.output).toStrictEqual('json'); - }); - - it('Baudrartsctste config', () => { - write(configurationFile, {...minimalConfig, advanced: {rtscts: true}}); - - settings.reRead(); - expect(settings.get().serial.rtscts).toStrictEqual(true); - }); - - it('Deprecated: Home Assistant config', () => { - write(configurationFile, { - ...minimalConfig, - homeassistant: {discovery_topic: 'new'}, - advanced: {homeassistant_discovery_topic: 'old', homeassistant_status_topic: 'olds'}, - }); - - settings.reRead(); - expect(settings.get().homeassistant).toStrictEqual({ - discovery_topic: 'new', - experimental_event_entities: false, - status_topic: 'olds', - }); - }); - - it('Deprecated: ban/whitelist config', () => { - write(configurationFile, {...minimalConfig, ban: ['ban'], whitelist: ['whitelist'], passlist: ['passlist'], blocklist: ['blocklist']}); - - settings.reRead(); - expect(settings.get().blocklist).toStrictEqual(['blocklist', 'ban']); - expect(settings.get().passlist).toStrictEqual(['passlist', 'whitelist']); - }); - - it('Deprecated: warn log level', () => { - write(configurationFile, {...minimalConfig, advanced: {log_level: 'warn'}}); - - settings.reRead(); - - expect(settings.get().advanced.log_level).toStrictEqual('warning'); - }); }); diff --git a/test/settingsMigration.test.ts b/test/settingsMigration.test.ts new file mode 100644 index 000000000..d5eb15322 --- /dev/null +++ b/test/settingsMigration.test.ts @@ -0,0 +1,710 @@ +// side-effect ensures using mock paths +import * as data from './mocks/data'; + +import {existsSync, readFileSync, rmSync} from 'fs'; + +import objectAssignDeep from 'object-assign-deep'; + +import mockedData from '../lib/util/data'; +import * as settings from '../lib/util/settings'; +import * as settingsMigration from '../lib/util/settingsMigration'; + +describe('Settings Migration', () => { + beforeAll(() => {}); + + afterAll(() => { + rmSync(data.mockDir, {recursive: true, force: true}); + }); + + beforeEach(() => { + data.writeDefaultConfiguration(); + settings.reRead(); + }); + + afterEach(() => { + // always validate after each test + expect(settings.validate()).toStrictEqual([]); + }); + + it('Fails on unsupported version', () => { + settings.set(['version'], 0); + + expect(() => settingsMigration.migrateIfNecessary()).toThrow( + `Your configuration.yaml has an unsupported version 0, expected one of undefined,2`, + ); + + settings.set(['version'], 99999); + + expect(() => settingsMigration.migrateIfNecessary()).toThrow( + `Your configuration.yaml has an unsupported version 99999, expected one of undefined,2`, + ); + }); + + describe('Migrates v1.x.x to v2.0.0', () => { + const DEFAULT_CONFIG_V2 = { + homeassistant: false, + mqtt: { + base_topic: 'zigbee2mqtt', + server: 'mqtt://localhost', + }, + serial: { + port: '/dev/dummy', + }, + devices: { + '0x18fc2600000d7ae2': { + friendly_name: 'bosch_radiator', + }, + '0x000b57fffec6a5b2': { + retain: true, + friendly_name: 'bulb', + description: 'this is my bulb', + }, + '0x0017880104e45517': { + retain: true, + friendly_name: 'remote', + }, + '0x0017880104e45520': { + retain: false, + friendly_name: 'button', + }, + '0x0017880104e45521': { + retain: false, + friendly_name: 'button_double_key', + }, + '0x0017880104e45522': { + qos: 1, + retain: false, + friendly_name: 'weather_sensor', + }, + '0x0017880104e45523': { + retain: false, + friendly_name: 'occupancy_sensor', + }, + '0x0017880104e45524': { + retain: false, + friendly_name: 'power_plug', + }, + '0x0017880104e45530': { + retain: false, + friendly_name: 'button_double_key_interviewing', + }, + '0x0017880104e45540': { + friendly_name: 'ikea_onoff', + }, + '0x000b57fffec6a5b7': { + retain: false, + friendly_name: 'bulb_2', + }, + '0x000b57fffec6a5b3': { + retain: false, + friendly_name: 'bulb_color', + }, + '0x000b57fffec6a5b4': { + retain: false, + friendly_name: 'bulb_color_2', + }, + '0x0017880104e45541': { + retain: false, + friendly_name: 'wall_switch', + }, + '0x0017880104e45542': { + retain: false, + friendly_name: 'wall_switch_double', + }, + '0x0017880104e45543': { + retain: false, + friendly_name: 'led_controller_1', + }, + '0x0017880104e45544': { + retain: false, + friendly_name: 'led_controller_2', + }, + '0x0017880104e45545': { + retain: false, + friendly_name: 'dimmer_wall_switch', + }, + '0x0017880104e45547': { + retain: false, + friendly_name: 'curtain', + }, + '0x0017880104e45548': { + retain: false, + friendly_name: 'fan', + }, + '0x0017880104e45549': { + retain: false, + friendly_name: 'siren', + }, + '0x0017880104e45529': { + retain: false, + friendly_name: 'unsupported2', + }, + '0x0017880104e45550': { + retain: false, + friendly_name: 'thermostat', + }, + '0x0017880104e45551': { + retain: false, + friendly_name: 'smart vent', + }, + '0x0017880104e45552': { + retain: false, + friendly_name: 'j1', + }, + '0x0017880104e45553': { + retain: false, + friendly_name: 'bulb_enddevice', + }, + '0x0017880104e45559': { + retain: false, + friendly_name: 'cc2530_router', + }, + '0x0017880104e45560': { + retain: false, + friendly_name: 'livolo', + }, + '0x90fd9ffffe4b64ae': { + retain: false, + friendly_name: 'tradfri_remote', + }, + '0x90fd9ffffe4b64af': { + friendly_name: 'roller_shutter', + }, + '0x90fd9ffffe4b64ax': { + friendly_name: 'ZNLDP12LM', + }, + '0x90fd9ffffe4b64aa': { + friendly_name: 'SP600_OLD', + }, + '0x90fd9ffffe4b64ab': { + friendly_name: 'SP600_NEW', + }, + '0x90fd9ffffe4b64ac': { + friendly_name: 'MKS-CM-W5', + }, + '0x0017880104e45526': { + friendly_name: 'GL-S-007ZS', + }, + '0x0017880104e43559': { + friendly_name: 'U202DST600ZB', + }, + '0xf4ce368a38be56a1': { + retain: false, + friendly_name: 'zigfred_plus', + front_surface_enabled: 'true', + dimmer_1_enabled: 'true', + dimmer_1_dimming_enabled: 'true', + dimmer_2_enabled: 'true', + dimmer_2_dimming_enabled: 'true', + dimmer_3_enabled: 'true', + dimmer_3_dimming_enabled: 'true', + dimmer_4_enabled: 'true', + dimmer_4_dimming_enabled: 'true', + cover_1_enabled: 'true', + cover_1_tilt_enabled: 'true', + cover_2_enabled: 'true', + cover_2_tilt_enabled: 'true', + }, + '0x0017880104e44559': { + friendly_name: '3157100_thermostat', + }, + '0x0017880104a44559': { + friendly_name: 'J1_cover', + }, + '0x0017882104a44559': { + friendly_name: 'TS0601_thermostat', + }, + '0x0017882104a44560': { + friendly_name: 'TS0601_switch', + }, + '0x0017882104a44562': { + friendly_name: 'TS0601_cover_switch', + }, + '0x0017882194e45543': { + friendly_name: 'QS-Zigbee-D02-TRIAC-2C-LN', + }, + '0x0017880104e45724': { + friendly_name: 'GLEDOPTO_2ID', + }, + '0x0017880104e45561': { + friendly_name: 'temperature_sensor', + }, + '0x0017880104e45562': { + friendly_name: 'heating_actuator', + }, + }, + groups: { + 1: { + friendly_name: 'group_1', + retain: false, + }, + 2: { + friendly_name: 'group_2', + retain: false, + }, + 15071: { + friendly_name: 'group_tradfri_remote', + retain: false, + }, + 11: { + friendly_name: 'group_with_tradfri', + retain: false, + }, + 12: { + friendly_name: 'thermostat_group', + retain: false, + }, + 14: { + friendly_name: 'switch_group', + retain: false, + }, + 21: { + friendly_name: 'gledopto_group', + }, + 9: { + friendly_name: 'ha_discovery_group', + }, + }, + }; + + beforeEach(() => { + data.writeDefaultConfiguration(DEFAULT_CONFIG_V2); + settings.reRead(); + }); + + it('no change needed - only add version', () => { + // @ts-expect-error workaround + const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + afterSettings.version = 2; + + settingsMigration.migrateIfNecessary(); + + const migratedSettings = settings.getInternalSettings(); + + expect(migratedSettings).toStrictEqual(afterSettings); + }); + + it('remove all', () => { + // @ts-expect-error workaround + const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + // @ts-expect-error workaround + const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + afterSettings.version = 2; + + settings.set(['homeassistant', 'legacy_triggers'], true); + settings.set(['homeassistant', 'legacy_entity_attributes'], true); + settings.set(['ota', 'ikea_ota_use_test_url'], true); + settings.set(['advanced', 'homeassistant_legacy_triggers'], true); + settings.set(['advanced', 'homeassistant_legacy_entity_attributes'], true); + settings.set(['permit_join'], true); + settings.set(['advanced', 'ikea_ota_use_test_url'], true); + settings.set(['advanced', 'legacy_api'], true); + settings.set(['advanced', 'legacy_availability_payload'], true); + settings.set(['advanced', 'soft_reset_timeout'], 12); + settings.set(['advanced', 'report'], true); + settings.set(['advanced', 'availability_timeout'], 65); + settings.set(['advanced', 'availability_blocklist'], ['abcd', 'efgh']); + settings.set(['advanced', 'availability_passlist'], ['abcd']); + settings.set(['advanced', 'availability_blacklist'], ['abcd', 'efgh']); + settings.set(['advanced', 'availability_whitelist'], ['abcd', 'efgh']); + settings.set(['device_options', 'legacy'], true); + settings.set(['devices', '0x18fc2600000d7ae2', 'retrieve_state'], true); + settings.set(['devices', '0x000b57fffec6a5b2', 'retrieve_state'], true); + settings.set(['groups', '15071', 'retrieve_state'], true); + settings.set(['groups', '12', 'devices'], ['0x0017880104e45521', '0x0017880104e45524']); + settings.set(['external_converters'], ['zyx.js']); + + // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + + expect(settings.getInternalSettings()).toStrictEqual( + // @ts-expect-error workaround + objectAssignDeep.noMutate(beforeSettings, { + permit_join: true, + homeassistant: { + legacy_triggers: true, + legacy_entity_attributes: true, + }, + ota: {ikea_ota_use_test_url: true}, + advanced: { + homeassistant_legacy_triggers: true, + homeassistant_legacy_entity_attributes: true, + ikea_ota_use_test_url: true, + legacy_api: true, + legacy_availability_payload: true, + soft_reset_timeout: 12, + report: true, + availability_timeout: 65, + availability_blocklist: ['abcd', 'efgh'], + availability_passlist: ['abcd'], + availability_blacklist: ['abcd', 'efgh'], + availability_whitelist: ['abcd', 'efgh'], + }, + device_options: {legacy: true}, + devices: { + '0x18fc2600000d7ae2': {retrieve_state: true}, + '0x000b57fffec6a5b2': {retrieve_state: true}, + }, + groups: { + 15071: {retrieve_state: true}, + 12: {devices: ['0x0017880104e45521', '0x0017880104e45524']}, + }, + external_converters: ['zyx.js'], + }), + ); + + settingsMigration.migrateIfNecessary(); + + const migratedSettings = settings.getInternalSettings(); + // console.log(JSON.stringify(migratedSettings, undefined, 2)); + + expect(migratedSettings.advanced).toStrictEqual({}); + expect(migratedSettings.device_options).toStrictEqual({}); + expect(migratedSettings.ota).toStrictEqual({}); + expect(migratedSettings.homeassistant).toStrictEqual({}); + + // defaults added automatically when pushing to these keys, remove to match against default (verified by above expects) + migratedSettings.homeassistant = false; + delete migratedSettings.advanced; + delete migratedSettings.device_options; + delete migratedSettings.ota; + + expect(migratedSettings).toStrictEqual(afterSettings); + expect(existsSync(mockedData.joinPath('configuration_backup_v1.yaml'))).toStrictEqual(true); + const migrationNotes = mockedData.joinPath('migration-1.x.x-to-2.0.0.log'); + expect(existsSync(migrationNotes)).toStrictEqual(true); + const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); + expect(migrationNotesContent).toContain('homeassistant.legacy_triggers'); + expect(migrationNotesContent).toContain('homeassistant.legacy_entity_attributes'); + expect(migrationNotesContent).toContain('ota.ikea_ota_use_test_url'); + expect(migrationNotesContent).toContain('permit_join'); + expect(migrationNotesContent).toContain('advanced.legacy_api'); + expect(migrationNotesContent).toContain('advanced.legacy_availability_payload'); + expect(migrationNotesContent).toContain('advanced.soft_reset_timeout'); + expect(migrationNotesContent).toContain('advanced.report'); + expect(migrationNotesContent).toContain('advanced.availability_timeout'); + expect(migrationNotesContent).toContain('advanced.availability_blocklist'); + expect(migrationNotesContent).toContain('advanced.availability_passlist'); + expect(migrationNotesContent).toContain('advanced.availability_blacklist'); + expect(migrationNotesContent).toContain('advanced.availability_whitelist'); + expect(migrationNotesContent).toContain('device_options.legacy'); + expect(migrationNotesContent).toContain('(devices|groups).xyz.retrieve_state'); + expect(migrationNotesContent).toContain('groups.xyz.devices'); + expect(migrationNotesContent).toContain('External converters are now automatically loaded'); + }); + + it('remove partial', () => { + // @ts-expect-error workaround + const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + // @ts-expect-error workaround + const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + afterSettings.version = 2; + + settings.set(['advanced', 'homeassistant_legacy_triggers'], true); + settings.set(['advanced', 'homeassistant_legacy_entity_attributes'], true); + settings.set(['permit_join'], true); + settings.set(['advanced', 'ikea_ota_use_test_url'], true); + settings.set(['advanced', 'legacy_api'], true); + settings.set(['advanced', 'legacy_availability_payload'], false); + settings.set(['advanced', 'soft_reset_timeout'], 16); + settings.set(['advanced', 'report'], true); + settings.set(['advanced', 'availability_timeout'], 64); + settings.set(['advanced', 'availability_passlist'], []); + settings.set(['device_options', 'legacy'], true); + settings.set(['groups', '12', 'devices'], ['0x0017880104e45521', '0x0017880104e45524']); + + // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + + expect(settings.getInternalSettings()).toStrictEqual( + // @ts-expect-error workaround + objectAssignDeep.noMutate(beforeSettings, { + permit_join: true, + advanced: { + homeassistant_legacy_triggers: true, + homeassistant_legacy_entity_attributes: true, + ikea_ota_use_test_url: true, + legacy_api: true, + legacy_availability_payload: false, + soft_reset_timeout: 16, + report: true, + availability_timeout: 64, + availability_passlist: [], + }, + device_options: {legacy: true}, + groups: {12: {devices: ['0x0017880104e45521', '0x0017880104e45524']}}, + }), + ); + + settingsMigration.migrateIfNecessary(); + + const migratedSettings = settings.getInternalSettings(); + // console.log(JSON.stringify(migratedSettings, undefined, 2)); + + expect(migratedSettings.advanced).toStrictEqual({}); + expect(migratedSettings.device_options).toStrictEqual({}); + + // defaults added automatically when pushing to these keys, remove to match against default (verified by above expects) + migratedSettings.homeassistant = false; + delete migratedSettings.advanced; + delete migratedSettings.device_options; + + expect(migratedSettings).toStrictEqual(afterSettings); + expect(existsSync(mockedData.joinPath('configuration_backup_v1.yaml'))).toStrictEqual(true); + const migrationNotes = mockedData.joinPath('migration-1.x.x-to-2.0.0.log'); + expect(existsSync(migrationNotes)).toStrictEqual(true); + const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); + expect(migrationNotesContent).toContain('homeassistant.legacy_triggers'); + expect(migrationNotesContent).toContain('homeassistant.legacy_entity_attributes'); + expect(migrationNotesContent).toContain('ota.ikea_ota_use_test_url'); + expect(migrationNotesContent).toContain('permit_join'); + expect(migrationNotesContent).toContain('advanced.legacy_api'); + expect(migrationNotesContent).not.toContain('advanced.legacy_availability_payload'); // was false, no impact + expect(migrationNotesContent).toContain('advanced.soft_reset_timeout'); + expect(migrationNotesContent).toContain('advanced.report'); + expect(migrationNotesContent).toContain('advanced.availability_timeout'); + expect(migrationNotesContent).not.toContain('advanced.availability_blocklist'); + expect(migrationNotesContent).not.toContain('advanced.availability_passlist'); // empty array + expect(migrationNotesContent).not.toContain('advanced.availability_blacklist'); + expect(migrationNotesContent).not.toContain('advanced.availability_whitelist'); + expect(migrationNotesContent).toContain('device_options.legacy'); + expect(migrationNotesContent).not.toContain('(devices|groups).xyz.retrieve_state'); + expect(migrationNotesContent).toContain('groups.xyz.devices'); + }); + + it('changes log_level', () => { + // @ts-expect-error workaround + const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + // @ts-expect-error workaround + const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + afterSettings.version = 2; + afterSettings.advanced = {log_level: 'warning'}; + + settings.set(['advanced', 'log_level'], 'warn'); + + // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + + expect(settings.getInternalSettings()).toStrictEqual( + // @ts-expect-error workaround + objectAssignDeep.noMutate(beforeSettings, { + advanced: { + log_level: 'warn', + }, + }), + ); + + settingsMigration.migrateIfNecessary(); + + const migratedSettings = settings.getInternalSettings(); + // console.log(JSON.stringify(migratedSettings, undefined, 2)); + + expect(migratedSettings).toStrictEqual(afterSettings); + expect(existsSync(mockedData.joinPath('configuration_backup_v1.yaml'))).toStrictEqual(true); + const migrationNotes = mockedData.joinPath('migration-1.x.x-to-2.0.0.log'); + expect(existsSync(migrationNotes)).toStrictEqual(true); + const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); + expect(migrationNotesContent).toContain(`Log level 'warn' has been renamed to 'warning'.`); + }); + + it('does not changes already migrated log_level', () => { + // @ts-expect-error workaround + const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + // @ts-expect-error workaround + const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + afterSettings.version = 2; + afterSettings.advanced = {log_level: 'warning'}; + + settings.set(['advanced', 'log_level'], 'warning'); + + // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + + expect(settings.getInternalSettings()).toStrictEqual( + // @ts-expect-error workaround + objectAssignDeep.noMutate(beforeSettings, { + advanced: { + log_level: 'warning', + }, + }), + ); + + settingsMigration.migrateIfNecessary(); + + const migratedSettings = settings.getInternalSettings(); + // console.log(JSON.stringify(migratedSettings, undefined, 2)); + + expect(migratedSettings).toStrictEqual(afterSettings); + expect(existsSync(mockedData.joinPath('configuration_backup_v1.yaml'))).toStrictEqual(true); + const migrationNotes = mockedData.joinPath('migration-1.x.x-to-2.0.0.log'); + expect(existsSync(migrationNotes)).toStrictEqual(true); + const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); + expect(migrationNotesContent).not.toContain(`Log level 'warn' has been renamed to 'warning'.`); + }); + + it('does not changes other log_level', () => { + // @ts-expect-error workaround + const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + // @ts-expect-error workaround + const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + afterSettings.version = 2; + afterSettings.advanced = {log_level: 'info'}; + + settings.set(['advanced', 'log_level'], 'info'); + + // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + + expect(settings.getInternalSettings()).toStrictEqual( + // @ts-expect-error workaround + objectAssignDeep.noMutate(beforeSettings, { + advanced: { + log_level: 'info', + }, + }), + ); + + settingsMigration.migrateIfNecessary(); + + const migratedSettings = settings.getInternalSettings(); + // console.log(JSON.stringify(migratedSettings, undefined, 2)); + + expect(migratedSettings).toStrictEqual(afterSettings); + expect(existsSync(mockedData.joinPath('configuration_backup_v1.yaml'))).toStrictEqual(true); + const migrationNotes = mockedData.joinPath('migration-1.x.x-to-2.0.0.log'); + expect(existsSync(migrationNotes)).toStrictEqual(true); + const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); + expect(migrationNotesContent).not.toContain(`Log level 'warn' has been renamed to 'warning'.`); + }); + + it('transfer all', () => { + // @ts-expect-error workaround + const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + // @ts-expect-error workaround + const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + afterSettings.version = 2; + afterSettings.advanced = { + transmit_power: 12, + output: 'attribute', + }; + afterSettings.serial.baudrate = 115200; + afterSettings.serial.rtscts = true; + afterSettings.blocklist = ['abcd']; + afterSettings.passlist = ['efgh']; + afterSettings.homeassistant = { + discovery_topic: 'ha_disc', + status_topic: 'ha_stat', + }; + // Here, the `experimental` section is also explicitly removed after the transfer, so the empty object is gone + // afterSettings.experimental = {}; // caused by pushing to key and removing all + + settings.set(['advanced', 'homeassistant_discovery_topic'], 'ha_disc'); + settings.set(['advanced', 'homeassistant_status_topic'], 'ha_stat'); + settings.set(['advanced', 'baudrate'], 115200); + settings.set(['advanced', 'rtscts'], true); // only deleted since also below + settings.set(['serial', 'rtscts'], true); + settings.set(['experimental', 'transmit_power'], 12); + settings.set(['experimental', 'output'], 'attribute'); + settings.set(['ban'], ['abcd']); + settings.set(['whitelist'], ['efgh']); + + // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + + expect(settings.getInternalSettings()).toStrictEqual( + // @ts-expect-error workaround + objectAssignDeep.noMutate(beforeSettings, { + advanced: { + homeassistant_discovery_topic: 'ha_disc', + homeassistant_status_topic: 'ha_stat', + baudrate: 115200, + rtscts: true, + }, + serial: { + rtscts: true, + }, + experimental: { + transmit_power: 12, + output: 'attribute', + }, + ban: ['abcd'], + whitelist: ['efgh'], + }), + ); + + settingsMigration.migrateIfNecessary(); + + const migratedSettings = settings.getInternalSettings(); + // console.log(JSON.stringify(migratedSettings, undefined, 2)); + + expect(migratedSettings).toStrictEqual(afterSettings); + expect(existsSync(mockedData.joinPath('configuration_backup_v1.yaml'))).toStrictEqual(true); + const migrationNotes = mockedData.joinPath('migration-1.x.x-to-2.0.0.log'); + expect(existsSync(migrationNotes)).toStrictEqual(true); + const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); + expect(migrationNotesContent).toContain( + `HA discovery_topic was moved from advanced.homeassistant_discovery_topic to homeassistant.discovery_topic.`, + ); + expect(migrationNotesContent).toContain( + `HA status_topic was moved from advanced.homeassistant_status_topic to homeassistant.status_topic.`, + ); + expect(migrationNotesContent).toContain(`Baudrate was moved from advanced.baudrate to serial.baudrate.`); + expect(migrationNotesContent).toContain(`RTSCTS was moved from advanced.rtscts to serial.rtscts.`); + expect(migrationNotesContent).toContain(`Transmit power was moved from experimental.transmit_power to advanced.transmit_power.`); + expect(migrationNotesContent).toContain(`Output was moved from experimental.output to advanced.output.`); + expect(migrationNotesContent).toContain(`ban was renamed to passlist.`); + expect(migrationNotesContent).toContain(`whitelist was renamed to passlist.`); + expect(migrationNotesContent).toContain(`The entire experimental section was removed.`); + }); + + it('transfer partial', () => { + // @ts-expect-error workaround + const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + // @ts-expect-error workaround + const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + afterSettings.version = 2; + afterSettings.advanced = {}; // caused by pushing to key and removing all + afterSettings.serial.baudrate = 115200; + afterSettings.serial.rtscts = true; + afterSettings.blocklist = ['abcd', 'efgh']; + afterSettings.homeassistant = { + discovery_topic: 'ha_disc_newer', // keeps the newer value, just removes the old path + }; + + settings.set(['homeassistant', 'discovery_topic'], 'ha_disc_newer'); + settings.set(['advanced', 'homeassistant_discovery_topic'], 'ha_disc'); + settings.set(['advanced', 'baudrate'], 115200); + settings.set(['advanced', 'rtscts'], true); // only deleted since also below + settings.set(['serial', 'rtscts'], true); + settings.set(['ban'], ['abcd']); + settings.set(['blocklist'], ['efgh']); + + // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + + expect(settings.getInternalSettings()).toStrictEqual( + // @ts-expect-error workaround + objectAssignDeep.noMutate(beforeSettings, { + homeassistant: {discovery_topic: 'ha_disc_newer'}, + advanced: { + homeassistant_discovery_topic: 'ha_disc', + baudrate: 115200, + rtscts: true, + }, + serial: { + rtscts: true, + }, + ban: ['abcd'], + blocklist: ['efgh'], + }), + ); + + settingsMigration.migrateIfNecessary(); + + const migratedSettings = settings.getInternalSettings(); + // console.log(JSON.stringify(migratedSettings, undefined, 2)); + + expect(migratedSettings).toStrictEqual(afterSettings); + expect(existsSync(mockedData.joinPath('configuration_backup_v1.yaml'))).toStrictEqual(true); + const migrationNotes = mockedData.joinPath('migration-1.x.x-to-2.0.0.log'); + expect(existsSync(migrationNotes)).toStrictEqual(true); + const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); + expect(migrationNotesContent).toContain(`[TRANSFER] Baudrate was moved from advanced.baudrate to serial.baudrate.`); + expect(migrationNotesContent).toContain(`[REMOVAL] RTSCTS was moved from advanced.rtscts to serial.rtscts.`); + expect(migrationNotesContent).toContain(`[TRANSFER] ban was renamed to passlist.`); + }); + }); +});