From 329b8c9c27c2ba2537b59ef16241a6e84167fa7b Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Sat, 28 Dec 2024 15:07:02 +0100 Subject: [PATCH] fix: Improve device icon serving (#25299) --- data/configuration.example.yaml | 2 +- lib/extension/bridge.ts | 10 ++++ lib/extension/frontend.ts | 11 +++- lib/util/settings.schema.json | 2 +- lib/util/settings.ts | 8 ++- lib/util/settingsMigration.ts | 46 ++++++++++++++++- lib/util/utils.ts | 25 +++++++++ test/extensions/bridge.test.ts | 35 +++++++++++++ test/extensions/frontend.test.ts | 89 ++++++++++++++++++++++---------- test/settings.test.ts | 20 +++++++ test/settingsMigration.test.ts | 79 ++++++++++++++++++++++++++++ 11 files changed, 295 insertions(+), 32 deletions(-) diff --git a/data/configuration.example.yaml b/data/configuration.example.yaml index e442c66f75..74e7a17ea0 100644 --- a/data/configuration.example.yaml +++ b/data/configuration.example.yaml @@ -1,5 +1,5 @@ # Indicates the configuration version (used by configuration migrations) -version: 2 +version: 4 # Home Assistant integration (MQTT discovery) homeassistant: diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 770d61b016..88d1d89fc0 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -432,6 +432,16 @@ export default class Bridge extends Extension { const ID = message.id; const entity = this.getEntity(entityType, ID); const oldOptions = objectAssignDeep({}, cleanup(entity.options)); + + if (message.options.icon) { + const base64Match = utils.matchBase64File(message.options.icon); + if (base64Match) { + const fileSettings = utils.saveBase64DeviceIcon(base64Match); + message.options.icon = fileSettings; + logger.debug(`Saved base64 image as file to '${fileSettings}'`); + } + } + const restartRequired = settings.changeEntityOptions(ID, message.options); if (restartRequired) this.restartRequired = true; const newOptions = cleanup(entity.options); diff --git a/lib/extension/frontend.ts b/lib/extension/frontend.ts index 39a6f80617..dc6e4c7c72 100644 --- a/lib/extension/frontend.ts +++ b/lib/extension/frontend.ts @@ -18,6 +18,7 @@ import WebSocket from 'ws'; import frontend from 'zigbee2mqtt-frontend'; +import data from '../util/data'; import logger from '../util/logger'; import * as settings from '../util/settings'; import utils from '../util/utils'; @@ -35,6 +36,7 @@ export default class Frontend extends Extension { private authToken: string | undefined; private server!: Server; private fileServer!: RequestHandler; + private deviceIconsFileServer!: RequestHandler; private wss!: WebSocket.Server; private baseUrl: string; @@ -89,6 +91,7 @@ export default class Frontend extends Extension { }, }; this.fileServer = expressStaticGzip(frontend.getPath(), options); + this.deviceIconsFileServer = expressStaticGzip(data.joinPath('device_icons'), options); this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, 'api')}); this.wss.on('connection', this.onWebSocketConnection); @@ -144,7 +147,13 @@ export default class Frontend extends Extension { request.url = '/' + newUrl; request.path = request.url; - this.fileServer(request, response, fin); + if (newUrl.startsWith('device_icons/')) { + request.path = request.path.replace('device_icons/', ''); + request.url = request.url.replace('/device_icons', ''); + this.deviceIconsFileServer(request, response, fin); + } else { + this.fileServer(request, response, fin); + } } private authenticate(request: IncomingMessage, cb: (authenticate: boolean) => void): void { diff --git a/lib/util/settings.schema.json b/lib/util/settings.schema.json index 68a47fee91..3ee0806e50 100644 --- a/lib/util/settings.schema.json +++ b/lib/util/settings.schema.json @@ -804,7 +804,7 @@ "icon": { "type": "string", "title": "Icon", - "description": "The user-defined device icon for the frontend. It can be a full URL link to an image (e.g. https://SOME.SITE/MODEL123.jpg) (you cannot use a path to a local file) or base64 encoded data URL (e.g. image/svg+xml;base64,PHN2ZyB3aW....R0aD)" + "description": "The user-defined device icon for the frontend. It can be a full URL link to an image (e.g. https://SOME.SITE/MODEL123.jpg) or a path to a local file inside the `device_icons` directory." }, "homeassistant": { "type": ["object", "null"], diff --git a/lib/util/settings.ts b/lib/util/settings.ts index 699771347e..c236ee3880 100644 --- a/lib/util/settings.ts +++ b/lib/util/settings.ts @@ -9,7 +9,10 @@ import utils from './utils'; import yaml, {YAMLFileException} from './yaml'; export {schemaJson}; -export const CURRENT_VERSION = 3; +// When updating also update: +// - https://github.com/Koenkk/zigbee2mqtt/blob/dev/data/configuration.example.yaml#L2 +// - https://github.com/zigbee2mqtt/hassio-zigbee2mqtt/blob/master/common/rootfs/docker-entrypoint.sh#L54 +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'; @@ -246,6 +249,9 @@ export function validate(): string[] { if (names.includes(e.friendly_name)) errors.push(`Duplicate friendly_name '${e.friendly_name}' found`); errors.push(...utils.validateFriendlyName(e.friendly_name)); names.push(e.friendly_name); + if ('icon' in e && e.icon && !e.icon.startsWith('http://') && !e.icon.startsWith('https://') && !e.icon.startsWith('device_icons/')) { + errors.push(`Device icon of '${e.friendly_name}' should start with 'device_icons/', got '${e.icon}'`); + } if (e.qos != null && ![0, 1, 2].includes(e.qos)) { errors.push(`QOS for '${e.friendly_name}' not valid, should be 0, 1 or 2 got ${e.qos}`); } diff --git a/lib/util/settingsMigration.ts b/lib/util/settingsMigration.ts index 99df9b54b7..56908fe994 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.matchBase64File(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 c605b008fe..6b3a6c91c7 100644 --- a/lib/util/utils.ts +++ b/lib/util/utils.ts @@ -2,12 +2,17 @@ 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 { const norm = Math.floor(Math.abs(num)); return (norm < 10 ? '0' : '') + norm; @@ -370,10 +375,30 @@ function deviceNotCoordinator(device: zh.Device): boolean { return device.type !== 'Coordinator'; } +function matchBase64File(value: string): {extension: string; data: string} | false { + const match = value.match(BASE64_IMAGE_REGEX); + if (match) { + assert(match.groups?.extension && match.groups?.data); + return {extension: match.groups.extension, data: match.groups.data}; + } + 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 { + matchBase64File, + saveBase64DeviceIcon, capitalize, getZigbee2MQTTVersion, getDependencyVersion, diff --git a/test/extensions/bridge.test.ts b/test/extensions/bridge.test.ts index c883a29b49..d345481c85 100644 --- a/test/extensions/bridge.test.ts +++ b/test/extensions/bridge.test.ts @@ -40,6 +40,8 @@ const mocksClear = [ devices.bulb.removeFromNetwork, ]; +const deviceIconsDir = path.join(data.mockDir, 'device_icons'); + describe('Extension: Bridge', () => { let controller: Controller; let mockRestart: Mock; @@ -80,6 +82,7 @@ describe('Extension: Bridge', () => { extension.restartRequired = false; // @ts-expect-error private controller.state.state = {[devices.bulb.ieeeAddr]: {brightness: 50}}; + fs.rmSync(deviceIconsDir, {force: true, recursive: true}); }); afterAll(async () => { @@ -3241,6 +3244,38 @@ describe('Extension: Bridge', () => { ); }); + it.each([ + ['data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ', 'device_icons/effcad234beeb56ea7c457cf2d36d10b.png', true], + ['some_icon.png', 'some_icon.png', false], + ])('Should save as image as file when changing device icon', async (mqttIcon, settingsIcon, checkFileExists) => { + mockMQTTPublishAsync.mockClear(); + mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/options', stringify({options: {icon: mqttIcon}, id: 'bulb'})); + await flushPromises(); + expect(settings.getDevice('bulb')).toStrictEqual({ + ID: '0x000b57fffec6a5b2', + friendly_name: 'bulb', + icon: settingsIcon, + description: 'this is my bulb', + retain: true, + }); + if (checkFileExists) { + expect(fs.existsSync(path.join(data.mockDir, settingsIcon))).toBeTruthy(); + } + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/options', + stringify({ + data: { + from: {retain: true, description: 'this is my bulb'}, + to: {retain: true, description: 'this is my bulb', icon: settingsIcon}, + id: 'bulb', + restart_required: false, + }, + status: 'ok', + }), + {retain: false, qos: 0}, + ); + }); + it('Should allow to remove device option', async () => { mockMQTTPublishAsync.mockClear(); settings.set(['devices', '0x000b57fffec6a5b2', 'qos'], 1); diff --git a/test/extensions/frontend.test.ts b/test/extensions/frontend.test.ts index 81d3f7172e..cfc90ba48d 100644 --- a/test/extensions/frontend.test.ts +++ b/test/extensions/frontend.test.ts @@ -7,6 +7,7 @@ import {devices} from '../mocks/zigbeeHerdsman'; import path from 'node:path'; import stringify from 'json-stable-stringify-without-jsonify'; +import {Mock} from 'vitest'; import ws from 'ws'; import {Controller} from '../../lib/controller'; @@ -61,8 +62,9 @@ const mockWS = { close: vi.fn<(code?: number, data?: string | Buffer) => void>(), }; -let mockNodeStaticPath: string = ''; -const mockNodeStatic = vi.fn(); +const frontendPath = 'frontend-path'; +const deviceIconsPath = path.join(data.mockDir, 'device_icons'); +let mockNodeStatic: {[s: string]: Mock} = {}; const mockFinalHandler = vi.fn(); @@ -83,15 +85,15 @@ vi.mock('node:https', () => ({ })); vi.mock('express-static-gzip', () => ({ - default: vi.fn().mockImplementation((path) => { - mockNodeStaticPath = path; - return mockNodeStatic; + default: vi.fn().mockImplementation((path: string) => { + mockNodeStatic[path] = vi.fn(); + return mockNodeStatic[path]; }), })); vi.mock('zigbee2mqtt-frontend', () => ({ default: { - getPath: (): string => 'my/dummy/path', + getPath: (): string => frontendPath, }, })); @@ -121,7 +123,6 @@ const mocksClear = [ mockWS.emit, mockWSClient.send, mockWSClient.terminate, - mockNodeStatic, mockFinalHandler, mockMQTTPublishAsync, mockLogger.error, @@ -135,6 +136,7 @@ describe('Extension: Frontend', () => { }); beforeEach(async () => { + mockNodeStatic = {}; mockWS.clients = []; data.writeDefaultConfiguration(); data.writeDefaultState(); @@ -157,7 +159,7 @@ describe('Extension: Frontend', () => { it('Start/stop with defaults', async () => { controller = new Controller(vi.fn(), vi.fn()); await controller.start(); - expect(mockNodeStaticPath).toBe('my/dummy/path'); + expect(Object.keys(mockNodeStatic)).toStrictEqual([frontendPath, deviceIconsPath]); expect(mockHTTP.listen).toHaveBeenCalledWith(8081, '127.0.0.1'); mockWS.clients.push(mockWSClient); await controller.stop(); @@ -170,7 +172,7 @@ describe('Extension: Frontend', () => { settings.set(['frontend'], {enabled: true, port: 8081}); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); - expect(mockNodeStaticPath).toBe('my/dummy/path'); + expect(Object.keys(mockNodeStatic)).toStrictEqual([frontendPath, deviceIconsPath]); expect(mockHTTP.listen).toHaveBeenCalledWith(8081); mockWS.clients.push(mockWSClient); await controller.stop(); @@ -183,7 +185,7 @@ describe('Extension: Frontend', () => { settings.set(['frontend', 'host'], '/tmp/zigbee2mqtt.sock'); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); - expect(mockNodeStaticPath).toBe('my/dummy/path'); + expect(Object.keys(mockNodeStatic)).toStrictEqual([frontendPath, deviceIconsPath]); expect(mockHTTP.listen).toHaveBeenCalledWith('/tmp/zigbee2mqtt.sock'); mockWS.clients.push(mockWSClient); await controller.stop(); @@ -305,8 +307,27 @@ describe('Extension: Frontend', () => { expect(mockWS.emit).toHaveBeenCalledWith('connection', 99, {url: 'http://localhost:8080/api'}); mockHTTPOnRequest({url: '/file.txt'}, 2); - expect(mockNodeStatic).toHaveBeenCalledTimes(1); - expect(mockNodeStatic).toHaveBeenCalledWith({originalUrl: '/file.txt', path: '/file.txt', url: '/file.txt'}, 2, expect.any(Function)); + expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(0); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith( + {originalUrl: '/file.txt', path: '/file.txt', url: '/file.txt'}, + 2, + expect.any(Function), + ); + }); + + it('Should serve device icons', async () => { + controller = new Controller(vi.fn(), vi.fn()); + await controller.start(); + + mockHTTPOnRequest({url: '/device_icons/my_device.png'}, 2); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(0); + expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1); + expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith( + {originalUrl: '/device_icons/my_device.png', path: '/my_device.png', url: '/my_device.png'}, + 2, + expect.any(Function), + ); }); it('Static server', async () => { @@ -351,21 +372,33 @@ describe('Extension: Frontend', () => { expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m/api'}); mockHTTPOnRequest({url: '/z2m'}, 2); - expect(mockNodeStatic).toHaveBeenCalledTimes(1); - expect(mockNodeStatic).toHaveBeenCalledWith({originalUrl: '/z2m', path: '/', url: '/'}, 2, expect.any(Function)); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({originalUrl: '/z2m', path: '/', url: '/'}, 2, expect.any(Function)); expect(mockFinalHandler).not.toHaveBeenCalledWith(); - mockNodeStatic.mockReset(); + mockNodeStatic[frontendPath].mockReset(); expect(mockFinalHandler).not.toHaveBeenCalledWith(); mockHTTPOnRequest({url: '/z2m/file.txt'}, 2); - expect(mockNodeStatic).toHaveBeenCalledTimes(1); - expect(mockNodeStatic).toHaveBeenCalledWith({originalUrl: '/z2m/file.txt', path: '/file.txt', url: '/file.txt'}, 2, expect.any(Function)); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith( + {originalUrl: '/z2m/file.txt', path: '/file.txt', url: '/file.txt'}, + 2, + expect.any(Function), + ); expect(mockFinalHandler).not.toHaveBeenCalledWith(); - mockNodeStatic.mockReset(); + mockNodeStatic[frontendPath].mockReset(); mockHTTPOnRequest({url: '/z/file.txt'}, 2); - expect(mockNodeStatic).not.toHaveBeenCalled(); + expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled(); expect(mockFinalHandler).toHaveBeenCalled(); + + mockHTTPOnRequest({url: '/z2m/device_icons/my-device.png'}, 2); + expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1); + expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith( + {originalUrl: '/z2m/device_icons/my-device.png', path: '/my-device.png', url: '/my-device.png'}, + 2, + expect.any(Function), + ); }); it('Works with non-default complex base url', async () => { @@ -377,24 +410,28 @@ describe('Extension: Frontend', () => { expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m-more++/c0mplex.url/api'}); mockHTTPOnRequest({url: '/z2m-more++/c0mplex.url'}, 2); - expect(mockNodeStatic).toHaveBeenCalledTimes(1); - expect(mockNodeStatic).toHaveBeenCalledWith({originalUrl: '/z2m-more++/c0mplex.url', path: '/', url: '/'}, 2, expect.any(Function)); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith( + {originalUrl: '/z2m-more++/c0mplex.url', path: '/', url: '/'}, + 2, + expect.any(Function), + ); expect(mockFinalHandler).not.toHaveBeenCalledWith(); - mockNodeStatic.mockReset(); + mockNodeStatic[frontendPath].mockReset(); expect(mockFinalHandler).not.toHaveBeenCalledWith(); mockHTTPOnRequest({url: '/z2m-more++/c0mplex.url/file.txt'}, 2); - expect(mockNodeStatic).toHaveBeenCalledTimes(1); - expect(mockNodeStatic).toHaveBeenCalledWith( + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); + expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith( {originalUrl: '/z2m-more++/c0mplex.url/file.txt', path: '/file.txt', url: '/file.txt'}, 2, expect.any(Function), ); expect(mockFinalHandler).not.toHaveBeenCalledWith(); - mockNodeStatic.mockReset(); + mockNodeStatic[frontendPath].mockReset(); mockHTTPOnRequest({url: '/z/file.txt'}, 2); - expect(mockNodeStatic).not.toHaveBeenCalled(); + expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled(); expect(mockFinalHandler).toHaveBeenCalled(); }); }); diff --git a/test/settings.test.ts b/test/settings.test.ts index 720b44fb99..77117eb8e6 100644 --- a/test/settings.test.ts +++ b/test/settings.test.ts @@ -753,6 +753,26 @@ describe('Settings', () => { expect(settings.validate()).toEqual(expect.arrayContaining([error])); }); + it.each([ + ['bla.png', `Device icon of 'myname' should start with 'device_icons/', got 'bla.png'`], + ['device_icons/bla.png', undefined], + ['https://www.example.org/my-device.png', undefined], + ['http://www.example.org/my-device.png', undefined], + ])('onlythis Device icons should be in `device_icons` directory or a url', (icon, error) => { + write(configurationFile, { + ...minimalConfig, + devices: {'0x0017880104e45519': {friendly_name: 'myname', icon}}, + }); + + settings.reRead(); + + if (error) { + expect(settings.validate()).toEqual(expect.arrayContaining([error])); + } else { + expect(settings.validate()).toEqual([]); + } + }); + it('Configuration friendly name cannot be empty', async () => { write(configurationFile, { ...minimalConfig, diff --git a/test/settingsMigration.test.ts b/test/settingsMigration.test.ts index ceaddbea45..d7a188db70 100644 --- a/test/settingsMigration.test.ts +++ b/test/settingsMigration.test.ts @@ -808,4 +808,83 @@ 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('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: 'device_icons/effcad234beeb56ea7c457cf2d36d10b.png', + }, + '0x323127fffe8d96bc': { + friendly_name: '0x323127fffe8d96bc', + }, + }; + + 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', + }, + }); + + 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', + }, + }, + }), + ); + + settingsMigration.migrateIfNecessary(); + + const migratedSettings = settings.getPersistedSettings(); + + expect(migratedSettings).toStrictEqual(afterSettings); + const migrationNotes = mockedData.joinPath('migration-3-to-4.log'); + expect(existsSync(migrationNotes)).toStrictEqual(true); + const migrationNotesContent = readFileSync(migrationNotes, 'utf8'); + expect(migrationNotesContent).toContain(`[SPECIAL] Device icons are now saved as images.`); + }); + }); });