feat: Automatic settings migration (#24871)

* feat: Automatic settings migration

* Handle failing on unsupported version.

* Handle change not needed.

* Test change not wanted.

* Cleanup.

* Add `external_converters` removal.

* Fix schema export.
This commit is contained in:
Nerivec
2024-12-01 20:11:34 +01:00
committed by Koen Kanters
parent e052988663
commit c6958c4991
12 changed files with 1229 additions and 195 deletions
+7
View File
@@ -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) {
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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;
-63
View File
@@ -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"],
+14 -63
View File
@@ -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<Settings>): void {
iterate(schemaJson.properties, []);
}
function getInternalSettings(): Partial<Settings> {
export function getInternalSettings(): Partial<Settings> {
if (!_settings) {
_settings = read();
}
+485
View File
@@ -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<SettingsMigration, 'noteIf'> {
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<Settings>, 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<Settings>, 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<Settings>, 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<Settings>,
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<Settings>,
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<string> = 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<string, unknown>);
settings.reRead();
}
}
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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},
+1 -1
View File
@@ -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());
+2 -2
View File
@@ -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',
+3 -59
View File
@@ -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');
});
});
+710
View File
@@ -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.`);
});
});
});