diff --git a/data/configuration.example.yaml b/data/configuration.example.yaml index 1a8e2799c7..e442c66f75 100644 --- a/data/configuration.example.yaml +++ b/data/configuration.example.yaml @@ -2,10 +2,13 @@ version: 2 # Home Assistant integration (MQTT discovery) -homeassistant: false +homeassistant: + enabled: false # Enable the frontend, runs on port 8080 by default -frontend: true +frontend: + enabled: true + # port: 8080 # MQTT settings mqtt: @@ -28,8 +31,13 @@ mqtt: # # Adapter type, allowed values: `zstack`, `ember`, `deconz`, `zigate` or `zboss` # adapter: zstack +# Periodically check whether devices are online/offline +# availability: +# enabled: false + # Advanced settings advanced: + # channel: 11 # Let Zigbee2MQTT generate a network key on first start network_key: GENERATE # Let Zigbee2MQTT generate a pan_id on first start diff --git a/lib/controller.ts b/lib/controller.ts index 46e90b59bc..7afdb0c36a 100644 --- a/lib/controller.ts +++ b/lib/controller.ts @@ -113,11 +113,11 @@ export class Controller { new ExtensionAvailability(...this.extensionArgs), ]; - if (settings.get().frontend) { + if (settings.get().frontend.enabled) { this.extensions.push(new ExtensionFrontend(...this.extensionArgs)); } - if (settings.get().homeassistant) { + if (settings.get().homeassistant.enabled) { this.extensions.push(new ExtensionHomeAssistant(...this.extensionArgs)); } } diff --git a/lib/extension/availability.ts b/lib/extension/availability.ts index f43576c2cc..52c63f9d32 100644 --- a/lib/extension/availability.ts +++ b/lib/extension/availability.ts @@ -31,14 +31,9 @@ export default class Availability extends Extension { return utils.minutes(device.options.availability.timeout); } - const key = this.isActiveDevice(device) ? 'active' : 'passive'; - let value = settings.get().availability?.[key]?.timeout; + const type = this.isActiveDevice(device) ? 'active' : 'passive'; - if (value == null) { - value = key == 'active' ? 10 : 1500; - } - - return utils.minutes(value); + return utils.minutes(settings.get().availability[type].timeout); } private isActiveDevice(device: Device): boolean { diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 0471689ed1..4e0a85996b 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -230,13 +230,12 @@ export default class Bridge extends Extension { throw new Error(`Invalid payload`); } - const newSettings = message.options; - const restartRequired = settings.apply(newSettings); - if (restartRequired) this.restartRequired = true; + const newSettings = message.options as Partial; + this.restartRequired = settings.apply(newSettings); // Apply some settings on-the-fly. - if (newSettings.homeassistant != undefined) { - await this.enableDisableExtension(!!settings.get().homeassistant, 'HomeAssistant'); + if (newSettings.homeassistant) { + await this.enableDisableExtension(settings.get().homeassistant.enabled, 'HomeAssistant'); } if (newSettings.advanced?.log_level != undefined) { @@ -671,10 +670,7 @@ export default class Bridge extends Extension { // @ts-expect-error hidden from publish delete config.advanced.network_key; delete config.mqtt.password; - - if (config.frontend) { - delete config.frontend.auth_token; - } + delete config.frontend.auth_token; const networkParams = await this.zigbee.getNetworkParameters(); const payload: Zigbee2MQTTAPI['bridge/info'] = { diff --git a/lib/extension/frontend.ts b/lib/extension/frontend.ts index 471a193edb..291b33e07e 100644 --- a/lib/extension/frontend.ts +++ b/lib/extension/frontend.ts @@ -49,7 +49,7 @@ export default class Frontend extends Extension { super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension); const frontendSettings = settings.get().frontend; - assert(frontendSettings, 'Frontend extension created without having frontend settings'); + assert(frontendSettings.enabled, `Frontend extension created with setting 'enabled: false'`); this.host = frontendSettings.host; this.port = frontendSettings.port; this.sslCert = frontendSettings.ssl_cert; diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index c2f651ff93..22c1075628 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -413,7 +413,7 @@ export default class HomeAssistant extends Extension { } const haSettings = settings.get().homeassistant; - assert(haSettings, 'Home Assistant extension used without settings'); + assert(haSettings.enabled, `Home Assistant extension created with setting 'enabled: false'`); this.discoveryTopic = haSettings.discovery_topic; this.discoveryRegex = new RegExp(`${haSettings.discovery_topic}/(.*)/(.*)/(.*)/config`); this.statusTopic = haSettings.status_topic; diff --git a/lib/extension/publish.ts b/lib/extension/publish.ts index a1ff62e244..e31e235f8e 100644 --- a/lib/extension/publish.ts +++ b/lib/extension/publish.ts @@ -82,7 +82,7 @@ export default class Publish extends Extension { * the color temperature. This would lead to 2 zigbee publishes, where the first one * (state) is probably unnecessary. */ - if (settings.get().homeassistant) { + if (settings.get().homeassistant.enabled) { const hasColorTemp = message.color_temp !== undefined; const hasColor = message.color !== undefined; const hasBrightness = message.brightness !== undefined; diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts index 09e0b85af8..84b105717b 100644 --- a/lib/types/types.d.ts +++ b/lib/types/types.d.ts @@ -96,13 +96,15 @@ declare global { // Settings interface Settings { version?: number; - homeassistant?: { + homeassistant: { + enabled: boolean; discovery_topic: string; status_topic: string; experimental_event_entities: boolean; legacy_action_sensor: boolean; }; - availability?: { + availability: { + enabled: boolean; active: {timeout: number}; passive: {timeout: number}; }; @@ -158,7 +160,8 @@ declare global { image_block_response_delay?: number; default_maximum_data_size?: number; }; - frontend?: { + frontend: { + enabled: boolean; auth_token?: string; host?: string; port: number; diff --git a/lib/util/settings.schema.json b/lib/util/settings.schema.json index 4d7916e906..27c9837afb 100644 --- a/lib/util/settings.schema.json +++ b/lib/util/settings.schema.json @@ -3,94 +3,93 @@ "properties": { "homeassistant": { "title": "Home Assistant integration", - "requiresRestart": true, "description": "Home Assistant integration (MQTT discovery)", - "default": false, - "oneOf": [ - { + "type": "object", + "properties": { + "enabled": { "type": "boolean", - "title": "Home Assistant (simple)" + "title": "Enabled", + "description": "Enable Home Assistant integration", + "default": false, + "requiresRestart": true }, - { - "type": "object", - "title": "Home Assistant (advanced)", - "properties": { - "discovery_topic": { - "type": "string", - "title": "Homeassistant discovery topic", - "description": "Home Assistant discovery topic", - "requiresRestart": true, - "examples": ["homeassistant"] - }, - "status_topic": { - "type": "string", - "title": "Home Assistant status topic", - "description": "Home Assistant status topic", - "requiresRestart": true, - "examples": ["homeassistant/status"] - }, - "legacy_action_sensor": { - "type": "boolean", - "title": "Home Assistant legacy action sensors", - "description": "Home Assistant legacy actions sensor, when enabled a action sensor will be discoverd and an empty `action` will be send after every published action.", - "default": false - }, - "experimental_event_entities": { - "type": "boolean", - "title": "Home Assistant experimental event entities", - "description": "Home Assistant experimental event entities, when enabled Zigbee2MQTT will add event entities for exposed actions. The events and attributes are currently deemed experimental and subject to change.", - "default": false - } - } + "discovery_topic": { + "type": "string", + "title": "Homeassistant discovery topic", + "description": "Home Assistant discovery topic", + "default": "homeassistant", + "requiresRestart": true, + "examples": ["homeassistant"] + }, + "status_topic": { + "type": "string", + "title": "Home Assistant status topic", + "description": "Home Assistant status topic", + "default": "hass/status", + "requiresRestart": true, + "examples": ["homeassistant/status"] + }, + "legacy_action_sensor": { + "type": "boolean", + "title": "Home Assistant legacy action sensors", + "description": "Home Assistant legacy actions sensor, when enabled a action sensor will be discoverd and an empty `action` will be send after every published action.", + "default": false + }, + "experimental_event_entities": { + "type": "boolean", + "title": "Home Assistant experimental event entities", + "description": "Home Assistant experimental event entities, when enabled Zigbee2MQTT will add event entities for exposed actions. The events and attributes are currently deemed experimental and subject to change.", + "default": false } - ] + }, + "required": ["enabled"] }, "availability": { - "oneOf": [ - { + "type": "object", + "title": "Availability", + "description": "Checks whether devices are online/offline", + "properties": { + "enabled": { "type": "boolean", - "title": "Availability (simple)" + "title": "Enabled", + "description": "Enable availability checks", + "default": false, + "requiresRestart": true }, - { + "active": { "type": "object", - "title": "Availability (advanced)", + "title": "Active", + "requiresRestart": true, + "description": "Options for active devices (routers/mains powered)", "properties": { - "active": { - "type": "object", - "title": "Active", + "timeout": { + "type": "number", + "title": "Timeout", "requiresRestart": true, - "description": "Options for active devices (routers/mains powered)", - "properties": { - "timeout": { - "type": "number", - "title": "Timeout", - "requiresRestart": true, - "default": 10, - "description": "Time after which an active device will be marked as offline in minutes" - } - } - }, - "passive": { - "type": "object", - "title": "Passive", + "default": 10, + "description": "Time after which an active device will be marked as offline in minutes" + } + }, + "required": ["timeout"] + }, + "passive": { + "type": "object", + "title": "Passive", + "requiresRestart": true, + "description": "Options for passive devices (mostly battery powered)", + "properties": { + "timeout": { + "type": "number", + "title": "Timeout", "requiresRestart": true, - "description": "Options for passive devices (mostly battery powered)", - "properties": { - "timeout": { - "type": "number", - "title": "Timeout", - "requiresRestart": true, - "default": 1500, - "description": "Time after which an passive device will be marked as offline in minutes" - } - } + "default": 1500, + "description": "Time after which an passive device will be marked as offline in minutes" } - } + }, + "required": ["timeout"] } - ], - "title": "Availability", - "requiresRestart": true, - "description": "Checks whether devices are online/offline" + }, + "required": ["enabled"] }, "mqtt": { "type": "object", @@ -357,66 +356,64 @@ } }, "frontend": { - "oneOf": [ - { + "type": "object", + "title": "Frontend", + "properties": { + "enabled": { "type": "boolean", - "title": "Frontend (simple)" + "title": "Enabled", + "description": "Enable frontend", + "default": false, + "requiresRestart": true }, - { - "type": "object", - "title": "Frontend (advanced)", - "properties": { - "port": { - "type": "number", - "title": "Port", - "description": "Frontend binding port. Ignored when using a unix domain socket", - "default": 8080, - "requiresRestart": true - }, - "host": { - "type": ["string", "null"], - "title": "Bind host", - "description": "Frontend binding host. Binds to a unix socket when an absolute path is given instead.", - "examples": ["127.0.0.1", "::1", "/run/zigbee2mqtt/zigbee2mqtt.sock"], - "requiresRestart": true - }, - "auth_token": { - "type": ["string", "null"], - "title": "Auth token", - "description": "Enables authentication, disabled by default", - "requiresRestart": true - }, - "url": { - "type": ["string", "null"], - "title": "URL", - "description": "URL on which the frontend can be reached, currently only used for the Home Assistant device configuration page", - "requiresRestart": true - }, - "ssl_cert": { - "type": ["string", "null"], - "title": "Certificate file path", - "description": "SSL Certificate file path for exposing HTTPS. The sibling property 'ssl_key' must be set for HTTPS to be activated.", - "requiresRestart": true - }, - "ssl_key": { - "type": ["string", "null"], - "title": "key file path", - "description": "SSL key file path for exposing HTTPS. The sibling property 'ssl_cert' must be set for HTTPS to be activated.", - "requiresRestart": true - }, - "base_url": { - "type": "string", - "pattern": "^\\/.*", - "title": "Base URL", - "description": "Base URL for the frontend. If hosted under a subpath, e.g. 'http://localhost:8080/z2m', set this to '/z2m'", - "default": "/", - "requiresRestart": true - } - } + "port": { + "type": "number", + "title": "Port", + "description": "Frontend binding port. Ignored when using a unix domain socket", + "default": 8080, + "requiresRestart": true + }, + "host": { + "type": ["string", "null"], + "title": "Bind host", + "description": "Frontend binding host. Binds to a unix socket when an absolute path is given instead.", + "examples": ["127.0.0.1", "::1", "/run/zigbee2mqtt/zigbee2mqtt.sock"], + "requiresRestart": true + }, + "auth_token": { + "type": ["string", "null"], + "title": "Auth token", + "description": "Enables authentication, disabled by default", + "requiresRestart": true + }, + "url": { + "type": ["string", "null"], + "title": "URL", + "description": "URL on which the frontend can be reached, currently only used for the Home Assistant device configuration page", + "requiresRestart": true + }, + "ssl_cert": { + "type": ["string", "null"], + "title": "Certificate file path", + "description": "SSL Certificate file path for exposing HTTPS. The sibling property 'ssl_key' must be set for HTTPS to be activated.", + "requiresRestart": true + }, + "ssl_key": { + "type": ["string", "null"], + "title": "key file path", + "description": "SSL key file path for exposing HTTPS. The sibling property 'ssl_cert' must be set for HTTPS to be activated.", + "requiresRestart": true + }, + "base_url": { + "type": "string", + "pattern": "^\\/.*", + "title": "Base URL", + "description": "Base URL for the frontend. If hosted under a subpath, e.g. 'http://localhost:8080/z2m', set this to '/z2m'", + "default": "/", + "requiresRestart": true } - ], - "title": "Frontend", - "requiresRestart": true + }, + "required": ["enabled"] }, "devices": { "type": "object", diff --git a/lib/util/settings.ts b/lib/util/settings.ts index a471dcd734..b5448ac124 100644 --- a/lib/util/settings.ts +++ b/lib/util/settings.ts @@ -9,7 +9,7 @@ import utils from './utils'; import yaml, {YAMLFileException} from './yaml'; export {schemaJson}; -export const CURRENT_VERSION = 2; +export const CURRENT_VERSION = 3; /** 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 = 'error' | 'warning' | 'info' | 'debug'; @@ -24,7 +24,24 @@ const ajvRestartRequiredDeviceOptions = new Ajv({allErrors: true}) const ajvRestartRequiredGroupOptions = new Ajv({allErrors: true}) .addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s}) .compile(schemaJson.definitions.group); -const defaults: RecursivePartial = { +export const defaults: RecursivePartial = { + homeassistant: { + enabled: false, + discovery_topic: 'homeassistant', + status_topic: 'hass/status', + legacy_action_sensor: false, + experimental_event_entities: false, + }, + availability: { + enabled: false, + active: {timeout: 10}, + passive: {timeout: 1500}, + }, + frontend: { + enabled: false, + port: 8080, + base_url: '/', + }, mqtt: { base_topic: 'zigbee2mqtt', include_device_information: false, @@ -99,7 +116,7 @@ function loadSettingsWithDefaults(): void { _settings = read(); } - _settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings; + _settingsWithDefaults = objectAssignDeep({}, defaults, getPersistedSettings()) as Settings; if (!_settingsWithDefaults.devices) { _settingsWithDefaults.devices = {}; @@ -108,41 +125,6 @@ function loadSettingsWithDefaults(): void { if (!_settingsWithDefaults.groups) { _settingsWithDefaults.groups = {}; } - - if (_settingsWithDefaults.homeassistant) { - const defaults = { - discovery_topic: 'homeassistant', - status_topic: 'hass/status', - legacy_action_sensor: false, - experimental_event_entities: false, - }; - const s = typeof _settingsWithDefaults.homeassistant === 'object' ? _settingsWithDefaults.homeassistant : {}; - // @ts-expect-error ignore typing - _settingsWithDefaults.homeassistant = {}; - - // @ts-expect-error ignore typing - objectAssignDeep(_settingsWithDefaults.homeassistant, defaults, s); - } - - if (_settingsWithDefaults.availability) { - const defaults = {}; - 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); - } - - if (_settingsWithDefaults.frontend) { - const defaults = {port: 8080, auth_token: null, base_url: '/'}; - 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); - } } function parseValueRef(text: string): {filename: string; key: string} | null { @@ -160,7 +142,7 @@ function parseValueRef(text: string): {filename: string; key: string} | null { } function write(): void { - const settings = getInternalSettings(); + const settings = getPersistedSettings(); const toWrite: KeyValue = objectAssignDeep({}, settings); // Read settings to check if we have to split devices/groups into separate file. @@ -215,7 +197,7 @@ function write(): void { export function validate(): string[] { try { - getInternalSettings(); + getPersistedSettings(); } catch (error) { if (error instanceof YAMLFileException) { return [`Your YAML file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`]; @@ -285,8 +267,8 @@ export function validate(): string[] { return errors; } -function read(): Settings { - const s = yaml.read(CONFIG_FILE_PATH) as Settings; +function read(): Partial { + const s = yaml.read(CONFIG_FILE_PATH) as Partial; applyEnvironmentVariables(s); // Read !secret MQTT username and password if set @@ -396,7 +378,12 @@ function applyEnvironmentVariables(settings: Partial): void { iterate(schemaJson.properties, []); } -export function getInternalSettings(): Partial { +/** + * Get the settings actually written in the yaml. + * Env vars are applied on top. + * Defaults merged on startup are not included. + */ +export function getPersistedSettings(): Partial { if (!_settings) { _settings = read(); } @@ -414,7 +401,7 @@ export function get(): Settings { export function set(path: string[], value: string | number | boolean | KeyValue): void { /* eslint-disable-next-line */ - let settings: any = getInternalSettings(); + let settings: any = getPersistedSettings(); for (let i = 0; i < path.length; i++) { const key = path[i]; @@ -432,16 +419,21 @@ export function set(path: string[], value: string | number | boolean | KeyValue) write(); } -export function apply(settings: Record): boolean { - getInternalSettings(); // Ensure _settings is initialized. +export function apply(settings: Record, throwOnError: boolean = true): boolean { + getPersistedSettings(); // Ensure _settings is initialized. // @ts-expect-error noMutate not typed properly const newSettings = objectAssignDeep.noMutate(_settings, settings); + utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS); ajvSetting(newSettings); - const errors = ajvSetting.errors && ajvSetting.errors.filter((e) => e.keyword !== 'required'); - if (errors?.length) { - const error = errors[0]; - throw new Error(`${error.instancePath.substring(1)} ${error.message}`); + + if (throwOnError) { + const errors = ajvSetting.errors && ajvSetting.errors.filter((e) => e.keyword !== 'required'); + + if (errors?.length) { + const error = errors[0]; + throw new Error(`${error.instancePath.substring(1)} ${error.message}`); + } } _settings = newSettings; @@ -512,7 +504,7 @@ export function addDevice(ID: string): DeviceOptionsWithId { throw new Error(`Device '${ID}' already exists`); } - const settings = getInternalSettings(); + const settings = getPersistedSettings(); if (!settings.devices) { settings.devices = {}; @@ -525,7 +517,7 @@ export function addDevice(ID: string): DeviceOptionsWithId { } export function blockDevice(ID: string): void { - const settings = getInternalSettings(); + const settings = getPersistedSettings(); if (!settings.blocklist) { settings.blocklist = []; } @@ -536,7 +528,7 @@ export function blockDevice(ID: string): void { export function removeDevice(IDorName: string): void { const device = getDeviceThrowIfNotExists(IDorName); - const settings = getInternalSettings(); + const settings = getPersistedSettings(); delete settings.devices?.[device.ID]; write(); } @@ -548,7 +540,7 @@ export function addGroup(name: string, ID?: string): GroupOptions { throw new Error(`friendly_name '${name}' is already in use`); } - const settings = getInternalSettings(); + const settings = getPersistedSettings(); if (!settings.groups) { settings.groups = {}; } @@ -577,14 +569,14 @@ export function addGroup(name: string, ID?: string): GroupOptions { export function removeGroup(IDorName: string | number): void { const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID!; - const settings = getInternalSettings(); + const settings = getPersistedSettings(); delete settings.groups![groupID]; write(); } export function changeEntityOptions(IDorName: string, newOptions: KeyValue): boolean { - const settings = getInternalSettings(); + const settings = getPersistedSettings(); delete newOptions.friendly_name; delete newOptions.devices; let validator: ValidateFunction; @@ -620,7 +612,7 @@ export function changeFriendlyName(IDorName: string, newName: string): void { throw new Error(`friendly_name '${newName}' is already in use`); } - const settings = getInternalSettings(); + const settings = getPersistedSettings(); const device = getDevice(IDorName); if (device) { @@ -640,7 +632,7 @@ export function changeFriendlyName(IDorName: string, newName: string): void { export function reRead(): void { _settings = undefined; - getInternalSettings(); + getPersistedSettings(); _settingsWithDefaults = undefined; get(); } @@ -652,4 +644,5 @@ export const testing = { _settingsWithDefaults = undefined; }, defaults, + CURRENT_VERSION, }; diff --git a/lib/util/settingsMigration.ts b/lib/util/settingsMigration.ts index 8b56650b2b..6573aa8355 100644 --- a/lib/util/settingsMigration.ts +++ b/lib/util/settingsMigration.ts @@ -24,7 +24,11 @@ interface SettingsTransfer extends SettingsMigration { newPath: string[]; } -const SUPPORTED_VERSIONS: Settings['version'][] = [undefined, settings.CURRENT_VERSION]; +interface SettingsCustomHandler extends Omit { + execute: (currentSettings: Partial) => [validPath: boolean, previousValue: unknown, changed: boolean]; +} + +const SUPPORTED_VERSIONS: Settings['version'][] = [undefined, 2, settings.CURRENT_VERSION]; function backupSettings(version: number): void { const filePath = data.joinPath('configuration.yaml'); @@ -187,6 +191,7 @@ function migrateToTwo( changes: SettingsChange[], additions: SettingsAdd[], removals: SettingsRemove[], + customHandlers: SettingsCustomHandler[], ): void { transfers.push( { @@ -378,21 +383,60 @@ function migrateToTwo( noteIf: noteIfWasDefined, }); } + + customHandlers.push(); } -// 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(); -// } +function migrateToThree( + currentSettings: Partial, + transfers: SettingsTransfer[], + changes: SettingsChange[], + additions: SettingsAdd[], + removals: SettingsRemove[], + customHandlers: SettingsCustomHandler[], +): void { + transfers.push(); + changes.push({ + path: ['version'], + note: `Migrated settings to version 3`, + newValue: 3, + }); + additions.push(); + removals.push(); + + const changeToObject = (currentSettings: Partial, path: string[]): ReturnType => { + const [validPath, previousValue] = getValue(currentSettings, path); + + /* istanbul ignore else */ + if (validPath) { + if (typeof previousValue === 'boolean') { + setValue(currentSettings, path, {enabled: previousValue}); + } else { + setValue(currentSettings, path, {enabled: true, ...(previousValue as object)}); + } + } + + return [validPath, previousValue, validPath]; + }; + + customHandlers.push( + { + note: `Property 'homeassistant' is now always an object.`, + noteIf: () => true, + execute: (currentSettings) => changeToObject(currentSettings, ['homeassistant']), + }, + { + note: `Property 'frontend' is now always an object.`, + noteIf: () => true, + execute: (currentSettings) => changeToObject(currentSettings, ['frontend']), + }, + { + note: `Property 'availability' is now always an object.`, + noteIf: () => true, + execute: (currentSettings) => changeToObject(currentSettings, ['availability']), + }, + ); +} /** * Order of execution: @@ -404,7 +448,7 @@ function migrateToTwo( * Should allow the most flexibility whenever combination of migrations is necessary (e.g. Transfer + Change) */ export function migrateIfNecessary(): void { - const currentSettings = settings.getInternalSettings(); + let currentSettings = settings.getPersistedSettings(); if (!SUPPORTED_VERSIONS.includes(currentSettings.version)) { throw new Error( @@ -412,8 +456,11 @@ export function migrateIfNecessary(): void { ); } + /* istanbul ignore next */ + const finalVersion = process.env.JEST_WORKER_ID ? settings.testing.CURRENT_VERSION : settings.CURRENT_VERSION; + // when same version as current, nothing left to do - while (currentSettings.version !== settings.CURRENT_VERSION) { + while (currentSettings.version !== finalVersion) { let migrationNotesFileName: string | undefined; // don't duplicate outputs const migrationNotes: Set = new Set(); @@ -421,23 +468,22 @@ export function migrateIfNecessary(): void { const changes: SettingsChange[] = []; const additions: SettingsAdd[] = []; const removals: SettingsRemove[] = []; + const customHandlers: SettingsCustomHandler[] = []; 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'; + // migrating from 1 (`version` did not exist) to 2 + migrationNotesFileName = 'migration-1-to-2.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'; + migrateToTwo(currentSettings, transfers, changes, additions, removals, customHandlers); + } else if (currentSettings.version === 2) { + migrationNotesFileName = 'migration-2-to-3.log'; - migrateFromTwoToThree(currentSettings, transfers, changes, additions, removals); - }*/ + migrateToThree(currentSettings, transfers, changes, additions, removals, customHandlers); + } /* else if (currentSettings.version === 2.1) {} */ for (const transfer of transfers) { const [validPath, previousValue, transfered] = transferValue(currentSettings, transfer); @@ -469,6 +515,15 @@ export function migrateIfNecessary(): void { } } + for (const customHandler of customHandlers) { + const [validPath, previousValue, changed] = customHandler.execute(currentSettings); + + /* istanbul ignore else */ + if (validPath && changed && (!customHandler.noteIf || customHandler.noteIf(previousValue))) { + migrationNotes.add(`[SPECIAL] ${customHandler.note}`); + } + } + /* istanbul ignore else */ if (migrationNotesFileName && migrationNotes.size > 0) { migrationNotes.add(`For more details, see https://github.com/Koenkk/zigbee2mqtt/discussions/24198`); @@ -479,7 +534,9 @@ export function migrateIfNecessary(): void { console.log(`Migration notes written in ${migrationNotesFilePath}`); } - settings.apply(currentSettings as unknown as Record); + // don't throw to allow stepping through versions (validates against current schema) + settings.apply(currentSettings as unknown as Record, false); settings.reRead(); + currentSettings = settings.getPersistedSettings(); } } diff --git a/lib/util/utils.ts b/lib/util/utils.ts index c1f5194a3c..d72d0f8f47 100644 --- a/lib/util/utils.ts +++ b/lib/util/utils.ts @@ -168,8 +168,12 @@ function parseJSON(value: string, fallback: string): KeyValue | string { */ function removeNullPropertiesFromObject(obj: KeyValue, ignoreKeys: string[] = []): void { for (const key of Object.keys(obj)) { - if (ignoreKeys.includes(key)) continue; + if (ignoreKeys.includes(key)) { + continue; + } + const value = obj[key]; + if (value == null) { delete obj[key]; } else if (typeof value === 'object') { @@ -258,7 +262,7 @@ function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settin return !!entity.options.availability; } - if (!settings.availability) { + if (!settings.availability.enabled) { return false; } diff --git a/test/extensions/availability.test.ts b/test/extensions/availability.test.ts index 3983e875e3..ba5c749c43 100644 --- a/test/extensions/availability.test.ts +++ b/test/extensions/availability.test.ts @@ -45,7 +45,7 @@ describe('Extension: Availability', () => { jest.spyOn(utils, 'sleep').mockImplementation(); jest.useFakeTimers(); settings.reRead(); - settings.set(['availability'], true); + settings.set(['availability'], {enabled: true}); controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); @@ -55,7 +55,7 @@ describe('Extension: Availability', () => { jest.setSystemTime(utils.minutes(1)); data.writeDefaultConfiguration(); settings.reRead(); - settings.set(['availability'], true); + settings.set(['availability'], {enabled: true}); settings.set(['devices', devices.bulb_color_2.ieeeAddr, 'availability'], false); Object.values(devices).forEach((d) => (d.lastSeen = utils.minutes(1))); mocksClear.forEach((m) => m.mockClear()); @@ -224,7 +224,7 @@ describe('Extension: Availability', () => { }); it('Should allow to change availability timeout via avaiability options', async () => { - settings.set(['availability'], {active: {timeout: 30}}); + settings.set(['availability', 'active', 'timeout'], 30); await resetExtension(); devices.bulb_color.ping.mockClear(); @@ -258,7 +258,7 @@ describe('Extension: Availability', () => { }); it('Should allow to be disabled', async () => { - settings.set(['availability'], false); + settings.set(['availability'], {enabled: false}); await resetExtension(); devices.bulb_color.ping.mockClear(); @@ -267,7 +267,7 @@ describe('Extension: Availability', () => { }); it('Should allow to enable availability for just one device', async () => { - settings.set(['availability'], false); + settings.set(['availability'], {enabled: false}); settings.set(['devices', devices.bulb_color.ieeeAddr, 'availability'], true); await resetExtension(); diff --git a/test/extensions/bridge.test.ts b/test/extensions/bridge.test.ts index b07f621582..314d573a51 100644 --- a/test/extensions/bridge.test.ts +++ b/test/extensions/bridge.test.ts @@ -237,7 +237,23 @@ describe('Extension: Bridge', () => { 21: {friendly_name: 'gledopto_group'}, 9: {friendly_name: 'ha_discovery_group'}, }, - homeassistant: false, + homeassistant: { + enabled: false, + discovery_topic: 'homeassistant', + status_topic: 'hass/status', + legacy_action_sensor: false, + experimental_event_entities: false, + }, + availability: { + enabled: false, + active: {timeout: 10}, + passive: {timeout: 1500}, + }, + frontend: { + enabled: false, + port: 8080, + base_url: '/', + }, map_options: { graphviz: { colors: { @@ -3722,7 +3738,7 @@ describe('Extension: Bridge', () => { // @ts-expect-error private expect(controller.extensions.find((e) => e.constructor.name === 'HomeAssistant')).toBeUndefined(); mockMQTT.publishAsync.mockClear(); - mockMQTTEvents.message('zigbee2mqtt/bridge/request/options', stringify({options: {homeassistant: true}})); + mockMQTTEvents.message('zigbee2mqtt/bridge/request/options', stringify({options: {homeassistant: {enabled: true}}})); await flushPromises(); // @ts-expect-error private expect(controller.extensions.find((e) => e.constructor.name === 'HomeAssistant')).not.toBeUndefined(); diff --git a/test/extensions/frontend.test.ts b/test/extensions/frontend.test.ts index 4d51876fdb..b9b7c93c17 100644 --- a/test/extensions/frontend.test.ts +++ b/test/extensions/frontend.test.ts @@ -134,8 +134,8 @@ describe('Extension: Frontend', () => { data.writeDefaultConfiguration(); data.writeDefaultState(); settings.reRead(); - settings.set(['frontend'], {port: 8081, host: '127.0.0.1'}); - settings.set(['homeassistant'], true); + settings.set(['frontend'], {enabled: true, port: 8081, host: '127.0.0.1'}); + settings.set(['homeassistant'], {enabled: true}); devices.bulb.linkquality = 10; mocksClear.forEach((m) => m.mockClear()); mockWSClient.readyState = 'close'; @@ -162,7 +162,7 @@ describe('Extension: Frontend', () => { }); it('Start/stop without host', async () => { - settings.set(['frontend'], {port: 8081}); + settings.set(['frontend'], {enabled: true, port: 8081}); controller = new Controller(jest.fn(), jest.fn()); await controller.start(); expect(mockNodeStaticPath).toBe('my/dummy/path'); @@ -175,7 +175,7 @@ describe('Extension: Frontend', () => { }); it('Start/stop unix socket', async () => { - settings.set(['frontend'], {host: '/tmp/zigbee2mqtt.sock'}); + settings.set(['frontend', 'host'], '/tmp/zigbee2mqtt.sock'); controller = new Controller(jest.fn(), jest.fn()); await controller.start(); expect(mockNodeStaticPath).toBe('my/dummy/path'); @@ -313,7 +313,7 @@ describe('Extension: Frontend', () => { it('Authentification', async () => { const authToken = 'sample-secure-token'; - settings.set(['frontend'], {auth_token: authToken}); + settings.set(['frontend', 'auth_token'], authToken); controller = new Controller(jest.fn(), jest.fn()); await controller.start(); @@ -339,7 +339,7 @@ describe('Extension: Frontend', () => { }); it.each(['/z2m/', '/z2m'])('Works with non-default base url %s', async (baseUrl) => { - settings.set(['frontend'], {base_url: baseUrl}); + settings.set(['frontend', 'base_url'], baseUrl); controller = new Controller(jest.fn(), jest.fn()); await controller.start(); @@ -365,7 +365,7 @@ describe('Extension: Frontend', () => { it('Works with non-default complex base url', async () => { const baseUrl = '/z2m-more++/c0mplex.url/'; - settings.set(['frontend'], {base_url: baseUrl}); + settings.set(['frontend', 'base_url'], baseUrl); controller = new Controller(jest.fn(), jest.fn()); await controller.start(); diff --git a/test/extensions/homeassistant.test.ts b/test/extensions/homeassistant.test.ts index 4bead63339..091b07125b 100644 --- a/test/extensions/homeassistant.test.ts +++ b/test/extensions/homeassistant.test.ts @@ -51,7 +51,7 @@ describe('Extension: HomeAssistant', () => { version = `Zigbee2MQTT ${z2m_version}`; origin.sw = z2m_version; jest.useFakeTimers(); - settings.set(['homeassistant'], true); + settings.set(['homeassistant'], {enabled: true}); data.writeDefaultConfiguration(); settings.reRead(); data.writeEmptyState(); @@ -69,7 +69,7 @@ describe('Extension: HomeAssistant', () => { beforeEach(async () => { data.writeDefaultConfiguration(); settings.reRead(); - settings.set(['homeassistant'], true); + settings.set(['homeassistant'], {enabled: true}); data.writeEmptyState(); // @ts-expect-error private controller.state.load(); @@ -110,7 +110,7 @@ describe('Extension: HomeAssistant', () => { }); it('Should discover devices and groups', async () => { - settings.set(['homeassistant'], {experimental_event_entities: true}); + settings.set(['homeassistant', 'experimental_event_entities'], true); await resetExtension(); let payload; @@ -1078,7 +1078,7 @@ describe('Extension: HomeAssistant', () => { }); it('Should discover devices with custom homeassistant.discovery_topic', async () => { - settings.set(['homeassistant'], {discovery_topic: 'my_custom_discovery_topic'}); + settings.set(['homeassistant', 'discovery_topic'], 'my_custom_discovery_topic'); await resetExtension(); const payload = { @@ -1110,7 +1110,7 @@ describe('Extension: HomeAssistant', () => { it('Should throw error when starting with attributes output', async () => { settings.set(['advanced', 'output'], 'attribute'); - settings.set(['homeassistant'], true); + settings.set(['homeassistant'], {enabled: true}); expect(() => { new Controller(jest.fn(), jest.fn()); }).toThrow('Home Assistant integration is not possible with attribute output!'); @@ -1410,7 +1410,7 @@ describe('Extension: HomeAssistant', () => { }); it('Should discover devices with availability', async () => { - settings.set(['availability'], true); + settings.set(['availability'], {enabled: true}); await resetExtension(); const payload = { @@ -1777,7 +1777,7 @@ describe('Extension: HomeAssistant', () => { }); it('Should enable experimental event entities', async () => { - settings.set(['homeassistant'], {experimental_event_entities: true}); + settings.set(['homeassistant', 'experimental_event_entities'], true); settings.set(['devices', '0x0017880104e45520'], { friendly_name: 'button', retain: false, @@ -2536,7 +2536,7 @@ describe('Extension: HomeAssistant', () => { }); it('Legacy action sensor', async () => { - settings.set(['homeassistant'], {legacy_action_sensor: true}); + settings.set(['homeassistant', 'legacy_action_sensor'], true); await resetExtension(); // Should discovery action sensor diff --git a/test/extensions/publish.test.ts b/test/extensions/publish.test.ts index 8741bffd77..cc5490e3fd 100644 --- a/test/extensions/publish.test.ts +++ b/test/extensions/publish.test.ts @@ -1240,7 +1240,7 @@ describe('Extension: Publish', () => { }); it('Home Assistant: should set state', async () => { - settings.set(['homeassistant'], true); + settings.set(['homeassistant'], {enabled: true}); const device = devices.bulb_color; const endpoint = device.getEndpoint(1)!; const payload = {state: 'ON'}; @@ -1254,7 +1254,7 @@ describe('Extension: Publish', () => { }); it('Home Assistant: should not set state when color temperature is also set and device is already on', async () => { - settings.set(['homeassistant'], true); + settings.set(['homeassistant'], {enabled: true}); // @ts-expect-error private const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr)!; // @ts-expect-error private @@ -1273,7 +1273,7 @@ describe('Extension: Publish', () => { }); it('Home Assistant: should set state when color temperature is also set and device is off', async () => { - settings.set(['homeassistant'], true); + settings.set(['homeassistant'], {enabled: true}); // @ts-expect-error private const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr)!; // @ts-expect-error private @@ -1296,7 +1296,7 @@ describe('Extension: Publish', () => { }); it('Home Assistant: should not set state when color is also set', async () => { - settings.set(['homeassistant'], true); + settings.set(['homeassistant'], {enabled: true}); // @ts-expect-error private const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr)!; // @ts-expect-error private diff --git a/test/mocks/data.ts b/test/mocks/data.ts index 9f9169a135..6375a4bac8 100644 --- a/test/mocks/data.ts +++ b/test/mocks/data.ts @@ -9,238 +9,242 @@ import yaml from '../../lib/util/yaml'; export const mockDir: string = tmp.dirSync().name; const stateFile = path.join(mockDir, 'state.json'); +export const DEFAULT_CONFIGURATION = { + homeassistant: {enabled: false}, + frontend: {enabled: false}, + availability: {enabled: 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', + }, + '0x000b57cdfec6a5b3': { + friendly_name: 'hue_twilight', + }, + }, + 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', + }, + 19: { + friendly_name: 'hue_twilight_group', + }, + }, +}; + export function writeDefaultConfiguration(config: unknown = undefined): void { - config = config || { - 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', - }, - '0x000b57cdfec6a5b3': { - friendly_name: 'hue_twilight', - }, - }, - 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', - }, - 19: { - friendly_name: 'hue_twilight_group', - }, - }, - }; + config = config || DEFAULT_CONFIGURATION; yaml.writeIfChanged(path.join(mockDir, 'configuration.yaml'), config); } diff --git a/test/settings.test.ts b/test/settings.test.ts index 307a41ae26..c9642bb037 100644 --- a/test/settings.test.ts +++ b/test/settings.test.ts @@ -15,7 +15,7 @@ const devicesFile2 = mockedData.joinPath('devices2.yaml'); const groupsFile = mockedData.joinPath('groups.yaml'); const secretFile = mockedData.joinPath('secret.yaml'); const minimalConfig = { - homeassistant: true, + homeassistant: {enabled: true}, mqtt: {base_topic: 'zigbee2mqtt', server: 'localhost'}, }; @@ -905,9 +905,9 @@ describe('Settings', () => { }); it('Frontend config', () => { - write(configurationFile, {...minimalConfig, frontend: true}); + write(configurationFile, {frontend: {enabled: true}}); settings.reRead(); - expect(settings.get().frontend).toStrictEqual({port: 8080, auth_token: null, base_url: '/'}); + expect(settings.get().frontend).toStrictEqual({enabled: true, port: 8080, base_url: '/'}); }); }); diff --git a/test/settingsMigration.test.ts b/test/settingsMigration.test.ts index d5eb153222..45ad7b6286 100644 --- a/test/settingsMigration.test.ts +++ b/test/settingsMigration.test.ts @@ -22,8 +22,12 @@ describe('Settings Migration', () => { }); afterEach(() => { - // always validate after each test - expect(settings.validate()).toStrictEqual([]); + settings.testing.CURRENT_VERSION = settings.CURRENT_VERSION; + + if (settings.get().version === settings.CURRENT_VERSION) { + // always validate after each test when up to current version (matching current schema) + expect(settings.validate()).toStrictEqual([]); + } }); it('Fails on unsupported version', () => { @@ -40,8 +44,8 @@ describe('Settings Migration', () => { ); }); - describe('Migrates v1.x.x to v2.0.0', () => { - const DEFAULT_CONFIG_V2 = { + describe('Migrates v1 to v2', () => { + const BASE_CONFIG = { homeassistant: false, mqtt: { base_topic: 'zigbee2mqtt', @@ -268,27 +272,28 @@ describe('Settings Migration', () => { }; beforeEach(() => { - data.writeDefaultConfiguration(DEFAULT_CONFIG_V2); + settings.testing.CURRENT_VERSION = 2; // stop update after this version + data.writeDefaultConfiguration(BASE_CONFIG); settings.reRead(); }); it('no change needed - only add version', () => { // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); afterSettings.version = 2; settingsMigration.migrateIfNecessary(); - const migratedSettings = settings.getInternalSettings(); + const migratedSettings = settings.getPersistedSettings(); expect(migratedSettings).toStrictEqual(afterSettings); }); it('remove all', () => { // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); afterSettings.version = 2; settings.set(['homeassistant', 'legacy_triggers'], true); @@ -314,9 +319,7 @@ describe('Settings Migration', () => { 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( + expect(settings.getPersistedSettings()).toStrictEqual( // @ts-expect-error workaround objectAssignDeep.noMutate(beforeSettings, { permit_join: true, @@ -354,8 +357,7 @@ describe('Settings Migration', () => { settingsMigration.migrateIfNecessary(); - const migratedSettings = settings.getInternalSettings(); - // console.log(JSON.stringify(migratedSettings, undefined, 2)); + const migratedSettings = settings.getPersistedSettings(); expect(migratedSettings.advanced).toStrictEqual({}); expect(migratedSettings.device_options).toStrictEqual({}); @@ -370,7 +372,7 @@ describe('Settings Migration', () => { 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'); + const migrationNotes = mockedData.joinPath('migration-1-to-2.log'); expect(existsSync(migrationNotes)).toStrictEqual(true); const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); expect(migrationNotesContent).toContain('homeassistant.legacy_triggers'); @@ -394,9 +396,9 @@ describe('Settings Migration', () => { it('remove partial', () => { // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); afterSettings.version = 2; settings.set(['advanced', 'homeassistant_legacy_triggers'], true); @@ -412,9 +414,9 @@ describe('Settings Migration', () => { settings.set(['device_options', 'legacy'], true); settings.set(['groups', '12', 'devices'], ['0x0017880104e45521', '0x0017880104e45524']); - // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + // console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2)); - expect(settings.getInternalSettings()).toStrictEqual( + expect(settings.getPersistedSettings()).toStrictEqual( // @ts-expect-error workaround objectAssignDeep.noMutate(beforeSettings, { permit_join: true, @@ -436,8 +438,7 @@ describe('Settings Migration', () => { settingsMigration.migrateIfNecessary(); - const migratedSettings = settings.getInternalSettings(); - // console.log(JSON.stringify(migratedSettings, undefined, 2)); + const migratedSettings = settings.getPersistedSettings(); expect(migratedSettings.advanced).toStrictEqual({}); expect(migratedSettings.device_options).toStrictEqual({}); @@ -449,7 +450,7 @@ describe('Settings Migration', () => { 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'); + const migrationNotes = mockedData.joinPath('migration-1-to-2.log'); expect(existsSync(migrationNotes)).toStrictEqual(true); const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); expect(migrationNotesContent).toContain('homeassistant.legacy_triggers'); @@ -472,17 +473,17 @@ describe('Settings Migration', () => { it('changes log_level', () => { // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); afterSettings.version = 2; afterSettings.advanced = {log_level: 'warning'}; settings.set(['advanced', 'log_level'], 'warn'); - // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + // console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2)); - expect(settings.getInternalSettings()).toStrictEqual( + expect(settings.getPersistedSettings()).toStrictEqual( // @ts-expect-error workaround objectAssignDeep.noMutate(beforeSettings, { advanced: { @@ -493,12 +494,11 @@ describe('Settings Migration', () => { settingsMigration.migrateIfNecessary(); - const migratedSettings = settings.getInternalSettings(); - // console.log(JSON.stringify(migratedSettings, undefined, 2)); + const migratedSettings = settings.getPersistedSettings(); 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'); + const migrationNotes = mockedData.joinPath('migration-1-to-2.log'); expect(existsSync(migrationNotes)).toStrictEqual(true); const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); expect(migrationNotesContent).toContain(`Log level 'warn' has been renamed to 'warning'.`); @@ -506,17 +506,17 @@ describe('Settings Migration', () => { it('does not changes already migrated log_level', () => { // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); afterSettings.version = 2; afterSettings.advanced = {log_level: 'warning'}; settings.set(['advanced', 'log_level'], 'warning'); - // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + // console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2)); - expect(settings.getInternalSettings()).toStrictEqual( + expect(settings.getPersistedSettings()).toStrictEqual( // @ts-expect-error workaround objectAssignDeep.noMutate(beforeSettings, { advanced: { @@ -527,12 +527,11 @@ describe('Settings Migration', () => { settingsMigration.migrateIfNecessary(); - const migratedSettings = settings.getInternalSettings(); - // console.log(JSON.stringify(migratedSettings, undefined, 2)); + const migratedSettings = settings.getPersistedSettings(); 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'); + const migrationNotes = mockedData.joinPath('migration-1-to-2.log'); expect(existsSync(migrationNotes)).toStrictEqual(true); const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); expect(migrationNotesContent).not.toContain(`Log level 'warn' has been renamed to 'warning'.`); @@ -540,17 +539,17 @@ describe('Settings Migration', () => { it('does not changes other log_level', () => { // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); afterSettings.version = 2; afterSettings.advanced = {log_level: 'info'}; settings.set(['advanced', 'log_level'], 'info'); - // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + // console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2)); - expect(settings.getInternalSettings()).toStrictEqual( + expect(settings.getPersistedSettings()).toStrictEqual( // @ts-expect-error workaround objectAssignDeep.noMutate(beforeSettings, { advanced: { @@ -561,12 +560,11 @@ describe('Settings Migration', () => { settingsMigration.migrateIfNecessary(); - const migratedSettings = settings.getInternalSettings(); - // console.log(JSON.stringify(migratedSettings, undefined, 2)); + const migratedSettings = settings.getPersistedSettings(); 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'); + const migrationNotes = mockedData.joinPath('migration-1-to-2.log'); expect(existsSync(migrationNotes)).toStrictEqual(true); const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); expect(migrationNotesContent).not.toContain(`Log level 'warn' has been renamed to 'warning'.`); @@ -574,9 +572,9 @@ describe('Settings Migration', () => { it('transfer all', () => { // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); afterSettings.version = 2; afterSettings.advanced = { transmit_power: 12, @@ -603,9 +601,9 @@ describe('Settings Migration', () => { settings.set(['ban'], ['abcd']); settings.set(['whitelist'], ['efgh']); - // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + // console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2)); - expect(settings.getInternalSettings()).toStrictEqual( + expect(settings.getPersistedSettings()).toStrictEqual( // @ts-expect-error workaround objectAssignDeep.noMutate(beforeSettings, { advanced: { @@ -628,12 +626,11 @@ describe('Settings Migration', () => { settingsMigration.migrateIfNecessary(); - const migratedSettings = settings.getInternalSettings(); - // console.log(JSON.stringify(migratedSettings, undefined, 2)); + const migratedSettings = settings.getPersistedSettings(); 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'); + const migrationNotes = mockedData.joinPath('migration-1-to-2.log'); expect(existsSync(migrationNotes)).toStrictEqual(true); const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); expect(migrationNotesContent).toContain( @@ -653,9 +650,9 @@ describe('Settings Migration', () => { it('transfer partial', () => { // @ts-expect-error workaround - const beforeSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); // @ts-expect-error workaround - const afterSettings = objectAssignDeep.noMutate({}, settings.getInternalSettings()); + const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); afterSettings.version = 2; afterSettings.advanced = {}; // caused by pushing to key and removing all afterSettings.serial.baudrate = 115200; @@ -673,9 +670,9 @@ describe('Settings Migration', () => { settings.set(['ban'], ['abcd']); settings.set(['blocklist'], ['efgh']); - // console.log(JSON.stringify(settings.getInternalSettings(), undefined, 2)); + // console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2)); - expect(settings.getInternalSettings()).toStrictEqual( + expect(settings.getPersistedSettings()).toStrictEqual( // @ts-expect-error workaround objectAssignDeep.noMutate(beforeSettings, { homeassistant: {discovery_topic: 'ha_disc_newer'}, @@ -694,12 +691,11 @@ describe('Settings Migration', () => { settingsMigration.migrateIfNecessary(); - const migratedSettings = settings.getInternalSettings(); - // console.log(JSON.stringify(migratedSettings, undefined, 2)); + const migratedSettings = settings.getPersistedSettings(); 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'); + const migrationNotes = mockedData.joinPath('migration-1-to-2.log'); expect(existsSync(migrationNotes)).toStrictEqual(true); const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); expect(migrationNotesContent).toContain(`[TRANSFER] Baudrate was moved from advanced.baudrate to serial.baudrate.`); @@ -707,4 +703,109 @@ describe('Settings Migration', () => { expect(migrationNotesContent).toContain(`[TRANSFER] ban was renamed to passlist.`); }); }); + + describe('Migrates v1 to v3', () => { + const BASE_CONFIG = { + mqtt: { + server: 'mqtt://localhost', + }, + }; + + beforeEach(() => { + settings.testing.CURRENT_VERSION = 3; // stop update after this version + data.writeDefaultConfiguration(BASE_CONFIG); + settings.reRead(); + }); + + it('Update', () => { + // @ts-expect-error workaround + const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + // @ts-expect-error workaround + const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + afterSettings.version = 3; + afterSettings.homeassistant = {enabled: false}; + afterSettings.frontend = {enabled: true}; + afterSettings.availability = {enabled: true, active: {timeout: 15}}; + afterSettings.advanced = { + log_level: 'warning', + transmit_power: 12, + }; + + settings.set(['homeassistant'], false); + settings.set(['frontend'], true); + settings.set(['availability'], {active: {timeout: 15}}); + settings.set(['permit_join'], true); + settings.set(['advanced', 'log_level'], 'warn'); + settings.set(['experimental', 'transmit_power'], 12); + + expect(settings.getPersistedSettings()).toStrictEqual( + // @ts-expect-error workaround + objectAssignDeep.noMutate(beforeSettings, { + homeassistant: false, + frontend: true, + availability: {active: {timeout: 15}}, + permit_join: true, + advanced: {log_level: 'warn'}, + experimental: {transmit_power: 12}, + }), + ); + + settingsMigration.migrateIfNecessary(); + + const migratedSettings = settings.getPersistedSettings(); + + expect(migratedSettings).toStrictEqual(afterSettings); + }); + }); + + describe('Migrates v2 to v3', () => { + const BASE_CONFIG = { + version: 2, + mqtt: { + server: 'mqtt://localhost', + }, + }; + + beforeEach(() => { + settings.testing.CURRENT_VERSION = 3; // stop update after this version + data.writeDefaultConfiguration(BASE_CONFIG); + settings.reRead(); + }); + + it('Update', () => { + // @ts-expect-error workaround + const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + // @ts-expect-error workaround + const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + afterSettings.version = 3; + afterSettings.homeassistant = {enabled: false}; + afterSettings.frontend = {enabled: true}; + afterSettings.availability = {enabled: true, active: {timeout: 15}}; + + settings.set(['homeassistant'], false); + settings.set(['frontend'], true); + settings.set(['availability'], {active: {timeout: 15}}); + + expect(settings.getPersistedSettings()).toStrictEqual( + // @ts-expect-error workaround + objectAssignDeep.noMutate(beforeSettings, { + homeassistant: false, + frontend: true, + availability: {active: {timeout: 15}}, + }), + ); + + settingsMigration.migrateIfNecessary(); + + const migratedSettings = settings.getPersistedSettings(); + + expect(migratedSettings).toStrictEqual(afterSettings); + const migrationNotes = mockedData.joinPath('migration-2-to-3.log'); + expect(existsSync(migrationNotes)).toStrictEqual(true); + const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); + expect(migrationNotesContent).toContain(`[SPECIAL] Property 'homeassistant' is now always an object.`); + expect(migrationNotesContent).toContain(`[SPECIAL] Property 'frontend' is now always an object.`); + expect(migrationNotesContent).toContain(`[SPECIAL] Property 'availability' is now always an object.`); + }); + }); });