feat: New health extension & extras in bridge/info (#27164)

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
This commit is contained in:
Nerivec
2025-06-16 19:45:36 +02:00
committed by GitHub
co-authored by Koen Kanters
parent 242815e139
commit cd9b752ede
14 changed files with 596 additions and 15 deletions
+28 -1
View File
@@ -41,6 +41,19 @@ const mocksClear = [
const deviceIconsDir = path.join(data.mockDir, "device_icons");
vi.mock("node:os", async (importOriginal) => ({
...(await importOriginal()),
version: vi.fn(() => "Linux"),
release: vi.fn(() => "0.0.1"),
arch: vi.fn(() => "x64"),
cpus: vi.fn(() => [{model: "Intel Core i7-9999"}]),
totalmem: vi.fn(() => 10485760),
}));
vi.mock("node:process", async (importOriginal) => ({
...(await importOriginal()),
version: "v1.2.3",
}));
describe("Extension: Bridge", () => {
let controller: Controller;
let mockRestart: Mock;
@@ -94,12 +107,16 @@ describe("Extension: Bridge", () => {
const zhVersion = await utils.getDependencyVersion("zigbee-herdsman");
const zhcVersion = await utils.getDependencyVersion("zigbee-herdsman-converters");
const directory = settings.get().advanced.log_directory;
// console.log(mockMQTTPublishAsync.mock.calls.find((c) => c[0] === 'zigbee2mqtt/bridge/info')![1]);
// console.log(mockMQTTPublishAsync.mock.calls.find((c) => c[0] === "zigbee2mqtt/bridge/info")![1]);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bridge/info",
stringify({
commit: version.commitHash,
config: {
health: {
interval: 10,
reset_on_check: false,
},
advanced: {
adapter_concurrent: undefined,
adapter_delay: undefined,
@@ -315,6 +332,16 @@ describe("Extension: Bridge", () => {
version: version.version,
zigbee_herdsman: zhVersion,
zigbee_herdsman_converters: zhcVersion,
os: {
version: "Linux - 0.0.1 - x64",
node_version: "v1.2.3",
cpus: "Intel Core i7-9999 (x1)",
memory_mb: 10,
},
mqtt: {
server: "mqtt://localhost:1883",
version: 5,
},
}),
{retain: true},
);
+313
View File
@@ -0,0 +1,313 @@
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
import {flushPromises} from "../mocks/utils";
import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman";
import {Controller} from "../../lib/controller";
import Health from "../../lib/extension/health";
import * as settings from "../../lib/util/settings";
import {minutes, seconds} from "../../lib/util/utils";
const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.info];
returnDevices.push(devices.bulb_color.ieeeAddr, devices.bulb_color_2.ieeeAddr, devices.coordinator.ieeeAddr);
describe("Extension: Health", () => {
let controller: Controller;
const getExtension = (): Health => controller.getExtension("Health") as Health;
const resetExtension = async (): Promise<void> => {
await controller.removeExtension(getExtension());
await controller.addExtension(new Health(...controller.extensionArgs));
};
beforeAll(async () => {
vi.useFakeTimers();
settings.reRead();
controller = new Controller(vi.fn(), vi.fn());
await controller.start();
await flushPromises();
});
beforeEach(() => {
data.writeDefaultConfiguration();
settings.reRead();
settings.set(["devices", devices.bulb_color_2.ieeeAddr, "health"], false);
for (const mock of mocksClear) {
mock.mockClear();
}
getExtension().clearStats();
});
afterEach(async () => {});
afterAll(async () => {
await controller?.stop();
await flushPromises();
vi.useRealTimers();
});
it("checks health at default interval", async () => {
await resetExtension();
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2});
await mockZHEvents.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr});
await mockZHEvents.deviceJoined({device: devices.bulb_color});
await mockZHEvents.deviceNetworkAddressChanged({device: devices.bulb_color});
await vi.advanceTimersByTimeAsync(seconds(1));
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await vi.advanceTimersByTimeAsync(seconds(1));
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await vi.advanceTimersByTimeAsync(seconds(1));
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await mockMQTTEvents.message("zigbee2mqtt/mock", "mocked");
await vi.advanceTimersByTimeAsync(minutes(11));
let calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 1,
},
devices: {
[devices.bulb_color.ieeeAddr]: {
leave_count: 1,
messages: 4,
messages_per_sec: 0.0067,
network_address_changes: 1,
},
[devices.bulb_color_2.ieeeAddr]: {
leave_count: 0,
messages: 1,
messages_per_sec: 0.0017,
network_address_changes: 0,
},
},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
mockMQTTPublishAsync.mockClear();
await mockMQTTEvents.message("zigbee2mqtt/mock2", "mocked2");
await vi.advanceTimersByTimeAsync(minutes(11));
calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 2,
},
devices: {
[devices.bulb_color.ieeeAddr]: {
leave_count: 1,
messages: 4,
messages_per_sec: 0.0033,
network_address_changes: 1,
},
[devices.bulb_color_2.ieeeAddr]: {
leave_count: 0,
messages: 1,
messages_per_sec: 0.0008,
network_address_changes: 0,
},
},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
});
it("checks health at given interval", async () => {
settings.set(["health", "interval"], 20);
await resetExtension();
await vi.advanceTimersByTimeAsync(minutes(11));
let calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(0);
await vi.advanceTimersByTimeAsync(minutes(10));
calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 0,
},
devices: {},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
});
it("init device health from leave", async () => {
await resetExtension();
await mockZHEvents.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr});
await mockZHEvents.deviceJoined({device: devices.bulb_color});
await vi.advanceTimersByTimeAsync(minutes(11));
const calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 0,
},
devices: {
[devices.bulb_color.ieeeAddr]: {
leave_count: 1,
messages: 0,
messages_per_sec: 0,
network_address_changes: 0,
},
},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
});
it("init device health from network address change", async () => {
await resetExtension();
await mockZHEvents.deviceNetworkAddressChanged({device: devices.bulb_color});
await vi.advanceTimersByTimeAsync(minutes(11));
const calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 0,
},
devices: {
[devices.bulb_color.ieeeAddr]: {
leave_count: 0,
messages: 0,
messages_per_sec: 0,
network_address_changes: 1,
},
},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
});
it("checks health then resets possible stats", async () => {
settings.set(["health", "reset_on_check"], true);
await resetExtension();
await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
await mockZHEvents.lastSeenChanged({device: devices.bulb_color}); // coverage no time diff first/last
await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2});
await vi.advanceTimersByTimeAsync(minutes(11));
let calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 0,
},
devices: {
[devices.bulb_color.ieeeAddr]: {
leave_count: 0,
messages: 2,
messages_per_sec: 0.0033,
network_address_changes: 0,
},
[devices.bulb_color_2.ieeeAddr]: {
leave_count: 0,
messages: 1,
messages_per_sec: 0.0017,
network_address_changes: 0,
},
},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
mockMQTTPublishAsync.mockClear();
await vi.advanceTimersByTimeAsync(minutes(11));
calls = mockMQTTPublishAsync.mock.calls.filter((call) => call[0] === "zigbee2mqtt/bridge/health");
expect(calls.length).toStrictEqual(1);
expect(JSON.parse(calls[0][1])).toStrictEqual({
response_time: expect.any(Number),
os: {
load_average: [expect.any(Number), expect.any(Number), expect.any(Number)],
memory_used_mb: expect.any(Number),
memory_percent: expect.any(Number),
},
process: {uptime_sec: expect.any(Number), memory_used_mb: expect.any(Number), memory_percent: expect.any(Number)},
mqtt: {
connected: true,
queued: 0,
published: expect.any(Number),
received: 0,
},
devices: {},
});
expect(calls[0][2]).toStrictEqual({retain: true, qos: 1});
});
});
+6 -3
View File
@@ -1564,7 +1564,8 @@ describe("Extension: HomeAssistant", () => {
await flushPromises();
await vi.runOnlyPendingTimersAsync();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/health", expect.any(String), expect.any(Object));
});
it("Shouldnt send all status when home assistant comes online with different topic", async () => {
@@ -1577,7 +1578,8 @@ describe("Extension: HomeAssistant", () => {
await flushPromises();
await vi.runOnlyPendingTimersAsync();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/health", expect.any(String), expect.any(Object));
});
it("Should discover devices with availability", async () => {
@@ -2456,7 +2458,8 @@ describe("Extension: HomeAssistant", () => {
stringify(payload),
{retain: true, qos: 1},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(6);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(7);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/health", expect.any(String), expect.any(Object));
});
it("Should not clear bridge entities unnecessarily", async () => {
+15 -8
View File
@@ -183,10 +183,11 @@ describe("Extension: Receive", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
vi.runOnlyPendingTimers();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: false});
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/bridge/health");
});
it("Should debounce and retain messages when set via device_options", async () => {
@@ -229,10 +230,11 @@ describe("Extension: Receive", () => {
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
vi.runOnlyPendingTimers();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 1, retain: true});
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/bridge/health");
});
it("Should debounce messages only with the same payload values for provided debounce_ignore keys", async () => {
@@ -281,8 +283,9 @@ describe("Extension: Receive", () => {
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, pressure: 2});
vi.runOnlyPendingTimers();
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({temperature: 0.07, pressure: 2, humidity: 0.03});
expect(mockMQTTPublishAsync.mock.calls[2][0]).toStrictEqual("zigbee2mqtt/bridge/health");
});
it("Should NOT publish old messages from State cache during debouncing", async () => {
@@ -322,9 +325,10 @@ describe("Extension: Receive", () => {
vi.runOnlyPendingTimers();
// Test that only one MQTT is sent out and test its values.
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1 + 1 /* "bridge/health" */);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/weather_sensor");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({temperature: 0.08, humidity: 0.01, pressure: 2});
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/bridge/health");
// Send another Zigbee message...
await mockZHEvents.message({
@@ -343,12 +347,14 @@ describe("Extension: Receive", () => {
vi.runOnlyPendingTimers();
// Total of 3 messages should have triggered.
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(5);
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/bridge/health");
// Test that message pushed by asynchronous message contains NEW measurement and not old.
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
// Test that messages after debouncing contains NEW measurement and not old.
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[2][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
// Test that messages after debouncing contains NEW measurement and not old.
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[3][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
expect(mockMQTTPublishAsync.mock.calls[4][0]).toStrictEqual("zigbee2mqtt/bridge/health");
});
it("Should throttle multiple messages from spamming devices", async () => {
@@ -439,9 +445,10 @@ describe("Extension: Receive", () => {
await mockMQTTEvents.message("zigbee2mqtt/bulb/set", stringify({state: "ON"}));
await flushPromises();
vi.runOnlyPendingTimers();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(2 + 1);
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({state: "ON"});
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({state: "ON"});
expect(mockMQTTPublishAsync.mock.calls[2][0]).toStrictEqual("zigbee2mqtt/bridge/health");
});
it("Should handle a zigbee message with 1 precision", async () => {
+7
View File
@@ -25,6 +25,13 @@ export const mockMQTTConnectAsync = vi.fn(() => ({
events[type] = handler;
}),
stream: {setMaxListeners: vi.fn()},
options: {
protocolVersion: 5,
protocol: "mqtt",
host: "localhost",
port: 1883,
},
queue: [],
}));
vi.mock("mqtt", () => ({