diff --git a/lib/controller.ts b/lib/controller.ts index e4293d12..f6c5feaa 100644 --- a/lib/controller.ts +++ b/lib/controller.ts @@ -16,6 +16,7 @@ import ExtensionConfigure from "./extension/configure"; import ExtensionExternalConverters from "./extension/externalConverters"; import ExtensionExternalExtensions from "./extension/externalExtensions"; import ExtensionGroups from "./extension/groups"; +import ExtensionHealth from "./extension/health"; import ExtensionNetworkMap from "./extension/networkMap"; import ExtensionOnEvent from "./extension/onEvent"; import ExtensionOTAUpdate from "./extension/otaUpdate"; @@ -76,6 +77,7 @@ export class Controller { new ExtensionOTAUpdate(...this.extensionArgs), new ExtensionExternalExtensions(...this.extensionArgs), new ExtensionAvailability(...this.extensionArgs), + new ExtensionHealth(...this.extensionArgs), ]); } diff --git a/lib/eventBus.ts b/lib/eventBus.ts index b524298c..3dfe36e5 100644 --- a/lib/eventBus.ts +++ b/lib/eventBus.ts @@ -34,9 +34,25 @@ type EventBusListener = K extends keyof EventBusMap : never : never; +type Stats = { + devices: Map< + string, // IEEE address + { + lastSeenChanges?: {messages: number; first: number}; + leaveCounts: number; + networkAddressChanges: number; + } + >; + mqtt: { + published: number; + received: number; + }; +}; + export default class EventBus { private callbacksByExtension = new Map}[]>(); private emitter = new events.EventEmitter(); + readonly stats: Stats = {devices: new Map(), mqtt: {published: 0, received: 0}}; constructor() { this.emitter.setMaxListeners(100); @@ -72,6 +88,18 @@ export default class EventBus { public emitLastSeenChanged(data: eventdata.LastSeenChanged): void { this.emitter.emit("lastSeenChanged", data); + + const device = this.stats.devices.get(data.device.ieeeAddr); + + if (device?.lastSeenChanges) { + device.lastSeenChanges.messages += 1; + } else { + this.stats.devices.set(data.device.ieeeAddr, { + lastSeenChanges: {messages: 1, first: Date.now()}, + leaveCounts: 0, + networkAddressChanges: 0, + }); + } } public onLastSeenChanged(key: ListenerKey, callback: (data: eventdata.LastSeenChanged) => void): void { this.on("lastSeenChanged", callback, key); @@ -79,6 +107,14 @@ export default class EventBus { public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void { this.emitter.emit("deviceNetworkAddressChanged", data); + + const device = this.stats.devices.get(data.device.ieeeAddr); + + if (device) { + device.networkAddressChanges += 1; + } else { + this.stats.devices.set(data.device.ieeeAddr, {leaveCounts: 0, networkAddressChanges: 1}); + } } public onDeviceNetworkAddressChanged(key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void { this.on("deviceNetworkAddressChanged", callback, key); @@ -121,6 +157,14 @@ export default class EventBus { public emitDeviceLeave(data: eventdata.DeviceLeave): void { this.emitter.emit("deviceLeave", data); + + const device = this.stats.devices.get(data.ieeeAddr); + + if (device) { + device.leaveCounts += 1; + } else { + this.stats.devices.set(data.ieeeAddr, {leaveCounts: 1, networkAddressChanges: 0}); + } } public onDeviceLeave(key: ListenerKey, callback: (data: eventdata.DeviceLeave) => void): void { this.on("deviceLeave", callback, key); @@ -135,6 +179,8 @@ export default class EventBus { public emitMQTTMessage(data: eventdata.MQTTMessage): void { this.emitter.emit("mqttMessage", data); + + this.stats.mqtt.received += 1; } public onMQTTMessage(key: ListenerKey, callback: (data: eventdata.MQTTMessage) => void): void { this.on("mqttMessage", callback, key); @@ -142,6 +188,8 @@ export default class EventBus { public emitMQTTMessagePublished(data: eventdata.MQTTMessagePublished): void { this.emitter.emit("mqttMessagePublished", data); + + this.stats.mqtt.published += 1; } public onMQTTMessagePublished(key: ListenerKey, callback: (data: eventdata.MQTTMessagePublished) => void): void { this.on("mqttMessagePublished", callback, key); diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 248f549e..586642a8 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -25,6 +25,8 @@ import Extension from "./extension"; const REQUEST_REGEX = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`); export default class Bridge extends Extension { + // set on `start` + #osInfo!: Zigbee2MQTTAPI["bridge/info"]["os"]; private zigbee2mqttVersion!: {commitHash?: string; version: string}; private zigbeeHerdsmanVersion!: {version: string}; private zigbeeHerdsmanConvertersVersion!: {version: string}; @@ -92,6 +94,15 @@ export default class Bridge extends Extension { logger.addTransport(this.logTransport); + const os = await import("node:os"); + const process = await import("node:process"); + const logicalCpuCores = os.cpus(); + this.#osInfo = { + version: `${os.version()} - ${os.release()} - ${os.arch()}`, + node_version: process.version, + cpus: `${[...new Set(logicalCpuCores.map((cpu) => cpu.model))].join(" | ")} (x${logicalCpuCores.length})`, + memory_mb: Math.round(os.totalmem() / 1024 / 1024), + }; this.zigbee2mqttVersion = await utils.getZigbee2MQTTVersion(); this.zigbeeHerdsmanVersion = await utils.getDependencyVersion("zigbee-herdsman"); this.zigbeeHerdsmanConvertersVersion = await utils.getDependencyVersion("zigbee-herdsman-converters"); @@ -691,6 +702,8 @@ export default class Bridge extends Extension { const networkParams = await this.zigbee.getNetworkParameters(); const payload: Zigbee2MQTTAPI["bridge/info"] = { + os: this.#osInfo, + mqtt: this.mqtt.info, version: this.zigbee2mqttVersion.version, commit: this.zigbee2mqttVersion.commitHash, zigbee_herdsman_converters: this.zigbeeHerdsmanConvertersVersion, diff --git a/lib/extension/health.ts b/lib/extension/health.ts new file mode 100644 index 00000000..3a8c844e --- /dev/null +++ b/lib/extension/health.ts @@ -0,0 +1,77 @@ +import * as os from "node:os"; +import * as process from "node:process"; +import type {Zigbee2MQTTAPI} from "../types/api"; +import * as settings from "../util/settings"; +import utils from "../util/utils"; +import Extension from "./extension"; + +/** Round with 2 decimals */ +const round2 = (n: number): number => Math.round(n * 100.0) / 100.0; +/** Round with 4 decimals */ +const round4 = (n: number): number => Math.round(n * 10000.0) / 10000.0; + +export default class Health extends Extension { + #checkTimer: NodeJS.Timeout | undefined; + + override async start(): Promise { + await super.start(); + + this.#checkTimer = setInterval(this.#checkHealth.bind(this), utils.minutes(settings.get().health.interval)); + } + + override async stop(): Promise { + clearInterval(this.#checkTimer); + await super.stop(); + } + + clearStats(): void { + this.eventBus.stats.devices.clear(); + this.eventBus.stats.mqtt.published = 0; + this.eventBus.stats.mqtt.received = 0; + } + + async #checkHealth(): Promise { + const sysMemTotalKb = os.totalmem() / 1024; + const sysMemFreeKb = os.freemem() / 1024; + const procMemUsedKb = process.memoryUsage().rss / 1024; + const healthcheck: Zigbee2MQTTAPI["bridge/health"] = { + response_time: Date.now(), + os: { + load_average: os.loadavg(), // will be [0,0,0] on Windows (not supported) + memory_used_mb: round2((sysMemTotalKb - sysMemFreeKb) / 1024), + memory_percent: round4((sysMemFreeKb / sysMemTotalKb) * 100.0), + }, + process: { + uptime_sec: Math.floor(process.uptime()), + memory_used_mb: round2(procMemUsedKb / 1024), + memory_percent: round4((procMemUsedKb / sysMemTotalKb) * 100.0), + }, + mqtt: {...this.mqtt.stats, ...this.eventBus.stats.mqtt}, + devices: {}, + }; + + for (const [ieeeAddr, device] of this.eventBus.stats.devices) { + let messages = 0; + let mps = 0; + + if (device.lastSeenChanges) { + const timeDiff = Date.now() - device.lastSeenChanges.first; + messages = device.lastSeenChanges.messages; + mps = timeDiff > 0 ? round4(messages / (timeDiff / 1000.0)) : 0; + } + + healthcheck.devices[ieeeAddr] = { + messages, + messages_per_sec: mps, + leave_count: device.leaveCounts, + network_address_changes: device.networkAddressChanges, + }; + } + + if (settings.get().health.reset_on_check) { + this.clearStats(); + } + + await this.mqtt.publish("bridge/health", JSON.stringify(healthcheck), {clientOptions: {retain: true, qos: 1}}); + } +} diff --git a/lib/mqtt.ts b/lib/mqtt.ts index af54b7ac..8173a7ea 100644 --- a/lib/mqtt.ts +++ b/lib/mqtt.ts @@ -30,6 +30,20 @@ export default class Mqtt { private defaultPublishOptions: MqttPublishOptions; public retainedMessages: {[s: string]: {topic: string; payload: string; options: MqttPublishOptions}} = {}; + get info() { + return { + version: this.client.options.protocolVersion, + server: `${this.client.options.protocol}://${this.client.options.host}:${this.client.options.port}`, + }; + } + + get stats() { + return { + connected: this.isConnected(), + queued: this.client.queue.length, + }; + } + constructor(eventBus: EventBus) { this.eventBus = eventBus; this.defaultPublishOptions = { diff --git a/lib/types/api.ts b/lib/types/api.ts index 891b4476..b774bdbc 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -183,6 +183,11 @@ export interface Zigbee2MQTTSettings { output: "json" | "attribute" | "attribute_and_json"; transmit_power?: number; }; + health: { + /** in minutes */ + interval: number; + reset_on_check: boolean; + }; } export interface Zigbee2MQTTScene { @@ -330,6 +335,16 @@ export interface Zigbee2MQTTAPI { }; "bridge/info": { + os: { + version: string; + node_version: string; + cpus: string; + memory_mb: number; + }; + mqtt: { + version: number | undefined; + server: string; + }; version: string; commit: string | undefined; zigbee_herdsman_converters: {version: string}; @@ -355,6 +370,36 @@ export interface Zigbee2MQTTAPI { config_schema: typeof schemaJson; }; + "bridge/health": { + /** time of message, msec from epoch, UTC */ + response_time: number; + os: { + load_average: number[]; + memory_used_mb: number; + memory_percent: number; + }; + process: { + uptime_sec: number; + memory_used_mb: number; + memory_percent: number; + }; + mqtt: { + connected: boolean; + queued: number; + received: number; + published: number; + }; + devices: Record< + string /* ieee */, + { + messages: number; + messages_per_sec: number; + leave_count: number; + network_address_changes: number; + } + >; + }; + "bridge/devices": Zigbee2MQTTDevice[]; "bridge/groups": Zigbee2MQTTGroup[]; diff --git a/lib/util/settings.schema.json b/lib/util/settings.schema.json index 9a5214ad..de292984 100644 --- a/lib/util/settings.schema.json +++ b/lib/util/settings.schema.json @@ -770,6 +770,27 @@ "description": "Examples when 'state' of a device is published json: topic: 'zigbee2mqtt/my_bulb' payload '{\"state\": \"ON\"}' attribute: topic 'zigbee2mqtt/my_bulb/state' payload 'ON' attribute_and_json: both json and attribute (see above)" } } + }, + "health": { + "title": "Health", + "description": "Periodically check the health of Zigbee2MQTT", + "type": "object", + "properties": { + "interval": { + "type": "number", + "title": "Interval", + "description": "Interval between checks in minutes", + "default": 10, + "requiresRestart": true + }, + "reset_on_check": { + "type": "boolean", + "title": "Reset on check", + "description": "If true, will reset stats every time the health check is executed (only applicable to stats that can be reset).", + "default": false + } + }, + "required": [] } }, "required": ["mqtt"], diff --git a/lib/util/settings.ts b/lib/util/settings.ts index a3260309..ab7ec864 100644 --- a/lib/util/settings.ts +++ b/lib/util/settings.ts @@ -113,6 +113,10 @@ export const defaults = { timestamp_format: "YYYY-MM-DD HH:mm:ss", output: "json", }, + health: { + interval: 10, + reset_on_check: false, + }, } satisfies RecursivePartial; let _settings: Partial | undefined; diff --git a/lib/util/utils.ts b/lib/util/utils.ts index 49123f4b..f4b5b5eb 100644 --- a/lib/util/utils.ts +++ b/lib/util/utils.ts @@ -279,9 +279,9 @@ function isZHGroup(obj: unknown): obj is zh.Group { return obj?.constructor.name.toLowerCase() === "group"; } -const hours = (hours: number): number => 1000 * 60 * 60 * hours; -const minutes = (minutes: number): number => 1000 * 60 * minutes; -const seconds = (seconds: number): number => 1000 * seconds; +export const hours = (hours: number): number => 1000 * 60 * 60 * hours; +export const minutes = (minutes: number): number => 1000 * 60 * minutes; +export const seconds = (seconds: number): number => 1000 * seconds; async function publishLastSeen( data: eventdata.LastSeenChanged, diff --git a/test/extensions/bridge.test.ts b/test/extensions/bridge.test.ts index 480be836..a1306955 100644 --- a/test/extensions/bridge.test.ts +++ b/test/extensions/bridge.test.ts @@ -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}, ); diff --git a/test/extensions/health.test.ts b/test/extensions/health.test.ts new file mode 100644 index 00000000..4b6fff23 --- /dev/null +++ b/test/extensions/health.test.ts @@ -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 => { + 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}); + }); +}); diff --git a/test/extensions/homeassistant.test.ts b/test/extensions/homeassistant.test.ts index e7207528..9eae1ad0 100644 --- a/test/extensions/homeassistant.test.ts +++ b/test/extensions/homeassistant.test.ts @@ -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 () => { diff --git a/test/extensions/receive.test.ts b/test/extensions/receive.test.ts index 6e04eb37..5c93398a 100644 --- a/test/extensions/receive.test.ts +++ b/test/extensions/receive.test.ts @@ -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 () => { diff --git a/test/mocks/mqtt.ts b/test/mocks/mqtt.ts index ee687e02..7ef53599 100644 --- a/test/mocks/mqtt.ts +++ b/test/mocks/mqtt.ts @@ -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", () => ({