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

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
This commit is contained in:
NerivecandKoen Kanters authored and GitHub committed 2025-06-16 19:45:36 +02:00
1 parent 242815e139
commit cd9b752ede
14 files changed
+596 -15

No files matched your search

+2
View File
@@ -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),
]);
}
+48
View File
@@ -34,9 +34,25 @@ type EventBusListener<K> = 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<string, {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]>();
private emitter = new events.EventEmitter<EventBusMap>();
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);
+13
View File
@@ -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,
+77
View File
@@ -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<void> {
await super.start();
this.#checkTimer = setInterval(this.#checkHealth.bind(this), utils.minutes(settings.get().health.interval));
}
override async stop(): Promise<void> {
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<void> {
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}});
}
}
+14
View File
@@ -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 = {
+45
View File
@@ -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[];
+21
View File
@@ -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"],
+4
View File
@@ -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<Settings>;
let _settings: Partial<Settings> | undefined;
+3 -3
View File
@@ -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,