From 83ab5228ff24779773e67bfc217d89f7c7520daa Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:40:04 +0200 Subject: [PATCH] feat: Add `clear_cache` option to device remove request (#32631) Co-authored-by: Koen Kanters --- biome.json | 2 +- lib/extension/bridge.ts | 34 ++++++++++++++++++----------- lib/types/api.ts | 2 ++ lib/zigbee.ts | 8 +++++-- package.json | 2 +- pnpm-lock.yaml | 14 ++++++------ test/extensions/bridge.test.ts | 40 +++++++++++++++++++++++++++++----- 7 files changed, 73 insertions(+), 29 deletions(-) diff --git a/biome.json b/biome.json index e2469947..10270541 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 1fb51377..4819c8f3 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -664,20 +664,25 @@ export default class Bridge extends Extension { entityType: T, message: string | KeyValue, ): Promise> { - const ID = typeof message === "object" ? message.id : message.trim(); + const messageIsObject = typeof message === "object"; + const ID = messageIsObject ? message.id : message.trim(); const entity = this.getEntity(entityType, ID); // note: entity.name is dynamically retrieved, will change once device is removed (friendly => ieee) const friendlyName = entity.name; let block = false; let force = false; + let clearCache = false; let blockForceLog = ""; - if (entityType === "device" && typeof message === "object") { - block = !!message.block; - force = !!message.force; - blockForceLog = ` (block: ${block}, force: ${force})`; - } else if (entityType === "group" && typeof message === "object") { - force = !!message.force; + if (entityType === "device" && messageIsObject) { + const payload = message as Zigbee2MQTTAPI["bridge/request/device/remove"]; + block = !!payload.block; + force = !!payload.force; + clearCache = !!payload.clear_cache; + blockForceLog = ` (block: ${block}, force: ${force}, clear cache: ${clearCache})`; + } else if (entityType === "group" && messageIsObject) { + const payload = message as Zigbee2MQTTAPI["bridge/request/group/remove"]; + force = !!payload.force; blockForceLog = ` (force: ${force})`; } @@ -690,9 +695,13 @@ export default class Bridge extends Extension { } if (force) { - entity.zh.removeFromDatabase(); + entity.zh.removeFromDatabase(clearCache); } else { - await entity.zh.removeFromNetwork(); + await entity.zh.removeFromNetwork(clearCache); + } + + if (clearCache) { + this.zigbee.removeDeviceFromLookup(entity.ID); } settings.removeDevice(entity.ID as string); @@ -717,19 +726,18 @@ export default class Bridge extends Extension { logger.info(`Successfully removed ${entityType} '${friendlyName}'${blockForceLog}`); + await this.publishGroups(); + if (entity instanceof Device) { - await this.publishGroups(); await this.publishDevices(); // Refresh Cluster definition await this.publishDefinitions(); - const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {id: ID, block, force}; + const responseData: Zigbee2MQTTAPI["bridge/response/device/remove"] = {id: ID, block, force, clear_cache: clearCache}; return utils.getResponse(message, responseData); } - await this.publishGroups(); - const responseData: Zigbee2MQTTAPI["bridge/response/group/remove"] = {id: ID, force}; return utils.getResponse( diff --git a/lib/types/api.ts b/lib/types/api.ts index eac20e8b..0c5e905a 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -622,12 +622,14 @@ export interface Zigbee2MQTTAPI { id: string; block?: boolean; force?: boolean; + clear_cache?: boolean; }; "bridge/response/device/remove": { id: string; block: boolean; force: boolean; + clear_cache: boolean; }; "bridge/request/device/ota_update/check": { diff --git a/lib/zigbee.ts b/lib/zigbee.ts index 78e343ce..aaeabd85 100644 --- a/lib/zigbee.ts +++ b/lib/zigbee.ts @@ -467,7 +467,11 @@ export default class Zigbee { return this.resolveGroup(id); } - removeGroupFromLookup(id: number): void { - this.groupLookup.delete(id); + removeDeviceFromLookup(ieee: string): boolean { + return this.deviceLookup.delete(ieee); + } + + removeGroupFromLookup(id: number): boolean { + return this.groupLookup.delete(id); } } diff --git a/package.json b/package.json index de93cdd2..14da171e 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "winston-syslog": "^2.7.1", "winston-transport": "^4.9.0", "ws": "^8.21.1", - "zigbee-herdsman": "10.6.3", + "zigbee-herdsman": "10.7.0", "zigbee-herdsman-converters": "26.85.0", "zigbee2mqtt-frontend": "0.9.21", "zigbee2mqtt-windfront": "2.13.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9781f40a..2f380233 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - zigbee-herdsman: 10.6.3 + zigbee-herdsman: 10.7.0 importers: @@ -63,8 +63,8 @@ importers: specifier: ^8.21.1 version: 8.21.1 zigbee-herdsman: - specifier: 10.6.3 - version: 10.6.3 + specifier: 10.7.0 + version: 10.7.0 zigbee-herdsman-converters: specifier: 26.85.0 version: 26.85.0 @@ -1521,8 +1521,8 @@ packages: resolution: {integrity: sha512-pqPq5ftbRSS/sxS6sM0Ax3zl/Z/NbDYspQriUSaQemIs4PEfa6Pqwz4F0tCi/kCg2XQNjV5fdfuqrZiiTJuCRQ==} engines: {node: '>=20.15.0'} - zigbee-herdsman@10.6.3: - resolution: {integrity: sha512-LSscRq7Q71J8naavNimH6twhcOMNn4lI+mar5AURvH4nnlDBtrrNy36t05TiMs6ZA4PkiYnpGeaSEU6ZMlus/w==} + zigbee-herdsman@10.7.0: + resolution: {integrity: sha512-eKp9Y2/+RJ+SxVttud1gkSvm5u5Vl5Uk6uWjmcZRVEdaMpcoJduNHQHSapXFtTqmYVgpsnydIhva7k5xAePZFg==} zigbee-on-host@0.2.4: resolution: {integrity: sha512-NIG6CWp+Yfn7PjqEIRvenHqpwT1U7rSkyimnFOUIFpnmxQOJKrmcwWDfn6WjbU/YIYYWSQPlj+g13icu1xwlyg==} @@ -2836,11 +2836,11 @@ snapshots: dependencies: iconv-lite: 0.7.3 semver: 7.8.5 - zigbee-herdsman: 10.6.3 + zigbee-herdsman: 10.7.0 transitivePeerDependencies: - supports-color - zigbee-herdsman@10.6.3: + zigbee-herdsman@10.7.0: dependencies: '@date-fns/tz': 1.5.0 '@serialport/bindings-cpp': 13.0.1 diff --git a/test/extensions/bridge.test.ts b/test/extensions/bridge.test.ts index 3e091d4f..1cbe7587 100644 --- a/test/extensions/bridge.test.ts +++ b/test/extensions/bridge.test.ts @@ -2973,6 +2973,7 @@ describe("Extension: Bridge", () => { it("Should allow to remove device by string", async () => { const device = devices.bulb; + const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup"); mockMQTTPublishAsync.mockClear(); mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", "bulb"); await flushPromises(); @@ -2981,11 +2982,12 @@ describe("Extension: Bridge", () => { expect(device.removeFromNetwork).toHaveBeenCalledTimes(1); expect(device.removeFromDatabase).not.toHaveBeenCalled(); expect(settings.getDevice("bulb")).toBeUndefined(); + expect(removeSpy).not.toHaveBeenCalled(); expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", "", {retain: true}); 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}, status: "ok"}), + stringify({data: {id: "bulb", block: false, force: false, clear_cache: false}, status: "ok"}), {}, ); expect(settings.get().blocklist).toStrictEqual([]); @@ -2995,52 +2997,76 @@ describe("Extension: Bridge", () => { it("Should allow to remove device by object ID", async () => { const device = devices.bulb; + const removeSpy = vi.spyOn(controller.zigbee, "removeDeviceFromLookup"); mockMQTTPublishAsync.mockClear(); mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb"})); await flushPromises(); expect(device.removeFromNetwork).toHaveBeenCalledTimes(1); expect(device.removeFromDatabase).not.toHaveBeenCalled(); expect(settings.getDevice("bulb")).toBeUndefined(); + 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}, status: "ok"}), + stringify({data: {id: "bulb", block: false, force: false, clear_cache: false}, status: "ok"}), {}, ); }); it("Should allow to force remove 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", force: true})); await flushPromises(); expect(device.removeFromDatabase).toHaveBeenCalledTimes(1); expect(device.removeFromNetwork).not.toHaveBeenCalled(); expect(settings.getDevice("bulb")).toBeUndefined(); + 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: true}, status: "ok"}), + stringify({data: {id: "bulb", block: false, force: true, clear_cache: false}, status: "ok"}), {}, ); }); it("Should allow to block 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", block: true, force: true})); await flushPromises(); expect(device.removeFromDatabase).toHaveBeenCalledTimes(1); expect(settings.getDevice("bulb")).toBeUndefined(); + 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: true, force: true}, status: "ok"}), + stringify({data: {id: "bulb", block: true, force: true, clear_cache: false}, status: "ok"}), {}, ); expect(settings.get().blocklist).toStrictEqual(["0x000b57fffec6a5b2"]); }); + it("Should allow to clear cache 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", clear_cache: true})); + await flushPromises(); + expect(device.removeFromNetwork).toHaveBeenCalledTimes(1); + expect(device.removeFromDatabase).not.toHaveBeenCalled(); + expect(settings.getDevice("bulb")).toBeUndefined(); + expect(removeSpy).toHaveNthReturnedWith(1, true); + 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, clear_cache: true}, status: "ok"}), + {}, + ); + }); + it("Should allow to remove group", async () => { const group = groups.group_1; const removeGroupFromLookup = vi.spyOn(controller.zigbee, "removeGroupFromLookup"); @@ -3105,7 +3131,11 @@ describe("Extension: Bridge", () => { await flushPromises(); expect(mockMQTTPublishAsync).toHaveBeenCalledWith( "zigbee2mqtt/bridge/response/device/remove", - stringify({data: {}, status: "error", error: "Failed to remove device 'bulb' (block: false, force: false) (Error: device timeout)"}), + stringify({ + data: {}, + status: "error", + error: "Failed to remove device 'bulb' (block: false, force: false, clear cache: false) (Error: device timeout)", + }), {}, ); });