zigbee-herdsman
Version:
An open source ZigBee gateway solution with node.js.
1,030 lines (1,029 loc) • 52.3 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Device = exports.InterviewState = void 0;
const node_assert_1 = __importDefault(require("node:assert"));
const utils_1 = require("../../utils");
const logger_1 = require("../../utils/logger");
const ZSpec = __importStar(require("../../zspec"));
const enums_1 = require("../../zspec/enums");
const Zcl = __importStar(require("../../zspec/zcl"));
const Zdo = __importStar(require("../../zspec/zdo"));
const helpers_1 = require("../helpers");
const zclTransactionSequenceNumber_1 = __importDefault(require("../helpers/zclTransactionSequenceNumber"));
const endpoint_1 = __importDefault(require("./endpoint"));
const entity_1 = __importDefault(require("./entity"));
/**
* @ignore
*/
const OneJanuary2000 = new Date("January 01, 2000 00:00:00 UTC+00:00").getTime();
const NS = "zh:controller:device";
var InterviewState;
(function (InterviewState) {
InterviewState["Pending"] = "PENDING";
InterviewState["InProgress"] = "IN_PROGRESS";
InterviewState["Successful"] = "SUCCESSFUL";
InterviewState["Failed"] = "FAILED";
})(InterviewState || (exports.InterviewState = InterviewState = {}));
class Device extends entity_1.default {
// biome-ignore lint/style/useNamingConvention: cross-repo impact
ID;
_applicationVersion;
_dateCode;
_endpoints;
_hardwareVersion;
_ieeeAddr;
_interviewState;
_lastSeen;
_manufacturerID;
_manufacturerName;
_modelID;
_networkAddress;
_powerSource;
_softwareBuildID;
_stackVersion;
_type;
_zclVersion;
_linkquality;
_skipDefaultResponse;
_customReadResponse;
_lastDefaultResponseSequenceNumber;
_checkinInterval;
_pendingRequestTimeout;
_customClusters = {};
_gpSecurityKey;
// Getters/setters
get ieeeAddr() {
return this._ieeeAddr;
}
set ieeeAddr(ieeeAddr) {
this._ieeeAddr = ieeeAddr;
}
get applicationVersion() {
return this._applicationVersion;
}
set applicationVersion(applicationVersion) {
this._applicationVersion = applicationVersion;
}
get endpoints() {
return this._endpoints;
}
get interviewState() {
return this._interviewState;
}
get lastSeen() {
return this._lastSeen;
}
get manufacturerID() {
return this._manufacturerID;
}
get isDeleted() {
return Device.deletedDevices.has(this.ieeeAddr);
}
set type(type) {
this._type = type;
}
get type() {
return this._type;
}
get dateCode() {
return this._dateCode;
}
set dateCode(dateCode) {
this._dateCode = dateCode;
}
set hardwareVersion(hardwareVersion) {
this._hardwareVersion = hardwareVersion;
}
get hardwareVersion() {
return this._hardwareVersion;
}
get manufacturerName() {
return this._manufacturerName;
}
set manufacturerName(manufacturerName) {
this._manufacturerName = manufacturerName;
}
set modelID(modelID) {
this._modelID = modelID;
}
get modelID() {
return this._modelID;
}
get networkAddress() {
return this._networkAddress;
}
set networkAddress(networkAddress) {
Device.nwkToIeeeCache.delete(this._networkAddress);
this._networkAddress = networkAddress;
Device.nwkToIeeeCache.set(this._networkAddress, this.ieeeAddr);
for (const endpoint of this._endpoints) {
endpoint.deviceNetworkAddress = networkAddress;
}
}
get powerSource() {
return this._powerSource;
}
set powerSource(powerSource) {
this._powerSource = typeof powerSource === "number" ? Zcl.POWER_SOURCES[powerSource & ~(1 << 7)] : powerSource;
}
get softwareBuildID() {
return this._softwareBuildID;
}
set softwareBuildID(softwareBuildID) {
this._softwareBuildID = softwareBuildID;
}
get stackVersion() {
return this._stackVersion;
}
set stackVersion(stackVersion) {
this._stackVersion = stackVersion;
}
get zclVersion() {
return this._zclVersion;
}
set zclVersion(zclVersion) {
this._zclVersion = zclVersion;
}
get linkquality() {
return this._linkquality;
}
set linkquality(linkquality) {
this._linkquality = linkquality;
}
get skipDefaultResponse() {
return this._skipDefaultResponse;
}
set skipDefaultResponse(skipDefaultResponse) {
this._skipDefaultResponse = skipDefaultResponse;
}
get customReadResponse() {
return this._customReadResponse;
}
set customReadResponse(customReadResponse) {
this._customReadResponse = customReadResponse;
}
get checkinInterval() {
return this._checkinInterval;
}
set checkinInterval(checkinInterval) {
this._checkinInterval = checkinInterval;
this.resetPendingRequestTimeout();
}
get pendingRequestTimeout() {
return this._pendingRequestTimeout;
}
set pendingRequestTimeout(pendingRequestTimeout) {
this._pendingRequestTimeout = pendingRequestTimeout;
}
get customClusters() {
return this._customClusters;
}
get gpSecurityKey() {
return this._gpSecurityKey;
}
meta;
// This lookup contains all devices that are queried from the database, this is to ensure that always
// the same instance is returned.
static devices = new Map();
static loadedFromDatabase = false;
static deletedDevices = new Map();
static nwkToIeeeCache = new Map();
static REPORTABLE_PROPERTIES_MAPPING = {
modelId: {
key: "modelID",
set: (v, d) => {
d.modelID = v;
},
},
manufacturerName: {
key: "manufacturerName",
set: (v, d) => {
d.manufacturerName = v;
},
},
powerSource: {
key: "powerSource",
set: (v, d) => {
d.powerSource = v;
},
},
zclVersion: {
key: "zclVersion",
set: (v, d) => {
d.zclVersion = v;
},
},
appVersion: {
key: "applicationVersion",
set: (v, d) => {
d.applicationVersion = v;
},
},
stackVersion: {
key: "stackVersion",
set: (v, d) => {
d.stackVersion = v;
},
},
hwVersion: {
key: "hardwareVersion",
set: (v, d) => {
d.hardwareVersion = v;
},
},
dateCode: {
key: "dateCode",
set: (v, d) => {
d.dateCode = v;
},
},
swBuildId: {
key: "softwareBuildID",
set: (v, d) => {
d.softwareBuildID = v;
},
},
};
constructor(id, type, ieeeAddr, networkAddress, manufacturerID, endpoints, manufacturerName, powerSource, modelID, applicationVersion, stackVersion, zclVersion, hardwareVersion, dateCode, softwareBuildID, interviewState, meta, lastSeen, checkinInterval, pendingRequestTimeout, gpSecurityKey) {
super();
this.ID = id;
this._type = type;
this._ieeeAddr = ieeeAddr;
this._networkAddress = networkAddress;
this._manufacturerID = manufacturerID;
this._endpoints = endpoints;
this._manufacturerName = manufacturerName;
this._powerSource = powerSource;
this._modelID = modelID;
this._applicationVersion = applicationVersion;
this._stackVersion = stackVersion;
this._zclVersion = zclVersion;
this._hardwareVersion = hardwareVersion;
this._dateCode = dateCode;
this._softwareBuildID = softwareBuildID;
this._interviewState = interviewState;
this._skipDefaultResponse = false;
this.meta = meta;
this._lastSeen = lastSeen;
this._checkinInterval = checkinInterval;
this._pendingRequestTimeout = pendingRequestTimeout;
this._gpSecurityKey = gpSecurityKey;
}
createEndpoint(id) {
if (this.getEndpoint(id)) {
throw new Error(`Device '${this.ieeeAddr}' already has an endpoint '${id}'`);
}
const endpoint = endpoint_1.default.create(id, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr);
this.endpoints.push(endpoint);
this.save();
return endpoint;
}
changeIeeeAddress(ieeeAddr) {
Device.devices.delete(this.ieeeAddr);
this.ieeeAddr = ieeeAddr;
Device.devices.set(this.ieeeAddr, this);
Device.nwkToIeeeCache.set(this.networkAddress, this.ieeeAddr);
for (const ep of this.endpoints) {
ep.deviceIeeeAddress = ieeeAddr;
}
this.save();
}
getEndpoint(id) {
return this.endpoints.find((e) => e.ID === id);
}
// There might be multiple endpoints with same DeviceId but it is not supported and first endpoint is returned
getEndpointByDeviceType(deviceType) {
const deviceID = Zcl.ENDPOINT_DEVICE_TYPE[deviceType];
return this.endpoints.find((d) => d.deviceID === deviceID);
}
implicitCheckin() {
// No need to do anythign in `catch` as `endpoint.sendRequest` already logs failures.
Promise.allSettled(this.endpoints.map((e) => e.sendPendingRequests(false))).catch(() => { });
}
updateLastSeen() {
this._lastSeen = Date.now();
}
resetPendingRequestTimeout() {
// pendingRequestTimeout can be changed dynamically at runtime, and it is not persisted.
// Default timeout is one checkin interval in milliseconds.
this._pendingRequestTimeout = (this._checkinInterval ?? 0) * 1000;
}
hasPendingRequests() {
return this.endpoints.find((e) => e.hasPendingRequests()) !== undefined;
}
async onZclData(dataPayload, frame, endpoint) {
// Update reportable properties
if (frame.isCluster("genBasic") && (frame.isCommand("readRsp") || frame.isCommand("report"))) {
const attrKeyValue = helpers_1.ZclFrameConverter.attributeKeyValue(frame, this.manufacturerID, this.customClusters);
for (const key in attrKeyValue) {
Device.REPORTABLE_PROPERTIES_MAPPING[key]?.set(attrKeyValue[key], this);
}
}
// Respond to enroll requests
if (frame.header.isSpecific && frame.isCluster("ssIasZone") && frame.isCommand("enrollReq")) {
logger_1.logger.debug(`IAS - '${this.ieeeAddr}' responding to enroll response`, NS);
const payload = { enrollrspcode: 0, zoneid: 23 };
await endpoint.command("ssIasZone", "enrollRsp", payload, { disableDefaultResponse: true });
}
// Reponse to read requests
if (frame.header.isGlobal && frame.isCommand("read") && !this._customReadResponse?.(frame, endpoint)) {
const time = Math.round((new Date().getTime() - OneJanuary2000) / 1000);
const attributes = {
...endpoint.clusters,
genTime: {
attributes: {
timeStatus: 3, // Time-master + synchronised
time: time,
timeZone: new Date().getTimezoneOffset() * -1 * 60,
localTime: time - new Date().getTimezoneOffset() * 60,
lastSetTime: time,
validUntilTime: time + 24 * 60 * 60, // valid for 24 hours
},
},
};
if (frame.cluster.name in attributes) {
const response = {};
for (const entry of frame.payload) {
if (frame.cluster.hasAttribute(entry.attrId)) {
const name = frame.cluster.getAttribute(entry.attrId).name;
if (name in attributes[frame.cluster.name].attributes) {
response[name] = attributes[frame.cluster.name].attributes[name];
}
}
}
try {
await endpoint.readResponse(frame.cluster.ID, frame.header.transactionSequenceNumber, response, {
srcEndpoint: dataPayload.destinationEndpoint,
});
}
catch (error) {
logger_1.logger.error(`Read response to ${this.ieeeAddr} failed (${error.message})`, NS);
}
}
}
// Handle check-in from sleeping end devices
if (frame.header.isSpecific && frame.isCluster("genPollCtrl") && frame.isCommand("checkin")) {
try {
if (this.hasPendingRequests() || this._checkinInterval === undefined) {
const payload = {
startFastPolling: true,
fastPollTimeout: 0,
};
logger_1.logger.debug(`check-in from ${this.ieeeAddr}: accepting fast-poll`, NS);
await endpoint.command(frame.cluster.ID, "checkinRsp", payload, { sendPolicy: "immediate" });
// This is a good time to read the checkin interval if we haven't stored it previously
if (this._checkinInterval === undefined) {
const pollPeriod = await endpoint.read("genPollCtrl", ["checkinInterval"], { sendPolicy: "immediate" });
this._checkinInterval = pollPeriod.checkinInterval / 4; // convert to seconds
this.resetPendingRequestTimeout();
logger_1.logger.debug(`Request Queue (${this.ieeeAddr}): default expiration timeout set to ${this.pendingRequestTimeout}`, NS);
}
await Promise.all(this.endpoints.map(async (e) => await e.sendPendingRequests(true)));
// We *must* end fast-poll when we're done sending things. Otherwise
// we cause undue power-drain.
logger_1.logger.debug(`check-in from ${this.ieeeAddr}: stopping fast-poll`, NS);
await endpoint.command(frame.cluster.ID, "fastPollStop", {}, { sendPolicy: "immediate" });
}
else {
const payload = {
startFastPolling: false,
fastPollTimeout: 0,
};
logger_1.logger.debug(`check-in from ${this.ieeeAddr}: declining fast-poll`, NS);
await endpoint.command(frame.cluster.ID, "checkinRsp", payload, { sendPolicy: "immediate" });
}
}
catch (error) {
logger_1.logger.error(`Handling of poll check-in from ${this.ieeeAddr} failed (${error.message})`, NS);
}
}
// Send a default response if necessary.
const isDefaultResponse = frame.header.isGlobal && frame.command.name === "defaultRsp";
const commandHasResponse = frame.command.response !== undefined;
const disableDefaultResponse = frame.header.frameControl.disableDefaultResponse;
/* v8 ignore next */
const disableTuyaDefaultResponse = endpoint.getDevice().manufacturerName?.startsWith("_TZ") && process.env.DISABLE_TUYA_DEFAULT_RESPONSE;
// Sometimes messages are received twice, prevent responding twice
const alreadyResponded = this._lastDefaultResponseSequenceNumber === frame.header.transactionSequenceNumber;
if (this.type !== "GreenPower" &&
!dataPayload.wasBroadcast &&
!disableDefaultResponse &&
!isDefaultResponse &&
!commandHasResponse &&
!this._skipDefaultResponse &&
!alreadyResponded &&
!disableTuyaDefaultResponse) {
try {
this._lastDefaultResponseSequenceNumber = frame.header.transactionSequenceNumber;
// In the ZCL it is not documented what the direction of the default response should be
// In https://github.com/Koenkk/zigbee2mqtt/issues/18096 a commandResponse (SERVER_TO_CLIENT)
// is send and the device expects a CLIENT_TO_SERVER back.
// Previously SERVER_TO_CLIENT was always used.
// Therefore for non-global commands we inverse the direction.
const direction = frame.header.isGlobal
? Zcl.Direction.SERVER_TO_CLIENT
: frame.header.frameControl.direction === Zcl.Direction.CLIENT_TO_SERVER
? Zcl.Direction.SERVER_TO_CLIENT
: Zcl.Direction.CLIENT_TO_SERVER;
await endpoint.defaultResponse(frame.command.ID, 0, frame.cluster.ID, frame.header.transactionSequenceNumber, { direction });
}
catch (error) {
logger_1.logger.debug(`Default response to ${this.ieeeAddr} failed (${error})`, NS);
}
}
}
/*
* CRUD
*/
/**
* Reset runtime lookups.
*/
static resetCache() {
Device.devices.clear();
Device.loadedFromDatabase = false;
Device.deletedDevices.clear();
Device.nwkToIeeeCache.clear();
}
static fromDatabaseEntry(entry) {
const networkAddress = entry.nwkAddr;
const ieeeAddr = entry.ieeeAddr;
const endpoints = [];
for (const id in entry.endpoints) {
endpoints.push(endpoint_1.default.fromDatabaseRecord(entry.endpoints[id], networkAddress, ieeeAddr));
}
const meta = entry.meta ?? {};
if (entry.type === "Group") {
throw new Error("Cannot load device from group");
}
// default: no timeout (messages expire immediately after first send attempt)
let pendingRequestTimeout = 0;
if (endpoints.filter((e) => e.inputClusters.includes(Zcl.Clusters.genPollCtrl.ID)).length > 0) {
// default for devices that support genPollCtrl cluster (RX off when idle): 1 day
pendingRequestTimeout = 86400000;
}
// always load value from database available (modernExtend.quirkCheckinInterval() exists for devices without genPollCtl)
if (entry.checkinInterval !== undefined) {
// if the checkin interval is known, messages expire by default after one checkin interval
pendingRequestTimeout = entry.checkinInterval * 1000; // milliseconds
}
logger_1.logger.debug(`Request Queue (${ieeeAddr}): default expiration timeout set to ${pendingRequestTimeout}`, NS);
// Migrate interviewCompleted to interviewState
if (!entry.interviewState) {
entry.interviewState = entry.interviewCompleted ? InterviewState.Successful : InterviewState.Failed;
logger_1.logger.debug(`Migrated interviewState for '${ieeeAddr}': ${entry.interviewCompleted} -> ${entry.interviewState}`, NS);
}
return new Device(entry.id, entry.type, ieeeAddr, networkAddress, entry.manufId, endpoints, entry.manufName, entry.powerSource, entry.modelId, entry.appVersion, entry.stackVersion, entry.zclVersion, entry.hwVersion, entry.dateCode, entry.swBuildId, entry.interviewState, meta, entry.lastSeen, entry.checkinInterval, pendingRequestTimeout, entry.gpSecurityKey);
}
toDatabaseEntry() {
const epList = this.endpoints.map((e) => e.ID);
const endpoints = {};
for (const endpoint of this.endpoints) {
endpoints[endpoint.ID] = endpoint.toDatabaseRecord();
}
return {
id: this.ID,
type: this.type,
ieeeAddr: this.ieeeAddr,
nwkAddr: this.networkAddress,
manufId: this.manufacturerID,
manufName: this.manufacturerName,
powerSource: this.powerSource,
modelId: this.modelID,
epList,
endpoints,
appVersion: this.applicationVersion,
stackVersion: this.stackVersion,
hwVersion: this.hardwareVersion,
dateCode: this.dateCode,
swBuildId: this.softwareBuildID,
zclVersion: this.zclVersion,
/** @deprecated Keep interviewCompleted for backwards compatibility (in case zh gets downgraded) */
interviewCompleted: this.interviewState === InterviewState.Successful,
interviewState: this.interviewState === InterviewState.InProgress ? InterviewState.Pending : this.interviewState,
meta: this.meta,
lastSeen: this.lastSeen,
checkinInterval: this.checkinInterval,
gpSecurityKey: this.gpSecurityKey,
};
}
save(writeDatabase = true) {
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
entity_1.default.database.update(this.toDatabaseEntry(), writeDatabase);
}
static loadFromDatabaseIfNecessary() {
if (!Device.loadedFromDatabase) {
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
for (const entry of entity_1.default.database.getEntriesIterator(["Coordinator", "EndDevice", "Router", "GreenPower", "Unknown"])) {
const device = Device.fromDatabaseEntry(entry);
Device.devices.set(device.ieeeAddr, device);
Device.nwkToIeeeCache.set(device.networkAddress, device.ieeeAddr);
}
Device.loadedFromDatabase = true;
}
}
static find(ieeeOrNwkAddress, includeDeleted = false) {
return typeof ieeeOrNwkAddress === "string"
? Device.byIeeeAddr(ieeeOrNwkAddress, includeDeleted)
: Device.byNetworkAddress(ieeeOrNwkAddress, includeDeleted);
}
static byIeeeAddr(ieeeAddr, includeDeleted = false) {
Device.loadFromDatabaseIfNecessary();
return includeDeleted ? (Device.deletedDevices.get(ieeeAddr) ?? Device.devices.get(ieeeAddr)) : Device.devices.get(ieeeAddr);
}
static byNetworkAddress(networkAddress, includeDeleted = false) {
Device.loadFromDatabaseIfNecessary();
const ieeeAddr = Device.nwkToIeeeCache.get(networkAddress);
return ieeeAddr ? Device.byIeeeAddr(ieeeAddr, includeDeleted) : undefined;
}
static byType(type) {
const devices = [];
for (const device of Device.allIterator((d) => d.type === type)) {
devices.push(device);
}
return devices;
}
/**
* @deprecated use allIterator()
*/
static all() {
Device.loadFromDatabaseIfNecessary();
return Array.from(Device.devices.values());
}
static *allIterator(predicate) {
Device.loadFromDatabaseIfNecessary();
for (const device of Device.devices.values()) {
if (!predicate || predicate(device)) {
yield device;
}
}
}
undelete() {
if (Device.deletedDevices.delete(this.ieeeAddr)) {
Device.devices.set(this.ieeeAddr, this);
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
entity_1.default.database.insert(this.toDatabaseEntry());
}
else {
throw new Error(`Device '${this.ieeeAddr}' is not deleted`);
}
}
static create(type, ieeeAddr, networkAddress, manufacturerID, manufacturerName, powerSource, modelID, interviewState, gpSecurityKey) {
Device.loadFromDatabaseIfNecessary();
if (Device.devices.has(ieeeAddr)) {
throw new Error(`Device with IEEE address '${ieeeAddr}' already exists`);
}
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
const ID = entity_1.default.database.newID();
const device = new Device(ID, type, ieeeAddr, networkAddress, manufacturerID, [], manufacturerName, powerSource, modelID, undefined, undefined, undefined, undefined, undefined, undefined, interviewState, {}, undefined, undefined, 0, gpSecurityKey);
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
entity_1.default.database.insert(device.toDatabaseEntry());
Device.devices.set(device.ieeeAddr, device);
Device.nwkToIeeeCache.set(device.networkAddress, device.ieeeAddr);
return device;
}
/*
* Zigbee functions
*/
async interview(ignoreCache = false) {
if (this.interviewState === InterviewState.InProgress) {
const message = `Interview - interview already in progress for '${this.ieeeAddr}'`;
logger_1.logger.debug(message, NS);
throw new Error(message);
}
let err;
this._interviewState = InterviewState.InProgress;
logger_1.logger.debug(`Interview - start device '${this.ieeeAddr}'`, NS);
try {
await this.interviewInternal(ignoreCache);
logger_1.logger.debug(`Interview - completed for device '${this.ieeeAddr}'`, NS);
this._interviewState = InterviewState.Successful;
}
catch (error) {
if (this.interviewQuirks()) {
this._interviewState = InterviewState.Successful;
logger_1.logger.debug(`Interview - completed for device '${this.ieeeAddr}' because of quirks ('${error}')`, NS);
}
else {
this._interviewState = InterviewState.Failed;
logger_1.logger.debug(`Interview - failed for device '${this.ieeeAddr}' with error '${error}'`, NS);
err = error;
}
}
finally {
this.save();
}
if (err) {
throw err;
}
}
interviewQuirks() {
logger_1.logger.debug(`Interview - quirks check for '${this.modelID}'-'${this.manufacturerName}'-'${this.type}'`, NS);
// Tuya devices are typically hard to interview. They also don't require a full interview to work correctly
// e.g. no ias enrolling is required for the devices to work.
// Assume that in case we got both the manufacturerName and modelID the device works correctly.
// https://github.com/Koenkk/zigbee2mqtt/issues/7564:
// Fails during ias enroll due to UNSUPPORTED_ATTRIBUTE
// https://github.com/Koenkk/zigbee2mqtt/issues/4655
// Device does not change zoneState after enroll (event with original gateway)
// modelID is mostly in the form of e.g. TS0202 and manufacturerName like e.g. _TYZB01_xph99wvr
if (this.modelID?.match("^TS\\d*$") && (this.manufacturerName?.match("^_TZ.*_.*$") || this.manufacturerName?.match("^_TYZB01_.*$"))) {
this._powerSource = this._powerSource || "Battery";
logger_1.logger.debug("Interview - quirks matched for Tuya end device", NS);
return true;
}
// Some devices, e.g. Xiaomi end devices have a different interview procedure, after pairing they
// report it's modelID trough a readResponse. The readResponse is received by the controller and set
// on the device.
const lookup = {
"^3R.*?Z": {
type: "EndDevice",
powerSource: "Battery",
},
"lumi..*": {
type: "EndDevice",
manufacturerID: 4151,
manufacturerName: "LUMI",
powerSource: "Battery",
},
"TERNCY-PP01": {
type: "EndDevice",
manufacturerID: 4648,
manufacturerName: "TERNCY",
powerSource: "Battery",
},
"3RWS18BZ": {}, // https://github.com/Koenkk/zigbee-herdsman-converters/pull/2710
"MULTI-MECI--EA01": {},
MOT003: {}, // https://github.com/Koenkk/zigbee2mqtt/issues/12471
"C-ZB-SEDC": {}, //candeo device that doesn't follow IAS enrollment process correctly and therefore fails to complete interview
"C-ZB-SEMO": {}, //candeo device that doesn't follow IAS enrollment process correctly and therefore fails to complete interview
};
let match;
for (const key in lookup) {
if (this.modelID?.match(key)) {
match = key;
break;
}
}
if (match) {
const info = lookup[match];
logger_1.logger.debug(`Interview procedure failed but got modelID matching '${match}', assuming interview succeeded`, NS);
this._type = this._type === "Unknown" && info.type ? info.type : this._type;
this._manufacturerID = this._manufacturerID || info.manufacturerID;
this._manufacturerName = this._manufacturerName || info.manufacturerName;
this._powerSource = this._powerSource || info.powerSource;
logger_1.logger.debug(`Interview - quirks matched on '${match}'`, NS);
return true;
}
logger_1.logger.debug("Interview - quirks did not match", NS);
return false;
}
async interviewInternal(ignoreCache) {
const hasNodeDescriptor = () => this._manufacturerID !== undefined && this._type !== "Unknown";
if (ignoreCache || !hasNodeDescriptor()) {
for (let attempt = 0; attempt < 6; attempt++) {
try {
await this.updateNodeDescriptor();
break;
}
catch (error) {
if (this.interviewQuirks()) {
logger_1.logger.debug(`Interview - completed for device '${this.ieeeAddr}' because of quirks ('${error}')`, NS);
return;
}
// Most of the times the first node descriptor query fails and the seconds one succeeds.
logger_1.logger.debug(`Interview - node descriptor request failed for '${this.ieeeAddr}', attempt ${attempt + 1}`, NS);
}
}
}
else {
logger_1.logger.debug(`Interview - skip node descriptor request for '${this.ieeeAddr}', already got it`, NS);
}
if (!hasNodeDescriptor()) {
throw new Error(`Interview failed because can not get node descriptor ('${this.ieeeAddr}')`);
}
if (this.manufacturerID === 4619 && this._type === "EndDevice") {
// Give Tuya end device some time to pair. Otherwise they leave immediately.
// https://github.com/Koenkk/zigbee2mqtt/issues/5814
logger_1.logger.debug("Interview - Detected Tuya end device, waiting 10 seconds...", NS);
await (0, utils_1.wait)(10000);
}
else if (this.manufacturerID === 0 || this.manufacturerID === 4098) {
// Potentially a Tuya device, some sleep fast so make sure to read the modelId and manufacturerName quickly.
// In case the device responds, the endoint and modelID/manufacturerName are set
// in controller.onZclOrRawData()
// https://github.com/Koenkk/zigbee2mqtt/issues/7553
logger_1.logger.debug("Interview - Detected potential Tuya end device, reading modelID and manufacturerName...", NS);
try {
const endpoint = endpoint_1.default.create(1, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr);
const result = await endpoint.read("genBasic", ["modelId", "manufacturerName"], { sendPolicy: "immediate" });
for (const key in result) {
Device.REPORTABLE_PROPERTIES_MAPPING[key].set(result[key], this);
}
}
catch (error) {
logger_1.logger.debug(`Interview - Tuya read modelID and manufacturerName failed (${error})`, NS);
}
}
// e.g. Xiaomi Aqara Opple devices fail to respond to the first active endpoints request, therefore try 2 times
// https://github.com/Koenkk/zigbee-herdsman/pull/103
let gotActiveEndpoints = false;
for (let attempt = 0; attempt < 2; attempt++) {
try {
await this.updateActiveEndpoints();
gotActiveEndpoints = true;
break;
}
catch (error) {
logger_1.logger.debug(`Interview - active endpoints request failed for '${this.ieeeAddr}', attempt ${attempt + 1} (${error})`, NS);
}
}
if (!gotActiveEndpoints) {
throw new Error(`Interview failed because can not get active endpoints ('${this.ieeeAddr}')`);
}
logger_1.logger.debug(`Interview - got active endpoints for device '${this.ieeeAddr}'`, NS);
const coordinator = Device.byType("Coordinator")[0];
for (const endpoint of this._endpoints) {
await endpoint.updateSimpleDescriptor();
logger_1.logger.debug(`Interview - got simple descriptor for endpoint '${endpoint.ID}' device '${this.ieeeAddr}'`, NS);
// Read attributes
// nice to have but not required for successful pairing as most of the attributes are not mandatory in ZCL specification
if (endpoint.supportsInputCluster("genBasic")) {
for (const key in Device.REPORTABLE_PROPERTIES_MAPPING) {
const item = Device.REPORTABLE_PROPERTIES_MAPPING[key];
if (ignoreCache || !this[item.key]) {
try {
let result;
try {
result = await endpoint.read("genBasic", [key], { sendPolicy: "immediate" });
}
catch (error) {
// Reading attributes can fail for many reason, e.g. it could be that device rejoins
// while joining like in:
// https://github.com/Koenkk/zigbee-herdsman-converters/issues/2485.
// The modelID and manufacturerName are crucial for device identification, so retry.
if (item.key === "modelID" || item.key === "manufacturerName") {
logger_1.logger.debug(`Interview - first ${item.key} retrieval attempt failed, retrying after 10 seconds...`, NS);
await (0, utils_1.wait)(10000);
result = await endpoint.read("genBasic", [key], { sendPolicy: "immediate" });
}
else {
throw error;
}
}
item.set(result[key], this);
logger_1.logger.debug(`Interview - got '${item.key}' for device '${this.ieeeAddr}'`, NS);
}
catch (error) {
logger_1.logger.debug(`Interview - failed to read attribute '${item.key}' from endpoint '${endpoint.ID}' (${error})`, NS);
}
}
}
}
// Enroll IAS device
if (endpoint.supportsInputCluster("ssIasZone")) {
logger_1.logger.debug(`Interview - IAS - enrolling '${this.ieeeAddr}' endpoint '${endpoint.ID}'`, NS);
const stateBefore = await endpoint.read("ssIasZone", ["iasCieAddr", "zoneState"], { sendPolicy: "immediate" });
logger_1.logger.debug(`Interview - IAS - before enrolling state: '${JSON.stringify(stateBefore)}'`, NS);
// Do not enroll when device has already been enrolled
if (stateBefore.zoneState !== 1 || stateBefore.iasCieAddr !== coordinator.ieeeAddr) {
logger_1.logger.debug("Interview - IAS - not enrolled, enrolling", NS);
await endpoint.write("ssIasZone", { iasCieAddr: coordinator.ieeeAddr }, { sendPolicy: "immediate" });
logger_1.logger.debug("Interview - IAS - wrote iasCieAddr", NS);
// There are 2 enrollment procedures:
// - Auto enroll: coordinator has to send enrollResponse without receiving an enroll request
// this case is handled below.
// - Manual enroll: coordinator replies to enroll request with an enroll response.
// this case in hanled in onZclData().
// https://github.com/Koenkk/zigbee2mqtt/issues/4569#issuecomment-706075676
await (0, utils_1.wait)(500);
logger_1.logger.debug(`IAS - '${this.ieeeAddr}' sending enroll response (auto enroll)`, NS);
const payload = { enrollrspcode: 0, zoneid: 23 };
await endpoint.command("ssIasZone", "enrollRsp", payload, { disableDefaultResponse: true, sendPolicy: "immediate" });
let enrolled = false;
for (let attempt = 0; attempt < 20; attempt++) {
await (0, utils_1.wait)(500);
const stateAfter = await endpoint.read("ssIasZone", ["iasCieAddr", "zoneState"], { sendPolicy: "immediate" });
logger_1.logger.debug(`Interview - IAS - after enrolling state (${attempt}): '${JSON.stringify(stateAfter)}'`, NS);
if (stateAfter.zoneState === 1) {
enrolled = true;
break;
}
}
if (enrolled) {
logger_1.logger.debug(`Interview - IAS successfully enrolled '${this.ieeeAddr}' endpoint '${endpoint.ID}'`, NS);
}
else {
throw new Error(`Interview failed because of failed IAS enroll (zoneState didn't change ('${this.ieeeAddr}')`);
}
}
else {
logger_1.logger.debug("Interview - IAS - already enrolled, skipping enroll", NS);
}
}
}
// Bind poll control
try {
for (const endpoint of this.endpoints.filter((e) => e.supportsInputCluster("genPollCtrl"))) {
logger_1.logger.debug(`Interview - Poll control - binding '${this.ieeeAddr}' endpoint '${endpoint.ID}'`, NS);
await endpoint.bind("genPollCtrl", coordinator.endpoints[0]);
const pollPeriod = await endpoint.read("genPollCtrl", ["checkinInterval"], { sendPolicy: "immediate" });
this._checkinInterval = pollPeriod.checkinInterval / 4; // convert to seconds
this.resetPendingRequestTimeout();
}
/* v8 ignore start */
}
catch (error) {
logger_1.logger.debug(`Interview - failed to bind genPollCtrl (${error})`, NS);
}
/* v8 ignore stop */
}
async updateNodeDescriptor() {
const clusterId = Zdo.ClusterId.NODE_DESCRIPTOR_REQUEST;
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, this.networkAddress);
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
const response = await entity_1.default.adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false);
if (!Zdo.Buffalo.checkStatus(response)) {
throw new Zdo.StatusError(response[0]);
}
// TODO: make use of: capabilities.rxOnWhenIdle, maxIncTxSize, maxOutTxSize, serverMask.stackComplianceRevision
const nodeDescriptor = response[1];
this._manufacturerID = nodeDescriptor.manufacturerCode;
switch (nodeDescriptor.logicalType) {
case 0x0:
this._type = "Coordinator";
break;
case 0x1:
this._type = "Router";
break;
case 0x2:
this._type = "EndDevice";
break;
}
logger_1.logger.debug(`Interview - got node descriptor for device '${this.ieeeAddr}'`, NS);
// TODO: define a property on Device for this value (would be good to have it displayed)
// log for devices older than 1 from current revision
if (nodeDescriptor.serverMask.stackComplianceRevision < ZSpec.ZIGBEE_REVISION - 1) {
// always 0 before revision 21 where field was added
const rev = nodeDescriptor.serverMask.stackComplianceRevision < 21 ? "pre-21" : nodeDescriptor.serverMask.stackComplianceRevision;
logger_1.logger.info(`Device '${this.ieeeAddr}' is only compliant to revision '${rev}' of the ZigBee specification (current revision: ${ZSpec.ZIGBEE_REVISION}).`, NS);
}
}
async updateActiveEndpoints() {
const clusterId = Zdo.ClusterId.ACTIVE_ENDPOINTS_REQUEST;
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, this.networkAddress);
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
const response = await entity_1.default.adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false);
if (!Zdo.Buffalo.checkStatus(response)) {
throw new Zdo.StatusError(response[0]);
}
const activeEndpoints = response[1];
// Make sure that the endpoint are sorted.
activeEndpoints.endpointList.sort((a, b) => a - b);
for (const endpoint of activeEndpoints.endpointList) {
// Some devices, e.g. TERNCY return endpoint 0 in the active endpoints request.
// This is not a valid endpoint number according to the ZCL, requesting a simple descriptor will result
// into an error. Therefore we filter it, more info: https://github.com/Koenkk/zigbee-herdsman/issues/82
if (endpoint !== 0 && !this.getEndpoint(endpoint)) {
this._endpoints.push(endpoint_1.default.create(endpoint, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr));
}
}
// Remove disappeared endpoints (can happen with e.g. custom devices).
this._endpoints = this._endpoints.filter((e) => activeEndpoints.endpointList.includes(e.ID));
}
/**
* Request device to advertise its network address.
* Note: This does not actually update the device property (if needed), as this is already done with `zdoResponse` event in Controller.
*/
async requestNetworkAddress() {
const clusterId = Zdo.ClusterId.NETWORK_ADDRESS_REQUEST;
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, this.ieeeAddr, false, 0);
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
await entity_1.default.adapter.sendZdo(this.ieeeAddr, ZSpec.BroadcastAddress.RX_ON_WHEN_IDLE, clusterId, zdoPayload, true);
}
async removeFromNetwork() {
if (this._type === "GreenPower") {
const payload = {
options: 0x002550,
srcID: Number(this.ieeeAddr),
};
const frame = Zcl.Frame.create(Zcl.FrameType.SPECIFIC, Zcl.Direction.SERVER_TO_CLIENT, true, undefined, zclTransactionSequenceNumber_1.default.next(), "pairing", 33, payload, this.customClusters);
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
await entity_1.default.adapter.sendZclFrameToAll(242, frame, 242, enums_1.BroadcastAddress.RX_ON_WHEN_IDLE);
}
else {
const clusterId = Zdo.ClusterId.LEAVE_REQUEST;
const zdoPayload = Zdo.Buffalo.buildRequest(
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
entity_1.default.adapter.hasZdoMessageOverhead, clusterId, this.ieeeAddr, Zdo.LeaveRequestFlags.WITHOUT_REJOIN);
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
const response = await entity_1.default.adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false);
if (!Zdo.Buffalo.checkStatus(response)) {
throw new Zdo.StatusError(response[0]);
}
}
this.removeFromDatabase();
}
removeFromDatabase() {
Device.loadFromDatabaseIfNecessary();
for (const endpoint of this.endpoints) {
endpoint.removeFromAllGroupsDatabase();
}
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
if (entity_1.default.database.has(this.ID)) {
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
entity_1.default.database.remove(this.ID);
}
Device.deletedDevices.set(this.ieeeAddr, this);
Device.devices.delete(this.ieeeAddr);
// Clear all data in case device joins again
// Green power devices are never interviewed, keep existing interview state.
this._interviewState = this.type === "GreenPower" ? this._interviewState : InterviewState.Pending;
this.meta = {};
const newEndpoints = [];
for (const endpoint of this.endpoints) {
newEndpoints.push(endpoint_1.default.create(endpoint.ID, endpoint.profileID, endpoint.deviceID, endpoint.inputClusters, endpoint.outputClusters, this.networkAddress, this.ieeeAddr));
}
this._endpoints = newEndpoints;
}
async lqi() {
const clusterId = Zdo.ClusterId.LQI_TABLE_REQUEST;
// TODO return Zdo.LQITableEntry directly (requires updates in other repos)
const neighbors = [];
const request = async (startIndex) => {
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, startIndex);
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
const response = await entity_1.default.adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false);
if (!Zdo.Buffalo.checkStatus(response)) {
throw new Zdo.StatusError(response[0]);
}
const result = response[1];
for (const entry of result.entryList) {
neighbors.push({
ieeeAddr: entry.eui64,
networkAddress: entry.nwkAddress,
linkquality: entry.lqi,
relationship: entry.relationship,
depth: entry.depth,
});
}
return [result.neighborTableEntries, result.entryList.length];
};
let [tableEntries, entryCount] = await request(0);
const size = tableEntries;
let nextStartIndex = entryCount;
while (neighbors.length < size) {
[tableEntries, entryCount] = await request(nextStartIndex);
nextStartIndex += entryCount;
}
return { neighbors };
}
async routingTable() {
const clusterId = Zdo.ClusterId.ROUTING_TABLE_REQUEST;
// TODO return Zdo.RoutingTableEntry directly (requires updates in other repos)
const table = [];
const request = async (startIndex) => {
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, startIndex);
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
const response = await entity_1.default.adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false);
if (!Zdo.Buffalo.checkStatus(response)) {