zigbee-herdsman
Version:
An open source ZigBee gateway solution with node.js.
1,267 lines (1,115 loc) • 51.8 kB
text/typescript
import {randomBytes} from "node:crypto";
import {mkdirSync, rmSync, writeFileSync} from "node:fs";
import {join} from "node:path";
import {SPINEL_HEADER_FLG_SPINEL, encodeSpinelFrame} from "zigbee-on-host/dist/spinel/spinel";
import {SpinelStatus} from "zigbee-on-host/dist/spinel/statuses";
import type {MACCapabilities} from "zigbee-on-host/dist/zigbee/mac";
import type {ZigbeeNWKLinkStatus} from "zigbee-on-host/dist/zigbee/zigbee-nwk";
import {bigUInt64ToHexBE} from "../../../src/adapter/zoh/adapter/utils";
import {ZoHAdapter} from "../../../src/adapter/zoh/adapter/zohAdapter";
import * as ZSpec from "../../../src/zspec";
import * as Zcl from "../../../src/zspec/zcl";
import * as Zdo from "../../../src/zspec/zdo";
const TEMP_PATH = "zoh-tmp";
const TEMP_PATH_SAVE = join(TEMP_PATH, "zoh.save");
const DEFAULT_PAN_ID = 0x1a62;
const DEFAULT_EXT_PAN_ID = [0xdd, 0x11, 0x22, 0xdd, 0xdd, 0x33, 0x44, 0xdd];
const DEFAULT_CHANNEL = 11;
const DEFAULT_NETWORK_KEY = [0x11, 0x03, 0x15, 0x07, 0x09, 0x0b, 0x0d, 0x0f, 0x00, 0x02, 0x04, 0x06, 0x08, 0x1a, 0x1c, 0x1d];
const DEFAULT_STATE_FILE_HEX =
"5a6f486f6e5a324d621add1122dddd3344dd0b001311031507090b0d0f00020406081a1c1d00040000005a6967426565416c6c69616e636530390004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
// biome-ignore lint/correctness/noUnusedVariables: dev
const randomBigInt = (): bigint => BigInt(`0x${randomBytes(8).toString("hex")}`);
/** SL-OPENTHREAD/2.5.2.0_GitHub-1fceb225b; EFR32; Mar 19 2025 13:45:44 */
const START_FRAMES_SILABS = {
protocolVersion: "7e8106010403db0a7e",
ncpVersion:
"7e820602534c2d4f50454e5448524541442f322e352e322e305f4769744875622d3166636562323235623b2045465233323b204d617220313920323032352031333a34353a343400b5dc7e",
interfaceType: "7e83060303573a7e",
rcpAPIVersion: "7e8406b0010a681f7e",
rcpMinHostAPIVersion: "7e8506b101048ea77e",
resetPowerOn: "7e80060070ee747e",
};
/** SL-OPENTHREAD/2.5.2.0_GitHub-1fceb225b; EFR32; Mar 19 2025 13:45:44 */
const FORM_FRAMES_SILABS = {
phyEnabled: "7e87062001f2627e",
phyChan: "7e88062114ff8e7e",
phyTxPowerSet: "7e8906257d339b817e",
mac154LAddr: "7e8a06344d325a6e6f486f5a8f327e",
mac154SAddr: "7e8b0635000047f67e",
mac154PANId: "7e8c0636d98579727e",
macRxOnWhenIdleMode: "7e8d060000e68c7e",
macRawStreamEnabled: "7e8e06370108437e",
phyTxPowerGet: "7e8106257d3343647e",
phyRSSIGet: "7e820626983d517e",
phyRXSensitivityGet: "7e8306279c7a127e",
phyCCAThresholdGet: "7e840624b5f0d37e",
};
// /** SL-OPENTHREAD/2.5.2.0_GitHub-1fceb225b; EFR32; Mar 19 2025 13:45:44 */
// const STOP_FRAMES_SILABS = {
// macRawStreamEnabled: "7e8b063700d63c7e",
// phyEnabled: "7e8c0620006eb37e",
// }
const COMMON_FFD_MAC_CAP: MACCapabilities = {
alternatePANCoordinator: false,
deviceType: 1,
powerSource: 1,
rxOnWhenIdle: true,
securityCapability: false,
allocateAddress: true,
};
const COMMON_RFD_MAC_CAP: MACCapabilities = {
alternatePANCoordinator: false,
deviceType: 0,
powerSource: 0,
rxOnWhenIdle: false,
securityCapability: false,
allocateAddress: true,
};
describe("ZigBee on Host", () => {
let adapter: ZoHAdapter;
let nextTidFromStartup = 1;
const deleteZoHSave = () => {
rmSync(TEMP_PATH_SAVE, {force: true});
};
const makeSpinelLastStatus = (tid: number, status: SpinelStatus = SpinelStatus.OK): Buffer => {
const respSpinelFrame = {
header: {
tid,
nli: 0,
flg: SPINEL_HEADER_FLG_SPINEL,
},
commandId: 6 /* PROP_VALUE_IS */,
payload: Buffer.from([0 /* LAST_STATUS */, status]),
};
const encRespHdlcFrame = encodeSpinelFrame(respSpinelFrame);
return Buffer.from(encRespHdlcFrame.data.subarray(0, encRespHdlcFrame.length));
};
// biome-ignore lint/correctness/noUnusedVariables: dev
const makeSpinelStreamRaw = (tid: number, macFrame: Buffer, spinelMeta?: Buffer): Buffer => {
const spinelFrame = {
header: {
tid,
nli: 0,
flg: SPINEL_HEADER_FLG_SPINEL,
},
commandId: 6 /* PROP_VALUE_IS */,
payload: Buffer.from([
113 /* STREAM_RAW */,
macFrame.byteLength & 0xff,
(macFrame.byteLength >> 8) & 0xff,
...macFrame,
...(spinelMeta || []),
]),
};
const encHdlcFrame = encodeSpinelFrame(spinelFrame);
return Buffer.from(encHdlcFrame.data.subarray(0, encHdlcFrame.length));
};
const mockStart = async (loadState = true, frames = START_FRAMES_SILABS) => {
if (adapter.driver) {
let loadStateSpy: ReturnType<typeof vi.spyOn> | undefined;
if (!loadState) {
loadStateSpy = vi.spyOn(adapter.driver, "loadState").mockResolvedValue(undefined);
}
let i = -1;
const orderedFrames = [
frames.protocolVersion,
frames.ncpVersion,
frames.interfaceType,
frames.rcpAPIVersion,
frames.rcpMinHostAPIVersion,
frames.resetPowerOn,
];
const reply = async () => {
await vi.advanceTimersByTimeAsync(5);
// skip cancel byte
if (i >= 0) {
adapter.driver.parser._transform(Buffer.from(orderedFrames[i], "hex"), "utf8", () => {});
await vi.advanceTimersByTimeAsync(5);
}
i++;
if (i === orderedFrames.length) {
adapter.driver.writer.removeListener("data", reply);
}
};
adapter.driver.writer.on("data", reply);
await adapter.driver.start();
loadStateSpy?.mockRestore();
await vi.advanceTimersByTimeAsync(100); // flush
nextTidFromStartup = adapter.driver.currentSpinelTID + 1;
}
};
const mockStop = async (expectThrow?: string) => {
if (adapter.driver) {
const setPropertySpy = vi.spyOn(adapter.driver, "setProperty").mockResolvedValue();
if (expectThrow !== undefined) {
await expect(adapter.driver.stop()).rejects.toThrow();
} else {
await adapter.driver.stop();
}
setPropertySpy.mockRestore();
await vi.advanceTimersByTimeAsync(100); // flush
}
nextTidFromStartup = 1;
};
const mockFormNetwork = async (registerTimers = false, frames = FORM_FRAMES_SILABS) => {
if (adapter.driver) {
let i = 0;
const orderedFrames = [
frames.phyEnabled,
frames.phyChan,
frames.phyTxPowerSet,
frames.mac154LAddr,
frames.mac154SAddr,
frames.mac154PANId,
frames.macRxOnWhenIdleMode,
frames.macRawStreamEnabled,
frames.phyTxPowerGet,
frames.phyRSSIGet,
frames.phyRXSensitivityGet,
frames.phyCCAThresholdGet,
];
const reply = async () => {
await vi.advanceTimersByTimeAsync(5);
adapter.driver.parser._transform(Buffer.from(orderedFrames[i], "hex"), "utf8", () => {});
await vi.advanceTimersByTimeAsync(5);
i++;
if (i === orderedFrames.length) {
adapter.driver.writer.removeListener("data", reply);
}
};
adapter.driver.writer.on("data", reply);
let registerTimersSpy: ReturnType<typeof vi.spyOn> | undefined;
if (registerTimers) {
await mockRegisterTimers();
} else {
registerTimersSpy = vi.spyOn(adapter.driver, "registerTimers").mockResolvedValue();
}
await adapter.driver.formNetwork();
registerTimersSpy?.mockRestore();
await vi.advanceTimersByTimeAsync(100); // flush
nextTidFromStartup = adapter.driver.currentSpinelTID + 1;
}
};
const mockRegisterTimers = async () => {
if (adapter.driver) {
let linksSpy: ZigbeeNWKLinkStatus[] | undefined;
let manyToOneSpy: number | undefined;
let destination16Spy: number | undefined;
// creates a bottleneck with vitest & promises, noop it
const savePeriodicStateSpy = vi.spyOn(adapter.driver, "savePeriodicState").mockResolvedValue();
const sendZigbeeNWKLinkStatusSpy = vi.spyOn(adapter.driver, "sendZigbeeNWKLinkStatus").mockImplementationOnce(async (links) => {
linksSpy = links;
const p = adapter.driver.sendZigbeeNWKLinkStatus(links);
// LINK_STATUS => OK
adapter.driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup), "utf8", () => {});
await vi.advanceTimersByTimeAsync(10);
await p;
});
const sendZigbeeNWKRouteReqSpy = vi
.spyOn(adapter.driver, "sendZigbeeNWKRouteReq")
.mockImplementationOnce(async (manyToOne, destination16) => {
manyToOneSpy = manyToOne;
destination16Spy = destination16;
const p = adapter.driver.sendZigbeeNWKRouteReq(manyToOne, destination16);
// ROUTE_REQ => OK
adapter.driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 1), "utf8", () => {});
await vi.advanceTimersByTimeAsync(10);
return await p;
});
await adapter.driver.registerTimers();
await vi.advanceTimersByTimeAsync(100); // flush
expect(savePeriodicStateSpy).toHaveBeenCalledTimes(1);
expect(sendZigbeeNWKLinkStatusSpy).toHaveBeenCalledTimes(1 + 1); // *2 by spy mock
expect(sendZigbeeNWKRouteReqSpy).toHaveBeenCalledTimes(1 + 1); // *2 by spy mock
nextTidFromStartup = adapter.driver.currentSpinelTID + 1;
return [linksSpy, manyToOneSpy, destination16Spy];
}
return [undefined, undefined, undefined];
};
beforeAll(() => {
vi.useFakeTimers();
rmSync(TEMP_PATH, {force: true, recursive: true});
mkdirSync(TEMP_PATH, {recursive: true});
});
afterAll(() => {
vi.useRealTimers();
rmSync(TEMP_PATH, {force: true, recursive: true});
});
beforeEach(async () => {
deleteZoHSave();
adapter = new ZoHAdapter(
{
panID: DEFAULT_PAN_ID,
extendedPanID: DEFAULT_EXT_PAN_ID,
channelList: [DEFAULT_CHANNEL],
networkKey: DEFAULT_NETWORK_KEY,
networkKeyDistribute: false,
},
{
baudRate: 460800,
rtscts: true,
path: "/dev/serial/by-id/mock-adapter",
adapter: "zoh",
},
join(TEMP_PATH, "ember_coordinator_backup.json"),
{
concurrent: 8,
disableLED: false,
transmitPower: 19,
},
);
vi.spyOn(adapter, "initPort").mockImplementation(async () => {});
vi.spyOn(adapter.driver, "start").mockImplementationOnce(async () => {
await mockStart();
});
vi.spyOn(adapter.driver, "formNetwork").mockImplementationOnce(async () => {
await mockFormNetwork();
});
vi.spyOn(adapter.driver, "stop").mockImplementationOnce(async () => {
await mockStop();
});
vi.spyOn(adapter.driver.writer, "pipe").mockImplementation(
// @ts-expect-error mock noop
() => {},
);
adapter.driver.parser.on("data", adapter.driver.onFrame.bind(adapter.driver));
});
afterEach(async () => {
await adapter.stop();
});
it("Adapter impl: gets state", async () => {
await expect(adapter.start()).resolves.toStrictEqual("reset");
await expect(adapter.getCoordinatorIEEE()).resolves.toStrictEqual("0x4d325a6e6f486f5a");
await expect(adapter.getCoordinatorVersion()).resolves.toStrictEqual({
type: "ZigBee on Host",
meta: {
major: 4,
minor: 3,
apiVersion: 10,
version: "SL-OPENTHREAD/2.5.2.0_GitHub-1fceb225b; EFR32; Mar 19 2025 13:45:44",
revision: "https://github.com/Nerivec/zigbee-on-host (using: SL-OPENTHREAD/2.5.2.0_GitHub-1fceb225b; EFR32; Mar 19 2025 13:45:44)",
},
});
await expect(adapter.getNetworkParameters()).resolves.toStrictEqual({
panID: DEFAULT_PAN_ID,
extendedPanID: `0x${bigUInt64ToHexBE(Buffer.from(DEFAULT_EXT_PAN_ID).readBigUint64LE())}`,
channel: DEFAULT_CHANNEL,
nwkUpdateID: 0,
});
});
it("Adapter impl: sendZdo to device", async () => {
await adapter.start();
await adapter.driver.associate(0x2211, BigInt("0x0807060504030201"), true, structuredClone(COMMON_FFD_MAC_CAP), true, false, true);
const p1 = adapter.sendZdo(
"0x0807060504030201",
0x2211,
Zdo.ClusterId.IEEE_ADDRESS_REQUEST,
Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.IEEE_ADDRESS_REQUEST, 0x2211, false, 0),
false,
);
await vi.advanceTimersByTimeAsync(10);
adapter.driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup, SpinelStatus.OK), "utf8", () => {});
await vi.advanceTimersByTimeAsync(10);
adapter.driver.emit(
"frame",
0x2211,
BigInt("0x0807060504030201"),
{
frameControl: {
frameType: 0 /* DATA */,
deliveryMode: 0 /* UNICAST */,
ackFormat: false,
security: false,
ackRequest: false,
extendedHeader: false,
},
profileId: 0x0,
clusterId: Zdo.ClusterId.IEEE_ADDRESS_RESPONSE,
sourceEndpoint: 0x0,
destEndpoint: 0x0,
},
Buffer.from([1, 0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x11, 0x22]),
150,
);
await vi.advanceTimersByTimeAsync(10);
await expect(p1).resolves.toStrictEqual([
0,
{
eui64: "0x0807060504030201",
nwkAddress: 0x2211,
startIndex: 0,
assocDevList: [],
},
]);
const p2 = adapter.sendZdo(
"0x0807060504030201",
0x2211,
Zdo.ClusterId.NETWORK_ADDRESS_REQUEST,
Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.NETWORK_ADDRESS_REQUEST, "0x0807060504030201", false, 0),
false,
);
await vi.advanceTimersByTimeAsync(10);
adapter.driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 1, SpinelStatus.OK), "utf8", () => {});
await vi.advanceTimersByTimeAsync(10);
adapter.driver.emit(
"frame",
0x2211,
BigInt("0x0807060504030201"),
{
frameControl: {
frameType: 0 /* DATA */,
deliveryMode: 0 /* UNICAST */,
ackFormat: false,
security: false,
ackRequest: false,
extendedHeader: false,
},
profileId: 0x0,
clusterId: Zdo.ClusterId.NETWORK_ADDRESS_RESPONSE,
sourceEndpoint: 0x0,
destEndpoint: 0x0,
},
Buffer.from([2, 0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x11, 0x22]),
150,
);
await vi.advanceTimersByTimeAsync(10);
await expect(p2).resolves.toStrictEqual([
0,
{
eui64: "0x0807060504030201",
nwkAddress: 0x2211,
startIndex: 0,
assocDevList: [],
},
]);
});
it("Adapter impl: sendZdo to coordinator", async () => {
await adapter.start();
const emitSpy = vi.spyOn(adapter, "emit");
await adapter.sendZdo(
`0x${bigUInt64ToHexBE(adapter.driver.netParams.eui64)}`,
0x0000,
Zdo.ClusterId.NODE_DESCRIPTOR_REQUEST,
Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.NODE_DESCRIPTOR_REQUEST, 0x0000),
false,
);
expect(emitSpy).toHaveBeenLastCalledWith("zdoResponse", Zdo.ClusterId.NODE_DESCRIPTOR_RESPONSE, [
0,
expect.objectContaining({
nwkAddress: 0x0000,
logicalType: 0x00,
manufacturerCode: Zcl.ManufacturerCode.CONNECTIVITY_STANDARDS_ALLIANCE,
serverMask: expect.objectContaining({primaryTrustCenter: 1, stackComplianceRevision: 22}),
}),
]);
await adapter.sendZdo(
`0x${bigUInt64ToHexBE(adapter.driver.netParams.eui64)}`,
0x0000,
Zdo.ClusterId.POWER_DESCRIPTOR_REQUEST,
Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.POWER_DESCRIPTOR_REQUEST, 0x0000),
false,
);
expect(emitSpy).toHaveBeenLastCalledWith("zdoResponse", Zdo.ClusterId.POWER_DESCRIPTOR_RESPONSE, [
0,
expect.objectContaining({nwkAddress: 0x0000}),
]);
await adapter.sendZdo(
`0x${bigUInt64ToHexBE(adapter.driver.netParams.eui64)}`,
0x0000,
Zdo.ClusterId.SIMPLE_DESCRIPTOR_REQUEST,
Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.SIMPLE_DESCRIPTOR_REQUEST, 0x0000, 1),
false,
);
expect(emitSpy).toHaveBeenLastCalledWith("zdoResponse", Zdo.ClusterId.SIMPLE_DESCRIPTOR_RESPONSE, [
0,
expect.objectContaining({endpoint: 1, profileId: ZSpec.HA_PROFILE_ID}),
]);
await adapter.sendZdo(
`0x${bigUInt64ToHexBE(adapter.driver.netParams.eui64)}`,
0x0000,
Zdo.ClusterId.ACTIVE_ENDPOINTS_REQUEST,
Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.ACTIVE_ENDPOINTS_REQUEST, 0x0000),
false,
);
expect(emitSpy).toHaveBeenLastCalledWith("zdoResponse", Zdo.ClusterId.ACTIVE_ENDPOINTS_RESPONSE, [
0,
{nwkAddress: 0, endpointList: [1, 242]},
]);
await expect(
adapter.sendZdo(
`0x${bigUInt64ToHexBE(adapter.driver.netParams.eui64)}`,
0x0000,
Zdo.ClusterId.PARENT_ANNOUNCE,
Zdo.Buffalo.buildRequest(true, Zdo.ClusterId.PARENT_ANNOUNCE, []),
false,
),
).rejects.toThrow(`Coordinator does not support ZDO cluster ${Zdo.ClusterId.PARENT_ANNOUNCE}`);
});
it("Adapter impl: permitJoin", async () => {
await adapter.start();
const sendZdoSpy = vi.spyOn(adapter, "sendZdo");
const allowJoinsSpy = vi.spyOn(adapter.driver, "allowJoins");
const gpEnterCommissioningModeSpy = vi.spyOn(adapter.driver, "gpEnterCommissioningMode");
sendZdoSpy.mockImplementationOnce(async () => [0, undefined]);
await adapter.permitJoin(254);
expect(allowJoinsSpy).toHaveBeenLastCalledWith(254, true);
expect(gpEnterCommissioningModeSpy).toHaveBeenLastCalledWith(254);
sendZdoSpy.mockImplementationOnce(async () => [0, undefined]);
await adapter.permitJoin(0);
expect(allowJoinsSpy).toHaveBeenLastCalledWith(0, true);
expect(gpEnterCommissioningModeSpy).toHaveBeenLastCalledWith(0);
await adapter.permitJoin(200, 0x0000);
expect(allowJoinsSpy).toHaveBeenLastCalledWith(200, true);
expect(gpEnterCommissioningModeSpy).toHaveBeenLastCalledWith(200);
sendZdoSpy.mockImplementationOnce(async () => [0, undefined]);
await adapter.permitJoin(0);
expect(allowJoinsSpy).toHaveBeenLastCalledWith(0, true);
expect(gpEnterCommissioningModeSpy).toHaveBeenLastCalledWith(0);
expect(gpEnterCommissioningModeSpy).toHaveBeenCalledTimes(4);
sendZdoSpy.mockImplementationOnce(async () => [0, undefined]);
await adapter.permitJoin(150, 0x1234);
expect(allowJoinsSpy).toHaveBeenLastCalledWith(150, false);
expect(gpEnterCommissioningModeSpy).toHaveBeenCalledTimes(4);
sendZdoSpy.mockImplementationOnce(async () => [0, undefined]);
await adapter.permitJoin(0);
expect(allowJoinsSpy).toHaveBeenLastCalledWith(0, true);
expect(gpEnterCommissioningModeSpy).toHaveBeenLastCalledWith(0);
expect(gpEnterCommissioningModeSpy).toHaveBeenCalledTimes(5);
});
it("Adapter impl: sendZclFrameToEndpoint", async () => {
await adapter.start();
await adapter.driver.associate(0x9876, BigInt("0x00000000000004d2"), true, structuredClone(COMMON_FFD_MAC_CAP), true, false, true);
const sendUnicastSpy = vi.spyOn(adapter.driver, "sendUnicast");
const zclPayload = Buffer.from([16, 123, Zcl.Foundation.read.ID]);
const zclFrame = Zcl.Frame.fromBuffer(Zcl.Clusters.genGroups.ID, Zcl.Header.fromBuffer(zclPayload), zclPayload, {});
const p1 = adapter.sendZclFrameToEndpoint("0x00000000000004d2", 0x9876, 1, zclFrame, 10000, false, false, 2);
await vi.advanceTimersByTimeAsync(10);
adapter.driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup, SpinelStatus.OK), "utf8", () => {});
await vi.advanceTimersByTimeAsync(10);
adapter.driver.emit(
"frame",
0x9876,
undefined,
{
frameControl: {
frameType: 0 /* DATA */,
deliveryMode: 0 /* UNICAST */,
ackFormat: false,
security: false,
ackRequest: false,
extendedHeader: false,
},
profileId: ZSpec.HA_PROFILE_ID,
clusterId: Zcl.Clusters.genGroups.ID,
sourceEndpoint: 0x1,
destEndpoint: 0x2,
},
Buffer.from([0, 123, Zcl.Foundation.read.response!, 0x01, 0xff]),
215,
);
await expect(p1).resolves.toStrictEqual({
address: 0x9876,
clusterID: Zcl.Clusters.genGroups.ID,
data: Buffer.from([0, 123, Zcl.Foundation.read.response!, 0x01, 0xff]),
destinationEndpoint: 2,
endpoint: 1,
groupID: undefined,
header: expect.objectContaining({
commandIdentifier: 1,
frameControl: {
direction: 0,
disableDefaultResponse: false,
frameType: 0,
manufacturerSpecific: false,
reservedBits: 0,
},
manufacturerCode: undefined,
transactionSequenceNumber: 123,
}),
linkquality: 215,
wasBroadcast: false,
});
expect(sendUnicastSpy).toHaveBeenLastCalledWith(zclFrame.toBuffer(), ZSpec.HA_PROFILE_ID, Zcl.Clusters.genGroups.ID, 0x9876, undefined, 1, 2);
sendUnicastSpy.mockResolvedValueOnce(2);
const p2 = adapter.sendZclFrameToEndpoint("0x00000000000004d2", 0x9876, 1, zclFrame, 10000, true, false);
await vi.advanceTimersByTimeAsync(10);
adapter.driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 1, SpinelStatus.OK), "utf8", () => {});
await vi.advanceTimersByTimeAsync(10);
await expect(p2).resolves.toStrictEqual(undefined);
expect(sendUnicastSpy).toHaveBeenLastCalledWith(zclFrame.toBuffer(), ZSpec.HA_PROFILE_ID, Zcl.Clusters.genGroups.ID, 0x9876, undefined, 1, 1);
const zclPayloadDefRsp = Buffer.from([0, 123, Zcl.Foundation.read.ID]);
const zclFrameDefRsp = Zcl.Frame.fromBuffer(Zcl.Clusters.genGroups.ID, Zcl.Header.fromBuffer(zclPayloadDefRsp), zclPayloadDefRsp, {});
sendUnicastSpy.mockResolvedValueOnce(3);
const p3 = adapter.sendZclFrameToEndpoint("0x00000000000004d2", 0x9876, 1, zclFrameDefRsp, 10000, true, false, 2);
await vi.advanceTimersByTimeAsync(10);
adapter.driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 2, SpinelStatus.OK), "utf8", () => {});
await vi.advanceTimersByTimeAsync(10);
adapter.driver.emit(
"frame",
0x9876,
undefined,
{
frameControl: {
frameType: 0 /* DATA */,
deliveryMode: 0 /* UNICAST */,
ackFormat: false,
security: false,
ackRequest: false,
extendedHeader: false,
},
profileId: ZSpec.HA_PROFILE_ID,
clusterId: Zcl.Clusters.genGroups.ID,
sourceEndpoint: 0x1,
destEndpoint: 0x2,
},
Buffer.from([0, 123, Zcl.Foundation.defaultRsp.ID, 0x01, 0xff]),
125,
);
await expect(p3).resolves.toStrictEqual({
address: 0x9876,
clusterID: Zcl.Clusters.genGroups.ID,
data: Buffer.from([0, 123, Zcl.Foundation.defaultRsp.ID, 0x01, 0xff]),
destinationEndpoint: 2,
endpoint: 1,
groupID: undefined,
header: expect.objectContaining({
commandIdentifier: Zcl.Foundation.defaultRsp.ID,
frameControl: {
direction: 0,
disableDefaultResponse: false,
frameType: 0,
manufacturerSpecific: false,
reservedBits: 0,
},
manufacturerCode: undefined,
transactionSequenceNumber: 123,
}),
linkquality: 125,
wasBroadcast: false,
});
expect(sendUnicastSpy).toHaveBeenLastCalledWith(
zclFrameDefRsp.toBuffer(),
ZSpec.HA_PROFILE_ID,
Zcl.Clusters.genGroups.ID,
0x9876,
undefined,
1,
2,
);
sendUnicastSpy.mockClear();
sendUnicastSpy.mockRejectedValueOnce(new Error("Failed")).mockResolvedValueOnce(2);
const p4 = adapter.sendZclFrameToEndpoint("0x00000000000004d2", 0x9876, 1, zclFrame, 10000, false, false, 2);
await vi.advanceTimersByTimeAsync(10);
adapter.driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 3, SpinelStatus.OK), "utf8", () => {});
await vi.advanceTimersByTimeAsync(10);
adapter.driver.emit(
"frame",
0x9876,
undefined,
{
frameControl: {
frameType: 0 /* DATA */,
deliveryMode: 0 /* UNICAST */,
ackFormat: false,
security: false,
ackRequest: false,
extendedHeader: false,
},
profileId: ZSpec.HA_PROFILE_ID,
clusterId: Zcl.Clusters.genGroups.ID,
sourceEndpoint: 0x1,
destEndpoint: 0x2,
},
Buffer.from([0, 123, Zcl.Foundation.read.response!, 0x01, 0xff]),
225,
);
await expect(p4).resolves.toStrictEqual({
address: 0x9876,
clusterID: Zcl.Clusters.genGroups.ID,
data: Buffer.from([0, 123, Zcl.Foundation.read.response!, 0x01, 0xff]),
destinationEndpoint: 2,
endpoint: 1,
groupID: undefined,
header: expect.objectContaining({
commandIdentifier: 1,
frameControl: {
direction: 0,
disableDefaultResponse: false,
frameType: 0,
manufacturerSpecific: false,
reservedBits: 0,
},
manufacturerCode: undefined,
transactionSequenceNumber: 123,
}),
linkquality: 225,
wasBroadcast: false,
});
expect(sendUnicastSpy).toHaveBeenLastCalledWith(zclFrame.toBuffer(), ZSpec.HA_PROFILE_ID, Zcl.Clusters.genGroups.ID, 0x9876, undefined, 1, 2);
expect(sendUnicastSpy).toHaveBeenCalledTimes(2);
sendUnicastSpy.mockClear();
sendUnicastSpy.mockRejectedValueOnce(new Error("Failed")).mockRejectedValueOnce(new Error("Failed"));
await expect(adapter.sendZclFrameToEndpoint("0x00000000000004d2", 0x9876, 1, zclFrame, 10000, false, false, 2)).rejects.toThrow("Failed");
expect(sendUnicastSpy).toHaveBeenCalledTimes(2);
const zclFrameGP = Zcl.Frame.fromBuffer(Zcl.Clusters.greenPower.ID, Zcl.Header.fromBuffer(zclPayload), zclPayload, {});
sendUnicastSpy.mockClear();
await expect(
adapter.sendZclFrameToEndpoint("0xb43a31fffe0f6aae", 57129, ZSpec.GP_ENDPOINT, zclFrameGP, 10000, true, false, ZSpec.GP_ENDPOINT),
).rejects.toThrow("Unknown destination");
expect(sendUnicastSpy).toHaveBeenCalledTimes(2);
});
it("Adapter impl: sendZclFrameToGroup", async () => {
await adapter.start();
const sendGroupcastSpy = vi.spyOn(adapter.driver, "sendGroupcast").mockResolvedValueOnce(1).mockResolvedValueOnce(1).mockResolvedValueOnce(1);
const zclPayload = Buffer.from([0, 123, Zcl.Foundation.read.ID]);
const zclFrame = Zcl.Frame.fromBuffer(Zcl.Clusters.genGroups.ID, Zcl.Header.fromBuffer(zclPayload), zclPayload, {});
const p1 = adapter.sendZclFrameToGroup(123, zclFrame, 5);
await vi.advanceTimersByTimeAsync(1000);
await expect(p1).resolves.toStrictEqual(undefined);
expect(sendGroupcastSpy).toHaveBeenLastCalledWith(zclFrame.toBuffer(), ZSpec.HA_PROFILE_ID, Zcl.Clusters.genGroups.ID, 123, 5);
const p2 = adapter.sendZclFrameToGroup(123, zclFrame);
await vi.advanceTimersByTimeAsync(1000);
await expect(p2).resolves.toStrictEqual(undefined);
expect(sendGroupcastSpy).toHaveBeenLastCalledWith(zclFrame.toBuffer(), ZSpec.HA_PROFILE_ID, Zcl.Clusters.genGroups.ID, 123, 1);
});
it("Adapter impl: sendZclFrameToAll", async () => {
await adapter.start();
const sendBroadcastSpy = vi.spyOn(adapter.driver, "sendBroadcast").mockResolvedValueOnce(1).mockResolvedValueOnce(1);
const zclPayload = Buffer.from([0, 123, Zcl.Foundation.read.ID]);
const zclFrame = Zcl.Frame.fromBuffer(Zcl.Clusters.genAlarms.ID, Zcl.Header.fromBuffer(zclPayload), zclPayload, {});
const p = adapter.sendZclFrameToAll(3, zclFrame, 1, 0xfffc);
await vi.advanceTimersByTimeAsync(1000);
await expect(p).resolves.toStrictEqual(undefined);
expect(sendBroadcastSpy).toHaveBeenLastCalledWith(zclFrame.toBuffer(), ZSpec.HA_PROFILE_ID, Zcl.Clusters.genAlarms.ID, 0xfffc, 3, 1);
const p2 = adapter.sendZclFrameToAll(ZSpec.GP_ENDPOINT, zclFrame, ZSpec.GP_ENDPOINT, 0xfffc);
await vi.advanceTimersByTimeAsync(1000);
await expect(p2).resolves.toStrictEqual(undefined);
expect(sendBroadcastSpy).toHaveBeenLastCalledWith(
zclFrame.toBuffer(),
ZSpec.GP_PROFILE_ID,
Zcl.Clusters.genAlarms.ID,
0xfffc,
ZSpec.GP_ENDPOINT,
ZSpec.GP_ENDPOINT,
);
});
it("receives ZDO frame", async () => {
await adapter.start();
const emitSpy = vi.spyOn(adapter, "emit");
adapter.driver.emit(
"frame",
0x2211,
578437695752307201n,
{
frameControl: {
frameType: 0 /* DATA */,
deliveryMode: 0 /* UNICAST */,
ackFormat: false,
security: false,
ackRequest: false,
extendedHeader: false,
},
profileId: 0x0,
clusterId: Zdo.ClusterId.IEEE_ADDRESS_RESPONSE,
sourceEndpoint: 0x0,
destEndpoint: 0x0,
},
Buffer.from([1, 0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x11, 0x22]),
-50,
);
expect(emitSpy).toHaveBeenLastCalledWith("zdoResponse", Zdo.ClusterId.IEEE_ADDRESS_RESPONSE, [
0,
{
eui64: "0x0807060504030201",
nwkAddress: 0x2211,
startIndex: 0,
assocDevList: [],
},
]);
// NETWORK_ADDRESS_RESPONSE codepath
adapter.driver.emit(
"frame",
0x2211,
578437695752307201n,
{
frameControl: {
frameType: 0 /* DATA */,
deliveryMode: 0 /* UNICAST */,
ackFormat: false,
security: false,
ackRequest: false,
extendedHeader: false,
},
profileId: 0x0,
clusterId: Zdo.ClusterId.NETWORK_ADDRESS_RESPONSE,
sourceEndpoint: 0x0,
destEndpoint: 0x0,
},
Buffer.from([1, 0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x11, 0x22]),
-50,
);
expect(emitSpy).toHaveBeenLastCalledWith("zdoResponse", Zdo.ClusterId.NETWORK_ADDRESS_RESPONSE, [
0,
{
eui64: "0x0807060504030201",
nwkAddress: 0x2211,
startIndex: 0,
assocDevList: [],
},
]);
});
it("receives ZCL frame", async () => {
await adapter.start();
const emitSpy = vi.spyOn(adapter, "emit");
adapter.driver.emit(
"frame",
0x9876,
undefined,
{
frameControl: {
frameType: 0 /* DATA */,
deliveryMode: 0 /* UNICAST */,
ackFormat: false,
security: false,
ackRequest: false,
extendedHeader: false,
},
profileId: ZSpec.HA_PROFILE_ID,
clusterId: Zcl.Clusters.genAlarms.ID,
sourceEndpoint: 0x1,
destEndpoint: 0x1,
},
Buffer.from([0, 123, Zcl.Foundation.read.ID, 0x01, 0xff]),
125,
);
expect(emitSpy).toHaveBeenLastCalledWith("zclPayload", {
address: 0x9876,
clusterID: Zcl.Clusters.genAlarms.ID,
data: Buffer.from([0, 123, Zcl.Foundation.read.ID, 0x01, 0xff]),
destinationEndpoint: 1,
endpoint: 1,
groupID: undefined,
header: {
commandIdentifier: Zcl.Foundation.read.ID,
frameControl: {
direction: 0,
disableDefaultResponse: false,
frameType: 0,
manufacturerSpecific: false,
reservedBits: 0,
},
manufacturerCode: undefined,
transactionSequenceNumber: 123,
},
linkquality: 125,
wasBroadcast: false,
});
adapter.driver.emit(
"frame",
0x9876,
1234n,
{
frameControl: {
frameType: 0 /* DATA */,
deliveryMode: 0 /* UNICAST */,
ackFormat: false,
security: false,
ackRequest: false,
extendedHeader: false,
},
profileId: ZSpec.HA_PROFILE_ID,
clusterId: Zcl.Clusters.genIdentify.ID,
sourceEndpoint: 0x1,
destEndpoint: 0x1,
},
Buffer.from([0, 123, 0x00, 0x01, 0xff]),
155,
);
expect(emitSpy).toHaveBeenLastCalledWith("zclPayload", {
address: "0x00000000000004d2",
clusterID: Zcl.Clusters.genIdentify.ID,
data: Buffer.from([0, 123, 0x00, 0x01, 0xff]),
destinationEndpoint: 1,
endpoint: 1,
groupID: undefined,
header: {
commandIdentifier: 0,
frameControl: {
direction: 0,
disableDefaultResponse: false,
frameType: 0,
manufacturerSpecific: false,
reservedBits: 0,
},
manufacturerCode: undefined,
transactionSequenceNumber: 123,
},
linkquality: 155,
wasBroadcast: false,
});
adapter.driver.emit(
"frame",
57129,
undefined,
{
frameControl: {
frameType: 0 /* DATA */,
deliveryMode: 0 /* UNICAST */,
ackFormat: false,
security: false,
ackRequest: false,
extendedHeader: false,
},
profileId: ZSpec.GP_PROFILE_ID,
clusterId: Zcl.Clusters.greenPower.ID,
sourceEndpoint: ZSpec.GP_ENDPOINT,
destEndpoint: ZSpec.GP_ENDPOINT,
},
Buffer.from("1102040008d755550114000000e01f0785f256b8e010b32e6921aca5d18ab7b7d44d0f063d4d140300001002050229dfe2", "hex"),
245,
);
expect(emitSpy).toHaveBeenLastCalledWith("zclPayload", {
address: 57129,
clusterID: Zcl.Clusters.greenPower.ID,
data: Buffer.from("1102040008d755550114000000e01f0785f256b8e010b32e6921aca5d18ab7b7d44d0f063d4d140300001002050229dfe2", "hex"),
destinationEndpoint: ZSpec.GP_ENDPOINT,
endpoint: ZSpec.GP_ENDPOINT,
groupID: undefined,
header: {
commandIdentifier: 4,
frameControl: {
direction: 0,
disableDefaultResponse: true,
frameType: 1,
manufacturerSpecific: false,
reservedBits: 0,
},
manufacturerCode: undefined,
transactionSequenceNumber: 2,
},
linkquality: 245,
wasBroadcast: false,
});
});
it("receives GP frame", async () => {
await adapter.start();
const emitSpy = vi.spyOn(adapter, "emit");
adapter.driver.emit(
"gpFrame",
0xe0,
Buffer.from([
0x2, 0x85, 0xf2, 0xc9, 0x25, 0x82, 0x1d, 0xf4, 0x6f, 0x45, 0x8c, 0xf0, 0xe6, 0x37, 0xaa, 0xc3, 0xba, 0xb6, 0xaa, 0x45, 0x83, 0x1a,
0x11, 0x46, 0x23, 0x0, 0x0, 0x4, 0x16, 0x10, 0x11, 0x22, 0x23, 0x18, 0x19, 0x14, 0x15, 0x12, 0x13, 0x64, 0x65, 0x62, 0x63, 0x1e, 0x1f,
0x1c, 0x1d, 0x1a, 0x1b, 0x16, 0x17,
]),
{
frameControl: {
frameType: 0x1,
securityEnabled: false,
framePending: false,
ackRequest: false,
panIdCompression: false,
seqNumSuppress: false,
iePresent: false,
destAddrMode: 0x2,
frameVersion: 0,
sourceAddrMode: 0x0,
},
sequenceNumber: 70,
destinationPANId: 0xffff,
destination16: 0xffff,
sourcePANId: 0xffff,
fcs: 0xffff,
},
{
frameControl: {
frameType: 0x0,
protocolVersion: 3,
autoCommissioning: false,
nwkFrameControlExtension: false,
},
sourceId: 0x0155f47a,
micSize: 0,
payloadLength: 52,
},
0,
);
const data = Buffer.from([
1, 70, 4, 0, 0, 122, 244, 85, 1, 0, 0, 0, 0, 0xe0, 51, 0x2, 0x85, 0xf2, 0xc9, 0x25, 0x82, 0x1d, 0xf4, 0x6f, 0x45, 0x8c, 0xf0, 0xe6, 0x37,
0xaa, 0xc3, 0xba, 0xb6, 0xaa, 0x45, 0x83, 0x1a, 0x11, 0x46, 0x23, 0x0, 0x0, 0x4, 0x16, 0x10, 0x11, 0x22, 0x23, 0x18, 0x19, 0x14, 0x15,
0x12, 0x13, 0x64, 0x65, 0x62, 0x63, 0x1e, 0x1f, 0x1c, 0x1d, 0x1a, 0x1b, 0x16, 0x17,
]);
const header = Zcl.Header.fromBuffer(data)!;
expect(emitSpy).toHaveBeenLastCalledWith("zclPayload", {
address: 0x0155f47a & 0xffff,
clusterID: Zcl.Clusters.greenPower.ID,
data,
destinationEndpoint: 242,
endpoint: 242,
groupID: ZSpec.GP_GROUP_ID,
header,
linkquality: 0,
wasBroadcast: true,
});
const frame = Zcl.Frame.fromBuffer(Zcl.Clusters.greenPower.ID, header, data, {});
expect(frame).toMatchObject({
header: {
frameControl: {
frameType: 1,
manufacturerSpecific: false,
direction: 0,
disableDefaultResponse: false,
reservedBits: 0,
},
manufacturerCode: undefined,
transactionSequenceNumber: 70,
commandIdentifier: 4,
},
payload: {
options: 0,
srcID: 22410362,
frameCounter: 0,
commandID: 0xe0,
payloadSize: 51,
commandFrame: {
deviceID: 2,
options: 133,
extendedOptions: 242,
securityKey: Buffer.from("c925821df46f458cf0e637aac3bab6aa", "hex"),
keyMic: 286950213,
outgoingCounter: 9030,
applicationInfo: 4,
manufacturerID: 0,
modelID: 0,
numGpdCommands: 22,
gpdCommandIdList: Buffer.from("10112223181914151213646562631e1f1c1d1a1b1617", "hex"),
numServerClusters: 0,
numClientClusters: 0,
gpdServerClusters: Buffer.alloc(0),
gpdClientClusters: Buffer.alloc(0),
},
},
cluster: {
ID: 0x21,
name: "greenPower",
},
command: {
ID: 0x04,
name: "commissioningNotification",
},
});
adapter.driver.emit(
"gpFrame",
0x10,
Buffer.from([]),
{
frameControl: {
frameType: 0x1,
securityEnabled: false,
framePending: false,
ackRequest: false,
panIdCompression: false,
seqNumSuppress: false,
iePresent: false,
destAddrMode: 0x2,
frameVersion: 0,
sourceAddrMode: 0x0,
},
sequenceNumber: 185,
destinationPANId: 0xffff,
destination16: 0xffff,
sourcePANId: 0xffff,
fcs: 0xffff,
},
{
frameControl: {
frameType: 0x0,
protocolVersion: 3,
autoCommissioning: false,
nwkFrameControlExtension: true,
},
frameControlExt: {
appId: 0,
direction: 0,
rxAfterTx: false,
securityKey: true,
securityLevel: 2,
},
sourceId: 24221335,
securityFrameCounter: 185,
micSize: 4,
payloadLength: 1,
mic: 3523079166,
},
0,
);
const data2 = Buffer.from([1, 185, 0, 0b10000000, 0, 151, 150, 113, 1, 185, 0, 0, 0, 0x10, 0]);
const header2 = Zcl.Header.fromBuffer(data2)!;
expect(emitSpy).toHaveBeenLastCalledWith("zclPayload", {
address: 24221335 & 0xffff,
clusterID: Zcl.Clusters.greenPower.ID,
data: data2,
destinationEndpoint: 242,
endpoint: 242,
groupID: ZSpec.GP_GROUP_ID,
header: header2,
linkquality: 0,
wasBroadcast: true,
});
const frame2 = Zcl.Frame.fromBuffer(Zcl.Clusters.greenPower.ID, header2, data2, {});
expect(frame2).toMatchObject({
header: {
frameControl: {
frameType: 1,
manufacturerSpecific: false,
direction: 0,
disableDefaultResponse: false,
reservedBits: 0,
},
manufacturerCode: undefined,
transactionSequenceNumber: 185,
commandIdentifier: 0,
},
payload: {
options: 0b10000000,
srcID: 24221335,
frameCounter: 185,
commandID: 0x10,
payloadSize: 0,
commandFrame: {},
},
cluster: {
ID: 0x21,
name: "greenPower",
},
command: {
ID: 0x00,
name: "notification",
},
});
});
it("receives device events", async () => {
await adapter.start();
const emitSpy = vi.spyOn(adapter, "emit");
adapter.driver.emit("deviceJoined", 0x123, 4321n, structuredClone(COMMON_FFD_MAC_CAP));
expect(emitSpy).toHaveBeenNthCalledWith(1, "deviceJoined", {networkAddress: 0x123, ieeeAddr: "0x00000000000010e1"});
adapter.driver.emit("deviceJoined", 0x321, 1234n, structuredClone(COMMON_RFD_MAC_CAP));
expect(emitSpy).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5500);
expect(emitSpy).toHaveBeenNthCalledWith(2, "deviceJoined", {networkAddress: 0x321, ieeeAddr: "0x00000000000004d2"});
adapter.driver.emit("deviceRejoined", 0x987, 4321n, structuredClone(COMMON_FFD_MAC_CAP));
expect(emitSpy).toHaveBeenLastCalledWith("deviceJoined", {networkAddress: 0x987, ieeeAddr: "0x00000000000010e1"});
adapter.driver.emit("deviceLeft", 0x123, 4321n);
expect(emitSpy).toHaveBeenLastCalledWith("deviceLeave", {networkAddress: 0x123, ieeeAddr: "0x00000000000010e1"});
// adapter.driver.emit('deviceAuthorized', 0x123, 4321n);
});
it("resumes network", async () => {
// create default
writeFileSync(TEMP_PATH_SAVE, Buffer.from(DEFAULT_STATE_FILE_HEX, "hex"));
await expect(adapter.start()).resolves.toStrictEqual("resumed");
expect(adapter.driver.netParams.networkKeyFrameCounter).toStrictEqual(1024); // jump means it loaded from saved state
});
it("resets network on mismatch PAN ID", async () => {
// create default
const state = Buffer.from(DEFAULT_STATE_FILE_HEX, "hex");