Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
4 changes: 1 addition & 3 deletions lib/extension/bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,9 +518,7 @@ export default class Bind extends Extension {

for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
for (const endpoint of device.zh.endpoints) {
for (const bind of endpoint.binds) {
allBinds.push(bind);
}
allBinds.push(...endpoint.binds);
}
}

Expand Down
18 changes: 18 additions & 0 deletions lib/extension/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default class Bridge extends Extension {
health_check: this.healthCheck,
coordinator_check: this.coordinatorCheck,
options: this.bridgeOptions,
action: this.action,
};

override async start(): Promise<void> {
Expand Down Expand Up @@ -597,6 +598,22 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {id: message.id, source});
}

@bind async action(message: string | KeyValue): Promise<Zigbee2MQTTResponse<"bridge/response/action">> {
if (typeof message !== "object" || !message.action) {
throw new Error("Invalid payload");
}

const action = zhc.ACTIONS[message.action];

if (action === undefined) {
throw new Error("Invalid action");
}

const response = await action(this.zigbee.zhController, message.params ?? {});

return utils.getResponse(message, response);
}

async renameEntity<T extends "device" | "group">(
entityType: T,
message: string | KeyValue,
Expand Down Expand Up @@ -855,6 +872,7 @@ export default class Bridge extends Extension {
const data: Zigbee2MQTTAPI["bridge/definitions"] = {
clusters: Zcl.Clusters,
custom_clusters: {},
actions: Object.keys(zhc.ACTIONS),
};

for (const device of this.zigbee.devicesIterator((d) => !utils.objectIsEmpty(d.customClusters))) {
Expand Down
12 changes: 10 additions & 2 deletions lib/types/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type * as zigbeeHerdsman from "zigbee-herdsman/dist";
import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events";
import type {Eui64} from "zigbee-herdsman/dist/zspec/tstypes";
import type {ClusterDefinition, ClusterName, CustomClusters} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
import type {RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
import type {GenericZdoResponse, RoutingTableEntry} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
import type * as zigbeeHerdsmanConverter from "zigbee-herdsman-converters";
import type {Base} from "zigbee-herdsman-converters/lib/exposes";

Expand Down Expand Up @@ -315,6 +316,7 @@ export interface Zigbee2MQTTAPI {
"bridge/definitions": {
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
custom_clusters: Record<string, CustomClusters>;
actions: string[];
};

"bridge/event":
Expand Down Expand Up @@ -852,6 +854,10 @@ export interface Zigbee2MQTTAPI {
channel: number;
};

"bridge/request/action": {action: string; params?: Record<string, unknown>};

"bridge/response/action": GenericZdoResponse | ZclPayload | undefined;

/**
* entity state response
*/
Expand Down Expand Up @@ -940,6 +946,7 @@ export type Zigbee2MQTTRequestEndpoints =
| "bridge/request/touchlink/factory_reset"
| "bridge/request/touchlink/scan"
| "bridge/request/touchlink/identify"
| "bridge/request/action"
| "{friendlyNameOrId}/set"
| "{friendlyNameOrId}/set/{attribute}"
| "{friendlyNameOrId}/{endpoint}/set"
Expand Down Expand Up @@ -986,7 +993,8 @@ export type Zigbee2MQTTResponseEndpoints =
| "bridge/response/group/members/remove_all"
| "bridge/response/touchlink/factory_reset"
| "bridge/response/touchlink/scan"
| "bridge/response/touchlink/identify";
| "bridge/response/touchlink/identify"
| "bridge/response/action";

export type Zigbee2MQTTRequest<T extends Zigbee2MQTTRequestEndpoints> = {
transaction?: string;
Expand Down
4 changes: 2 additions & 2 deletions lib/util/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ async function startOnboardingServer(): Promise<boolean> {
}
});

server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
console.log(`Onboarding page is available at ${serverUrl.href}`);
});
});
Expand All @@ -515,7 +515,7 @@ async function startFailureServer(errors: string): Promise<void> {
}
});

server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => {
server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => {
console.error(`Failure page is available at ${serverUrl.href}`);
});
});
Expand Down
2 changes: 1 addition & 1 deletion lib/util/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ export function addGroup(name: string, id?: string): GroupOptions {
id = "1";

while (settings.groups[id]) {
id = (Number.parseInt(id) + 1).toString();
id = (Number.parseInt(id, 10) + 1).toString();
}
} else {
// ensure provided ID is not in use
Expand Down
79 changes: 41 additions & 38 deletions lib/zigbee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import stringify from "json-stable-stringify-without-jsonify";
import type {Events as ZHEvents} from "zigbee-herdsman";
import {Controller} from "zigbee-herdsman";
import type {StartResult} from "zigbee-herdsman/dist/adapter/tstype";

import Device from "./model/device";
import Group from "./model/group";
import data from "./util/data";
Expand All @@ -15,7 +14,7 @@ import utils from "./util/utils";
const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/;

export default class Zigbee {
private herdsman!: Controller;
#herdsman!: Controller;
private eventBus: EventBus;
private groupLookup = new Map<number /* group ID */, Group>();
private deviceLookup = new Map<string /* IEEE address */, Device>();
Expand All @@ -25,6 +24,10 @@ export default class Zigbee {
this.eventBus = eventBus;
}

get zhController() {
return this.#herdsman;
}

async start(): Promise<StartResult> {
const infoHerdsman = await utils.getDependencyVersion("zigbee-herdsman");
logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`);
Expand Down Expand Up @@ -63,57 +66,57 @@ export default class Zigbee {

let startResult: StartResult;
try {
this.herdsman = new Controller(herdsmanSettings);
startResult = await this.herdsman.start();
this.#herdsman = new Controller(herdsmanSettings);
startResult = await this.#herdsman.start();
} catch (error) {
logger.error("Error while starting zigbee-herdsman");
throw error;
}

this.coordinatorIeeeAddr = this.herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
this.coordinatorIeeeAddr = this.#herdsman.getDevicesByType("Coordinator")[0].ieeeAddr;
await this.resolveDevicesDefinitions();

this.herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected());
this.herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => {
this.#herdsman.on("adapterDisconnected", () => this.eventBus.emitAdapterDisconnected());
this.#herdsman.on("lastSeenChanged", (data: ZHEvents.LastSeenChangedPayload) => {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)!, reason: data.reason});
});
this.herdsman.on("permitJoinChanged", (data: ZHEvents.PermitJoinChangedPayload) => {
this.#herdsman.on("permitJoinChanged", (data: ZHEvents.PermitJoinChangedPayload) => {
this.eventBus.emitPermitJoinChanged(data);
});
this.herdsman.on("deviceNetworkAddressChanged", (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
this.#herdsman.on("deviceNetworkAddressChanged", (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
const device = this.resolveDevice(data.device.ieeeAddr)!;
logger.debug(`Device '${device.name}' changed network address`);
this.eventBus.emitDeviceNetworkAddressChanged({device});
});
this.herdsman.on("deviceAnnounce", (data: ZHEvents.DeviceAnnouncePayload) => {
this.#herdsman.on("deviceAnnounce", (data: ZHEvents.DeviceAnnouncePayload) => {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
const device = this.resolveDevice(data.device.ieeeAddr)!;
logger.debug(`Device '${device.name}' announced itself`);
this.eventBus.emitDeviceAnnounce({device});
});
this.herdsman.on("deviceInterview", async (data: ZHEvents.DeviceInterviewPayload) => {
this.#herdsman.on("deviceInterview", async (data: ZHEvents.DeviceInterviewPayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
/* v8 ignore next */ if (!device) return; // Prevent potential race
await device.resolveDefinition();
const d = {device, status: data.status};
this.logDeviceInterview(d);
this.eventBus.emitDeviceInterview(d);
});
this.herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => {
this.#herdsman.on("deviceJoined", async (data: ZHEvents.DeviceJoinedPayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
/* v8 ignore next */ if (!device) return; // Prevent potential race
await device.resolveDefinition();
logger.info(`Device '${device.name}' joined`);
this.eventBus.emitDeviceJoined({device});
});
this.herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => {
this.#herdsman.on("deviceLeave", (data: ZHEvents.DeviceLeavePayload) => {
const name = settings.getDevice(data.ieeeAddr)?.friendly_name || data.ieeeAddr;
logger.warning(`Device '${name}' left the network`);
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name, device: this.deviceLookup.get(data.ieeeAddr)});
});
this.herdsman.on("message", async (data: ZHEvents.MessagePayload) => {
this.#herdsman.on("message", async (data: ZHEvents.MessagePayload) => {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
const device = this.resolveDevice(data.device.ieeeAddr)!;
await device.resolveDefinition();
Expand All @@ -129,7 +132,7 @@ export default class Zigbee {

logger.info(`zigbee-herdsman started (${startResult})`);
logger.info(`Coordinator firmware version: '${stringify(await this.getCoordinatorVersion())}'`);
logger.debug(`Zigbee network parameters: ${stringify(await this.herdsman.getNetworkParameters())}`);
logger.debug(`Zigbee network parameters: ${stringify(await this.#herdsman.getNetworkParameters())}`);

for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
// If a passlist is used, all other device will be removed from the network.
Expand Down Expand Up @@ -198,39 +201,39 @@ export default class Zigbee {
}

async getCoordinatorVersion(): Promise<zh.CoordinatorVersion> {
return await this.herdsman.getCoordinatorVersion();
return await this.#herdsman.getCoordinatorVersion();
}

isStopping(): boolean {
return this.herdsman.isStopping();
return this.#herdsman.isStopping();
}

async backup(): Promise<void> {
return await this.herdsman.backup();
return await this.#herdsman.backup();
}

async coordinatorCheck(): Promise<{missingRouters: Device[]}> {
const check = await this.herdsman.coordinatorCheck();
const check = await this.#herdsman.coordinatorCheck();
// biome-ignore lint/style/noNonNullAssertion: assumed valid
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr)!)};
}

async getNetworkParameters(): Promise<zh.NetworkParameters> {
return await this.herdsman.getNetworkParameters();
return await this.#herdsman.getNetworkParameters();
}

async stop(): Promise<void> {
logger.info("Stopping zigbee-herdsman...");
await this.herdsman.stop();
await this.#herdsman.stop();
logger.info("Stopped zigbee-herdsman");
}

getPermitJoin(): boolean {
return this.herdsman.getPermitJoin();
return this.#herdsman.getPermitJoin();
}

getPermitJoinEnd(): number | undefined {
return this.herdsman.getPermitJoinEnd();
return this.#herdsman.getPermitJoinEnd();
}

async permitJoin(time: number, device?: Device): Promise<void> {
Expand All @@ -240,7 +243,7 @@ export default class Zigbee {
logger.info("Zigbee: disabling joining new devices.");
}

await this.herdsman.permitJoin(time, device?.zh);
await this.#herdsman.permitJoin(time, device?.zh);
}

async resolveDevicesDefinitions(ignoreCache = false): Promise<void> {
Expand All @@ -251,7 +254,7 @@ export default class Zigbee {

@bind private resolveDevice(ieeeAddr: string): Device | undefined {
if (!this.deviceLookup.has(ieeeAddr)) {
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
const device = this.#herdsman.getDeviceByIeeeAddr(ieeeAddr);
if (device) {
this.deviceLookup.set(ieeeAddr, new Device(device));
}
Expand All @@ -266,7 +269,7 @@ export default class Zigbee {

private resolveGroup(groupID: number): Group | undefined {
if (!this.groupLookup.has(groupID)) {
const group = this.herdsman.getGroupByID(groupID);
const group = this.#herdsman.getGroupByID(groupID);

if (group) {
this.groupLookup.set(groupID, new Group(group, this.resolveDevice));
Expand Down Expand Up @@ -339,33 +342,33 @@ export default class Zigbee {
}

firstCoordinatorEndpoint(): zh.Endpoint {
return this.herdsman.getDevicesByType("Coordinator")[0].endpoints[0];
return this.#herdsman.getDevicesByType("Coordinator")[0].endpoints[0];
}

*devicesAndGroupsIterator(
devicePredicate?: (value: zh.Device) => boolean,
groupPredicate?: (value: zh.Group) => boolean,
): Generator<Device | Group> {
for (const device of this.herdsman.getDevicesIterator(devicePredicate)) {
for (const device of this.#herdsman.getDevicesIterator(devicePredicate)) {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
yield this.resolveDevice(device.ieeeAddr)!;
}

for (const group of this.herdsman.getGroupsIterator(groupPredicate)) {
for (const group of this.#herdsman.getGroupsIterator(groupPredicate)) {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
yield this.resolveGroup(group.groupID)!;
}
}

*groupsIterator(predicate?: (value: zh.Group) => boolean): Generator<Group> {
for (const group of this.herdsman.getGroupsIterator(predicate)) {
for (const group of this.#herdsman.getGroupsIterator(predicate)) {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
yield this.resolveGroup(group.groupID)!;
}
}

*devicesIterator(predicate?: (value: zh.Device) => boolean): Generator<Device> {
for (const device of this.herdsman.getDevicesIterator(predicate)) {
for (const device of this.#herdsman.getDevicesIterator(predicate)) {
// biome-ignore lint/style/noNonNullAssertion: assumed valid
yield this.resolveDevice(device.ieeeAddr)!;
}
Expand Down Expand Up @@ -399,33 +402,33 @@ export default class Zigbee {
}

async touchlinkFactoryResetFirst(): Promise<boolean> {
return await this.herdsman.touchlink.factoryResetFirst();
return await this.#herdsman.touchlink.factoryResetFirst();
}

async touchlinkFactoryReset(ieeeAddr: string, channel: number): Promise<boolean> {
return await this.herdsman.touchlink.factoryReset(ieeeAddr, channel);
return await this.#herdsman.touchlink.factoryReset(ieeeAddr, channel);
}

async addInstallCode(installCode: string): Promise<void> {
await this.herdsman.addInstallCode(installCode);
await this.#herdsman.addInstallCode(installCode);
}

async touchlinkIdentify(ieeeAddr: string, channel: number): Promise<void> {
await this.herdsman.touchlink.identify(ieeeAddr, channel);
await this.#herdsman.touchlink.identify(ieeeAddr, channel);
}

async touchlinkScan(): Promise<{ieeeAddr: string; channel: number}[]> {
return await this.herdsman.touchlink.scan();
return await this.#herdsman.touchlink.scan();
}

createGroup(id: number): Group {
this.herdsman.createGroup(id);
this.#herdsman.createGroup(id);
// biome-ignore lint/style/noNonNullAssertion: just created
return this.resolveGroup(id)!;
}

deviceByNetworkAddress(networkAddress: number): Device | undefined {
const device = this.herdsman.getDeviceByNetworkAddress(networkAddress);
const device = this.#herdsman.getDeviceByNetworkAddress(networkAddress);
return device && this.resolveDevice(device.ieeeAddr);
}

Expand Down
Loading