mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-09-01 16:48:31 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c46057130a | ||
|
|
ce064c6d5f | ||
|
|
af932b0b41 | ||
|
|
467ef4f405 | ||
|
|
01b25f15bf | ||
|
|
b441091f6f | ||
|
|
9a6b25afa5 | ||
|
|
9473312667 | ||
|
|
22de3f10d8 | ||
|
|
c867ca5182 | ||
|
|
7fbbbc3b17 | ||
|
|
dccf0a98c6 | ||
|
|
cf11b1a188 | ||
|
|
75bb5eed00 | ||
|
|
95d072a788 | ||
|
|
be1cbb5f3b | ||
|
|
07ae13ad2d | ||
|
|
c649d04b1c | ||
|
|
d3ba21828c | ||
|
|
d276190ab8 | ||
|
|
e7deca42bf | ||
|
|
07a4a0bc75 | ||
|
|
368e31b1e3 | ||
|
|
10611654c7 | ||
|
|
f06673506f | ||
|
|
06290da5be | ||
|
|
86dfc90869 | ||
|
|
60786528ae | ||
|
|
3aaa8c1546 | ||
|
|
046817624f | ||
|
|
106a4bdef2 | ||
|
|
a8171a5464 | ||
|
|
2f976b7dca | ||
|
|
29b041ce57 | ||
|
|
828038717f | ||
|
|
5fd4d6b37a |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "2.13.0"
|
".": "2.14.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,48 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.14.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.13.0...2.14.0) (2026-08-30)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Allow to preserve device config when removing device ([#32765](https://github.com/Koenkk/zigbee2mqtt/issues/32765)) ([95d072a](https://github.com/Koenkk/zigbee2mqtt/commit/95d072a788e2d3d3588bb9e553a5cb5ae763d0c8))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Apply debounce to messages without payload ([#32791](https://github.com/Koenkk/zigbee2mqtt/issues/32791)) ([1061165](https://github.com/Koenkk/zigbee2mqtt/commit/10611654c711ba2cd7c17b42effc01b66bdcb81d))
|
||||||
|
* Clamp availability ping delay to the maximum supported timeout ([#32937](https://github.com/Koenkk/zigbee2mqtt/issues/32937)) ([dccf0a9](https://github.com/Koenkk/zigbee2mqtt/commit/dccf0a98c6d78cf888f7a9d907da795f285ae50a))
|
||||||
|
* Fix crash on a `null` color in attribute output ([#32784](https://github.com/Koenkk/zigbee2mqtt/issues/32784)) ([3aaa8c1](https://github.com/Koenkk/zigbee2mqtt/commit/3aaa8c15464fbcfbdd7af17a0a46a2fc5df1eabd))
|
||||||
|
* Fix stringify don't emit invalid JSON for an empty typed array ([#32780](https://github.com/Koenkk/zigbee2mqtt/issues/32780)) ([86dfc90](https://github.com/Koenkk/zigbee2mqtt/commit/86dfc908693021dcb52bad364e9a72b3bc82220a))
|
||||||
|
* Fix stringify emit every element of a typed array ([#32779](https://github.com/Koenkk/zigbee2mqtt/issues/32779)) ([6078652](https://github.com/Koenkk/zigbee2mqtt/commit/60786528ae14e51702c7a386dfdbe61032131e28))
|
||||||
|
* Home Assistant: avoid duplicate door names ([#32752](https://github.com/Koenkk/zigbee2mqtt/issues/32752)) ([f066735](https://github.com/Koenkk/zigbee2mqtt/commit/f06673506f5b5ee98310fb856058f2e378dd5f76))
|
||||||
|
* Home Assistant: fix `motor_state` not exposed for SONOFF MINI-ZBRBS ([#32702](https://github.com/Koenkk/zigbee2mqtt/issues/32702)) ([be1cbb5](https://github.com/Koenkk/zigbee2mqtt/commit/be1cbb5f3bbf4f6e8e609592ad0fa52c39c6317f))
|
||||||
|
* Home Assistant: Improve various discovery mappings ([#32842](https://github.com/Koenkk/zigbee2mqtt/issues/32842)) ([c649d04](https://github.com/Koenkk/zigbee2mqtt/commit/c649d04b1ca9676f6574d16a3079e972f95d100c))
|
||||||
|
* **ignore:** bump the minor-patch group across 1 directory with 2 updates ([#32827](https://github.com/Koenkk/zigbee2mqtt/issues/32827)) ([d276190](https://github.com/Koenkk/zigbee2mqtt/commit/d276190ab85447fe67ea1d2ed0d95d67757650b4))
|
||||||
|
* **ignore:** bump the minor-patch group with 2 updates ([#32755](https://github.com/Koenkk/zigbee2mqtt/issues/32755)) ([106a4bd](https://github.com/Koenkk/zigbee2mqtt/commit/106a4bdef223be8b63e34c0b81df3c55e735441e))
|
||||||
|
* **ignore:** bump the minor-patch group with 3 updates ([#32949](https://github.com/Koenkk/zigbee2mqtt/issues/32949)) ([9a6b25a](https://github.com/Koenkk/zigbee2mqtt/commit/9a6b25afa58daf86a64759d4635af7635503ea33))
|
||||||
|
* **ignore:** update zigbee-herdsman to 10.8.1 ([#32745](https://github.com/Koenkk/zigbee2mqtt/issues/32745)) ([2f976b7](https://github.com/Koenkk/zigbee2mqtt/commit/2f976b7dca740ec8f7b7bc604d2337c1341b7036))
|
||||||
|
* **ignore:** update zigbee-herdsman to 10.9.0 ([#32828](https://github.com/Koenkk/zigbee2mqtt/issues/32828)) ([07a4a0b](https://github.com/Koenkk/zigbee2mqtt/commit/07a4a0bc75f49d3a78b1f83606f98cf5bcb15dc8))
|
||||||
|
* **ignore:** update zigbee-herdsman to 10.9.1 ([#32959](https://github.com/Koenkk/zigbee2mqtt/issues/32959)) ([9473312](https://github.com/Koenkk/zigbee2mqtt/commit/9473312667a8138262d0087f84d12ebd48a745e9))
|
||||||
|
* **ignore:** update zigbee-herdsman-converters to 26.100.0 ([#32960](https://github.com/Koenkk/zigbee2mqtt/issues/32960)) ([01b25f1](https://github.com/Koenkk/zigbee2mqtt/commit/01b25f15bf31c4b64883a8b261f108e994cb5bc9))
|
||||||
|
* **ignore:** update zigbee-herdsman-converters to 26.101.0 ([#32973](https://github.com/Koenkk/zigbee2mqtt/issues/32973)) ([467ef4f](https://github.com/Koenkk/zigbee2mqtt/commit/467ef4f405b85aa09dd179fada26e771d0217fe8))
|
||||||
|
* **ignore:** update zigbee-herdsman-converters to 26.102.0 ([#32976](https://github.com/Koenkk/zigbee2mqtt/issues/32976)) ([ce064c6](https://github.com/Koenkk/zigbee2mqtt/commit/ce064c6d5f0e86bc2aa78f087d9dd9907e173182))
|
||||||
|
* **ignore:** update zigbee-herdsman-converters to 26.91.0 ([#32746](https://github.com/Koenkk/zigbee2mqtt/issues/32746)) ([a8171a5](https://github.com/Koenkk/zigbee2mqtt/commit/a8171a5464a325a8b24f47162897b895766df1f5))
|
||||||
|
* **ignore:** update zigbee-herdsman-converters to 26.92.0 ([#32757](https://github.com/Koenkk/zigbee2mqtt/issues/32757)) ([0468176](https://github.com/Koenkk/zigbee2mqtt/commit/046817624f624934a5e637baba2eedad844ccbb5))
|
||||||
|
* **ignore:** update zigbee-herdsman-converters to 26.93.0 ([#32790](https://github.com/Koenkk/zigbee2mqtt/issues/32790)) ([06290da](https://github.com/Koenkk/zigbee2mqtt/commit/06290da5be805dac56d58ba5ac6d1bc7aab5f545))
|
||||||
|
* **ignore:** update zigbee-herdsman-converters to 26.94.0 ([#32808](https://github.com/Koenkk/zigbee2mqtt/issues/32808)) ([368e31b](https://github.com/Koenkk/zigbee2mqtt/commit/368e31b1e34ac9281b5238597fa1db3bcc794853))
|
||||||
|
* **ignore:** update zigbee-herdsman-converters to 26.95.0 ([#32829](https://github.com/Koenkk/zigbee2mqtt/issues/32829)) ([e7deca4](https://github.com/Koenkk/zigbee2mqtt/commit/e7deca42bfaef6cf13a5f4a855e6a3fcda825c08))
|
||||||
|
* **ignore:** update zigbee-herdsman-converters to 26.96.0 ([#32841](https://github.com/Koenkk/zigbee2mqtt/issues/32841)) ([d3ba218](https://github.com/Koenkk/zigbee2mqtt/commit/d3ba21828cf809a6bea66cc7b0c99d9ce37632f6))
|
||||||
|
* **ignore:** update zigbee-herdsman-converters to 26.97.0 ([#32848](https://github.com/Koenkk/zigbee2mqtt/issues/32848)) ([07ae13a](https://github.com/Koenkk/zigbee2mqtt/commit/07ae13ad2d7025f8af0acfb753558d7c0320bff2))
|
||||||
|
* **ignore:** update zigbee-herdsman-converters to 26.98.0 ([#32879](https://github.com/Koenkk/zigbee2mqtt/issues/32879)) ([75bb5ee](https://github.com/Koenkk/zigbee2mqtt/commit/75bb5eed0085ebb7184afbce21c8dd73b7f0f09d))
|
||||||
|
* **ignore:** update zigbee-herdsman-converters to 26.99.0 ([#32899](https://github.com/Koenkk/zigbee2mqtt/issues/32899)) ([cf11b1a](https://github.com/Koenkk/zigbee2mqtt/commit/cf11b1a188fdf160f181fde9eb4925ec0054eac9))
|
||||||
|
* Improve Home Assistant cover state discovery ([c867ca5](https://github.com/Koenkk/zigbee2mqtt/commit/c867ca5182d457b15ca0176b1eece83a6e677055))
|
||||||
|
* Preserve duration in state ([#32900](https://github.com/Koenkk/zigbee2mqtt/issues/32900)) ([7fbbbc3](https://github.com/Koenkk/zigbee2mqtt/commit/7fbbbc3b17f7f1cc6e8ada0e70d57287ac1a9bf2))
|
||||||
|
* Replace express-static-gzip and finalhandler with srvx ([#32685](https://github.com/Koenkk/zigbee2mqtt/issues/32685)) ([29b041c](https://github.com/Koenkk/zigbee2mqtt/commit/29b041ce57bf42c8ae820318c7494e2d2e87f8ff))
|
||||||
|
* Replace object-assign-deep with local utility ([#32684](https://github.com/Koenkk/zigbee2mqtt/issues/32684)) ([8280387](https://github.com/Koenkk/zigbee2mqtt/commit/828038717fcf659fd368d9a008364eb0ba707529))
|
||||||
|
* Require Node `<=26.2` for serialport ([#32975](https://github.com/Koenkk/zigbee2mqtt/issues/32975)) ([af932b0](https://github.com/Koenkk/zigbee2mqtt/commit/af932b0b41fd05b2a590997b1ea3d429ed96d691))
|
||||||
|
* Warn when setting unsupported device option ([#32928](https://github.com/Koenkk/zigbee2mqtt/issues/32928)) ([b441091](https://github.com/Koenkk/zigbee2mqtt/commit/b441091f6fd51dbd77f0c75d17064ea19adc8dd8))
|
||||||
|
|
||||||
## [2.13.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.12.1...2.13.0) (2026-08-01)
|
## [2.13.0](https://github.com/Koenkk/zigbee2mqtt/compare/2.12.1...2.13.0) (2026-08-01)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -465,7 +465,10 @@ export class Controller {
|
|||||||
let message: string | undefined;
|
let message: string | undefined;
|
||||||
|
|
||||||
// Special cases
|
// Special cases
|
||||||
if (key === "color" && utils.objectHasProperties(subPayload, ["r", "g", "b"])) {
|
// `objectHasProperties` indexes its argument, so it has to be given an object.
|
||||||
|
// The null check three lines below is too late: `color` is nullable like any
|
||||||
|
// other attribute, and a null one reaches here before that branch runs.
|
||||||
|
if (key === "color" && subPayload != null && utils.objectHasProperties(subPayload, ["r", "g", "b"])) {
|
||||||
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
subPayload = [subPayload.r, subPayload.g, subPayload.b];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import * as settings from "../util/settings";
|
|||||||
import utils from "../util/utils";
|
import utils from "../util/utils";
|
||||||
import Extension from "./extension";
|
import Extension from "./extension";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound for a `setTimeout` delay. Node.js stores the delay as a 32-bit signed integer; anything above this
|
||||||
|
* is coerced to `1`, which would turn an ever-growing backoff into a tight loop instead of an ever-longer wait.
|
||||||
|
*/
|
||||||
|
const MAX_TIMEOUT = 2147483647;
|
||||||
|
|
||||||
const RETRIEVE_ON_RECONNECT: readonly {keys: string[]; condition?: (state: KeyValue) => boolean}[] = [
|
const RETRIEVE_ON_RECONNECT: readonly {keys: string[]; condition?: (state: KeyValue) => boolean}[] = [
|
||||||
{keys: ["state"]},
|
{keys: ["state"]},
|
||||||
{keys: ["brightness"], condition: (state: KeyValue): boolean => state.state === "ON"},
|
{keys: ["brightness"], condition: (state: KeyValue): boolean => state.state === "ON"},
|
||||||
@@ -108,7 +114,10 @@ export default class Availability extends Extension {
|
|||||||
// If device did not check in, ping it, if that fails it will be marked as offline
|
// If device did not check in, ping it, if that fails it will be marked as offline
|
||||||
this.timers.set(
|
this.timers.set(
|
||||||
device.ieeeAddr,
|
device.ieeeAddr,
|
||||||
setTimeout(this.addToPingQueue.bind(this, device), (this.getTimeout(device) + utils.seconds(1) + jitter) * backoff),
|
setTimeout(
|
||||||
|
this.addToPingQueue.bind(this, device),
|
||||||
|
Math.min((this.getTimeout(device) + utils.seconds(1) + jitter) * backoff, MAX_TIMEOUT),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -322,6 +331,9 @@ export default class Availability extends Extension {
|
|||||||
options,
|
options,
|
||||||
state,
|
state,
|
||||||
device: device.zh,
|
device: device.zh,
|
||||||
|
/* v8 ignore start */
|
||||||
|
deviceExposesChanged: (): void => this.eventBus.emitExposesAndDevicesChanged(device),
|
||||||
|
/* v8 ignore stop */
|
||||||
/* v8 ignore next */
|
/* v8 ignore next */
|
||||||
publish: (payload: KeyValue) => this.publishEntityState(device, payload),
|
publish: (payload: KeyValue) => this.publishEntityState(device, payload),
|
||||||
};
|
};
|
||||||
|
|||||||
+28
-4
@@ -2,7 +2,6 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import bind from "bind-decorator";
|
import bind from "bind-decorator";
|
||||||
import {zip} from "fflate";
|
import {zip} from "fflate";
|
||||||
import objectAssignDeep from "object-assign-deep";
|
|
||||||
import type winston from "winston";
|
import type winston from "winston";
|
||||||
import Transport from "winston-transport";
|
import Transport from "winston-transport";
|
||||||
import {Zcl} from "zigbee-herdsman";
|
import {Zcl} from "zigbee-herdsman";
|
||||||
@@ -13,6 +12,7 @@ import type Group from "../model/group";
|
|||||||
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from "../types/api";
|
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from "../types/api";
|
||||||
import data from "../util/data";
|
import data from "../util/data";
|
||||||
import logger from "../util/logger";
|
import logger from "../util/logger";
|
||||||
|
import {objectAssignDeep} from "../util/objectAssignDeep";
|
||||||
import * as settings from "../util/settings";
|
import * as settings from "../util/settings";
|
||||||
import {stringify} from "../util/stringify";
|
import {stringify} from "../util/stringify";
|
||||||
import utils, {assertString, DEFAULT_BIND_GROUP_ID} from "../util/utils";
|
import utils, {assertString, DEFAULT_BIND_GROUP_ID} from "../util/utils";
|
||||||
@@ -466,6 +466,20 @@ export default class Bridge extends Extension {
|
|||||||
|
|
||||||
const ID = message.id;
|
const ID = message.id;
|
||||||
const entity = this.getEntity(entityType, ID);
|
const entity = this.getEntity(entityType, ID);
|
||||||
|
|
||||||
|
if (entity instanceof Device) {
|
||||||
|
const supportedOptions = new Set(Object.keys(settings.schemaJson.definitions.device.properties));
|
||||||
|
for (const option of entity.definition?.options ?? []) {
|
||||||
|
supportedOptions.add(option.property);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const option of Object.keys(message.options)) {
|
||||||
|
if (!supportedOptions.has(option)) {
|
||||||
|
logger.warning(`Device '${ID}' does not support option '${option}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const oldOptions = objectAssignDeep({}, cleanup(entity.options));
|
const oldOptions = objectAssignDeep({}, cleanup(entity.options));
|
||||||
|
|
||||||
if (message.options.icon) {
|
if (message.options.icon) {
|
||||||
@@ -677,6 +691,7 @@ export default class Bridge extends Extension {
|
|||||||
const friendlyName = entity.name;
|
const friendlyName = entity.name;
|
||||||
let block = false;
|
let block = false;
|
||||||
let force = false;
|
let force = false;
|
||||||
|
let keepConfig = false;
|
||||||
let clearCache = false;
|
let clearCache = false;
|
||||||
let blockForceLog = "";
|
let blockForceLog = "";
|
||||||
|
|
||||||
@@ -684,8 +699,9 @@ export default class Bridge extends Extension {
|
|||||||
const payload = message as Zigbee2MQTTAPI["bridge/request/device/remove"];
|
const payload = message as Zigbee2MQTTAPI["bridge/request/device/remove"];
|
||||||
block = !!payload.block;
|
block = !!payload.block;
|
||||||
force = !!payload.force;
|
force = !!payload.force;
|
||||||
|
keepConfig = !!payload.keep_config;
|
||||||
clearCache = !!payload.clear_cache;
|
clearCache = !!payload.clear_cache;
|
||||||
blockForceLog = ` (block: ${block}, force: ${force}, clear cache: ${clearCache})`;
|
blockForceLog = ` (block: ${block}, force: ${force}, keep config: ${keepConfig}, clear cache: ${clearCache})`;
|
||||||
} else if (entityType === "group" && messageIsObject) {
|
} else if (entityType === "group" && messageIsObject) {
|
||||||
const payload = message as Zigbee2MQTTAPI["bridge/request/group/remove"];
|
const payload = message as Zigbee2MQTTAPI["bridge/request/group/remove"];
|
||||||
force = !!payload.force;
|
force = !!payload.force;
|
||||||
@@ -710,7 +726,9 @@ export default class Bridge extends Extension {
|
|||||||
this.zigbee.removeDeviceFromLookup(entity.ID);
|
this.zigbee.removeDeviceFromLookup(entity.ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.removeDevice(entity.ID as string);
|
if (!keepConfig) {
|
||||||
|
settings.removeDevice(entity.ID as string);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (force) {
|
if (force) {
|
||||||
entity.zh.removeFromDatabase();
|
entity.zh.removeFromDatabase();
|
||||||
@@ -739,7 +757,13 @@ export default class Bridge extends Extension {
|
|||||||
// Refresh Cluster definition
|
// Refresh Cluster definition
|
||||||
await this.publishDefinitions();
|
await this.publishDefinitions();
|
||||||
|
|
||||||
const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {id: ID, block, force, clear_cache: clearCache};
|
const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {
|
||||||
|
id: ID,
|
||||||
|
block,
|
||||||
|
force,
|
||||||
|
keep_config: keepConfig,
|
||||||
|
clear_cache: clearCache,
|
||||||
|
};
|
||||||
|
|
||||||
return utils.getResponse(message, responseData);
|
return utils.getResponse(message, responseData);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-26
@@ -6,12 +6,11 @@ import {createServer as createSecureServer} from "node:https";
|
|||||||
import type {Socket} from "node:net";
|
import type {Socket} from "node:net";
|
||||||
import {posix} from "node:path";
|
import {posix} from "node:path";
|
||||||
import bind from "bind-decorator";
|
import bind from "bind-decorator";
|
||||||
import expressStaticGzip from "express-static-gzip";
|
|
||||||
import finalhandler from "finalhandler";
|
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import data from "../util/data";
|
import data from "../util/data";
|
||||||
import logger from "../util/logger";
|
import logger from "../util/logger";
|
||||||
import * as settings from "../util/settings";
|
import * as settings from "../util/settings";
|
||||||
|
import {createStaticFileServer, sendNotFound} from "../util/staticFileServer";
|
||||||
import {stringify} from "../util/stringify";
|
import {stringify} from "../util/stringify";
|
||||||
import utils from "../util/utils";
|
import utils from "../util/utils";
|
||||||
import Extension from "./extension";
|
import Extension from "./extension";
|
||||||
@@ -65,46 +64,39 @@ export class Frontend extends Extension {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
const options: expressStaticGzip.ExpressStaticGzipOptions = {
|
|
||||||
enableBrotli: true,
|
|
||||||
serveStatic: {
|
|
||||||
/* v8 ignore start */
|
|
||||||
setHeaders: (res: ServerResponse, path: string): void => {
|
|
||||||
if (path.endsWith("index.html")) {
|
|
||||||
res.setHeader("Cache-Control", "no-store");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/* v8 ignore stop */
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend");
|
const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend");
|
||||||
const fileServer = expressStaticGzip(frontend.default.getPath(), options);
|
const logError = logger.error.bind(logger);
|
||||||
const deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options);
|
const fileServer = createStaticFileServer(frontend.default.getPath(), logError);
|
||||||
|
const deviceIconsFileServer = createStaticFileServer(data.joinPath("device_icons"), logError);
|
||||||
const onRequest = (request: IncomingMessage, response: ServerResponse): void => {
|
const onRequest = (request: IncomingMessage, response: ServerResponse): void => {
|
||||||
const next = finalhandler(request, response);
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
|
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
|
||||||
const newUrl = posix.relative(this.baseUrl, request.url!);
|
const url = request.url!;
|
||||||
|
const newUrl = posix.relative(this.baseUrl, url);
|
||||||
|
|
||||||
// The request url is not within the frontend base url, so the relative path starts with '..'
|
// The request url is not within the frontend base url, so the relative path starts with '..'
|
||||||
if (newUrl.startsWith(".")) {
|
if (newUrl.startsWith(".")) {
|
||||||
next();
|
sendNotFound(request, response);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The base url itself is a directory, redirect to its trailing slash form so the browser resolves the
|
||||||
|
// relative asset paths in `index.html` against the frontend root instead of against its parent.
|
||||||
|
if (newUrl === "" && !url.endsWith("/")) {
|
||||||
|
response.writeHead(301, {Location: `${url}/`});
|
||||||
|
response.end();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
|
|
||||||
// This is necessary for the browser to resolve relative assets paths correctly.
|
|
||||||
request.originalUrl = request.url;
|
|
||||||
request.url = `/${newUrl}`;
|
request.url = `/${newUrl}`;
|
||||||
request.path = request.url;
|
|
||||||
|
|
||||||
if (newUrl.startsWith("device_icons/")) {
|
if (newUrl.startsWith("device_icons/")) {
|
||||||
request.path = request.path.replace("device_icons/", "");
|
|
||||||
request.url = request.url.replace("/device_icons", "");
|
request.url = request.url.replace("/device_icons", "");
|
||||||
|
|
||||||
deviceIconsFileServer(request, response, next);
|
deviceIconsFileServer(request, response);
|
||||||
} else {
|
} else {
|
||||||
fileServer(request, response, next);
|
fileServer(request, response);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,18 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
|||||||
boost_heating_countdown_time_set: {entity_category: "config", icon: "mdi:timer"},
|
boost_heating_countdown_time_set: {entity_category: "config", icon: "mdi:timer"},
|
||||||
boost_time: {entity_category: "config", icon: "mdi:timer"},
|
boost_time: {entity_category: "config", icon: "mdi:timer"},
|
||||||
calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
calibration: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||||
|
calibration_button_hold_time: {
|
||||||
|
enabled_by_default: false,
|
||||||
|
entity_category: "config",
|
||||||
|
icon: "mdi:wrench-clock",
|
||||||
|
},
|
||||||
|
calibration_closing_time: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||||
|
calibration_motor_start_delay: {
|
||||||
|
enabled_by_default: false,
|
||||||
|
entity_category: "config",
|
||||||
|
icon: "mdi:wrench-clock",
|
||||||
|
},
|
||||||
|
calibration_opening_time: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||||
calibration_time: {entity_category: "config", icon: "mdi:wrench-clock"},
|
calibration_time: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||||
calibration_time_left: {entity_category: "config", icon: "mdi:wrench-clock"},
|
calibration_time_left: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||||
calibration_time_right: {entity_category: "config", icon: "mdi:wrench-clock"},
|
calibration_time_right: {entity_category: "config", icon: "mdi:wrench-clock"},
|
||||||
@@ -188,6 +200,11 @@ const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
|||||||
duration: {entity_category: "config", icon: "mdi:timer"},
|
duration: {entity_category: "config", icon: "mdi:timer"},
|
||||||
eco2: {device_class: "volatile_organic_compounds_parts", state_class: "measurement"},
|
eco2: {device_class: "volatile_organic_compounds_parts", state_class: "measurement"},
|
||||||
eco_temperature: {entity_category: "config", icon: "mdi:thermometer"},
|
eco_temperature: {entity_category: "config", icon: "mdi:thermometer"},
|
||||||
|
effect_speed: {
|
||||||
|
enabled_by_default: false,
|
||||||
|
entity_category: "config",
|
||||||
|
icon: "mdi:motion-outline",
|
||||||
|
},
|
||||||
energy: {device_class: "energy", state_class: "total_increasing"},
|
energy: {device_class: "energy", state_class: "total_increasing"},
|
||||||
external_temperature_input: {device_class: "temperature", icon: "mdi:thermometer"},
|
external_temperature_input: {device_class: "temperature", icon: "mdi:thermometer"},
|
||||||
external_temperature: {device_class: "temperature", icon: "mdi:thermometer", state_class: "measurement"},
|
external_temperature: {device_class: "temperature", icon: "mdi:thermometer", state_class: "measurement"},
|
||||||
@@ -323,7 +340,7 @@ const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
|||||||
effect: {enabled_by_default: false, icon: "mdi:palette"},
|
effect: {enabled_by_default: false, icon: "mdi:palette"},
|
||||||
force: {entity_category: "config", icon: "mdi:valve"},
|
force: {entity_category: "config", icon: "mdi:valve"},
|
||||||
keep_time: {entity_category: "config", icon: "mdi:av-timer"},
|
keep_time: {entity_category: "config", icon: "mdi:av-timer"},
|
||||||
identify: {device_class: "identify"},
|
identify: {entity_category: "diagnostic", device_class: "identify"},
|
||||||
keypad_lockout: {entity_category: "config", icon: "mdi:lock"},
|
keypad_lockout: {entity_category: "config", icon: "mdi:lock"},
|
||||||
load_detection_mode: {entity_category: "config", icon: "mdi:tune"},
|
load_detection_mode: {entity_category: "config", icon: "mdi:tune"},
|
||||||
load_dimmable: {entity_category: "config", icon: "mdi:chart-bell-curve"},
|
load_dimmable: {entity_category: "config", icon: "mdi:chart-bell-curve"},
|
||||||
@@ -333,6 +350,7 @@ const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
|||||||
mode: {entity_category: "config", icon: "mdi:tune"},
|
mode: {entity_category: "config", icon: "mdi:tune"},
|
||||||
mode_switch: {icon: "mdi:tune"},
|
mode_switch: {icon: "mdi:tune"},
|
||||||
motor_direction: {entity_category: "config", icon: "mdi:arrow-left-right"},
|
motor_direction: {entity_category: "config", icon: "mdi:arrow-left-right"},
|
||||||
|
motor_state: {entity_category: "diagnostic", icon: "mdi:state-machine"},
|
||||||
motion_sensitivity: {entity_category: "config", icon: "mdi:tune"},
|
motion_sensitivity: {entity_category: "config", icon: "mdi:tune"},
|
||||||
operation_mode: {entity_category: "config", icon: "mdi:tune"},
|
operation_mode: {entity_category: "config", icon: "mdi:tune"},
|
||||||
power_on_behavior: {entity_category: "config", icon: "mdi:power-settings"},
|
power_on_behavior: {entity_category: "config", icon: "mdi:power-settings"},
|
||||||
@@ -359,6 +377,11 @@ const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
|||||||
const LIST_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
const LIST_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = {
|
||||||
action: {icon: "mdi:gesture-double-tap"},
|
action: {icon: "mdi:gesture-double-tap"},
|
||||||
color_options: {icon: "mdi:palette"},
|
color_options: {icon: "mdi:palette"},
|
||||||
|
effect_color: {
|
||||||
|
enabled_by_default: false,
|
||||||
|
entity_category: "config",
|
||||||
|
icon: "mdi:palette-swatch",
|
||||||
|
},
|
||||||
level_config: {entity_category: "diagnostic"},
|
level_config: {entity_category: "diagnostic"},
|
||||||
programming_mode: {icon: "mdi:calendar-clock"},
|
programming_mode: {icon: "mdi:calendar-clock"},
|
||||||
schedule_settings: {entity_category: "config", icon: "mdi:calendar-clock"},
|
schedule_settings: {entity_category: "config", icon: "mdi:calendar-clock"},
|
||||||
@@ -401,6 +424,10 @@ const applyHomeAssistantExposeMetadata = (payload: DiscoveryEntry, homeAssistant
|
|||||||
payload.discovery_payload.icon = homeAssistant.icon;
|
payload.discovery_payload.icon = homeAssistant.icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (homeAssistant.name !== undefined) {
|
||||||
|
payload.discovery_payload.name = homeAssistant.name;
|
||||||
|
}
|
||||||
|
|
||||||
if (homeAssistant.valueTemplate !== undefined) {
|
if (homeAssistant.valueTemplate !== undefined) {
|
||||||
if (homeAssistant.valueTemplate === null) {
|
if (homeAssistant.valueTemplate === null) {
|
||||||
delete payload.discovery_payload.value_template;
|
delete payload.discovery_payload.value_template;
|
||||||
@@ -934,7 +961,7 @@ export class HomeAssistant extends Extension {
|
|||||||
?.features.find((f) => f.name === "tilt");
|
?.features.find((f) => f.name === "tilt");
|
||||||
const motorState = allExposes
|
const motorState = allExposes
|
||||||
?.filter(isEnumExpose)
|
?.filter(isEnumExpose)
|
||||||
.find((e) => ["motor_state", "moving"].includes(e.name) && e.access === ACCESS_STATE);
|
.find((e) => ["motor_state", "moving"].includes(e.name) && e.access & ACCESS_STATE);
|
||||||
const running = allExposes?.filter(isBinaryExpose)?.find((e) => e.name === "running");
|
const running = allExposes?.filter(isBinaryExpose)?.find((e) => e.name === "running");
|
||||||
|
|
||||||
const discoveryEntry: DiscoveryEntry = {
|
const discoveryEntry: DiscoveryEntry = {
|
||||||
@@ -960,6 +987,9 @@ export class HomeAssistant extends Extension {
|
|||||||
// If curtains have `motor_state` or `moving` property, lookup for possible
|
// If curtains have `motor_state` or `moving` property, lookup for possible
|
||||||
// state names to detect movement direction and use this in discovery.
|
// state names to detect movement direction and use this in discovery.
|
||||||
if (motorState) {
|
if (motorState) {
|
||||||
|
const motorStateProperty = featurePropertyWithoutEndpoint(motorState);
|
||||||
|
const stateProperty = featurePropertyWithoutEndpoint(state);
|
||||||
|
|
||||||
const openingState = motorState.values.find((s) => COVER_OPENING_LOOKUP.includes(s.toString().toLowerCase()));
|
const openingState = motorState.values.find((s) => COVER_OPENING_LOOKUP.includes(s.toString().toLowerCase()));
|
||||||
const closingState = motorState.values.find((s) => COVER_CLOSING_LOOKUP.includes(s.toString().toLowerCase()));
|
const closingState = motorState.values.find((s) => COVER_CLOSING_LOOKUP.includes(s.toString().toLowerCase()));
|
||||||
const stoppedState = motorState.values.find((s) => COVER_STOPPED_LOOKUP.includes(s.toString().toLowerCase()));
|
const stoppedState = motorState.values.find((s) => COVER_STOPPED_LOOKUP.includes(s.toString().toLowerCase()));
|
||||||
@@ -967,8 +997,19 @@ export class HomeAssistant extends Extension {
|
|||||||
if (openingState && closingState && stoppedState) {
|
if (openingState && closingState && stoppedState) {
|
||||||
discoveryEntry.discovery_payload.state_opening = openingState;
|
discoveryEntry.discovery_payload.state_opening = openingState;
|
||||||
discoveryEntry.discovery_payload.state_closing = closingState;
|
discoveryEntry.discovery_payload.state_closing = closingState;
|
||||||
|
discoveryEntry.discovery_payload.state_open = "OPEN";
|
||||||
|
discoveryEntry.discovery_payload.state_closed = "CLOSE";
|
||||||
discoveryEntry.discovery_payload.state_stopped = stoppedState;
|
discoveryEntry.discovery_payload.state_stopped = stoppedState;
|
||||||
discoveryEntry.discovery_payload.value_template = `{% if "${featurePropertyWithoutEndpoint(motorState)}" in value_json and value_json["${featurePropertyWithoutEndpoint(motorState)}"] %} {{ value_json["${featurePropertyWithoutEndpoint(motorState)}"] }} {% else %} ${stoppedState} {% endif %}`;
|
discoveryEntry.discovery_payload.value_template =
|
||||||
|
`{% if "${motorStateProperty}" in value_json and value_json["${motorStateProperty}"] == "${openingState}" %}` +
|
||||||
|
`${openingState}` +
|
||||||
|
`{% elif "${motorStateProperty}" in value_json and value_json["${motorStateProperty}"] == "${closingState}" %}` +
|
||||||
|
`${closingState}` +
|
||||||
|
`{% elif "${stateProperty}" in value_json %}` +
|
||||||
|
`{{ value_json["${stateProperty}"] }}` +
|
||||||
|
"{% else %}" +
|
||||||
|
`${stoppedState}` +
|
||||||
|
"{% endif %}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1443,7 +1484,11 @@ export class HomeAssistant extends Extension {
|
|||||||
|
|
||||||
// Let Home Assistant generate entity name when device_class is present.
|
// Let Home Assistant generate entity name when device_class is present.
|
||||||
// preserve_name allows device_class and explicit name to coexist (e.g. derived sensors).
|
// preserve_name allows device_class and explicit name to coexist (e.g. derived sensors).
|
||||||
if (entry.discovery_payload.device_class && !NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.preserve_name) {
|
if (
|
||||||
|
entry.discovery_payload.device_class &&
|
||||||
|
entry.discovery_payload.name !== null &&
|
||||||
|
!NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.preserve_name
|
||||||
|
) {
|
||||||
delete entry.discovery_payload.name;
|
delete entry.discovery_payload.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,11 @@ export default class Publish extends Extension {
|
|||||||
state: entityState,
|
state: entityState,
|
||||||
membersState,
|
membersState,
|
||||||
mapped: definition,
|
mapped: definition,
|
||||||
|
/* v8 ignore start */
|
||||||
|
deviceExposesChanged: (): void => {
|
||||||
|
if (re instanceof Device) this.eventBus.emitExposesAndDevicesChanged(re);
|
||||||
|
},
|
||||||
|
/* v8 ignore stop */
|
||||||
/* v8 ignore next */
|
/* v8 ignore next */
|
||||||
publish: (payload: KeyValue) => this.publishEntityState(re, payload),
|
publish: (payload: KeyValue) => this.publishEntityState(re, payload),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -180,8 +180,11 @@ export default class Receive extends Extension {
|
|||||||
|
|
||||||
if (!utils.objectIsEmpty(payload)) {
|
if (!utils.objectIsEmpty(payload)) {
|
||||||
await publish(payload);
|
await publish(payload);
|
||||||
} else {
|
} else if (settings.get().advanced.last_seen && settings.get().advanced.last_seen !== "disable") {
|
||||||
await utils.publishLastSeen({device: data.device, reason: "messageEmitted"}, settings.get(), true, this.publishEntityState);
|
// A message was received that produced no payload (e.g. a frame the converter has no data
|
||||||
|
// for). Publish through the regular publish() path so the per-device debounce/throttle
|
||||||
|
// still applies, instead of publishing the full cached state immediately via publishLastSeen.
|
||||||
|
await publish({});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -1,9 +1,8 @@
|
|||||||
import {existsSync, readFileSync, writeFileSync} from "node:fs";
|
import {existsSync, readFileSync, writeFileSync} from "node:fs";
|
||||||
|
|
||||||
import objectAssignDeep from "object-assign-deep";
|
|
||||||
|
|
||||||
import data from "./util/data";
|
import data from "./util/data";
|
||||||
import logger from "./util/logger";
|
import logger from "./util/logger";
|
||||||
|
import {objectAssignDeep} from "./util/objectAssignDeep";
|
||||||
import * as settings from "./util/settings";
|
import * as settings from "./util/settings";
|
||||||
import utils from "./util/utils";
|
import utils from "./util/utils";
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ const CACHE_IGNORE_PROPERTIES = [
|
|||||||
"no_occupancy_since",
|
"no_occupancy_since",
|
||||||
"step_mode",
|
"step_mode",
|
||||||
"transition_time",
|
"transition_time",
|
||||||
"duration",
|
|
||||||
"elapsed",
|
"elapsed",
|
||||||
"from_side",
|
"from_side",
|
||||||
"to_side",
|
"to_side",
|
||||||
|
|||||||
+8
-6
@@ -59,7 +59,7 @@ export type OnboardData = OnboardInitData | OnboardDoneData | OnboardFailureData
|
|||||||
|
|
||||||
export type OnboardSubmitResponse = {success: true; frontendUrl: string | null} | {success: false; error: string};
|
export type OnboardSubmitResponse = {success: true; frontendUrl: string | null} | {success: false; error: string};
|
||||||
|
|
||||||
export interface Zigbee2MQTTDeviceOptions {
|
export type Zigbee2MQTTDeviceOptions = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
retention?: number;
|
retention?: number;
|
||||||
availability?:
|
availability?:
|
||||||
@@ -83,9 +83,9 @@ export interface Zigbee2MQTTDeviceOptions {
|
|||||||
description?: string;
|
description?: string;
|
||||||
qos?: 0 | 1 | 2;
|
qos?: 0 | 1 | 2;
|
||||||
disable_automatic_update_check?: boolean;
|
disable_automatic_update_check?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface Zigbee2MQTTGroupOptions {
|
export type Zigbee2MQTTGroupOptions = {
|
||||||
ID: number;
|
ID: number;
|
||||||
optimistic?: boolean;
|
optimistic?: boolean;
|
||||||
off_state?: "all_members_off" | "last_member_state";
|
off_state?: "all_members_off" | "last_member_state";
|
||||||
@@ -96,9 +96,9 @@ export interface Zigbee2MQTTGroupOptions {
|
|||||||
friendly_name: string;
|
friendly_name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
qos?: 0 | 1 | 2;
|
qos?: 0 | 1 | 2;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface Zigbee2MQTTSettings {
|
export type Zigbee2MQTTSettings = {
|
||||||
version?: number;
|
version?: number;
|
||||||
/** only used internally during startup, removed on successful Z2M start */
|
/** only used internally during startup, removed on successful Z2M start */
|
||||||
onboarding?: true;
|
onboarding?: true;
|
||||||
@@ -224,7 +224,7 @@ export interface Zigbee2MQTTSettings {
|
|||||||
interval: number;
|
interval: number;
|
||||||
reset_on_check: boolean;
|
reset_on_check: boolean;
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface Zigbee2MQTTScene {
|
export interface Zigbee2MQTTScene {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -622,6 +622,7 @@ export interface Zigbee2MQTTAPI {
|
|||||||
id: string;
|
id: string;
|
||||||
block?: boolean;
|
block?: boolean;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
keep_config?: boolean;
|
||||||
clear_cache?: boolean;
|
clear_cache?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -629,6 +630,7 @@ export interface Zigbee2MQTTAPI {
|
|||||||
id: string;
|
id: string;
|
||||||
block: boolean;
|
block: boolean;
|
||||||
force: boolean;
|
force: boolean;
|
||||||
|
keep_config: boolean;
|
||||||
clear_cache: boolean;
|
clear_cache: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Vendored
+6
@@ -10,6 +10,12 @@ declare global {
|
|||||||
const removeEventListener: import("node:events").EventEmitter["removeListener"];
|
const removeEventListener: import("node:events").EventEmitter["removeListener"];
|
||||||
/** @deprecated DOM SHIM, DO NOT USE */
|
/** @deprecated DOM SHIM, DO NOT USE */
|
||||||
const postMessage: import("node:worker_threads").MessagePort["postMessage"];
|
const postMessage: import("node:worker_threads").MessagePort["postMessage"];
|
||||||
|
/**
|
||||||
|
* Required by `srvx` <= 0.12.5, remove once a release including https://github.com/h3js/srvx/pull/288 is out.
|
||||||
|
*
|
||||||
|
* @deprecated DOM SHIM, DO NOT USE
|
||||||
|
*/
|
||||||
|
type HeadersInit = string[][] | Record<string, string> | Headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+3
@@ -26,6 +26,9 @@ declare global {
|
|||||||
type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise<void>;
|
type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise<void>;
|
||||||
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>};
|
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>};
|
||||||
type MakePartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>;
|
type MakePartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>;
|
||||||
|
/** Convert `A | B | C` into `A & B & C` */
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: distributive conditional requires `any`
|
||||||
|
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
|
||||||
interface KeyValue {
|
interface KeyValue {
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: API
|
// biome-ignore lint/suspicious/noExplicitAny: API
|
||||||
[s: string]: any;
|
[s: string]: any;
|
||||||
|
|||||||
Vendored
-7
@@ -5,10 +5,3 @@ declare module "zigbee2mqtt-frontend" {
|
|||||||
|
|
||||||
export default frontend;
|
export default frontend;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "node:http" {
|
|
||||||
interface IncomingMessage {
|
|
||||||
originalUrl?: string;
|
|
||||||
path?: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/** Anything mergeable: a plain-ish object, explicitly not an array or another iterable. */
|
||||||
|
export type UnknownRecord = Record<string | number, unknown> & {[Symbol.iterator]?: never};
|
||||||
|
|
||||||
|
function isUnknownRecord(value: unknown): value is UnknownRecord {
|
||||||
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneArray(input: readonly unknown[]): unknown[] {
|
||||||
|
const len = input.length;
|
||||||
|
const output: unknown[] = new Array(len);
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const val = input[i];
|
||||||
|
output[i] = isUnknownRecord(val) ? cloneObject(val) : Array.isArray(val) ? cloneArray(val) : val;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneObject(input: UnknownRecord): UnknownRecord {
|
||||||
|
const output: UnknownRecord = {};
|
||||||
|
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== "__proto__" && key !== "constructor" && key !== "prototype") {
|
||||||
|
const val = input[key];
|
||||||
|
output[key] = isUnknownRecord(val) ? cloneObject(val) : Array.isArray(val) ? cloneArray(val) : val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge all sources into `target` recursively.
|
||||||
|
*
|
||||||
|
* Key behavior:
|
||||||
|
* - ignore properties `__proto__`, `constructor` & `prototype`
|
||||||
|
* - assumes no infinite circular possible (unhandled for perf)
|
||||||
|
*
|
||||||
|
* Pass empty object `{}` as `target` to return a new object without modifying any existing objects.
|
||||||
|
*/
|
||||||
|
export function objectAssignDeep<T extends UnknownRecord, S extends readonly UnknownRecord[]>(
|
||||||
|
target: T,
|
||||||
|
...sources: S
|
||||||
|
): T & UnionToIntersection<S[number]> {
|
||||||
|
for (const source of sources) {
|
||||||
|
for (const key of Object.keys(source)) {
|
||||||
|
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = source[key];
|
||||||
|
|
||||||
|
if (isUnknownRecord(value)) {
|
||||||
|
const existing = target[key];
|
||||||
|
|
||||||
|
(target as UnknownRecord)[key] = isUnknownRecord(existing) ? objectAssignDeep({}, existing, value) : cloneObject(value);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
(target as UnknownRecord)[key] = cloneArray(value);
|
||||||
|
} else {
|
||||||
|
(target as UnknownRecord)[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return target as T & UnionToIntersection<S[number]>;
|
||||||
|
}
|
||||||
+5
-25
@@ -1,31 +1,15 @@
|
|||||||
import {existsSync, mkdirSync, writeFileSync} from "node:fs";
|
import {existsSync, mkdirSync, writeFileSync} from "node:fs";
|
||||||
import type {ServerResponse} from "node:http";
|
|
||||||
import {createServer} from "node:http";
|
import {createServer} from "node:http";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import expressStaticGzip from "express-static-gzip";
|
|
||||||
import {type Unzipped, unzip} from "fflate";
|
import {type Unzipped, unzip} from "fflate";
|
||||||
import finalhandler from "finalhandler";
|
|
||||||
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
|
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
|
||||||
import type {OnboardData, OnboardFailureData, OnboardSubmitResponse, Zigbee2MQTTSettings} from "../types/api";
|
import type {OnboardData, OnboardFailureData, OnboardSubmitResponse, Zigbee2MQTTSettings} from "../types/api";
|
||||||
import {stringify} from "../util/stringify";
|
import {stringify} from "../util/stringify";
|
||||||
import data from "./data";
|
import data from "./data";
|
||||||
import * as settings from "./settings";
|
import * as settings from "./settings";
|
||||||
|
import {createStaticFileServer} from "./staticFileServer";
|
||||||
import {YAMLFileException} from "./yaml";
|
import {YAMLFileException} from "./yaml";
|
||||||
|
|
||||||
/** same as extension/frontend */
|
|
||||||
const FILE_SERVER_OPTIONS: expressStaticGzip.ExpressStaticGzipOptions = {
|
|
||||||
enableBrotli: true,
|
|
||||||
serveStatic: {
|
|
||||||
/* v8 ignore start */
|
|
||||||
setHeaders: (res: ServerResponse, path: string): void => {
|
|
||||||
if (path.endsWith("index.html")) {
|
|
||||||
res.setHeader("Cache-Control", "no-store");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/* v8 ignore stop */
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getServerUrl(): URL {
|
function getServerUrl(): URL {
|
||||||
return new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080");
|
return new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080");
|
||||||
}
|
}
|
||||||
@@ -72,7 +56,7 @@ async function startOnboardingServer(): Promise<boolean> {
|
|||||||
const currentSettings = settings.get();
|
const currentSettings = settings.get();
|
||||||
const serverUrl = getServerUrl();
|
const serverUrl = getServerUrl();
|
||||||
let server: ReturnType<typeof createServer> | undefined;
|
let server: ReturnType<typeof createServer> | undefined;
|
||||||
const fileServer = expressStaticGzip((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), FILE_SERVER_OPTIONS);
|
const fileServer = createStaticFileServer((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), console.error);
|
||||||
|
|
||||||
const success = await new Promise<boolean>((resolve) => {
|
const success = await new Promise<boolean>((resolve) => {
|
||||||
server = createServer(async (req, res) => {
|
server = createServer(async (req, res) => {
|
||||||
@@ -196,9 +180,7 @@ async function startOnboardingServer(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = finalhandler(req, res);
|
fileServer(req, res);
|
||||||
|
|
||||||
fileServer(req, res, next);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on("error", (error: Error) => {
|
server.on("error", (error: Error) => {
|
||||||
@@ -219,7 +201,7 @@ async function startOnboardingServer(): Promise<boolean> {
|
|||||||
async function startFailureServer(errors: string[]): Promise<void> {
|
async function startFailureServer(errors: string[]): Promise<void> {
|
||||||
const serverUrl = getServerUrl();
|
const serverUrl = getServerUrl();
|
||||||
let server: ReturnType<typeof createServer> | undefined;
|
let server: ReturnType<typeof createServer> | undefined;
|
||||||
const fileServer = expressStaticGzip((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), FILE_SERVER_OPTIONS);
|
const fileServer = createStaticFileServer((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), console.error);
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
server = createServer((req, res) => {
|
server = createServer((req, res) => {
|
||||||
@@ -244,9 +226,7 @@ async function startFailureServer(errors: string[]): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = finalhandler(req, res);
|
fileServer(req, res);
|
||||||
|
|
||||||
fileServer(req, res, next);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
|
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type {ValidateFunction} from "ajv";
|
import type {ValidateFunction} from "ajv";
|
||||||
import Ajv from "ajv";
|
import Ajv from "ajv";
|
||||||
import objectAssignDeep from "object-assign-deep";
|
|
||||||
import data from "./data";
|
import data from "./data";
|
||||||
|
import {objectAssignDeep} from "./objectAssignDeep";
|
||||||
import schemaJson from "./settings.schema.json";
|
import schemaJson from "./settings.schema.json";
|
||||||
import utils from "./utils";
|
import utils from "./utils";
|
||||||
import yaml from "./yaml";
|
import yaml from "./yaml";
|
||||||
@@ -231,7 +231,10 @@ export function write(): void {
|
|||||||
const writeDevicesOrGroups = (type: "devices" | "groups"): void => {
|
const writeDevicesOrGroups = (type: "devices" | "groups"): void => {
|
||||||
if (typeof actual[type] === "string" || (Array.isArray(actual[type]) && actual[type].length > 0)) {
|
if (typeof actual[type] === "string" || (Array.isArray(actual[type]) && actual[type].length > 0)) {
|
||||||
const fileToWrite = Array.isArray(actual[type]) ? actual[type][0] : actual[type];
|
const fileToWrite = Array.isArray(actual[type]) ? actual[type][0] : actual[type];
|
||||||
const content = objectAssignDeep({}, settings[type]);
|
// `readDevicesOrGroups()` already set this to an object whenever the config points at separate files, but the
|
||||||
|
// persisted settings are `Partial`, so the fallback is only here to satisfy the type
|
||||||
|
/* v8 ignore next */
|
||||||
|
const content = objectAssignDeep({}, settings[type] ?? {});
|
||||||
|
|
||||||
// If an array, only write to first file and only devices which are not in the other files.
|
// If an array, only write to first file and only devices which are not in the other files.
|
||||||
if (Array.isArray(actual[type])) {
|
if (Array.isArray(actual[type])) {
|
||||||
@@ -368,8 +371,7 @@ function read(): Partial<Settings> {
|
|||||||
s[type] = {};
|
s[type] = {};
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const content = yaml.readIfExists(data.joinPath(file));
|
const content = yaml.readIfExists(data.joinPath(file));
|
||||||
// @ts-expect-error noMutate not typed properly
|
s[type] = objectAssignDeep({}, s[type], content);
|
||||||
s[type] = objectAssignDeep.noMutate(s[type], content);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -479,9 +481,7 @@ export function set(path: string[], value: string | number | boolean | KeyValue)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function apply(settings: Record<string, unknown>, throwOnError = true): boolean {
|
export function apply(settings: Record<string, unknown>, throwOnError = true): boolean {
|
||||||
getPersistedSettings(); // Ensure _settings is initialized.
|
const newSettings = objectAssignDeep({}, getPersistedSettings(), settings);
|
||||||
// @ts-expect-error noMutate not typed properly
|
|
||||||
const newSettings = objectAssignDeep.noMutate(_settings, settings);
|
|
||||||
|
|
||||||
utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS);
|
utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type {IncomingMessage, ServerResponse} from "node:http";
|
||||||
|
import {NodeRequest, sendNodeResponse} from "srvx/node";
|
||||||
|
import {staticMiddleware} from "srvx/static";
|
||||||
|
|
||||||
|
export type StaticFileServer = (request: IncomingMessage, response: ServerResponse) => void;
|
||||||
|
|
||||||
|
const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
||||||
|
|
||||||
|
/** Terminal `404` handler for requests no file matched, mirroring the response `finalhandler` used to produce. */
|
||||||
|
export function sendNotFound(request: IncomingMessage, response: ServerResponse): void {
|
||||||
|
const method = request.method /* v8 ignore next */ ?? "GET";
|
||||||
|
const url = request.url /* v8 ignore next */ ?? "/";
|
||||||
|
const message = escapeHtml(`Cannot ${method} ${encodeURI(url)}`);
|
||||||
|
const body = `<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<title>Error</title>\n</head>\n<body>\n<pre>${message}</pre>\n</body>\n</html>\n`;
|
||||||
|
|
||||||
|
response.setHeader("Content-Security-Policy", "default-src 'none'");
|
||||||
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
|
response.setHeader("Content-Length", Buffer.byteLength(body));
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves `dir` on top of a plain `node:http` server, preferring the precompressed `.br`/`.gz` variant of a file when the client accepts it.
|
||||||
|
*
|
||||||
|
* Requests that match no file are answered by {@link sendNotFound}.
|
||||||
|
*/
|
||||||
|
export function createStaticFileServer(dir: string, logError: (message: string) => void): StaticFileServer {
|
||||||
|
// `compress: false` restricts serving to the precompressed variants shipped on disk, never compressing on the fly
|
||||||
|
const serveDir = staticMiddleware({dir, encodings: true, compress: false});
|
||||||
|
const handle = async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
|
||||||
|
let matched = true;
|
||||||
|
const staticResponse = await serveDir(new NodeRequest({req: request, res: response}), () => {
|
||||||
|
matched = false;
|
||||||
|
|
||||||
|
return new Response(null, {status: 404});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
sendNotFound(request, response);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the HTML entry document must never be cached, so a newly installed frontend version is picked up right away
|
||||||
|
if (staticResponse.headers.get("Content-Type")?.startsWith("text/html")) {
|
||||||
|
staticResponse.headers.set("Cache-Control", "no-store");
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendNodeResponse(response, staticResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (request, response) => {
|
||||||
|
handle(request, response).catch((error) => {
|
||||||
|
logError(`Failed to serve '${request.url}': ${(error as Error).message}`);
|
||||||
|
|
||||||
|
if (!response.headersSent) {
|
||||||
|
response.writeHead(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -40,11 +40,15 @@ function isTypedArray(value: unknown): value is unknown[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stringifyTypedArray(array: unknown[]): string {
|
function stringifyTypedArray(array: unknown[]): string {
|
||||||
|
if (array.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
const isBigInt = typeof array[0] === "bigint";
|
const isBigInt = typeof array[0] === "bigint";
|
||||||
let res = `"0":${isBigInt ? `"${array[0]}"` : array[0]}`;
|
let res = `"0":${isBigInt ? `"${array[0]}"` : array[0]}`;
|
||||||
|
|
||||||
for (let i = 1; i < array.length; i++) {
|
for (let i = 1; i < array.length; i++) {
|
||||||
res += `,"${i}":${isBigInt ? `"${array[1]}"` : array[1]}`;
|
res += `,"${i}":${isBigInt ? `"${array[i]}"` : array[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
@@ -115,7 +119,8 @@ function stringifySimple(key: string, value: unknown, stack: unknown[]): string
|
|||||||
res += stringifyTypedArray(value);
|
res += stringifyTypedArray(value);
|
||||||
keys = keys.slice(value.length);
|
keys = keys.slice(value.length);
|
||||||
propsToStringify -= value.length;
|
propsToStringify -= value.length;
|
||||||
separator = ",";
|
// Only separate from something that was actually written.
|
||||||
|
separator = value.length > 0 ? "," : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
sort(keys);
|
sort(keys);
|
||||||
|
|||||||
+11
-16
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "zigbee2mqtt",
|
"name": "zigbee2mqtt",
|
||||||
"version": "2.13.0",
|
"version": "2.14.0",
|
||||||
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
"description": "Zigbee to MQTT bridge using Zigbee-herdsman",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "dist/types/api.d.ts",
|
"types": "dist/types/api.d.ts",
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
|
"url": "git+https://github.com/Koenkk/zigbee2mqtt.git"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.2.0 || ^24 || ^26"
|
"node": "^22.2.0 || ^24 || <=26.2"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"xiaomi",
|
"xiaomi",
|
||||||
@@ -45,33 +45,28 @@
|
|||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"bind-decorator": "^1.0.11",
|
"bind-decorator": "^1.0.11",
|
||||||
"debounce": "^3.0.0",
|
"debounce": "^3.0.0",
|
||||||
"express-static-gzip": "^3.0.1",
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fflate": "^0.8.3",
|
"fflate": "^0.8.3",
|
||||||
"finalhandler": "^2.1.1",
|
"humanize-duration": "^3.34.1",
|
||||||
"humanize-duration": "^3.34.0",
|
"js-yaml": "^5.3.0",
|
||||||
"js-yaml": "^5.2.2",
|
|
||||||
"mqtt": "^5.15.2",
|
"mqtt": "^5.15.2",
|
||||||
"object-assign-deep": "^0.4.0",
|
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
|
"srvx": "^0.12.7",
|
||||||
"throttleit": "^3.0.0",
|
"throttleit": "^3.0.0",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"winston-syslog": "^2.7.1",
|
"winston-syslog": "^2.7.1",
|
||||||
"winston-transport": "^4.9.0",
|
"winston-transport": "^4.9.0",
|
||||||
"ws": "^8.21.1",
|
"ws": "^8.21.3",
|
||||||
"zigbee-herdsman": "10.8.0",
|
"zigbee-herdsman": "10.9.1",
|
||||||
"zigbee-herdsman-converters": "26.90.0",
|
"zigbee-herdsman-converters": "26.102.0",
|
||||||
"zigbee2mqtt-frontend": "0.9.21",
|
"zigbee2mqtt-frontend": "0.9.21",
|
||||||
"zigbee2mqtt-windfront": "2.14.0"
|
"zigbee2mqtt-windfront": "2.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.5.3",
|
"@biomejs/biome": "^2.5.3",
|
||||||
"@types/finalhandler": "^1.2.3",
|
|
||||||
"@types/humanize-duration": "^3.27.4",
|
"@types/humanize-duration": "^3.27.4",
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.2.0",
|
||||||
"@types/object-assign-deep": "^0.4.3",
|
|
||||||
"@types/readable-stream": "4.0.24",
|
"@types/readable-stream": "4.0.24",
|
||||||
"@types/serve-static": "^2.2.0",
|
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@vitest/coverage-v8": "^3.1.1",
|
"@vitest/coverage-v8": "^3.1.1",
|
||||||
"tmp": "^0.2.7",
|
"tmp": "^0.2.7",
|
||||||
@@ -95,4 +90,4 @@
|
|||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"unix-dgram": "^2.0.7"
|
"unix-dgram": "^2.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+65
-260
@@ -5,7 +5,7 @@ settings:
|
|||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
zigbee-herdsman: 10.8.0
|
zigbee-herdsman: 10.9.1
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
@@ -20,33 +20,27 @@ importers:
|
|||||||
debounce:
|
debounce:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
express-static-gzip:
|
|
||||||
specifier: ^3.0.1
|
|
||||||
version: 3.0.1
|
|
||||||
fast-deep-equal:
|
fast-deep-equal:
|
||||||
specifier: ^3.1.3
|
specifier: ^3.1.3
|
||||||
version: 3.1.3
|
version: 3.1.3
|
||||||
fflate:
|
fflate:
|
||||||
specifier: ^0.8.3
|
specifier: ^0.8.3
|
||||||
version: 0.8.3
|
version: 0.8.3
|
||||||
finalhandler:
|
|
||||||
specifier: ^2.1.1
|
|
||||||
version: 2.1.1
|
|
||||||
humanize-duration:
|
humanize-duration:
|
||||||
specifier: ^3.34.0
|
specifier: ^3.34.1
|
||||||
version: 3.34.0
|
version: 3.34.1
|
||||||
js-yaml:
|
js-yaml:
|
||||||
specifier: ^5.2.2
|
specifier: ^5.3.0
|
||||||
version: 5.2.2
|
version: 5.3.0
|
||||||
mqtt:
|
mqtt:
|
||||||
specifier: ^5.15.2
|
specifier: ^5.15.2
|
||||||
version: 5.15.2
|
version: 5.15.2
|
||||||
object-assign-deep:
|
|
||||||
specifier: ^0.4.0
|
|
||||||
version: 0.4.0
|
|
||||||
semver:
|
semver:
|
||||||
specifier: ^7.8.5
|
specifier: ^7.8.5
|
||||||
version: 7.8.5
|
version: 7.8.5
|
||||||
|
srvx:
|
||||||
|
specifier: ^0.12.7
|
||||||
|
version: 0.12.7
|
||||||
throttleit:
|
throttleit:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
@@ -60,48 +54,39 @@ importers:
|
|||||||
specifier: ^4.9.0
|
specifier: ^4.9.0
|
||||||
version: 4.9.0
|
version: 4.9.0
|
||||||
ws:
|
ws:
|
||||||
specifier: ^8.21.1
|
specifier: ^8.21.3
|
||||||
version: 8.21.1
|
version: 8.21.3
|
||||||
zigbee-herdsman:
|
zigbee-herdsman:
|
||||||
specifier: 10.8.0
|
specifier: 10.9.1
|
||||||
version: 10.8.0
|
version: 10.9.1
|
||||||
zigbee-herdsman-converters:
|
zigbee-herdsman-converters:
|
||||||
specifier: 26.90.0
|
specifier: 26.102.0
|
||||||
version: 26.90.0
|
version: 26.102.0
|
||||||
zigbee2mqtt-frontend:
|
zigbee2mqtt-frontend:
|
||||||
specifier: 0.9.21
|
specifier: 0.9.21
|
||||||
version: 0.9.21
|
version: 0.9.21
|
||||||
zigbee2mqtt-windfront:
|
zigbee2mqtt-windfront:
|
||||||
specifier: 2.14.0
|
specifier: 2.14.1
|
||||||
version: 2.14.0
|
version: 2.14.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: ^2.5.3
|
specifier: ^2.5.3
|
||||||
version: 2.5.3
|
version: 2.5.3
|
||||||
'@types/finalhandler':
|
|
||||||
specifier: ^1.2.3
|
|
||||||
version: 1.2.4
|
|
||||||
'@types/humanize-duration':
|
'@types/humanize-duration':
|
||||||
specifier: ^3.27.4
|
specifier: ^3.27.4
|
||||||
version: 3.27.4
|
version: 3.27.4
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^26.1.2
|
specifier: ^26.2.0
|
||||||
version: 26.1.2
|
version: 26.2.0
|
||||||
'@types/object-assign-deep':
|
|
||||||
specifier: ^0.4.3
|
|
||||||
version: 0.4.3
|
|
||||||
'@types/readable-stream':
|
'@types/readable-stream':
|
||||||
specifier: 4.0.24
|
specifier: 4.0.24
|
||||||
version: 4.0.24
|
version: 4.0.24
|
||||||
'@types/serve-static':
|
|
||||||
specifier: ^2.2.0
|
|
||||||
version: 2.2.0
|
|
||||||
'@types/ws':
|
'@types/ws':
|
||||||
specifier: 8.18.1
|
specifier: 8.18.1
|
||||||
version: 8.18.1
|
version: 8.18.1
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.1.1
|
specifier: ^3.1.1
|
||||||
version: 3.2.4(vitest@3.2.4(@types/node@26.1.2))
|
version: 3.2.4(vitest@3.2.4(@types/node@26.2.0))
|
||||||
tmp:
|
tmp:
|
||||||
specifier: ^0.2.7
|
specifier: ^0.2.7
|
||||||
version: 0.2.7
|
version: 0.2.7
|
||||||
@@ -110,7 +95,7 @@ importers:
|
|||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.1.1
|
specifier: ^3.1.1
|
||||||
version: 3.2.4(@types/node@26.1.2)
|
version: 3.2.4(@types/node@26.2.0)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
unix-dgram:
|
unix-dgram:
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
@@ -525,27 +510,15 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
'@types/finalhandler@1.2.4':
|
|
||||||
resolution: {integrity: sha512-ojpQ5ywnKZko/+tw8lR4xvUN5Uvfnar4ZtfpoLG1TdxlmiIqcQGUbXmc9iV5ud7J6rRtacNbwTkCE98NmsBPYw==}
|
|
||||||
|
|
||||||
'@types/http-errors@2.0.5':
|
|
||||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
|
||||||
|
|
||||||
'@types/humanize-duration@3.27.4':
|
'@types/humanize-duration@3.27.4':
|
||||||
resolution: {integrity: sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==}
|
resolution: {integrity: sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==}
|
||||||
|
|
||||||
'@types/node@26.1.2':
|
'@types/node@26.2.0':
|
||||||
resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==}
|
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
|
||||||
|
|
||||||
'@types/object-assign-deep@0.4.3':
|
|
||||||
resolution: {integrity: sha512-d9Gxaj5j1hzrxJ61EFEg13B4g4FgrT/DYtcDWFXPehR8DF2SUZbVMFtZIs8exkVRiqrqBpdTc/lUUZjncsPpMw==}
|
|
||||||
|
|
||||||
'@types/readable-stream@4.0.24':
|
'@types/readable-stream@4.0.24':
|
||||||
resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==}
|
resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==}
|
||||||
|
|
||||||
'@types/serve-static@2.2.0':
|
|
||||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
|
||||||
|
|
||||||
'@types/triple-beam@1.3.5':
|
'@types/triple-beam@1.3.5':
|
||||||
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
||||||
|
|
||||||
@@ -848,10 +821,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
depd@2.0.0:
|
|
||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
|
||||||
engines: {node: '>= 0.8'}
|
|
||||||
|
|
||||||
dns-packet@5.6.1:
|
dns-packet@5.6.1:
|
||||||
resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==}
|
resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -859,9 +828,6 @@ packages:
|
|||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
|
||||||
|
|
||||||
emoji-regex@8.0.0:
|
emoji-regex@8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
|
|
||||||
@@ -871,10 +837,6 @@ packages:
|
|||||||
enabled@2.0.0:
|
enabled@2.0.0:
|
||||||
resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
|
resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
|
||||||
|
|
||||||
encodeurl@2.0.0:
|
|
||||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
|
||||||
engines: {node: '>= 0.8'}
|
|
||||||
|
|
||||||
es-module-lexer@1.7.0:
|
es-module-lexer@1.7.0:
|
||||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||||
|
|
||||||
@@ -883,16 +845,9 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
escape-html@1.0.3:
|
|
||||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
|
||||||
|
|
||||||
estree-walker@3.0.3:
|
estree-walker@3.0.3:
|
||||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
etag@1.8.1:
|
|
||||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
event-target-shim@5.0.1:
|
event-target-shim@5.0.1:
|
||||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -905,9 +860,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==}
|
resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
express-static-gzip@3.0.1:
|
|
||||||
resolution: {integrity: sha512-LMeU/3YjFlFUa4vrPX+RoMMRW5mIpF4Iysgs6gX7A59WCY4BzyF3O28mBr4eMlWuW4DU9wVAVuVcfx29ln1N6g==}
|
|
||||||
|
|
||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
@@ -935,10 +887,6 @@ packages:
|
|||||||
file-uri-to-path@1.0.0:
|
file-uri-to-path@1.0.0:
|
||||||
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
||||||
|
|
||||||
finalhandler@2.1.1:
|
|
||||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
|
||||||
engines: {node: '>= 18.0.0'}
|
|
||||||
|
|
||||||
fn.name@1.1.0:
|
fn.name@1.1.0:
|
||||||
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
|
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
|
||||||
|
|
||||||
@@ -946,10 +894,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
fresh@2.0.0:
|
|
||||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
|
||||||
engines: {node: '>= 0.8'}
|
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -974,12 +918,8 @@ packages:
|
|||||||
html-escaper@2.0.2:
|
html-escaper@2.0.2:
|
||||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
humanize-duration@3.34.1:
|
||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-YIiigjQ+O31rvcyDJPK1ptTZtvSErjBtmHy93VhbwxB/VwMG0FszKs2IB667tFxmOhNapePQQxa24luQRDkKFQ==}
|
||||||
engines: {node: '>= 0.8'}
|
|
||||||
|
|
||||||
humanize-duration@3.34.0:
|
|
||||||
resolution: {integrity: sha512-NDxhYxiiRzsST6xULIHuYWRvsCtviJXRdjarhyWyh78S4Ou+MWhM2k4HQ+y7iegdrzreXe11isd0au1N2PB/pQ==}
|
|
||||||
|
|
||||||
iconv-lite@0.7.3:
|
iconv-lite@0.7.3:
|
||||||
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
|
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
|
||||||
@@ -1031,8 +971,8 @@ packages:
|
|||||||
js-tokens@9.0.1:
|
js-tokens@9.0.1:
|
||||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||||
|
|
||||||
js-yaml@5.2.2:
|
js-yaml@5.3.0:
|
||||||
resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==}
|
resolution: {integrity: sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
json-schema-traverse@1.0.0:
|
json-schema-traverse@1.0.0:
|
||||||
@@ -1061,14 +1001,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
mime-db@1.54.0:
|
|
||||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
mime-types@3.0.2:
|
|
||||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
minimatch@9.0.5:
|
minimatch@9.0.5:
|
||||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
@@ -1114,24 +1046,12 @@ packages:
|
|||||||
number-allocator@1.0.14:
|
number-allocator@1.0.14:
|
||||||
resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==}
|
resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==}
|
||||||
|
|
||||||
object-assign-deep@0.4.0:
|
|
||||||
resolution: {integrity: sha512-54Uvn3s+4A/cMWx9tlRez1qtc7pN7pbQ+Yi7mjLjcBpWLlP+XbSHiHbQW6CElDiV4OvuzqnMrBdkgxI1mT8V/Q==}
|
|
||||||
engines: {node: '>=6'}
|
|
||||||
|
|
||||||
on-finished@2.4.1:
|
|
||||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
|
||||||
engines: {node: '>= 0.8'}
|
|
||||||
|
|
||||||
one-time@1.0.0:
|
one-time@1.0.0:
|
||||||
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
|
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1:
|
package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
parseurl@1.3.3:
|
|
||||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
|
||||||
engines: {node: '>= 0.8'}
|
|
||||||
|
|
||||||
path-key@3.1.1:
|
path-key@3.1.1:
|
||||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1165,10 +1085,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||||
engines: {node: '>= 0.6.0'}
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
range-parser@1.2.1:
|
|
||||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -1204,17 +1120,6 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
send@1.2.1:
|
|
||||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
|
||||||
engines: {node: '>= 18'}
|
|
||||||
|
|
||||||
serve-static@2.2.1:
|
|
||||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
|
||||||
engines: {node: '>= 18'}
|
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1249,16 +1154,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
engines: {node: '>= 10.x'}
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
|
srvx@0.12.7:
|
||||||
|
resolution: {integrity: sha512-PIaq1pDGg3EU2OGSN3pzRfYVu9MJDzLdojlGN6E3lvhAdxqn/lNMJFMA1xGA38jYkBb0V9xw02QEVVMDb0PExA==}
|
||||||
|
engines: {node: '>=20.16.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
stack-trace@0.0.10:
|
stack-trace@0.0.10:
|
||||||
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
|
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
|
||||||
|
|
||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
statuses@2.0.2:
|
|
||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
|
||||||
engines: {node: '>= 0.8'}
|
|
||||||
|
|
||||||
std-env@3.9.0:
|
std-env@3.9.0:
|
||||||
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
|
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
|
||||||
|
|
||||||
@@ -1328,10 +1234,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
|
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
|
||||||
engines: {node: '>=14.14'}
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
toidentifier@1.0.1:
|
|
||||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
|
||||||
engines: {node: '>=0.6'}
|
|
||||||
|
|
||||||
triple-beam@1.4.1:
|
triple-beam@1.4.1:
|
||||||
resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==}
|
resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==}
|
||||||
engines: {node: '>= 14.0.0'}
|
engines: {node: '>= 14.0.0'}
|
||||||
@@ -1478,8 +1380,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
ws@8.21.1:
|
ws@8.21.3:
|
||||||
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
|
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
bufferutil: ^4.0.1
|
bufferutil: ^4.0.1
|
||||||
@@ -1490,12 +1392,12 @@ packages:
|
|||||||
utf-8-validate:
|
utf-8-validate:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
zigbee-herdsman-converters@26.90.0:
|
zigbee-herdsman-converters@26.102.0:
|
||||||
resolution: {integrity: sha512-a5yn+ZjWdRenp9ax3hfh+blz5QO4i7WbW7XXygo+Hnz4QG9wp8TkS0zzZbvuQHjhddLzgcBzXMiJl8++Rrpd2Q==}
|
resolution: {integrity: sha512-OJ4j6/0PlirjQu48RdTocEisuNTQjJyJHNt5j6HPCcoN/dEqBrX8UJc3Vo6EWqIApG3xOb8qek7KAgpFXAj6ew==}
|
||||||
engines: {node: '>=20.15.0'}
|
engines: {node: '>=20.15.0'}
|
||||||
|
|
||||||
zigbee-herdsman@10.8.0:
|
zigbee-herdsman@10.9.1:
|
||||||
resolution: {integrity: sha512-m9bVPYjGVT8kqGWjHCbMfpA4jUuSNAq3kH9Xx6i9Y56OXXg9zpBD+Z6xXDCwZL6VS+iranZAX2d7EwqFSm4JZw==}
|
resolution: {integrity: sha512-Tq4wSk4JhQgBOhPwlpq/SU1V3tAtd66u/VVhQfnTfcoomvA331JbspmasAsT6Ba5UnASJvXz7jViNHHCicjhRA==}
|
||||||
|
|
||||||
zigbee-on-host@0.2.4:
|
zigbee-on-host@0.2.4:
|
||||||
resolution: {integrity: sha512-NIG6CWp+Yfn7PjqEIRvenHqpwT1U7rSkyimnFOUIFpnmxQOJKrmcwWDfn6WjbU/YIYYWSQPlj+g13icu1xwlyg==}
|
resolution: {integrity: sha512-NIG6CWp+Yfn7PjqEIRvenHqpwT1U7rSkyimnFOUIFpnmxQOJKrmcwWDfn6WjbU/YIYYWSQPlj+g13icu1xwlyg==}
|
||||||
@@ -1505,8 +1407,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-ClHYlG7g0v/tnIsD6kycNCi1ZEKIbQuU7lGJR3WqYcDmuqEMVcHDGbRiRwYRQcZGMxkqCf7guxNsXiKnC10+kg==}
|
resolution: {integrity: sha512-ClHYlG7g0v/tnIsD6kycNCi1ZEKIbQuU7lGJR3WqYcDmuqEMVcHDGbRiRwYRQcZGMxkqCf7guxNsXiKnC10+kg==}
|
||||||
engines: {node: '>=20.11'}
|
engines: {node: '>=20.11'}
|
||||||
|
|
||||||
zigbee2mqtt-windfront@2.14.0:
|
zigbee2mqtt-windfront@2.14.1:
|
||||||
resolution: {integrity: sha512-Xoqce+q3R1OWl8Gaaqtw0VH0J2hxFZEyYGX7+4y2G8H2ZNPDAdS8goF15PD5cm5x+gRP6jq2YOhePv6vZoquFA==}
|
resolution: {integrity: sha512-p7AyI5oqnGzQ+D3gC2Xk+GTBnbDoNEyORG9IXYy4TwvhXmO8dPhzRtb/XB8a+Bt41bjVni/PnFKDiRH7/G6KUQ==}
|
||||||
engines: {node: '>=22.12.0'}
|
engines: {node: '>=22.12.0'}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
@@ -1784,34 +1686,21 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/finalhandler@1.2.4':
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 26.1.2
|
|
||||||
|
|
||||||
'@types/http-errors@2.0.5': {}
|
|
||||||
|
|
||||||
'@types/humanize-duration@3.27.4': {}
|
'@types/humanize-duration@3.27.4': {}
|
||||||
|
|
||||||
'@types/node@26.1.2':
|
'@types/node@26.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 8.3.0
|
undici-types: 8.3.0
|
||||||
|
|
||||||
'@types/object-assign-deep@0.4.3': {}
|
|
||||||
|
|
||||||
'@types/readable-stream@4.0.24':
|
'@types/readable-stream@4.0.24':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 26.1.2
|
'@types/node': 26.2.0
|
||||||
|
|
||||||
'@types/serve-static@2.2.0':
|
|
||||||
dependencies:
|
|
||||||
'@types/http-errors': 2.0.5
|
|
||||||
'@types/node': 26.1.2
|
|
||||||
|
|
||||||
'@types/triple-beam@1.3.5': {}
|
'@types/triple-beam@1.3.5': {}
|
||||||
|
|
||||||
'@types/ws@8.18.1':
|
'@types/ws@8.18.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 26.1.2
|
'@types/node': 26.2.0
|
||||||
|
|
||||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
@@ -1873,7 +1762,7 @@ snapshots:
|
|||||||
'@typescript/typescript-win32-x64@7.0.2':
|
'@typescript/typescript-win32-x64@7.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@26.1.2))':
|
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@26.2.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
'@bcoe/v8-coverage': 1.0.2
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
@@ -1888,7 +1777,7 @@ snapshots:
|
|||||||
std-env: 3.9.0
|
std-env: 3.9.0
|
||||||
test-exclude: 7.0.1
|
test-exclude: 7.0.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vitest: 3.2.4(@types/node@26.1.2)
|
vitest: 3.2.4(@types/node@26.2.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -1900,13 +1789,13 @@ snapshots:
|
|||||||
chai: 5.2.0
|
chai: 5.2.0
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@vitest/mocker@3.2.4(vite@6.3.5(@types/node@26.1.2))':
|
'@vitest/mocker@3.2.4(vite@6.3.5(@types/node@26.2.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 3.2.4
|
'@vitest/spy': 3.2.4
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.17
|
magic-string: 0.30.17
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 6.3.5(@types/node@26.1.2)
|
vite: 6.3.5(@types/node@26.2.0)
|
||||||
|
|
||||||
'@vitest/pretty-format@3.2.4':
|
'@vitest/pretty-format@3.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2068,24 +1957,18 @@ snapshots:
|
|||||||
|
|
||||||
deep-eql@5.0.2: {}
|
deep-eql@5.0.2: {}
|
||||||
|
|
||||||
depd@2.0.0: {}
|
|
||||||
|
|
||||||
dns-packet@5.6.1:
|
dns-packet@5.6.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@leichtgewicht/ip-codec': 2.0.5
|
'@leichtgewicht/ip-codec': 2.0.5
|
||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
|
||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
|
|
||||||
emoji-regex@9.2.2: {}
|
emoji-regex@9.2.2: {}
|
||||||
|
|
||||||
enabled@2.0.0: {}
|
enabled@2.0.0: {}
|
||||||
|
|
||||||
encodeurl@2.0.0: {}
|
|
||||||
|
|
||||||
es-module-lexer@1.7.0: {}
|
es-module-lexer@1.7.0: {}
|
||||||
|
|
||||||
esbuild@0.25.5:
|
esbuild@0.25.5:
|
||||||
@@ -2116,28 +1999,16 @@ snapshots:
|
|||||||
'@esbuild/win32-ia32': 0.25.5
|
'@esbuild/win32-ia32': 0.25.5
|
||||||
'@esbuild/win32-x64': 0.25.5
|
'@esbuild/win32-x64': 0.25.5
|
||||||
|
|
||||||
escape-html@1.0.3: {}
|
|
||||||
|
|
||||||
estree-walker@3.0.3:
|
estree-walker@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
|
|
||||||
etag@1.8.1: {}
|
|
||||||
|
|
||||||
event-target-shim@5.0.1: {}
|
event-target-shim@5.0.1: {}
|
||||||
|
|
||||||
events@3.3.0: {}
|
events@3.3.0: {}
|
||||||
|
|
||||||
expect-type@1.2.1: {}
|
expect-type@1.2.1: {}
|
||||||
|
|
||||||
express-static-gzip@3.0.1:
|
|
||||||
dependencies:
|
|
||||||
mime-types: 3.0.2
|
|
||||||
parseurl: 1.3.3
|
|
||||||
serve-static: 2.2.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-unique-numbers@9.0.27:
|
fast-unique-numbers@9.0.27:
|
||||||
@@ -2158,17 +2029,6 @@ snapshots:
|
|||||||
file-uri-to-path@1.0.0:
|
file-uri-to-path@1.0.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
finalhandler@2.1.1:
|
|
||||||
dependencies:
|
|
||||||
debug: 4.4.3
|
|
||||||
encodeurl: 2.0.0
|
|
||||||
escape-html: 1.0.3
|
|
||||||
on-finished: 2.4.1
|
|
||||||
parseurl: 1.3.3
|
|
||||||
statuses: 2.0.2
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
fn.name@1.1.0: {}
|
fn.name@1.1.0: {}
|
||||||
|
|
||||||
foreground-child@3.3.1:
|
foreground-child@3.3.1:
|
||||||
@@ -2176,8 +2036,6 @@ snapshots:
|
|||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
fresh@2.0.0: {}
|
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2198,15 +2056,7 @@ snapshots:
|
|||||||
|
|
||||||
html-escaper@2.0.2: {}
|
html-escaper@2.0.2: {}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
humanize-duration@3.34.1: {}
|
||||||
dependencies:
|
|
||||||
depd: 2.0.0
|
|
||||||
inherits: 2.0.4
|
|
||||||
setprototypeof: 1.2.0
|
|
||||||
statuses: 2.0.2
|
|
||||||
toidentifier: 1.0.1
|
|
||||||
|
|
||||||
humanize-duration@3.34.0: {}
|
|
||||||
|
|
||||||
iconv-lite@0.7.3:
|
iconv-lite@0.7.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2255,7 +2105,7 @@ snapshots:
|
|||||||
|
|
||||||
js-tokens@9.0.1: {}
|
js-tokens@9.0.1: {}
|
||||||
|
|
||||||
js-yaml@5.2.2:
|
js-yaml@5.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
|
|
||||||
@@ -2290,12 +2140,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.8.5
|
semver: 7.8.5
|
||||||
|
|
||||||
mime-db@1.54.0: {}
|
|
||||||
|
|
||||||
mime-types@3.0.2:
|
|
||||||
dependencies:
|
|
||||||
mime-db: 1.54.0
|
|
||||||
|
|
||||||
minimatch@9.0.5:
|
minimatch@9.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 2.1.0
|
brace-expansion: 2.1.0
|
||||||
@@ -2329,7 +2173,7 @@ snapshots:
|
|||||||
socks: 2.8.9
|
socks: 2.8.9
|
||||||
split2: 4.2.0
|
split2: 4.2.0
|
||||||
worker-timers: 8.0.33
|
worker-timers: 8.0.33
|
||||||
ws: 8.21.1
|
ws: 8.21.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -2358,20 +2202,12 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
object-assign-deep@0.4.0: {}
|
|
||||||
|
|
||||||
on-finished@2.4.1:
|
|
||||||
dependencies:
|
|
||||||
ee-first: 1.1.1
|
|
||||||
|
|
||||||
one-time@1.0.0:
|
one-time@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
fn.name: 1.1.0
|
fn.name: 1.1.0
|
||||||
|
|
||||||
package-json-from-dist@1.0.1: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
parseurl@1.3.3: {}
|
|
||||||
|
|
||||||
path-key@3.1.1: {}
|
path-key@3.1.1: {}
|
||||||
|
|
||||||
path-scurry@1.11.1:
|
path-scurry@1.11.1:
|
||||||
@@ -2397,8 +2233,6 @@ snapshots:
|
|||||||
|
|
||||||
process@0.11.10: {}
|
process@0.11.10: {}
|
||||||
|
|
||||||
range-parser@1.2.1: {}
|
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
@@ -2451,33 +2285,6 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.8.5: {}
|
semver@7.8.5: {}
|
||||||
|
|
||||||
send@1.2.1:
|
|
||||||
dependencies:
|
|
||||||
debug: 4.4.3
|
|
||||||
encodeurl: 2.0.0
|
|
||||||
escape-html: 1.0.3
|
|
||||||
etag: 1.8.1
|
|
||||||
fresh: 2.0.0
|
|
||||||
http-errors: 2.0.1
|
|
||||||
mime-types: 3.0.2
|
|
||||||
ms: 2.1.3
|
|
||||||
on-finished: 2.4.1
|
|
||||||
range-parser: 1.2.1
|
|
||||||
statuses: 2.0.2
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
serve-static@2.2.1:
|
|
||||||
dependencies:
|
|
||||||
encodeurl: 2.0.0
|
|
||||||
escape-html: 1.0.3
|
|
||||||
parseurl: 1.3.3
|
|
||||||
send: 1.2.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
@@ -2501,12 +2308,12 @@ snapshots:
|
|||||||
|
|
||||||
split2@4.2.0: {}
|
split2@4.2.0: {}
|
||||||
|
|
||||||
|
srvx@0.12.7: {}
|
||||||
|
|
||||||
stack-trace@0.0.10: {}
|
stack-trace@0.0.10: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
|
||||||
|
|
||||||
std-env@3.9.0: {}
|
std-env@3.9.0: {}
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
@@ -2570,8 +2377,6 @@ snapshots:
|
|||||||
|
|
||||||
tmp@0.2.7: {}
|
tmp@0.2.7: {}
|
||||||
|
|
||||||
toidentifier@1.0.1: {}
|
|
||||||
|
|
||||||
triple-beam@1.4.1: {}
|
triple-beam@1.4.1: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
@@ -2617,13 +2422,13 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
vite-node@3.2.4(@types/node@26.1.2):
|
vite-node@3.2.4(@types/node@26.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
es-module-lexer: 1.7.0
|
es-module-lexer: 1.7.0
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
vite: 6.3.5(@types/node@26.1.2)
|
vite: 6.3.5(@types/node@26.2.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- jiti
|
- jiti
|
||||||
@@ -2638,7 +2443,7 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
vite@6.3.5(@types/node@26.1.2):
|
vite@6.3.5(@types/node@26.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.25.5
|
esbuild: 0.25.5
|
||||||
fdir: 6.4.6(picomatch@4.0.2)
|
fdir: 6.4.6(picomatch@4.0.2)
|
||||||
@@ -2647,14 +2452,14 @@ snapshots:
|
|||||||
rollup: 4.44.0
|
rollup: 4.44.0
|
||||||
tinyglobby: 0.2.14
|
tinyglobby: 0.2.14
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 26.1.2
|
'@types/node': 26.2.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
vitest@3.2.4(@types/node@26.1.2):
|
vitest@3.2.4(@types/node@26.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.2
|
'@types/chai': 5.2.2
|
||||||
'@vitest/expect': 3.2.4
|
'@vitest/expect': 3.2.4
|
||||||
'@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@26.1.2))
|
'@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@26.2.0))
|
||||||
'@vitest/pretty-format': 3.2.4
|
'@vitest/pretty-format': 3.2.4
|
||||||
'@vitest/runner': 3.2.4
|
'@vitest/runner': 3.2.4
|
||||||
'@vitest/snapshot': 3.2.4
|
'@vitest/snapshot': 3.2.4
|
||||||
@@ -2672,11 +2477,11 @@ snapshots:
|
|||||||
tinyglobby: 0.2.14
|
tinyglobby: 0.2.14
|
||||||
tinypool: 1.1.1
|
tinypool: 1.1.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vite: 6.3.5(@types/node@26.1.2)
|
vite: 6.3.5(@types/node@26.2.0)
|
||||||
vite-node: 3.2.4(@types/node@26.1.2)
|
vite-node: 3.2.4(@types/node@26.2.0)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 26.1.2
|
'@types/node': 26.2.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
- less
|
- less
|
||||||
@@ -2768,17 +2573,17 @@ snapshots:
|
|||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
strip-ansi: 7.1.2
|
strip-ansi: 7.1.2
|
||||||
|
|
||||||
ws@8.21.1: {}
|
ws@8.21.3: {}
|
||||||
|
|
||||||
zigbee-herdsman-converters@26.90.0:
|
zigbee-herdsman-converters@26.102.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
iconv-lite: 0.7.3
|
iconv-lite: 0.7.3
|
||||||
semver: 7.8.5
|
semver: 7.8.5
|
||||||
zigbee-herdsman: 10.8.0
|
zigbee-herdsman: 10.9.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
zigbee-herdsman@10.8.0:
|
zigbee-herdsman@10.9.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@date-fns/tz': 1.5.0
|
'@date-fns/tz': 1.5.0
|
||||||
'@serialport/bindings-cpp': 13.0.1
|
'@serialport/bindings-cpp': 13.0.1
|
||||||
@@ -2796,4 +2601,4 @@ snapshots:
|
|||||||
|
|
||||||
zigbee2mqtt-frontend@0.9.21: {}
|
zigbee2mqtt-frontend@0.9.21: {}
|
||||||
|
|
||||||
zigbee2mqtt-windfront@2.14.0: {}
|
zigbee2mqtt-windfront@2.14.1: {}
|
||||||
|
|||||||
@@ -1061,6 +1061,17 @@ describe("Controller", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Publish entity state attribute output with a null color", async () => {
|
||||||
|
await controller.start();
|
||||||
|
settings.set(["advanced", "output"], "attribute_and_json");
|
||||||
|
mockMQTTPublishAsync.mockClear();
|
||||||
|
const device = getZ2MDevice("bulb");
|
||||||
|
await controller.publishEntityState(device, {state: "ON", color: null});
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb/state", "ON", {qos: 0, retain: true});
|
||||||
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb/color", "", {qos: 0, retain: true});
|
||||||
|
});
|
||||||
|
|
||||||
it("Publish entity state attribute_json output filtered", async () => {
|
it("Publish entity state attribute_json output filtered", async () => {
|
||||||
await controller.start();
|
await controller.start();
|
||||||
settings.set(["advanced", "output"], "attribute_and_json");
|
settings.set(["advanced", "output"], "attribute_and_json");
|
||||||
@@ -1089,6 +1100,28 @@ describe("Controller", () => {
|
|||||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({state: "ON", brightness: 200}), {qos: 0, retain: true});
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({state: "ON", brightness: 200}), {qos: 0, retain: true});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Publish entity state caches a duration reported by the device", async () => {
|
||||||
|
await controller.start();
|
||||||
|
mockMQTTPublishAsync.mockClear();
|
||||||
|
|
||||||
|
const device = getZ2MDevice("bulb");
|
||||||
|
await controller.publishEntityState(device, {state: "ON", duration: 30});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON", duration: 30});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Publish entity state keeps an action_duration out of the cache", async () => {
|
||||||
|
await controller.start();
|
||||||
|
mockMQTTPublishAsync.mockClear();
|
||||||
|
|
||||||
|
const device = getZ2MDevice("bulb");
|
||||||
|
await controller.publishEntityState(device, {state: "ON", action_duration: 1500});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: "ON"});
|
||||||
|
});
|
||||||
|
|
||||||
it("Publish entity state attribute_json output filtered cache", async () => {
|
it("Publish entity state attribute_json output filtered cache", async () => {
|
||||||
await controller.start();
|
await controller.start();
|
||||||
settings.set(["advanced", "output"], "attribute_and_json");
|
settings.set(["advanced", "output"], "attribute_and_json");
|
||||||
|
|||||||
@@ -633,6 +633,19 @@ describe("Extension: Availability", () => {
|
|||||||
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(4);
|
expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clamps the ping delay to the maximum supported timeout", async () => {
|
||||||
|
// `setTimeout` takes a 32-bit signed integer and coerces anything above it to `1`. A delay can exceed
|
||||||
|
// that either directly, through a long `timeout`, or gradually, once `backoff` has multiplied a normal
|
||||||
|
// one over successive failures. Unclamped, that turns an ever-longer wait into a tight ping loop.
|
||||||
|
settings.set(["devices", devices.bulb_color.ieeeAddr, "availability"], {timeout: 40000, max_jitter: 0}); // ~27.8 days
|
||||||
|
await resetExtension();
|
||||||
|
|
||||||
|
// unclamped, the delay collapses to 1ms, so pings would already be looping by now
|
||||||
|
await setTimeAndAdvanceTimers(utils.seconds(1));
|
||||||
|
|
||||||
|
expect(devices.bulb_color.ping).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("allows to disable backoff", async () => {
|
it("allows to disable backoff", async () => {
|
||||||
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
|
settings.set(["availability", "active", "max_jitter"], 0); // easier testing
|
||||||
settings.set(["availability", "active", "backoff"], false);
|
settings.set(["availability", "active", "backoff"], false);
|
||||||
|
|||||||
@@ -784,6 +784,16 @@ describe("Extension: Bridge", () => {
|
|||||||
property: "effect_color",
|
property: "effect_color",
|
||||||
type: "text",
|
type: "text",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
access: 2,
|
||||||
|
category: "config",
|
||||||
|
description: "Initiate device identification",
|
||||||
|
label: "Identify",
|
||||||
|
name: "identify",
|
||||||
|
property: "identify",
|
||||||
|
type: "enum",
|
||||||
|
values: ["identify"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
access: 1,
|
access: 1,
|
||||||
category: "diagnostic",
|
category: "diagnostic",
|
||||||
@@ -831,6 +841,17 @@ describe("Extension: Bridge", () => {
|
|||||||
value_min: 0,
|
value_min: 0,
|
||||||
value_step: 0.1,
|
value_step: 0.1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
access: 2,
|
||||||
|
description:
|
||||||
|
"Sets the duration of the identification procedure in seconds (i.e., how long the device would flash).The value ranges from 1 to 30 seconds (default: 3).",
|
||||||
|
label: "Identify timeout",
|
||||||
|
name: "identify_timeout",
|
||||||
|
property: "identify_timeout",
|
||||||
|
type: "numeric",
|
||||||
|
value_max: 30,
|
||||||
|
value_min: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
access: 2,
|
access: 2,
|
||||||
description: "State actions will also be published as 'action' when true (default false).",
|
description: "State actions will also be published as 'action' when true (default false).",
|
||||||
@@ -1162,6 +1183,17 @@ describe("Extension: Bridge", () => {
|
|||||||
property: "power_outage_count",
|
property: "power_outage_count",
|
||||||
type: "numeric",
|
type: "numeric",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
access: 2,
|
||||||
|
category: "config",
|
||||||
|
description:
|
||||||
|
"Initiate device identification. This device is asleep by default.You may need to wake it up first before sending the identify command.",
|
||||||
|
label: "Identify",
|
||||||
|
name: "identify",
|
||||||
|
property: "identify",
|
||||||
|
type: "enum",
|
||||||
|
values: ["identify"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
access: 1,
|
access: 1,
|
||||||
category: "diagnostic",
|
category: "diagnostic",
|
||||||
@@ -1196,6 +1228,17 @@ describe("Extension: Bridge", () => {
|
|||||||
type: "numeric",
|
type: "numeric",
|
||||||
value_step: 0.1,
|
value_step: 0.1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
access: 2,
|
||||||
|
description:
|
||||||
|
"Sets the duration of the identification procedure in seconds (i.e., how long the device would flash).The value ranges from 1 to 30 seconds (default: 3).",
|
||||||
|
label: "Identify timeout",
|
||||||
|
name: "identify_timeout",
|
||||||
|
property: "identify_timeout",
|
||||||
|
type: "numeric",
|
||||||
|
value_max: 30,
|
||||||
|
value_min: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
supports_ota: false,
|
supports_ota: false,
|
||||||
vendor: "Aqara",
|
vendor: "Aqara",
|
||||||
@@ -1962,7 +2005,7 @@ describe("Extension: Bridge", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
access: 2,
|
access: 2,
|
||||||
description: "Inverts the cover position, false: open=100,close=0, true: open=0,close=100 (default false).",
|
description: "Inverts the cover position and state, false: open=100,close=0, true: open=0,close=100 (default false).",
|
||||||
label: "Invert cover",
|
label: "Invert cover",
|
||||||
name: "invert_cover",
|
name: "invert_cover",
|
||||||
property: "invert_cover",
|
property: "invert_cover",
|
||||||
@@ -2988,7 +3031,7 @@ describe("Extension: Bridge", () => {
|
|||||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||||
"zigbee2mqtt/bridge/response/device/remove",
|
"zigbee2mqtt/bridge/response/device/remove",
|
||||||
stringify({data: {id: "bulb", block: false, force: false, clear_cache: false}, status: "ok"}),
|
stringify({data: {id: "bulb", block: false, force: false, keep_config: false, clear_cache: false}, status: "ok"}),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
expect(settings.get().blocklist).toStrictEqual([]);
|
expect(settings.get().blocklist).toStrictEqual([]);
|
||||||
@@ -3009,7 +3052,7 @@ describe("Extension: Bridge", () => {
|
|||||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||||
"zigbee2mqtt/bridge/response/device/remove",
|
"zigbee2mqtt/bridge/response/device/remove",
|
||||||
stringify({data: {id: "bulb", block: false, force: false, clear_cache: false}, status: "ok"}),
|
stringify({data: {id: "bulb", block: false, force: false, keep_config: false, clear_cache: false}, status: "ok"}),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -3027,7 +3070,7 @@ describe("Extension: Bridge", () => {
|
|||||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||||
"zigbee2mqtt/bridge/response/device/remove",
|
"zigbee2mqtt/bridge/response/device/remove",
|
||||||
stringify({data: {id: "bulb", block: false, force: true, clear_cache: false}, status: "ok"}),
|
stringify({data: {id: "bulb", block: false, force: true, keep_config: false, clear_cache: false}, status: "ok"}),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -3044,12 +3087,30 @@ describe("Extension: Bridge", () => {
|
|||||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||||
"zigbee2mqtt/bridge/response/device/remove",
|
"zigbee2mqtt/bridge/response/device/remove",
|
||||||
stringify({data: {id: "bulb", block: true, force: true, clear_cache: false}, status: "ok"}),
|
stringify({data: {id: "bulb", block: true, force: true, keep_config: false, clear_cache: false}, status: "ok"}),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
expect(settings.get().blocklist).toStrictEqual(["0x000b57fffec6a5b2"]);
|
expect(settings.get().blocklist).toStrictEqual(["0x000b57fffec6a5b2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Should allow to keep configuration when removing device", async () => {
|
||||||
|
const device = devices.bulb;
|
||||||
|
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||||
|
mockMQTTPublishAsync.mockClear();
|
||||||
|
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb", keep_config: true}));
|
||||||
|
await flushPromises();
|
||||||
|
expect(device.removeFromDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
|
||||||
|
expect(settings.getDevice("bulb")).toBeDefined();
|
||||||
|
expect(removeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||||
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||||
|
"zigbee2mqtt/bridge/response/device/remove",
|
||||||
|
stringify({data: {id: "bulb", block: false, force: false, keep_config: true, clear_cache: false}, status: "ok"}),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("Should allow to clear cache when removing device", async () => {
|
it("Should allow to clear cache when removing device", async () => {
|
||||||
const device = devices.bulb;
|
const device = devices.bulb;
|
||||||
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup");
|
||||||
@@ -3063,7 +3124,7 @@ describe("Extension: Bridge", () => {
|
|||||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), expect.any(Object));
|
||||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
||||||
"zigbee2mqtt/bridge/response/device/remove",
|
"zigbee2mqtt/bridge/response/device/remove",
|
||||||
stringify({data: {id: "bulb", block: false, force: false, clear_cache: true}, status: "ok"}),
|
stringify({data: {id: "bulb", block: false, force: false, keep_config: false, clear_cache: true}, status: "ok"}),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -3135,7 +3196,7 @@ describe("Extension: Bridge", () => {
|
|||||||
stringify({
|
stringify({
|
||||||
data: {},
|
data: {},
|
||||||
status: "error",
|
status: "error",
|
||||||
error: "Failed to remove device 'bulb' (block: false, force: false, clear cache: false) (Error: device timeout)",
|
error: "Failed to remove device 'bulb' (block: false, force: false, keep config: false, clear cache: false) (Error: device timeout)",
|
||||||
}),
|
}),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@@ -3402,7 +3463,7 @@ describe("Extension: Bridge", () => {
|
|||||||
" model: 'lumi.plug',\n" +
|
" model: 'lumi.plug',\n" +
|
||||||
" vendor: '',\n" +
|
" vendor: '',\n" +
|
||||||
" description: 'Automatically generated definition',\n" +
|
" description: 'Automatically generated definition',\n" +
|
||||||
' extend: [m.onOff({"powerOnBehavior":false})],\n' +
|
" extend: [m.onOff()],\n" +
|
||||||
"};\n",
|
"};\n",
|
||||||
},
|
},
|
||||||
status: "ok",
|
status: "ok",
|
||||||
@@ -3585,6 +3646,36 @@ describe("Extension: Bridge", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Should warn on unsupported device option", async () => {
|
||||||
|
mockMQTTPublishAsync.mockClear();
|
||||||
|
mockLogger.warning.mockClear();
|
||||||
|
|
||||||
|
const device = controller.zigbee.resolveEntity(devices.bulb.ieeeAddr);
|
||||||
|
assert(device && "definition" in device);
|
||||||
|
const definitionOptions = device.definition?.options;
|
||||||
|
device.definition!.options = undefined;
|
||||||
|
|
||||||
|
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/options", stringify({options: {unsupported: true}, id: "bulb"}));
|
||||||
|
await flushPromises();
|
||||||
|
device.definition!.options = definitionOptions;
|
||||||
|
|
||||||
|
expect(settings.getDevice("bulb")).toHaveProperty("unsupported");
|
||||||
|
expect(mockLogger.warning).toHaveBeenCalledWith("Device 'bulb' does not support option 'unsupported'");
|
||||||
|
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", unsupported: true},
|
||||||
|
id: "bulb",
|
||||||
|
restart_required: false,
|
||||||
|
},
|
||||||
|
status: "ok",
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("Should allow to add group by string", async () => {
|
it("Should allow to add group by string", async () => {
|
||||||
mockMQTTPublishAsync.mockClear();
|
mockMQTTPublishAsync.mockClear();
|
||||||
mockMQTTEvents.message("zigbee2mqtt/bridge/request/group/add", "group_193");
|
mockMQTTEvents.message("zigbee2mqtt/bridge/request/group/add", "group_193");
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import ws from "ws";
|
|||||||
import {Controller} from "../../lib/controller";
|
import {Controller} from "../../lib/controller";
|
||||||
import * as settings from "../../lib/util/settings";
|
import * as settings from "../../lib/util/settings";
|
||||||
|
|
||||||
let mockHTTPOnRequest: (request: {url: string}, response: number) => void;
|
const mockRedirectResponse = {
|
||||||
|
writeHead: vi.fn<(statusCode: number, headers: Record<string, string>) => void>(),
|
||||||
|
end: vi.fn<() => void>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mockHTTPOnRequest: (request: {url: string}, response: number | typeof mockRedirectResponse) => void;
|
||||||
const mockHTTPEvents: Record<string, EventHandler> = {};
|
const mockHTTPEvents: Record<string, EventHandler> = {};
|
||||||
const mockHTTP = {
|
const mockHTTP = {
|
||||||
listen: vi.fn(),
|
listen: vi.fn(),
|
||||||
@@ -64,7 +69,7 @@ const frontendPath = "frontend-path";
|
|||||||
const deviceIconsPath = path.join(data.mockDir, "device_icons");
|
const deviceIconsPath = path.join(data.mockDir, "device_icons");
|
||||||
let mockNodeStatic: {[s: string]: Mock} = {};
|
let mockNodeStatic: {[s: string]: Mock} = {};
|
||||||
|
|
||||||
const mockFinalHandler = vi.fn();
|
const mockSendNotFound = vi.fn();
|
||||||
|
|
||||||
vi.mock("node:http", () => ({
|
vi.mock("node:http", () => ({
|
||||||
createServer: vi.fn().mockImplementation((onRequest) => {
|
createServer: vi.fn().mockImplementation((onRequest) => {
|
||||||
@@ -79,11 +84,12 @@ vi.mock("node:https", () => ({
|
|||||||
Agent: vi.fn(),
|
Agent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("express-static-gzip", () => ({
|
vi.mock("../../lib/util/staticFileServer", () => ({
|
||||||
default: vi.fn().mockImplementation((path: string) => {
|
createStaticFileServer: vi.fn().mockImplementation((path: string) => {
|
||||||
mockNodeStatic[path] = vi.fn();
|
mockNodeStatic[path] = vi.fn();
|
||||||
return mockNodeStatic[path];
|
return mockNodeStatic[path];
|
||||||
}),
|
}),
|
||||||
|
sendNotFound: vi.fn().mockImplementation((...args: unknown[]) => mockSendNotFound(...args)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("zigbee2mqtt-windfront", () => ({
|
vi.mock("zigbee2mqtt-windfront", () => ({
|
||||||
@@ -101,12 +107,6 @@ vi.mock("ws", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("finalhandler", () => ({
|
|
||||||
default: vi.fn().mockImplementation(() => {
|
|
||||||
return mockFinalHandler;
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mocksClear = [
|
const mocksClear = [
|
||||||
mockHTTP.close,
|
mockHTTP.close,
|
||||||
mockHTTP.listen,
|
mockHTTP.listen,
|
||||||
@@ -118,7 +118,9 @@ const mocksClear = [
|
|||||||
mockWS.emit,
|
mockWS.emit,
|
||||||
mockWSClient.send,
|
mockWSClient.send,
|
||||||
mockWSClient.terminate,
|
mockWSClient.terminate,
|
||||||
mockFinalHandler,
|
mockSendNotFound,
|
||||||
|
mockRedirectResponse.writeHead,
|
||||||
|
mockRedirectResponse.end,
|
||||||
mockMQTTPublishAsync,
|
mockMQTTPublishAsync,
|
||||||
mockLogger.error,
|
mockLogger.error,
|
||||||
];
|
];
|
||||||
@@ -247,6 +249,7 @@ describe("Extension: Frontend", () => {
|
|||||||
effect: null,
|
effect: null,
|
||||||
effect_color: null,
|
effect_color: null,
|
||||||
effect_speed: null,
|
effect_speed: null,
|
||||||
|
identify: null,
|
||||||
power_on_behavior: null,
|
power_on_behavior: null,
|
||||||
linkquality: 20,
|
linkquality: 20,
|
||||||
update: {state: null, installed_version: -1, latest_version: -1},
|
update: {state: null, installed_version: -1, latest_version: -1},
|
||||||
@@ -273,6 +276,7 @@ describe("Extension: Frontend", () => {
|
|||||||
effect: null,
|
effect: null,
|
||||||
effect_color: null,
|
effect_color: null,
|
||||||
effect_speed: null,
|
effect_speed: null,
|
||||||
|
identify: null,
|
||||||
linkquality: 20,
|
linkquality: 20,
|
||||||
update: {state: null, installed_version: -1, latest_version: -1},
|
update: {state: null, installed_version: -1, latest_version: -1},
|
||||||
},
|
},
|
||||||
@@ -302,6 +306,7 @@ describe("Extension: Frontend", () => {
|
|||||||
effect: null,
|
effect: null,
|
||||||
effect_color: null,
|
effect_color: null,
|
||||||
effect_speed: null,
|
effect_speed: null,
|
||||||
|
identify: null,
|
||||||
linkquality: 20,
|
linkquality: 20,
|
||||||
update: {state: null, installed_version: -1, latest_version: -1},
|
update: {state: null, installed_version: -1, latest_version: -1},
|
||||||
},
|
},
|
||||||
@@ -340,11 +345,7 @@ describe("Extension: Frontend", () => {
|
|||||||
mockHTTPOnRequest({url: "/file.txt"}, 2);
|
mockHTTPOnRequest({url: "/file.txt"}, 2);
|
||||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(0);
|
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(0);
|
||||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/file.txt"}, 2);
|
||||||
{originalUrl: "/file.txt", path: "/file.txt", url: "/file.txt"},
|
|
||||||
2,
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Should serve device icons", async () => {
|
it("Should serve device icons", async () => {
|
||||||
@@ -354,11 +355,7 @@ describe("Extension: Frontend", () => {
|
|||||||
mockHTTPOnRequest({url: "/device_icons/my_device.png"}, 2);
|
mockHTTPOnRequest({url: "/device_icons/my_device.png"}, 2);
|
||||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(0);
|
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(0);
|
||||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1);
|
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1);
|
||||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith(
|
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith({url: "/my_device.png"}, 2);
|
||||||
{originalUrl: "/device_icons/my_device.png", path: "/my_device.png", url: "/my_device.png"},
|
|
||||||
2,
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Static server", async () => {
|
it("Static server", async () => {
|
||||||
@@ -402,34 +399,33 @@ describe("Extension: Frontend", () => {
|
|||||||
|
|
||||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m/api"});
|
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m/api"});
|
||||||
|
|
||||||
mockHTTPOnRequest({url: "/z2m"}, 2);
|
// the base url without trailing slash points at a directory, redirect so relative asset paths resolve against it
|
||||||
|
mockHTTPOnRequest({url: "/z2m"}, mockRedirectResponse);
|
||||||
|
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||||
|
expect(mockRedirectResponse.writeHead).toHaveBeenCalledWith(301, {Location: "/z2m/"});
|
||||||
|
expect(mockRedirectResponse.end).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockSendNotFound).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
mockHTTPOnRequest({url: "/z2m/"}, 2);
|
||||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({originalUrl: "/z2m", path: "/", url: "/"}, 2, expect.any(Function));
|
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/"}, 2);
|
||||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||||
|
|
||||||
mockNodeStatic[frontendPath].mockReset();
|
mockNodeStatic[frontendPath].mockReset();
|
||||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||||
mockHTTPOnRequest({url: "/z2m/file.txt"}, 2);
|
mockHTTPOnRequest({url: "/z2m/file.txt"}, 2);
|
||||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/file.txt"}, 2);
|
||||||
{originalUrl: "/z2m/file.txt", path: "/file.txt", url: "/file.txt"},
|
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||||
2,
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
|
||||||
|
|
||||||
mockNodeStatic[frontendPath].mockReset();
|
mockNodeStatic[frontendPath].mockReset();
|
||||||
mockHTTPOnRequest({url: "/z/file.txt"}, 2);
|
mockHTTPOnRequest({url: "/z/file.txt"}, 2);
|
||||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||||
expect(mockFinalHandler).toHaveBeenCalled();
|
expect(mockSendNotFound).toHaveBeenCalled();
|
||||||
|
|
||||||
mockHTTPOnRequest({url: "/z2m/device_icons/my-device.png"}, 2);
|
mockHTTPOnRequest({url: "/z2m/device_icons/my-device.png"}, 2);
|
||||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1);
|
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1);
|
||||||
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith(
|
expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith({url: "/my-device.png"}, 2);
|
||||||
{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 () => {
|
it("Works with non-default complex base url", async () => {
|
||||||
@@ -440,30 +436,28 @@ describe("Extension: Frontend", () => {
|
|||||||
|
|
||||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m-more++/c0mplex.url/api"});
|
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m-more++/c0mplex.url/api"});
|
||||||
|
|
||||||
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url"}, 2);
|
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url"}, mockRedirectResponse);
|
||||||
|
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||||
|
expect(mockRedirectResponse.writeHead).toHaveBeenCalledWith(301, {Location: "/z2m-more++/c0mplex.url/"});
|
||||||
|
expect(mockRedirectResponse.end).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockSendNotFound).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url/"}, 2);
|
||||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/"}, 2);
|
||||||
{originalUrl: "/z2m-more++/c0mplex.url", path: "/", url: "/"},
|
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||||
2,
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
|
||||||
|
|
||||||
mockNodeStatic[frontendPath].mockReset();
|
mockNodeStatic[frontendPath].mockReset();
|
||||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||||
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url/file.txt"}, 2);
|
mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url/file.txt"}, 2);
|
||||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1);
|
||||||
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith(
|
expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({url: "/file.txt"}, 2);
|
||||||
{originalUrl: "/z2m-more++/c0mplex.url/file.txt", path: "/file.txt", url: "/file.txt"},
|
expect(mockSendNotFound).not.toHaveBeenCalledWith();
|
||||||
2,
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
expect(mockFinalHandler).not.toHaveBeenCalledWith();
|
|
||||||
|
|
||||||
mockNodeStatic[frontendPath].mockReset();
|
mockNodeStatic[frontendPath].mockReset();
|
||||||
mockHTTPOnRequest({url: "/z/file.txt"}, 2);
|
mockHTTPOnRequest({url: "/z/file.txt"}, 2);
|
||||||
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled();
|
||||||
expect(mockFinalHandler).toHaveBeenCalled();
|
expect(mockSendNotFound).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prevents mismatching setting/extension state", async () => {
|
it("prevents mismatching setting/extension state", async () => {
|
||||||
|
|||||||
@@ -281,6 +281,25 @@ describe("Extension: HomeAssistant", () => {
|
|||||||
expect(configs.find((config) => config.object_id === "voltage")?.discovery_payload).not.toHaveProperty("type");
|
expect(configs.find((config) => config.object_id === "voltage")?.discovery_payload).not.toHaveProperty("type");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Should set discovery name to null when expose specifies homeassistant name null", () => {
|
||||||
|
const createDevice = (exposes: zhc.Expose[]): Device =>
|
||||||
|
({
|
||||||
|
definition: {},
|
||||||
|
isDevice: (): boolean => true,
|
||||||
|
isGroup: (): boolean => false,
|
||||||
|
endpoint: () => undefined,
|
||||||
|
options: {},
|
||||||
|
exposes: (): zhc.Expose[] => exposes,
|
||||||
|
zh: {endpoints: []},
|
||||||
|
}) as Device;
|
||||||
|
|
||||||
|
const contactExpose = new zhc.Binary("contact", zhc.access.STATE, false, true).withHomeAssistant({name: null});
|
||||||
|
|
||||||
|
// @ts-expect-error private
|
||||||
|
const configs = extension.getConfigs(createDevice([contactExpose]));
|
||||||
|
expect(configs.find((config) => config.object_id === "contact")?.discovery_payload.name).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("Should discover devices and groups", async () => {
|
it("Should discover devices and groups", async () => {
|
||||||
settings.set(["homeassistant", "experimental_event_entities"], true);
|
settings.set(["homeassistant", "experimental_event_entities"], true);
|
||||||
settings.set(["groups", "9", "homeassistant"], {name: "HA Discovery Group", icon: "mdi:lightbulb-group"});
|
settings.set(["groups", "9", "homeassistant"], {name: "HA Discovery Group", icon: "mdi:lightbulb-group"});
|
||||||
@@ -1650,12 +1669,15 @@ describe("Extension: HomeAssistant", () => {
|
|||||||
position_topic: "zigbee2mqtt/0xa4c138018cf95021/left",
|
position_topic: "zigbee2mqtt/0xa4c138018cf95021/left",
|
||||||
set_position_template: '{ "position_left": {{ position }} }',
|
set_position_template: '{ "position_left": {{ position }} }',
|
||||||
set_position_topic: "zigbee2mqtt/0xa4c138018cf95021/left/set",
|
set_position_topic: "zigbee2mqtt/0xa4c138018cf95021/left/set",
|
||||||
|
state_closed: "CLOSE",
|
||||||
state_closing: "DOWN",
|
state_closing: "DOWN",
|
||||||
|
state_open: "OPEN",
|
||||||
state_opening: "UP",
|
state_opening: "UP",
|
||||||
state_stopped: "STOP",
|
state_stopped: "STOP",
|
||||||
state_topic: "zigbee2mqtt/0xa4c138018cf95021/left",
|
state_topic: "zigbee2mqtt/0xa4c138018cf95021/left",
|
||||||
unique_id: "0xa4c138018cf95021_cover_left_zigbee2mqtt",
|
unique_id: "0xa4c138018cf95021_cover_left_zigbee2mqtt",
|
||||||
value_template: '{% if "moving" in value_json and value_json["moving"] %} {{ value_json["moving"] }} {% else %} STOP {% endif %}',
|
value_template:
|
||||||
|
'{% if "moving" in value_json and value_json["moving"] == "UP" %}UP{% elif "moving" in value_json and value_json["moving"] == "DOWN" %}DOWN{% elif "state" in value_json %}{{ value_json["state"] }}{% else %}STOP{% endif %}',
|
||||||
};
|
};
|
||||||
const payload_right = {
|
const payload_right = {
|
||||||
availability: [
|
availability: [
|
||||||
@@ -1681,14 +1703,25 @@ describe("Extension: HomeAssistant", () => {
|
|||||||
position_topic: "zigbee2mqtt/0xa4c138018cf95021/right",
|
position_topic: "zigbee2mqtt/0xa4c138018cf95021/right",
|
||||||
set_position_template: '{ "position_right": {{ position }} }',
|
set_position_template: '{ "position_right": {{ position }} }',
|
||||||
set_position_topic: "zigbee2mqtt/0xa4c138018cf95021/right/set",
|
set_position_topic: "zigbee2mqtt/0xa4c138018cf95021/right/set",
|
||||||
|
state_closed: "CLOSE",
|
||||||
state_closing: "DOWN",
|
state_closing: "DOWN",
|
||||||
|
state_open: "OPEN",
|
||||||
state_opening: "UP",
|
state_opening: "UP",
|
||||||
state_stopped: "STOP",
|
state_stopped: "STOP",
|
||||||
state_topic: "zigbee2mqtt/0xa4c138018cf95021/right",
|
state_topic: "zigbee2mqtt/0xa4c138018cf95021/right",
|
||||||
unique_id: "0xa4c138018cf95021_cover_right_zigbee2mqtt",
|
unique_id: "0xa4c138018cf95021_cover_right_zigbee2mqtt",
|
||||||
value_template: '{% if "moving" in value_json and value_json["moving"] %} {{ value_json["moving"] }} {% else %} STOP {% endif %}',
|
value_template:
|
||||||
|
'{% if "moving" in value_json and value_json["moving"] == "UP" %}UP{% elif "moving" in value_json and value_json["moving"] == "DOWN" %}DOWN{% elif "state" in value_json %}{{ value_json["state"] }}{% else %}STOP{% endif %}',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const coverLeftCalls = mockMQTTPublishAsync.mock.calls.filter(
|
||||||
|
([topic]) => topic === "homeassistant/cover/0xa4c138018cf95021/cover_left/config",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [, actualPayload] of coverLeftCalls) {
|
||||||
|
console.log(JSON.parse(actualPayload));
|
||||||
|
}
|
||||||
|
|
||||||
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/cover/0xa4c138018cf95021/cover_left/config", stringify(payload_left), {
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/cover/0xa4c138018cf95021/cover_left/config", stringify(payload_left), {
|
||||||
retain: true,
|
retain: true,
|
||||||
qos: 1,
|
qos: 1,
|
||||||
@@ -1855,6 +1888,7 @@ describe("Extension: HomeAssistant", () => {
|
|||||||
effect: null,
|
effect: null,
|
||||||
effect_color: null,
|
effect_color: null,
|
||||||
effect_speed: null,
|
effect_speed: null,
|
||||||
|
identify: null,
|
||||||
linkquality: null,
|
linkquality: null,
|
||||||
state: null,
|
state: null,
|
||||||
power_on_behavior: null,
|
power_on_behavior: null,
|
||||||
@@ -1880,6 +1914,7 @@ describe("Extension: HomeAssistant", () => {
|
|||||||
effect: null,
|
effect: null,
|
||||||
effect_color: null,
|
effect_color: null,
|
||||||
effect_speed: null,
|
effect_speed: null,
|
||||||
|
identify: null,
|
||||||
linkquality: null,
|
linkquality: null,
|
||||||
state: null,
|
state: null,
|
||||||
power_on_behavior: null,
|
power_on_behavior: null,
|
||||||
@@ -1904,6 +1939,7 @@ describe("Extension: HomeAssistant", () => {
|
|||||||
effect: null,
|
effect: null,
|
||||||
effect_color: null,
|
effect_color: null,
|
||||||
effect_speed: null,
|
effect_speed: null,
|
||||||
|
identify: null,
|
||||||
state: "ON",
|
state: "ON",
|
||||||
power_on_behavior: null,
|
power_on_behavior: null,
|
||||||
update: {state: null, installed_version: -1, latest_version: -1},
|
update: {state: null, installed_version: -1, latest_version: -1},
|
||||||
@@ -2375,7 +2411,7 @@ describe("Extension: HomeAssistant", () => {
|
|||||||
|
|
||||||
it("Should discover trigger when action is published", async () => {
|
it("Should discover trigger when action is published", async () => {
|
||||||
const discovered = mockMQTTPublishAsync.mock.calls.filter((c) => c[0].includes("0x0017880104e45520")).map((c) => c[0]);
|
const discovered = mockMQTTPublishAsync.mock.calls.filter((c) => c[0].includes("0x0017880104e45520")).map((c) => c[0]);
|
||||||
expect(discovered.length).toBe(5);
|
expect(discovered.length).toBe(6);
|
||||||
|
|
||||||
mockMQTTPublishAsync.mockClear();
|
mockMQTTPublishAsync.mockClear();
|
||||||
|
|
||||||
@@ -2412,6 +2448,7 @@ describe("Extension: HomeAssistant", () => {
|
|||||||
stringify({
|
stringify({
|
||||||
action: "single",
|
action: "single",
|
||||||
battery: null,
|
battery: null,
|
||||||
|
identify: null,
|
||||||
linkquality: null,
|
linkquality: null,
|
||||||
voltage: null,
|
voltage: null,
|
||||||
power_outage_count: null,
|
power_outage_count: null,
|
||||||
@@ -3327,6 +3364,7 @@ describe("Extension: HomeAssistant", () => {
|
|||||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({
|
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({
|
||||||
action: "single",
|
action: "single",
|
||||||
battery: null,
|
battery: null,
|
||||||
|
identify: null,
|
||||||
linkquality: null,
|
linkquality: null,
|
||||||
voltage: null,
|
voltage: null,
|
||||||
power_outage_count: null,
|
power_outage_count: null,
|
||||||
@@ -3337,6 +3375,7 @@ describe("Extension: HomeAssistant", () => {
|
|||||||
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({
|
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({
|
||||||
action: "",
|
action: "",
|
||||||
battery: null,
|
battery: null,
|
||||||
|
identify: null,
|
||||||
linkquality: null,
|
linkquality: null,
|
||||||
voltage: null,
|
voltage: null,
|
||||||
power_outage_count: null,
|
power_outage_count: null,
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ describe("Extension: NetworkMap", () => {
|
|||||||
description: "Hue Go",
|
description: "Hue Go",
|
||||||
model: "7146060PH",
|
model: "7146060PH",
|
||||||
supports:
|
supports:
|
||||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, linkquality",
|
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, identify, linkquality",
|
||||||
vendor: "Philips",
|
vendor: "Philips",
|
||||||
},
|
},
|
||||||
failed: [],
|
failed: [],
|
||||||
@@ -616,7 +616,7 @@ describe("Extension: NetworkMap", () => {
|
|||||||
description: "Hue Go",
|
description: "Hue Go",
|
||||||
model: "7146060PH",
|
model: "7146060PH",
|
||||||
supports:
|
supports:
|
||||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, linkquality",
|
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, identify, linkquality",
|
||||||
vendor: "Philips",
|
vendor: "Philips",
|
||||||
},
|
},
|
||||||
failed: [],
|
failed: [],
|
||||||
@@ -785,7 +785,7 @@ describe("Extension: NetworkMap", () => {
|
|||||||
description: "Hue Go",
|
description: "Hue Go",
|
||||||
model: "7146060PH",
|
model: "7146060PH",
|
||||||
supports:
|
supports:
|
||||||
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, linkquality",
|
"light (state, brightness, color_temp, color_temp_startup, color_xy, color_hs), power_on_behavior, effect, effect_speed, effect_color, identify, linkquality",
|
||||||
vendor: "Philips",
|
vendor: "Philips",
|
||||||
},
|
},
|
||||||
failed: [],
|
failed: [],
|
||||||
|
|||||||
@@ -190,6 +190,30 @@ describe("Extension: Receive", () => {
|
|||||||
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/bridge/health");
|
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/bridge/health");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Should not bypass the debounce when a message produces no payload", async () => {
|
||||||
|
const device = devices.WSDCGQ11LM;
|
||||||
|
settings.set(["devices", device.ieeeAddr, "debounce"], 0.1);
|
||||||
|
settings.set(["advanced", "last_seen"], "ISO_8601");
|
||||||
|
// Attribute report without measuredValue: the lumi_temperature converter returns nothing.
|
||||||
|
const payload = {
|
||||||
|
data: {},
|
||||||
|
cluster: "msTemperatureMeasurement",
|
||||||
|
device,
|
||||||
|
endpoint: device.getEndpoint(1),
|
||||||
|
type: "attributeReport",
|
||||||
|
linkquality: 10,
|
||||||
|
};
|
||||||
|
await mockZHEvents.message(payload);
|
||||||
|
await flushPromises();
|
||||||
|
// The empty payload must not be published immediately (bypassing the debounce).
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
|
||||||
|
});
|
||||||
|
|
||||||
it("Should debounce and retain messages when set via device_options", async () => {
|
it("Should debounce and retain messages when set via device_options", async () => {
|
||||||
const device = devices.WSDCGQ11LM;
|
const device = devices.WSDCGQ11LM;
|
||||||
settings.set(["device_options", "debounce"], 0.1);
|
settings.set(["device_options", "debounce"], 0.1);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const CLUSTERS = {
|
|||||||
lightingColorCtrl: Zcl.Clusters.lightingColorCtrl.ID,
|
lightingColorCtrl: Zcl.Clusters.lightingColorCtrl.ID,
|
||||||
closuresWindowCovering: Zcl.Clusters.closuresWindowCovering.ID,
|
closuresWindowCovering: Zcl.Clusters.closuresWindowCovering.ID,
|
||||||
hvacThermostat: Zcl.Clusters.hvacThermostat.ID,
|
hvacThermostat: Zcl.Clusters.hvacThermostat.ID,
|
||||||
|
hvacFanCtrl: Zcl.Clusters.hvacFanCtrl.ID,
|
||||||
msIlluminanceMeasurement: Zcl.Clusters.msIlluminanceMeasurement.ID,
|
msIlluminanceMeasurement: Zcl.Clusters.msIlluminanceMeasurement.ID,
|
||||||
msTemperatureMeasurement: Zcl.Clusters.msTemperatureMeasurement.ID,
|
msTemperatureMeasurement: Zcl.Clusters.msTemperatureMeasurement.ID,
|
||||||
msRelativeHumidity: Zcl.Clusters.msRelativeHumidity.ID,
|
msRelativeHumidity: Zcl.Clusters.msRelativeHumidity.ID,
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {describe, expect, it} from "vitest";
|
||||||
|
import {objectAssignDeep} from "../lib/util/objectAssignDeep";
|
||||||
|
|
||||||
|
/** Creates an object with a real own `__proto__`/`constructor`/`prototype` property, like a parsed YAML/JSON payload can. */
|
||||||
|
const parse = (json: string): Record<string, unknown> => JSON.parse(json);
|
||||||
|
|
||||||
|
describe("objectAssignDeep", () => {
|
||||||
|
it("Mutates and returns the target", () => {
|
||||||
|
const target = {a: 1};
|
||||||
|
const result = objectAssignDeep(target, {b: 2});
|
||||||
|
|
||||||
|
expect(result).toBe(target);
|
||||||
|
expect(result).toStrictEqual({a: 1, b: 2});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Applies sources in order, later ones win", () => {
|
||||||
|
expect(objectAssignDeep({}, {a: 1, b: 1}, {b: 2, c: 2})).toStrictEqual({a: 1, b: 2, c: 2});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Copies keys missing from the target", () => {
|
||||||
|
expect(objectAssignDeep({}, {nested: {deep: {value: 1}}})).toStrictEqual({nested: {deep: {value: 1}}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Deep merges nested objects present in both", () => {
|
||||||
|
const target = {mqtt: {base_topic: "zigbee2mqtt", server: "old"}, advanced: {channel: 11}};
|
||||||
|
const result = objectAssignDeep(target, {mqtt: {server: "new"}});
|
||||||
|
|
||||||
|
expect(result).toStrictEqual({mqtt: {base_topic: "zigbee2mqtt", server: "new"}, advanced: {channel: 11}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Replaces nested objects of the target instead of mutating them", () => {
|
||||||
|
const nested = {a: 1};
|
||||||
|
const target = {nested};
|
||||||
|
|
||||||
|
objectAssignDeep(target, {nested: {b: 2}});
|
||||||
|
|
||||||
|
expect(nested).toStrictEqual({a: 1});
|
||||||
|
expect(target.nested).not.toBe(nested);
|
||||||
|
expect(target.nested).toStrictEqual({a: 1, b: 2});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Replaces an existing non-object value with a clone of the source object", () => {
|
||||||
|
expect(objectAssignDeep({a: 5}, {a: {b: 1}})).toStrictEqual({a: {b: 1}});
|
||||||
|
expect(objectAssignDeep({a: "str"}, {a: {b: 1}})).toStrictEqual({a: {b: 1}});
|
||||||
|
expect(objectAssignDeep({a: [1, 2]}, {a: {b: 1}})).toStrictEqual({a: {b: 1}});
|
||||||
|
// `null` is not `undefined`, so it takes the "existing value" path but is not merged into
|
||||||
|
expect(objectAssignDeep({a: null}, {a: {b: 1}})).toStrictEqual({a: {b: 1}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Overwrites with null and undefined", () => {
|
||||||
|
expect(objectAssignDeep({a: {b: 1}, c: 1}, {a: null, c: null})).toStrictEqual({a: null, c: null});
|
||||||
|
expect(objectAssignDeep({a: {b: 1}, c: 1}, {a: undefined, c: undefined})).toStrictEqual({a: undefined, c: undefined});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Replaces arrays instead of concatenating them", () => {
|
||||||
|
expect(objectAssignDeep({a: [1, 2, 3]}, {a: [4]})).toStrictEqual({a: [4]});
|
||||||
|
expect(objectAssignDeep({a: [1, 2, 3]}, {a: []})).toStrictEqual({a: []});
|
||||||
|
// no existing array either
|
||||||
|
expect(objectAssignDeep({a: 1}, {a: [4]})).toStrictEqual({a: [4]});
|
||||||
|
expect(objectAssignDeep({}, {a: [4]})).toStrictEqual({a: [4]});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Clones arrays and the objects nested inside them", () => {
|
||||||
|
const source = {a: [{b: 1}, [{c: 2}]]};
|
||||||
|
const result = objectAssignDeep({}, source) as typeof source;
|
||||||
|
|
||||||
|
expect(result).toStrictEqual(source);
|
||||||
|
expect(result.a).not.toBe(source.a);
|
||||||
|
expect(result.a[0]).not.toBe(source.a[0]);
|
||||||
|
expect((result.a[1] as {c: number}[])[0]).not.toBe((source.a[1] as {c: number}[])[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Breaks all references to the sources", () => {
|
||||||
|
const source = {a: {b: {c: 1}}};
|
||||||
|
const result = objectAssignDeep({}, source) as typeof source;
|
||||||
|
|
||||||
|
source.a.b.c = 99;
|
||||||
|
|
||||||
|
expect(result.a.b.c).toStrictEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Does not mutate the sources", () => {
|
||||||
|
const source = {a: {b: 1}};
|
||||||
|
|
||||||
|
objectAssignDeep({a: {c: 2}}, source);
|
||||||
|
|
||||||
|
expect(source).toStrictEqual({a: {b: 1}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Copies functions and primitives by value/reference", () => {
|
||||||
|
const fn = (): number => 1;
|
||||||
|
const symbol = Symbol("s");
|
||||||
|
const result = objectAssignDeep({}, {fn, symbol, big: 1n, nan: Number.NaN});
|
||||||
|
|
||||||
|
expect(result.fn).toBe(fn);
|
||||||
|
expect(result.symbol).toBe(symbol);
|
||||||
|
expect(result.big).toStrictEqual(1n);
|
||||||
|
expect(result.nan).toBeNaN();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Reduces non-plain objects to their own enumerable properties", () => {
|
||||||
|
// documented (inherited) behaviour: only own enumerable properties survive, the prototype is lost
|
||||||
|
expect(objectAssignDeep({}, {date: new Date(0)})).toStrictEqual({date: {}});
|
||||||
|
expect(objectAssignDeep({}, {regexp: /abc/g})).toStrictEqual({regexp: {}});
|
||||||
|
expect(objectAssignDeep({}, {map: new Map([["k", 1]])})).toStrictEqual({map: {}});
|
||||||
|
expect(objectAssignDeep({}, {set: new Set([1])})).toStrictEqual({set: {}});
|
||||||
|
|
||||||
|
class Device {
|
||||||
|
id = 1;
|
||||||
|
get computed(): number {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = objectAssignDeep({}, {device: new Device()});
|
||||||
|
|
||||||
|
expect(result.device).toStrictEqual({id: 1});
|
||||||
|
expect(result.device).not.toBeInstanceOf(Device);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Merges deeply nested objects coming from multiple sources", () => {
|
||||||
|
const result = objectAssignDeep({}, {a: {b: {c: 1}}}, {a: {b: {d: 2}, e: 3}});
|
||||||
|
|
||||||
|
expect(result).toStrictEqual({a: {b: {c: 1, d: 2}, e: 3}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Never copies keys that could tamper with the prototype chain", () => {
|
||||||
|
const result = objectAssignDeep({}, parse('{"__proto__": {"polluted": "yes"}, "constructor": {"x": 1}, "prototype": {"y": 2}, "safe": 1}'));
|
||||||
|
|
||||||
|
expect(result).toStrictEqual({safe: 1});
|
||||||
|
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
|
||||||
|
expect(({} as {polluted?: string}).polluted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Never copies unsafe keys nested inside cloned objects", () => {
|
||||||
|
const result = objectAssignDeep({}, {nested: parse('{"__proto__": {"polluted": "yes"}, "constructor": 1, "prototype": 2, "safe": 1}')});
|
||||||
|
|
||||||
|
expect(result).toStrictEqual({nested: {safe: 1}});
|
||||||
|
expect(Object.getPrototypeOf(result.nested)).toBe(Object.prototype);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Never copies unsafe keys when merging into an existing object", () => {
|
||||||
|
const result = objectAssignDeep({nested: {safe: 1}}, {nested: parse('{"__proto__": {"polluted": "yes"}, "other": 2}')});
|
||||||
|
|
||||||
|
expect(result).toStrictEqual({nested: {safe: 1, other: 2}});
|
||||||
|
expect(Object.getPrototypeOf(result.nested)).toBe(Object.prototype);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Leaves every source untouched when given an empty target", () => {
|
||||||
|
const first = {a: {b: 1}};
|
||||||
|
const second = {a: {c: 2}};
|
||||||
|
const result = objectAssignDeep({}, first, second);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual({a: {b: 1, c: 2}});
|
||||||
|
expect(result).not.toBe(first);
|
||||||
|
expect(result).not.toBe(second);
|
||||||
|
expect(result.a).not.toBe(first.a);
|
||||||
|
expect(first).toStrictEqual({a: {b: 1}});
|
||||||
|
expect(second).toStrictEqual({a: {c: 2}});
|
||||||
|
});
|
||||||
|
});
|
||||||
+5
-19
@@ -31,16 +31,10 @@ const mockHttpClose = vi.fn<Server["close"]>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const mockFindAllDevices = vi.fn<typeof findAllDevices>(async () => []);
|
const mockFindAllDevices = vi.fn<typeof findAllDevices>(async () => []);
|
||||||
const mockStaticFileServer = vi.fn((_req, res, next) => {
|
const mockStaticFileServer = vi.fn((_req, res) => {
|
||||||
if (typeof next === "function") {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
res.end();
|
res.end();
|
||||||
});
|
});
|
||||||
const mockExpressStaticGzip = vi.fn((_path: unknown, _options: unknown) => mockStaticFileServer);
|
const mockCreateStaticFileServer = vi.fn((_dir: unknown, _logError: unknown) => mockStaticFileServer);
|
||||||
const mockFinalHandlerNext = vi.fn();
|
|
||||||
const mockFinalhandler = vi.fn((_req: unknown, _res: unknown) => mockFinalHandlerNext);
|
|
||||||
|
|
||||||
vi.mock("node:fs", {spy: true});
|
vi.mock("node:fs", {spy: true});
|
||||||
vi.mock("node:http", () => ({
|
vi.mock("node:http", () => ({
|
||||||
@@ -62,11 +56,8 @@ vi.mock("node:http", () => ({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("express-static-gzip", () => ({
|
vi.mock("../lib/util/staticFileServer", () => ({
|
||||||
default: vi.fn((path, options) => mockExpressStaticGzip(path, options)),
|
createStaticFileServer: vi.fn((dir, logError) => mockCreateStaticFileServer(dir, logError)),
|
||||||
}));
|
|
||||||
vi.mock("finalhandler", () => ({
|
|
||||||
default: vi.fn((req, res) => mockFinalhandler(req, res)),
|
|
||||||
}));
|
}));
|
||||||
vi.mock("zigbee-herdsman/dist/adapter/adapterDiscovery", () => ({
|
vi.mock("zigbee-herdsman/dist/adapter/adapterDiscovery", () => ({
|
||||||
findAllDevices: vi.fn(() => mockFindAllDevices()),
|
findAllDevices: vi.fn(() => mockFindAllDevices()),
|
||||||
@@ -194,10 +185,7 @@ describe("Onboarding", () => {
|
|||||||
mockFindAllDevices.mockClear();
|
mockFindAllDevices.mockClear();
|
||||||
mockHttpErrorListener = undefined;
|
mockHttpErrorListener = undefined;
|
||||||
mockStaticFileServer.mockClear();
|
mockStaticFileServer.mockClear();
|
||||||
mockExpressStaticGzip.mockClear();
|
mockCreateStaticFileServer.mockClear();
|
||||||
mockFinalHandlerNext.mockClear();
|
|
||||||
mockFinalhandler.mockClear();
|
|
||||||
mockStaticFileServer.mockClear();
|
|
||||||
settings.reRead();
|
settings.reRead();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -735,7 +723,6 @@ describe("Onboarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(p).resolves.toStrictEqual(true);
|
await expect(p).resolves.toStrictEqual(true);
|
||||||
expect(mockFinalhandler).toHaveBeenCalled();
|
|
||||||
expect(mockStaticFileServer).toHaveBeenCalled();
|
expect(mockStaticFileServer).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -757,7 +744,6 @@ describe("Onboarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(p).resolves.toStrictEqual(false);
|
await expect(p).resolves.toStrictEqual(false);
|
||||||
expect(mockFinalhandler).toHaveBeenCalled();
|
|
||||||
expect(mockStaticFileServer).toHaveBeenCalled();
|
expect(mockStaticFileServer).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+10
-11
@@ -4,11 +4,15 @@ import "./mocks/data";
|
|||||||
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import {dump, load} from "js-yaml";
|
import {dump, load} from "js-yaml";
|
||||||
import objectAssignDeep from "object-assign-deep";
|
|
||||||
|
|
||||||
import mockedData from "../lib/util/data";
|
import mockedData from "../lib/util/data";
|
||||||
|
import {objectAssignDeep} from "../lib/util/objectAssignDeep";
|
||||||
import * as settings from "../lib/util/settings";
|
import * as settings from "../lib/util/settings";
|
||||||
|
|
||||||
|
// mirrors the global `KeyValue`, which is not visible from the test project, previously implied by the untyped `object-assign-deep`
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: freely mutated to build the expected settings
|
||||||
|
type ExpectedSettings = Record<string, any>;
|
||||||
|
|
||||||
const configurationFile = mockedData.joinPath("configuration.yaml");
|
const configurationFile = mockedData.joinPath("configuration.yaml");
|
||||||
const devicesFile = mockedData.joinPath("devices.yaml");
|
const devicesFile = mockedData.joinPath("devices.yaml");
|
||||||
const devicesFile2 = mockedData.joinPath("devices2.yaml");
|
const devicesFile2 = mockedData.joinPath("devices2.yaml");
|
||||||
@@ -86,8 +90,7 @@ describe("Settings", () => {
|
|||||||
it("Should return default settings", () => {
|
it("Should return default settings", () => {
|
||||||
write(configurationFile, {});
|
write(configurationFile, {});
|
||||||
const s = settings.get();
|
const s = settings.get();
|
||||||
// @ts-expect-error workaround
|
const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults);
|
||||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
|
||||||
expected.devices = {};
|
expected.devices = {};
|
||||||
expected.groups = {};
|
expected.groups = {};
|
||||||
expect(s).toStrictEqual(expected);
|
expect(s).toStrictEqual(expected);
|
||||||
@@ -96,8 +99,7 @@ describe("Settings", () => {
|
|||||||
it("Should return settings", () => {
|
it("Should return settings", () => {
|
||||||
write(configurationFile, {serial: {disable_led: true}});
|
write(configurationFile, {serial: {disable_led: true}});
|
||||||
const s = settings.get();
|
const s = settings.get();
|
||||||
// @ts-expect-error workaround
|
const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults);
|
||||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
|
||||||
expected.devices = {};
|
expected.devices = {};
|
||||||
expected.groups = {};
|
expected.groups = {};
|
||||||
expected.serial = {disable_led: true};
|
expected.serial = {disable_led: true};
|
||||||
@@ -124,8 +126,7 @@ describe("Settings", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// @ts-expect-error workaround
|
const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults);
|
||||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
|
||||||
expected.devices = {
|
expected.devices = {
|
||||||
"0x00158d00018255df": {
|
"0x00158d00018255df": {
|
||||||
friendly_name: "0x00158d00018255df",
|
friendly_name: "0x00158d00018255df",
|
||||||
@@ -178,8 +179,7 @@ describe("Settings", () => {
|
|||||||
|
|
||||||
write(configurationFile, {});
|
write(configurationFile, {});
|
||||||
|
|
||||||
// @ts-expect-error workaround
|
const expected: ExpectedSettings = objectAssignDeep({}, settings.testing.defaults);
|
||||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
|
||||||
expected.frontend.enabled = true;
|
expected.frontend.enabled = true;
|
||||||
expected.frontend.port = 8099;
|
expected.frontend.port = 8099;
|
||||||
expected.homeassistant.enabled = true;
|
expected.homeassistant.enabled = true;
|
||||||
@@ -212,8 +212,7 @@ describe("Settings", () => {
|
|||||||
expect(settings.validate()).toStrictEqual([]);
|
expect(settings.validate()).toStrictEqual([]);
|
||||||
|
|
||||||
const s = settings.get();
|
const s = settings.get();
|
||||||
// @ts-expect-error workaround
|
const expected: ExpectedSettings = objectAssignDeep({}, {groups: {}, devices: {}}, settings.testing.defaults);
|
||||||
const expected = objectAssignDeep.noMutate({groups: {}, devices: {}}, settings.testing.defaults);
|
|
||||||
expected.mqtt.password = "password-in-env-var";
|
expected.mqtt.password = "password-in-env-var";
|
||||||
expected.mqtt.server = "server";
|
expected.mqtt.server = "server";
|
||||||
expect(s).toStrictEqual(expected);
|
expect(s).toStrictEqual(expected);
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} fr
|
|||||||
import * as data from "./mocks/data";
|
import * as data from "./mocks/data";
|
||||||
|
|
||||||
import {existsSync, readFileSync, rmSync, writeFileSync} from "node:fs";
|
import {existsSync, readFileSync, rmSync, writeFileSync} from "node:fs";
|
||||||
import objectAssignDeep from "object-assign-deep";
|
|
||||||
import mockedData from "../lib/util/data";
|
import mockedData from "../lib/util/data";
|
||||||
|
import {objectAssignDeep} from "../lib/util/objectAssignDeep";
|
||||||
import * as settings from "../lib/util/settings";
|
import * as settings from "../lib/util/settings";
|
||||||
import * as settingsMigration from "../lib/util/settingsMigration";
|
import * as settingsMigration from "../lib/util/settingsMigration";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -278,8 +278,7 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("no change needed - only add version", () => {
|
it("no change needed - only add version", () => {
|
||||||
// @ts-expect-error workaround
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 2;
|
afterSettings.version = 2;
|
||||||
|
|
||||||
settingsMigration.migrateIfNecessary();
|
settingsMigration.migrateIfNecessary();
|
||||||
@@ -290,10 +289,8 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("remove all", () => {
|
it("remove all", () => {
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 2;
|
afterSettings.version = 2;
|
||||||
|
|
||||||
settings.set(["homeassistant", "legacy_triggers"], true);
|
settings.set(["homeassistant", "legacy_triggers"], true);
|
||||||
@@ -320,8 +317,7 @@ describe("Settings Migration", () => {
|
|||||||
settings.set(["external_converters"], ["zyx.js"]);
|
settings.set(["external_converters"], ["zyx.js"]);
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||||
// @ts-expect-error workaround
|
objectAssignDeep({}, beforeSettings, {
|
||||||
objectAssignDeep.noMutate(beforeSettings, {
|
|
||||||
permit_join: true,
|
permit_join: true,
|
||||||
homeassistant: {
|
homeassistant: {
|
||||||
legacy_triggers: true,
|
legacy_triggers: true,
|
||||||
@@ -395,10 +391,8 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("remove partial", () => {
|
it("remove partial", () => {
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 2;
|
afterSettings.version = 2;
|
||||||
|
|
||||||
settings.set(["advanced", "homeassistant_legacy_triggers"], true);
|
settings.set(["advanced", "homeassistant_legacy_triggers"], true);
|
||||||
@@ -417,8 +411,7 @@ describe("Settings Migration", () => {
|
|||||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||||
// @ts-expect-error workaround
|
objectAssignDeep({}, beforeSettings, {
|
||||||
objectAssignDeep.noMutate(beforeSettings, {
|
|
||||||
permit_join: true,
|
permit_join: true,
|
||||||
advanced: {
|
advanced: {
|
||||||
homeassistant_legacy_triggers: true,
|
homeassistant_legacy_triggers: true,
|
||||||
@@ -472,10 +465,8 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("changes log_level", () => {
|
it("changes log_level", () => {
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 2;
|
afterSettings.version = 2;
|
||||||
afterSettings.advanced = {log_level: "warning"};
|
afterSettings.advanced = {log_level: "warning"};
|
||||||
|
|
||||||
@@ -484,8 +475,7 @@ describe("Settings Migration", () => {
|
|||||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||||
// @ts-expect-error workaround
|
objectAssignDeep({}, beforeSettings, {
|
||||||
objectAssignDeep.noMutate(beforeSettings, {
|
|
||||||
advanced: {
|
advanced: {
|
||||||
log_level: "warn",
|
log_level: "warn",
|
||||||
},
|
},
|
||||||
@@ -505,10 +495,8 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not changes already migrated log_level", () => {
|
it("does not changes already migrated log_level", () => {
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 2;
|
afterSettings.version = 2;
|
||||||
afterSettings.advanced = {log_level: "warning"};
|
afterSettings.advanced = {log_level: "warning"};
|
||||||
|
|
||||||
@@ -517,8 +505,7 @@ describe("Settings Migration", () => {
|
|||||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||||
// @ts-expect-error workaround
|
objectAssignDeep({}, beforeSettings, {
|
||||||
objectAssignDeep.noMutate(beforeSettings, {
|
|
||||||
advanced: {
|
advanced: {
|
||||||
log_level: "warning",
|
log_level: "warning",
|
||||||
},
|
},
|
||||||
@@ -538,10 +525,8 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not changes other log_level", () => {
|
it("does not changes other log_level", () => {
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 2;
|
afterSettings.version = 2;
|
||||||
afterSettings.advanced = {log_level: "info"};
|
afterSettings.advanced = {log_level: "info"};
|
||||||
|
|
||||||
@@ -550,8 +535,7 @@ describe("Settings Migration", () => {
|
|||||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||||
// @ts-expect-error workaround
|
objectAssignDeep({}, beforeSettings, {
|
||||||
objectAssignDeep.noMutate(beforeSettings, {
|
|
||||||
advanced: {
|
advanced: {
|
||||||
log_level: "info",
|
log_level: "info",
|
||||||
},
|
},
|
||||||
@@ -571,10 +555,8 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("transfer all", () => {
|
it("transfer all", () => {
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 2;
|
afterSettings.version = 2;
|
||||||
afterSettings.advanced = {
|
afterSettings.advanced = {
|
||||||
transmit_power: 12,
|
transmit_power: 12,
|
||||||
@@ -604,8 +586,7 @@ describe("Settings Migration", () => {
|
|||||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||||
// @ts-expect-error workaround
|
objectAssignDeep({}, beforeSettings, {
|
||||||
objectAssignDeep.noMutate(beforeSettings, {
|
|
||||||
advanced: {
|
advanced: {
|
||||||
homeassistant_discovery_topic: "ha_disc",
|
homeassistant_discovery_topic: "ha_disc",
|
||||||
homeassistant_status_topic: "ha_stat",
|
homeassistant_status_topic: "ha_stat",
|
||||||
@@ -649,10 +630,8 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("transfer partial", () => {
|
it("transfer partial", () => {
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 2;
|
afterSettings.version = 2;
|
||||||
afterSettings.advanced = {}; // caused by pushing to key and removing all
|
afterSettings.advanced = {}; // caused by pushing to key and removing all
|
||||||
afterSettings.serial.baudrate = 115200;
|
afterSettings.serial.baudrate = 115200;
|
||||||
@@ -673,8 +652,7 @@ describe("Settings Migration", () => {
|
|||||||
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
// console.log(JSON.stringify(settings.getWrittenSettings(), undefined, 2));
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||||
// @ts-expect-error workaround
|
objectAssignDeep({}, beforeSettings, {
|
||||||
objectAssignDeep.noMutate(beforeSettings, {
|
|
||||||
homeassistant: {discovery_topic: "ha_disc_newer"},
|
homeassistant: {discovery_topic: "ha_disc_newer"},
|
||||||
advanced: {
|
advanced: {
|
||||||
homeassistant_discovery_topic: "ha_disc",
|
homeassistant_discovery_topic: "ha_disc",
|
||||||
@@ -718,10 +696,8 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("Update", () => {
|
it("Update", () => {
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 3;
|
afterSettings.version = 3;
|
||||||
afterSettings.homeassistant = {enabled: false};
|
afterSettings.homeassistant = {enabled: false};
|
||||||
afterSettings.frontend = {enabled: true};
|
afterSettings.frontend = {enabled: true};
|
||||||
@@ -739,8 +715,7 @@ describe("Settings Migration", () => {
|
|||||||
settings.set(["experimental", "transmit_power"], 12);
|
settings.set(["experimental", "transmit_power"], 12);
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||||
// @ts-expect-error workaround
|
objectAssignDeep({}, beforeSettings, {
|
||||||
objectAssignDeep.noMutate(beforeSettings, {
|
|
||||||
homeassistant: false,
|
homeassistant: false,
|
||||||
frontend: true,
|
frontend: true,
|
||||||
availability: {active: {timeout: 15}},
|
availability: {active: {timeout: 15}},
|
||||||
@@ -773,10 +748,8 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("Update", () => {
|
it("Update", () => {
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 3;
|
afterSettings.version = 3;
|
||||||
afterSettings.homeassistant = {enabled: false};
|
afterSettings.homeassistant = {enabled: false};
|
||||||
afterSettings.frontend = {enabled: true};
|
afterSettings.frontend = {enabled: true};
|
||||||
@@ -787,8 +760,7 @@ describe("Settings Migration", () => {
|
|||||||
settings.set(["availability"], {active: {timeout: 15}});
|
settings.set(["availability"], {active: {timeout: 15}});
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||||
// @ts-expect-error workaround
|
objectAssignDeep({}, beforeSettings, {
|
||||||
objectAssignDeep.noMutate(beforeSettings, {
|
|
||||||
homeassistant: false,
|
homeassistant: false,
|
||||||
frontend: true,
|
frontend: true,
|
||||||
availability: {active: {timeout: 15}},
|
availability: {active: {timeout: 15}},
|
||||||
@@ -809,18 +781,15 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("Update when not set, tests that frontend/availability is not added when not set", () => {
|
it("Update when not set, tests that frontend/availability is not added when not set", () => {
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 3;
|
afterSettings.version = 3;
|
||||||
afterSettings.homeassistant = {enabled: false};
|
afterSettings.homeassistant = {enabled: false};
|
||||||
|
|
||||||
settings.set(["homeassistant"], false);
|
settings.set(["homeassistant"], false);
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||||
// @ts-expect-error workaround
|
objectAssignDeep({}, beforeSettings, {
|
||||||
objectAssignDeep.noMutate(beforeSettings, {
|
|
||||||
homeassistant: false,
|
homeassistant: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -854,10 +823,8 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("Update", () => {
|
it("Update", () => {
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 4;
|
afterSettings.version = 4;
|
||||||
afterSettings.devices = {
|
afterSettings.devices = {
|
||||||
"0x123127fffe8d96bc": {
|
"0x123127fffe8d96bc": {
|
||||||
@@ -888,8 +855,7 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(
|
expect(settings.getPersistedSettings()).toStrictEqual(
|
||||||
// @ts-expect-error workaround
|
objectAssignDeep({}, beforeSettings, {
|
||||||
objectAssignDeep.noMutate(beforeSettings, {
|
|
||||||
devices: {
|
devices: {
|
||||||
"0x123127fffe8d96bc": {
|
"0x123127fffe8d96bc": {
|
||||||
friendly_name: "0x847127fffe8d96bc",
|
friendly_name: "0x847127fffe8d96bc",
|
||||||
@@ -950,10 +916,8 @@ describe("Settings Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("Update", () => {
|
it("Update", () => {
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 5;
|
afterSettings.version = 5;
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
|
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
|
||||||
@@ -964,8 +928,7 @@ describe("Settings Migration", () => {
|
|||||||
const migratedSettings = settings.getPersistedSettings();
|
const migratedSettings = settings.getPersistedSettings();
|
||||||
expect(migratedSettings).toStrictEqual(afterSettings);
|
expect(migratedSettings).toStrictEqual(afterSettings);
|
||||||
|
|
||||||
// @ts-expect-error workaround
|
const migratedState = objectAssignDeep({}, DEFAULT_STATE);
|
||||||
const migratedState = objectAssignDeep.noMutate({}, DEFAULT_STATE);
|
|
||||||
delete (migratedState["0x0017880104e45517"] as Record<string, unknown>).update;
|
delete (migratedState["0x0017880104e45517"] as Record<string, unknown>).update;
|
||||||
delete (migratedState[1] as Record<string, unknown>).update;
|
delete (migratedState[1] as Record<string, unknown>).update;
|
||||||
|
|
||||||
@@ -976,10 +939,8 @@ describe("Settings Migration", () => {
|
|||||||
const consoleErrorSpy = vi.spyOn(console, "error");
|
const consoleErrorSpy = vi.spyOn(console, "error");
|
||||||
writeFileSync(path.join(data.mockDir, "state.json"), "notjson", "utf8");
|
writeFileSync(path.join(data.mockDir, "state.json"), "notjson", "utf8");
|
||||||
|
|
||||||
// @ts-expect-error workaround
|
const beforeSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
const beforeSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
const afterSettings = objectAssignDeep({}, settings.getPersistedSettings());
|
||||||
// @ts-expect-error workaround
|
|
||||||
const afterSettings = objectAssignDeep.noMutate({}, settings.getPersistedSettings());
|
|
||||||
afterSettings.version = 5;
|
afterSettings.version = 5;
|
||||||
|
|
||||||
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
|
expect(settings.getPersistedSettings()).toStrictEqual(beforeSettings);
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import {mkdirSync, writeFileSync} from "node:fs";
|
||||||
|
import {createServer, type Server} from "node:http";
|
||||||
|
import {type AddressInfo, connect} from "node:net";
|
||||||
|
import {join} from "node:path";
|
||||||
|
import {brotliCompressSync, gzipSync} from "node:zlib";
|
||||||
|
import tmp from "tmp";
|
||||||
|
import {afterAll, beforeAll, describe, expect, it, vi} from "vitest";
|
||||||
|
import {createStaticFileServer, type sendNotFound} from "../lib/util/staticFileServer";
|
||||||
|
|
||||||
|
const INDEX_HTML = "<!DOCTYPE html><html lang='en'><body>index</body></html>";
|
||||||
|
const APP_JS = `console.log("${"x".repeat(2048)}");`;
|
||||||
|
/** Written next to the served directory, never inside it, so a traversal that succeeds is actually observable. */
|
||||||
|
const SECRET = "topsecret-must-never-be-served";
|
||||||
|
|
||||||
|
const mockLogError = vi.fn<(message: string) => void>();
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let server: Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
/** Starts a `node:http` server serving `dir`, mirroring how the frontend/onboarding extensions wire it up. */
|
||||||
|
function listen(handler: (request: Parameters<typeof sendNotFound>[0], response: Parameters<typeof sendNotFound>[1]) => void): Promise<void> {
|
||||||
|
server = createServer(handler);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a request line verbatim, bypassing the path normalization `fetch` applies before sending. */
|
||||||
|
function rawRequest(target: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = connect((server.address() as AddressInfo).port, "127.0.0.1", () => {
|
||||||
|
socket.write(`GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`);
|
||||||
|
});
|
||||||
|
let received = "";
|
||||||
|
|
||||||
|
socket.setEncoding("utf8");
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
received += chunk;
|
||||||
|
});
|
||||||
|
socket.on("end", () => resolve(received));
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("StaticFileServer", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const root = tmp.dirSync().name;
|
||||||
|
|
||||||
|
dir = join(root, "public");
|
||||||
|
|
||||||
|
// outside the served directory: only a working traversal could reach it
|
||||||
|
writeFileSync(join(root, "secret.txt"), SECRET);
|
||||||
|
mkdirSync(join(dir, "sub"), {recursive: true});
|
||||||
|
writeFileSync(join(dir, "index.html"), INDEX_HTML);
|
||||||
|
writeFileSync(join(dir, "app.js"), APP_JS);
|
||||||
|
// precompressed variants, as shipped by the frontend packages
|
||||||
|
writeFileSync(join(dir, "app.js.gz"), gzipSync(APP_JS));
|
||||||
|
writeFileSync(join(dir, "app.js.br"), brotliCompressSync(APP_JS));
|
||||||
|
writeFileSync(join(dir, "sub", "icon.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||||
|
|
||||||
|
await listen(createStaticFileServer(dir, mockLogError));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves a file with its content type", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/sub/icon.png`);
|
||||||
|
|
||||||
|
expect(response.status).toStrictEqual(200);
|
||||||
|
expect(response.headers.get("content-type")).toStrictEqual("image/png");
|
||||||
|
expect(response.headers.get("content-encoding")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves index.html for the root, never cached", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/`);
|
||||||
|
|
||||||
|
expect(response.status).toStrictEqual(200);
|
||||||
|
expect(response.headers.get("content-type")).toStrictEqual("text/html; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toStrictEqual("no-store");
|
||||||
|
await expect(response.text()).resolves.toStrictEqual(INDEX_HTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the precompressed brotli variant", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "br"}});
|
||||||
|
|
||||||
|
expect(response.status).toStrictEqual(200);
|
||||||
|
expect(response.headers.get("content-encoding")).toStrictEqual("br");
|
||||||
|
expect(response.headers.get("content-type")).toStrictEqual("text/javascript; charset=utf-8");
|
||||||
|
expect(response.headers.get("vary")).toStrictEqual("Accept-Encoding");
|
||||||
|
// decoded by fetch, so the served bytes must be the brotli variant of the original file
|
||||||
|
await expect(response.text()).resolves.toStrictEqual(APP_JS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the precompressed gzip variant", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "gzip"}});
|
||||||
|
|
||||||
|
expect(response.status).toStrictEqual(200);
|
||||||
|
expect(response.headers.get("content-encoding")).toStrictEqual("gzip");
|
||||||
|
await expect(response.text()).resolves.toStrictEqual(APP_JS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the identity file when no encoding is accepted", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "identity"}});
|
||||||
|
|
||||||
|
expect(response.status).toStrictEqual(200);
|
||||||
|
expect(response.headers.get("content-encoding")).toBeNull();
|
||||||
|
expect(response.headers.get("content-length")).toStrictEqual(String(Buffer.byteLength(APP_JS)));
|
||||||
|
await expect(response.text()).resolves.toStrictEqual(APP_JS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revalidates with an etag", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "identity"}});
|
||||||
|
const etag = response.headers.get("etag");
|
||||||
|
|
||||||
|
expect(etag).toBeTruthy();
|
||||||
|
|
||||||
|
const revalidated = await fetch(`${baseUrl}/app.js`, {headers: {"Accept-Encoding": "identity", "If-None-Match": etag as string}});
|
||||||
|
|
||||||
|
expect(revalidated.status).toStrictEqual(304);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for an unknown file", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/nope.js`);
|
||||||
|
|
||||||
|
expect(response.status).toStrictEqual(404);
|
||||||
|
expect(response.headers.get("content-type")).toStrictEqual("text/html; charset=utf-8");
|
||||||
|
expect(response.headers.get("content-security-policy")).toStrictEqual("default-src 'none'");
|
||||||
|
expect(response.headers.get("x-content-type-options")).toStrictEqual("nosniff");
|
||||||
|
await expect(response.text()).resolves.toContain("Cannot GET /nope.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes the url in the 404 body", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/%3Cscript%3E`);
|
||||||
|
|
||||||
|
expect(response.status).toStrictEqual(404);
|
||||||
|
await expect(response.text()).resolves.not.toContain("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not serve files outside of the served directory", async () => {
|
||||||
|
// `fetch` resolves `..` and `%2e%2e` segments away before they ever reach the server, so these have to go out raw
|
||||||
|
for (const target of ["/../secret.txt", "/sub/../../secret.txt", "/%2e%2e/secret.txt", "/..%2fsecret.txt"]) {
|
||||||
|
const response = await rawRequest(target);
|
||||||
|
|
||||||
|
expect(response).toContain("404 Not Found");
|
||||||
|
expect(response).not.toContain(SECRET);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a failure to serve with a 500", async () => {
|
||||||
|
const failing = createStaticFileServer(dir, mockLogError);
|
||||||
|
const failingServer = createServer((request, response) => {
|
||||||
|
const setHeader = response.setHeader.bind(response);
|
||||||
|
|
||||||
|
response.setHeader = (name: string, value: number | string | readonly string[]): never => {
|
||||||
|
if (name === "Content-Security-Policy") {
|
||||||
|
throw new Error("socket gone");
|
||||||
|
}
|
||||||
|
|
||||||
|
setHeader(name, value);
|
||||||
|
|
||||||
|
return undefined as never;
|
||||||
|
};
|
||||||
|
|
||||||
|
failing(request, response);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => failingServer.listen(0, "127.0.0.1", resolve));
|
||||||
|
|
||||||
|
const port = (failingServer.address() as AddressInfo).port;
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/nope.js`);
|
||||||
|
|
||||||
|
expect(response.status).toStrictEqual(500);
|
||||||
|
expect(mockLogError).toHaveBeenCalledWith("Failed to serve '/nope.js': socket gone");
|
||||||
|
|
||||||
|
await new Promise((resolve) => failingServer.close(resolve));
|
||||||
|
});
|
||||||
|
});
|
||||||
+13
-3
@@ -164,14 +164,19 @@ describe("Utils", () => {
|
|||||||
b: 2,
|
b: 2,
|
||||||
3: "c",
|
3: "c",
|
||||||
d: Buffer.from([1, 2]),
|
d: Buffer.from([1, 2]),
|
||||||
e: new Int16Array([0xfffd, 0xff11]),
|
e: new Int16Array([0xfffd, 0xff11, 0x0001, 0x7fff]),
|
||||||
beef: 0xfacen,
|
beef: 0xfacen,
|
||||||
zed: new BigUint64Array([1n, 0xffffffffn]),
|
zed: new BigUint64Array([1n, 0xffffffffn, 42n]),
|
||||||
ris: [1, undefined, "b", 0xfeefn, Number.NaN, undefined],
|
ris: [1, undefined, "b", 0xfeefn, Number.NaN, undefined],
|
||||||
ls: undefined,
|
ls: undefined,
|
||||||
|
// one and two elements, on both the number and the bigint branch
|
||||||
|
one: new Uint8Array([7]),
|
||||||
|
two: new Int16Array([0x0001, 0x7fff]),
|
||||||
|
oneBig: new BigInt64Array([-9n]),
|
||||||
|
twoBig: new BigUint64Array([1n, 42n]),
|
||||||
}),
|
}),
|
||||||
).toStrictEqual(
|
).toStrictEqual(
|
||||||
`{"3":"c","a":"a","b":2,"beef":"64206","d":{"data":[1,2],"type":"Buffer"},"e":{"0":-3,"1":-239},"ris":[1,null,"b","65263",null,null],"zed":{"0":"1","1":"4294967295"}}`,
|
`{"3":"c","a":"a","b":2,"beef":"64206","d":{"data":[1,2],"type":"Buffer"},"e":{"0":-3,"1":-239,"2":1,"3":32767},"one":{"0":7},"oneBig":{"0":"-9"},"ris":[1,null,"b","65263",null,null],"two":{"0":1,"1":32767},"twoBig":{"0":"1","1":"42"},"zed":{"0":"1","1":"4294967295","2":"42"}}`,
|
||||||
);
|
);
|
||||||
// @ts-expect-error intentional to reach code for coverage
|
// @ts-expect-error intentional to reach code for coverage
|
||||||
expect(stringify(undefined)).toStrictEqual("null");
|
expect(stringify(undefined)).toStrictEqual("null");
|
||||||
@@ -188,5 +193,10 @@ describe("Utils", () => {
|
|||||||
const toJSONIsNull = {a: 1, toJSON: () => null};
|
const toJSONIsNull = {a: 1, toJSON: () => null};
|
||||||
|
|
||||||
expect(stringify(toJSONIsNull)).toStrictEqual("null");
|
expect(stringify(toJSONIsNull)).toStrictEqual("null");
|
||||||
|
|
||||||
|
const emptyTypedArrayWithProperty = Object.assign(new Uint8Array(0), {unit: "raw"});
|
||||||
|
|
||||||
|
expect(stringify({data: emptyTypedArrayWithProperty})).toStrictEqual(JSON.stringify({data: {unit: "raw"}}));
|
||||||
|
expect(stringify({data: new Uint8Array(0)})).toStrictEqual(JSON.stringify({data: {}}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user