Skip to content

Commit cd9b752

Browse files
NerivecKoenkk
andauthored
feat: New health extension & extras in bridge/info (#27164)
Co-authored-by: Koen Kanters <[email protected]>
1 parent 242815e commit cd9b752

14 files changed

Lines changed: 596 additions & 15 deletions

File tree

lib/controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import ExtensionConfigure from "./extension/configure";
1616
import ExtensionExternalConverters from "./extension/externalConverters";
1717
import ExtensionExternalExtensions from "./extension/externalExtensions";
1818
import ExtensionGroups from "./extension/groups";
19+
import ExtensionHealth from "./extension/health";
1920
import ExtensionNetworkMap from "./extension/networkMap";
2021
import ExtensionOnEvent from "./extension/onEvent";
2122
import ExtensionOTAUpdate from "./extension/otaUpdate";
@@ -76,6 +77,7 @@ export class Controller {
7677
new ExtensionOTAUpdate(...this.extensionArgs),
7778
new ExtensionExternalExtensions(...this.extensionArgs),
7879
new ExtensionAvailability(...this.extensionArgs),
80+
new ExtensionHealth(...this.extensionArgs),
7981
]);
8082
}
8183

lib/eventBus.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,25 @@ type EventBusListener<K> = K extends keyof EventBusMap
3434
: never
3535
: never;
3636

37+
type Stats = {
38+
devices: Map<
39+
string, // IEEE address
40+
{
41+
lastSeenChanges?: {messages: number; first: number};
42+
leaveCounts: number;
43+
networkAddressChanges: number;
44+
}
45+
>;
46+
mqtt: {
47+
published: number;
48+
received: number;
49+
};
50+
};
51+
3752
export default class EventBus {
3853
private callbacksByExtension = new Map<string, {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]>();
3954
private emitter = new events.EventEmitter<EventBusMap>();
55+
readonly stats: Stats = {devices: new Map(), mqtt: {published: 0, received: 0}};
4056

4157
constructor() {
4258
this.emitter.setMaxListeners(100);
@@ -72,13 +88,33 @@ export default class EventBus {
7288

7389
public emitLastSeenChanged(data: eventdata.LastSeenChanged): void {
7490
this.emitter.emit("lastSeenChanged", data);
91+
92+
const device = this.stats.devices.get(data.device.ieeeAddr);
93+
94+
if (device?.lastSeenChanges) {
95+
device.lastSeenChanges.messages += 1;
96+
} else {
97+
this.stats.devices.set(data.device.ieeeAddr, {
98+
lastSeenChanges: {messages: 1, first: Date.now()},
99+
leaveCounts: 0,
100+
networkAddressChanges: 0,
101+
});
102+
}
75103
}
76104
public onLastSeenChanged(key: ListenerKey, callback: (data: eventdata.LastSeenChanged) => void): void {
77105
this.on("lastSeenChanged", callback, key);
78106
}
79107

80108
public emitDeviceNetworkAddressChanged(data: eventdata.DeviceNetworkAddressChanged): void {
81109
this.emitter.emit("deviceNetworkAddressChanged", data);
110+
111+
const device = this.stats.devices.get(data.device.ieeeAddr);
112+
113+
if (device) {
114+
device.networkAddressChanges += 1;
115+
} else {
116+
this.stats.devices.set(data.device.ieeeAddr, {leaveCounts: 0, networkAddressChanges: 1});
117+
}
82118
}
83119
public onDeviceNetworkAddressChanged(key: ListenerKey, callback: (data: eventdata.DeviceNetworkAddressChanged) => void): void {
84120
this.on("deviceNetworkAddressChanged", callback, key);
@@ -121,6 +157,14 @@ export default class EventBus {
121157

122158
public emitDeviceLeave(data: eventdata.DeviceLeave): void {
123159
this.emitter.emit("deviceLeave", data);
160+
161+
const device = this.stats.devices.get(data.ieeeAddr);
162+
163+
if (device) {
164+
device.leaveCounts += 1;
165+
} else {
166+
this.stats.devices.set(data.ieeeAddr, {leaveCounts: 1, networkAddressChanges: 0});
167+
}
124168
}
125169
public onDeviceLeave(key: ListenerKey, callback: (data: eventdata.DeviceLeave) => void): void {
126170
this.on("deviceLeave", callback, key);
@@ -135,13 +179,17 @@ export default class EventBus {
135179

136180
public emitMQTTMessage(data: eventdata.MQTTMessage): void {
137181
this.emitter.emit("mqttMessage", data);
182+
183+
this.stats.mqtt.received += 1;
138184
}
139185
public onMQTTMessage(key: ListenerKey, callback: (data: eventdata.MQTTMessage) => void): void {
140186
this.on("mqttMessage", callback, key);
141187
}
142188

143189
public emitMQTTMessagePublished(data: eventdata.MQTTMessagePublished): void {
144190
this.emitter.emit("mqttMessagePublished", data);
191+
192+
this.stats.mqtt.published += 1;
145193
}
146194
public onMQTTMessagePublished(key: ListenerKey, callback: (data: eventdata.MQTTMessagePublished) => void): void {
147195
this.on("mqttMessagePublished", callback, key);

lib/extension/bridge.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import Extension from "./extension";
2525
const REQUEST_REGEX = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
2626

2727
export default class Bridge extends Extension {
28+
// set on `start`
29+
#osInfo!: Zigbee2MQTTAPI["bridge/info"]["os"];
2830
private zigbee2mqttVersion!: {commitHash?: string; version: string};
2931
private zigbeeHerdsmanVersion!: {version: string};
3032
private zigbeeHerdsmanConvertersVersion!: {version: string};
@@ -92,6 +94,15 @@ export default class Bridge extends Extension {
9294

9395
logger.addTransport(this.logTransport);
9496

97+
const os = await import("node:os");
98+
const process = await import("node:process");
99+
const logicalCpuCores = os.cpus();
100+
this.#osInfo = {
101+
version: `${os.version()} - ${os.release()} - ${os.arch()}`,
102+
node_version: process.version,
103+
cpus: `${[...new Set(logicalCpuCores.map((cpu) => cpu.model))].join(" | ")} (x${logicalCpuCores.length})`,
104+
memory_mb: Math.round(os.totalmem() / 1024 / 1024),
105+
};
95106
this.zigbee2mqttVersion = await utils.getZigbee2MQTTVersion();
96107
this.zigbeeHerdsmanVersion = await utils.getDependencyVersion("zigbee-herdsman");
97108
this.zigbeeHerdsmanConvertersVersion = await utils.getDependencyVersion("zigbee-herdsman-converters");
@@ -691,6 +702,8 @@ export default class Bridge extends Extension {
691702

692703
const networkParams = await this.zigbee.getNetworkParameters();
693704
const payload: Zigbee2MQTTAPI["bridge/info"] = {
705+
os: this.#osInfo,
706+
mqtt: this.mqtt.info,
694707
version: this.zigbee2mqttVersion.version,
695708
commit: this.zigbee2mqttVersion.commitHash,
696709
zigbee_herdsman_converters: this.zigbeeHerdsmanConvertersVersion,

lib/extension/health.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import * as os from "node:os";
2+
import * as process from "node:process";
3+
import type {Zigbee2MQTTAPI} from "../types/api";
4+
import * as settings from "../util/settings";
5+
import utils from "../util/utils";
6+
import Extension from "./extension";
7+
8+
/** Round with 2 decimals */
9+
const round2 = (n: number): number => Math.round(n * 100.0) / 100.0;
10+
/** Round with 4 decimals */
11+
const round4 = (n: number): number => Math.round(n * 10000.0) / 10000.0;
12+
13+
export default class Health extends Extension {
14+
#checkTimer: NodeJS.Timeout | undefined;
15+
16+
override async start(): Promise<void> {
17+
await super.start();
18+
19+
this.#checkTimer = setInterval(this.#checkHealth.bind(this), utils.minutes(settings.get().health.interval));
20+
}
21+
22+
override async stop(): Promise<void> {
23+
clearInterval(this.#checkTimer);
24+
await super.stop();
25+
}
26+
27+
clearStats(): void {
28+
this.eventBus.stats.devices.clear();
29+
this.eventBus.stats.mqtt.published = 0;
30+
this.eventBus.stats.mqtt.received = 0;
31+
}
32+
33+
async #checkHealth(): Promise<void> {
34+
const sysMemTotalKb = os.totalmem() / 1024;
35+
const sysMemFreeKb = os.freemem() / 1024;
36+
const procMemUsedKb = process.memoryUsage().rss / 1024;
37+
const healthcheck: Zigbee2MQTTAPI["bridge/health"] = {
38+
response_time: Date.now(),
39+
os: {
40+
load_average: os.loadavg(), // will be [0,0,0] on Windows (not supported)
41+
memory_used_mb: round2((sysMemTotalKb - sysMemFreeKb) / 1024),
42+
memory_percent: round4((sysMemFreeKb / sysMemTotalKb) * 100.0),
43+
},
44+
process: {
45+
uptime_sec: Math.floor(process.uptime()),
46+
memory_used_mb: round2(procMemUsedKb / 1024),
47+
memory_percent: round4((procMemUsedKb / sysMemTotalKb) * 100.0),
48+
},
49+
mqtt: {...this.mqtt.stats, ...this.eventBus.stats.mqtt},
50+
devices: {},
51+
};
52+
53+
for (const [ieeeAddr, device] of this.eventBus.stats.devices) {
54+
let messages = 0;
55+
let mps = 0;
56+
57+
if (device.lastSeenChanges) {
58+
const timeDiff = Date.now() - device.lastSeenChanges.first;
59+
messages = device.lastSeenChanges.messages;
60+
mps = timeDiff > 0 ? round4(messages / (timeDiff / 1000.0)) : 0;
61+
}
62+
63+
healthcheck.devices[ieeeAddr] = {
64+
messages,
65+
messages_per_sec: mps,
66+
leave_count: device.leaveCounts,
67+
network_address_changes: device.networkAddressChanges,
68+
};
69+
}
70+
71+
if (settings.get().health.reset_on_check) {
72+
this.clearStats();
73+
}
74+
75+
await this.mqtt.publish("bridge/health", JSON.stringify(healthcheck), {clientOptions: {retain: true, qos: 1}});
76+
}
77+
}

lib/mqtt.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ export default class Mqtt {
3030
private defaultPublishOptions: MqttPublishOptions;
3131
public retainedMessages: {[s: string]: {topic: string; payload: string; options: MqttPublishOptions}} = {};
3232

33+
get info() {
34+
return {
35+
version: this.client.options.protocolVersion,
36+
server: `${this.client.options.protocol}://${this.client.options.host}:${this.client.options.port}`,
37+
};
38+
}
39+
40+
get stats() {
41+
return {
42+
connected: this.isConnected(),
43+
queued: this.client.queue.length,
44+
};
45+
}
46+
3347
constructor(eventBus: EventBus) {
3448
this.eventBus = eventBus;
3549
this.defaultPublishOptions = {

lib/types/api.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ export interface Zigbee2MQTTSettings {
183183
output: "json" | "attribute" | "attribute_and_json";
184184
transmit_power?: number;
185185
};
186+
health: {
187+
/** in minutes */
188+
interval: number;
189+
reset_on_check: boolean;
190+
};
186191
}
187192

188193
export interface Zigbee2MQTTScene {
@@ -330,6 +335,16 @@ export interface Zigbee2MQTTAPI {
330335
};
331336

332337
"bridge/info": {
338+
os: {
339+
version: string;
340+
node_version: string;
341+
cpus: string;
342+
memory_mb: number;
343+
};
344+
mqtt: {
345+
version: number | undefined;
346+
server: string;
347+
};
333348
version: string;
334349
commit: string | undefined;
335350
zigbee_herdsman_converters: {version: string};
@@ -355,6 +370,36 @@ export interface Zigbee2MQTTAPI {
355370
config_schema: typeof schemaJson;
356371
};
357372

373+
"bridge/health": {
374+
/** time of message, msec from epoch, UTC */
375+
response_time: number;
376+
os: {
377+
load_average: number[];
378+
memory_used_mb: number;
379+
memory_percent: number;
380+
};
381+
process: {
382+
uptime_sec: number;
383+
memory_used_mb: number;
384+
memory_percent: number;
385+
};
386+
mqtt: {
387+
connected: boolean;
388+
queued: number;
389+
received: number;
390+
published: number;
391+
};
392+
devices: Record<
393+
string /* ieee */,
394+
{
395+
messages: number;
396+
messages_per_sec: number;
397+
leave_count: number;
398+
network_address_changes: number;
399+
}
400+
>;
401+
};
402+
358403
"bridge/devices": Zigbee2MQTTDevice[];
359404

360405
"bridge/groups": Zigbee2MQTTGroup[];

lib/util/settings.schema.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,27 @@
770770
"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)"
771771
}
772772
}
773+
},
774+
"health": {
775+
"title": "Health",
776+
"description": "Periodically check the health of Zigbee2MQTT",
777+
"type": "object",
778+
"properties": {
779+
"interval": {
780+
"type": "number",
781+
"title": "Interval",
782+
"description": "Interval between checks in minutes",
783+
"default": 10,
784+
"requiresRestart": true
785+
},
786+
"reset_on_check": {
787+
"type": "boolean",
788+
"title": "Reset on check",
789+
"description": "If true, will reset stats every time the health check is executed (only applicable to stats that can be reset).",
790+
"default": false
791+
}
792+
},
793+
"required": []
773794
}
774795
},
775796
"required": ["mqtt"],

lib/util/settings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ export const defaults = {
113113
timestamp_format: "YYYY-MM-DD HH:mm:ss",
114114
output: "json",
115115
},
116+
health: {
117+
interval: 10,
118+
reset_on_check: false,
119+
},
116120
} satisfies RecursivePartial<Settings>;
117121

118122
let _settings: Partial<Settings> | undefined;

lib/util/utils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,9 +279,9 @@ function isZHGroup(obj: unknown): obj is zh.Group {
279279
return obj?.constructor.name.toLowerCase() === "group";
280280
}
281281

282-
const hours = (hours: number): number => 1000 * 60 * 60 * hours;
283-
const minutes = (minutes: number): number => 1000 * 60 * minutes;
284-
const seconds = (seconds: number): number => 1000 * seconds;
282+
export const hours = (hours: number): number => 1000 * 60 * 60 * hours;
283+
export const minutes = (minutes: number): number => 1000 * 60 * minutes;
284+
export const seconds = (seconds: number): number => 1000 * seconds;
285285

286286
async function publishLastSeen(
287287
data: eventdata.LastSeenChanged,

0 commit comments

Comments
 (0)