diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 03aaea7c76..fc68f87b07 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -1,8 +1,6 @@ import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from 'lib/types/api'; -import crypto from 'node:crypto'; import fs from 'node:fs'; -import path from 'node:path'; import bind from 'bind-decorator'; import stringify from 'json-stable-stringify-without-jsonify'; @@ -438,11 +436,7 @@ export default class Bridge extends Extension { if (message.options.icon) { const base64Match = utils.isBase64File(message.options.icon); if (base64Match) { - const md5Hash = crypto.createHash('md5').update(base64Match.data).digest('hex'); - const fileSettings = `device_icons/${md5Hash}.${base64Match.extension}`; - const file = path.join(data.getPath(), fileSettings); - fs.mkdirSync(path.dirname(file), {recursive: true}); - fs.writeFileSync(file, base64Match.data, {encoding: 'base64'}); + const fileSettings = utils.saveBase64DeviceIcon(base64Match); message.options.icon = fileSettings; logger.debug(`Saved base64 image as file to '${fileSettings}'`); } diff --git a/lib/util/settings.ts b/lib/util/settings.ts index 178a1d8d94..63e08b8981 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 = 3; +export const CURRENT_VERSION = 4; /** 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'; diff --git a/lib/util/settingsMigration.ts b/lib/util/settingsMigration.ts index 99df9b54b7..7aa45ae1e1 100644 --- a/lib/util/settingsMigration.ts +++ b/lib/util/settingsMigration.ts @@ -2,6 +2,7 @@ import {copyFileSync, writeFileSync} from 'node:fs'; import data from './data'; import * as settings from './settings'; +import utils from './utils'; interface SettingsMigration { path: string[]; @@ -28,7 +29,7 @@ interface SettingsCustomHandler extends Omit { execute: (currentSettings: Partial) => [validPath: boolean, previousValue: unknown, changed: boolean]; } -const SUPPORTED_VERSIONS: Settings['version'][] = [undefined, 2, settings.CURRENT_VERSION]; +const SUPPORTED_VERSIONS: Settings['version'][] = [undefined, 2, 3, settings.CURRENT_VERSION]; function backupSettings(version: number): void { const filePath = data.joinPath('configuration.yaml'); @@ -438,6 +439,43 @@ function migrateToThree( ); } +function migrateToFour( + currentSettings: Partial, + transfers: SettingsTransfer[], + changes: SettingsChange[], + additions: SettingsAdd[], + removals: SettingsRemove[], + customHandlers: SettingsCustomHandler[], +): void { + transfers.push(); + changes.push({ + path: ['version'], + note: `Migrated settings to version 4`, + newValue: 4, + }); + additions.push(); + removals.push(); + + const saveBase64DeviceIconsAsImage = (currentSettings: Partial): ReturnType => { + const [validPath, previousValue] = getValue(currentSettings, ['devices']); + + for (const deviceKey in currentSettings.devices) { + const base64Match = utils.isBase64File(currentSettings.devices[deviceKey].icon ?? ''); + if (base64Match) { + currentSettings.devices[deviceKey].icon = utils.saveBase64DeviceIcon(base64Match); + } + } + + return [validPath, previousValue, validPath]; + }; + + customHandlers.push({ + note: `Device icons are now saved as images.`, + noteIf: () => true, + execute: (currentSettings) => saveBase64DeviceIconsAsImage(currentSettings), + }); +} + /** * Order of execution: * - Transfer @@ -482,7 +520,11 @@ export function migrateIfNecessary(): void { migrationNotesFileName = 'migration-2-to-3.log'; migrateToThree(currentSettings, transfers, changes, additions, removals, customHandlers); - } /* else if (currentSettings.version === 2.1) {} */ + } else if (currentSettings.version === 3) { + migrationNotesFileName = 'migration-3-to-4.log'; + + migrateToFour(currentSettings, transfers, changes, additions, removals, customHandlers); + } for (const transfer of transfers) { const [validPath, previousValue, transfered] = transferValue(currentSettings, transfer); diff --git a/lib/util/utils.ts b/lib/util/utils.ts index 1594d9a033..71d6ba82a9 100644 --- a/lib/util/utils.ts +++ b/lib/util/utils.ts @@ -2,12 +2,15 @@ import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, import type * as zhc from 'zigbee-herdsman-converters'; import assert from 'node:assert'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import equals from 'fast-deep-equal/es6'; import humanizeDuration from 'humanize-duration'; +import data from './data'; + const BASE64_IMAGE_REGEX = new RegExp(`data:image/(?.+);base64,(?.+)`); function pad(num: number): string { @@ -381,11 +384,21 @@ function isBase64File(value: string): {extension: string; data: string} | false return false; } +function saveBase64DeviceIcon(base64Match: {extension: string; data: string}): string { + const md5Hash = crypto.createHash('md5').update(base64Match.data).digest('hex'); + const fileSettings = `device_icons/${md5Hash}.${base64Match.extension}`; + const file = path.join(data.getPath(), fileSettings); + fs.mkdirSync(path.dirname(file), {recursive: true}); + fs.writeFileSync(file, base64Match.data, {encoding: 'base64'}); + return fileSettings; +} + /* v8 ignore next */ const noop = (): void => {}; export default { isBase64File, + saveBase64DeviceIcon, capitalize, getZigbee2MQTTVersion, getDependencyVersion, diff --git a/test/settingsMigration.test.ts b/test/settingsMigration.test.ts index ceaddbea45..9ae357daa2 100644 --- a/test/settingsMigration.test.ts +++ b/test/settingsMigration.test.ts @@ -808,4 +808,88 @@ describe('Settings Migration', () => { expect(migrationNotesContent).toContain(`[SPECIAL] Property 'availability' is now always an object.`); }); }); + + describe('Migrates v3 to v4', () => { + const BASE_CONFIG = { + version: 3, + mqtt: { + server: 'mqtt://localhost', + }, + }; + + beforeEach(() => { + settings.testing.CURRENT_VERSION = 4; // stop update after this version + data.writeDefaultConfiguration(BASE_CONFIG); + settings.reRead(); + }); + + it('onlythis Update', () => { + // @ts-expect-error workaround + const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + // @ts-expect-error workaround + const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings()); + afterSettings.version = 4; + afterSettings.devices = { + '0x123127fffe8d96bc': { + friendly_name: '0x847127fffe8d96bc', + icon: 'device_icons/08a9016bbc0657cf5f581ae9c19c31a5.png', + }, + '0x223127fffe8d96bc': { + friendly_name: '0x223127fffe8d96bc', + icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ', + }, + '0x323127fffe8d96bc': { + friendly_name: '0x323127fffe8d96bc', + icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ', + }, + }; + + settings.set(['devices'], { + '0x123127fffe8d96bc': { + friendly_name: '0x847127fffe8d96bc', + icon: 'device_icons/08a9016bbc0657cf5f581ae9c19c31a5.png', + }, + '0x223127fffe8d96bc': { + friendly_name: '0x223127fffe8d96bc', + icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ', + }, + '0x323127fffe8d96bc': { + friendly_name: '0x323127fffe8d96bc', + icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ', + }, + }); + + expect(settings.getPersistedSettings()).toStrictEqual( + // @ts-expect-error workaround + objectAssignDeep.noMutate(beforeSettings, { + devices: { + '0x123127fffe8d96bc': { + friendly_name: '0x847127fffe8d96bc', + icon: 'device_icons/08a9016bbc0657cf5f581ae9c19c31a5.png', + }, + '0x223127fffe8d96bc': { + friendly_name: '0x223127fffe8d96bc', + icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ', + }, + '0x323127fffe8d96bc': { + friendly_name: '0x323127fffe8d96bc', + icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ', + }, + }, + }), + ); + + 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.`); + }); + }); });