Skip to content

Commit

Permalink
fix: Improve device icon serving (#25299)
Browse files Browse the repository at this point in the history
  • Loading branch information
Koenkk authored Dec 28, 2024
1 parent 0e19c22 commit 329b8c9
Show file tree
Hide file tree
Showing 11 changed files with 295 additions and 32 deletions.
2 changes: 1 addition & 1 deletion data/configuration.example.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Indicates the configuration version (used by configuration migrations)
version: 2
version: 4

# Home Assistant integration (MQTT discovery)
homeassistant:
Expand Down
10 changes: 10 additions & 0 deletions lib/extension/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion lib/extension/frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion lib/util/settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
8 changes: 7 additions & 1 deletion lib/util/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`);
}
Expand Down
46 changes: 44 additions & 2 deletions lib/util/settingsMigration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -28,7 +29,7 @@ interface SettingsCustomHandler extends Omit<SettingsMigration, 'path'> {
execute: (currentSettings: Partial<Settings>) => [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');
Expand Down Expand Up @@ -438,6 +439,43 @@ function migrateToThree(
);
}

function migrateToFour(
currentSettings: Partial<Settings>,
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<Settings>): ReturnType<SettingsCustomHandler['execute']> => {
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
Expand Down Expand Up @@ -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);
Expand Down
25 changes: 25 additions & 0 deletions lib/util/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/(?<extension>.+);base64,(?<data>.+)`);

function pad(num: number): string {
const norm = Math.floor(Math.abs(num));
return (norm < 10 ? '0' : '') + norm;
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions test/extensions/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 329b8c9

Please sign in to comment.