zigbee-herdsman 8.0.3 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/controller/controller.js +2 -2
- package/dist/controller/controller.js.map +1 -1
- package/dist/controller/helpers/ota.d.ts +52 -0
- package/dist/controller/helpers/ota.d.ts.map +1 -0
- package/dist/controller/helpers/ota.js +450 -0
- package/dist/controller/helpers/ota.js.map +1 -0
- package/dist/controller/model/device.d.ts +12 -2
- package/dist/controller/model/device.d.ts.map +1 -1
- package/dist/controller/model/device.js +347 -62
- package/dist/controller/model/device.js.map +1 -1
- package/dist/controller/model/endpoint.d.ts +0 -7
- package/dist/controller/model/endpoint.d.ts.map +1 -1
- package/dist/controller/model/endpoint.js +0 -18
- package/dist/controller/model/endpoint.js.map +1 -1
- package/dist/controller/tstype.d.ts +69 -1
- package/dist/controller/tstype.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/timeService.d.ts +1 -0
- package/dist/utils/timeService.d.ts.map +1 -1
- package/dist/utils/timeService.js +1 -0
- package/dist/utils/timeService.js.map +1 -1
- package/dist/zspec/zcl/definition/foundation.d.ts +2 -2
- package/dist/zspec/zcl/definition/foundation.d.ts.map +1 -1
- package/dist/zspec/zcl/definition/tstype.d.ts +16 -18
- package/dist/zspec/zcl/definition/tstype.d.ts.map +1 -1
- package/dist/zspec/zcl/zclFrame.d.ts +16 -3
- package/dist/zspec/zcl/zclFrame.d.ts.map +1 -1
- package/dist/zspec/zcl/zclFrame.js +8 -3
- package/dist/zspec/zcl/zclFrame.js.map +1 -1
- package/package.json +2 -2
|
@@ -45,6 +45,7 @@ const ZSpec = __importStar(require("../../zspec"));
|
|
|
45
45
|
const enums_1 = require("../../zspec/enums");
|
|
46
46
|
const Zcl = __importStar(require("../../zspec/zcl"));
|
|
47
47
|
const Zdo = __importStar(require("../../zspec/zdo"));
|
|
48
|
+
const ota_1 = require("../helpers/ota");
|
|
48
49
|
const zclTransactionSequenceNumber_1 = __importDefault(require("../helpers/zclTransactionSequenceNumber"));
|
|
49
50
|
const endpoint_1 = __importDefault(require("./endpoint"));
|
|
50
51
|
const entity_1 = __importDefault(require("./entity"));
|
|
@@ -86,6 +87,8 @@ class Device extends entity_1.default {
|
|
|
86
87
|
_pendingRequestTimeout;
|
|
87
88
|
_customClusters = {};
|
|
88
89
|
_gpSecurityKey;
|
|
90
|
+
#scheduledOta;
|
|
91
|
+
#otaInProgress = false;
|
|
89
92
|
// Getters/setters
|
|
90
93
|
get ieeeAddr() {
|
|
91
94
|
return this._ieeeAddr;
|
|
@@ -230,6 +233,12 @@ class Device extends entity_1.default {
|
|
|
230
233
|
get genBasic() {
|
|
231
234
|
return this.#genBasic;
|
|
232
235
|
}
|
|
236
|
+
get scheduledOta() {
|
|
237
|
+
return this.#scheduledOta;
|
|
238
|
+
}
|
|
239
|
+
get otaInProgress() {
|
|
240
|
+
return this.#otaInProgress;
|
|
241
|
+
}
|
|
233
242
|
meta;
|
|
234
243
|
// This lookup contains all devices that are queried from the database, this is to ensure that always
|
|
235
244
|
// the same instance is returned.
|
|
@@ -237,7 +246,7 @@ class Device extends entity_1.default {
|
|
|
237
246
|
static loadedFromDatabase = false;
|
|
238
247
|
static deletedDevices = new Map();
|
|
239
248
|
static nwkToIeeeCache = new Map();
|
|
240
|
-
constructor(id, type, ieeeAddr, networkAddress, manufacturerID, endpoints, manufacturerName, powerSource, modelID, applicationVersion, stackVersion, zclVersion, hardwareVersion, dateCode, softwareBuildID, interviewState, meta, lastSeen, checkinInterval, pendingRequestTimeout, gpSecurityKey) {
|
|
249
|
+
constructor(id, type, ieeeAddr, networkAddress, manufacturerID, endpoints, manufacturerName, powerSource, modelID, applicationVersion, stackVersion, zclVersion, hardwareVersion, dateCode, softwareBuildID, interviewState, meta, lastSeen, checkinInterval, pendingRequestTimeout, gpSecurityKey, scheduledOta) {
|
|
241
250
|
super();
|
|
242
251
|
this.ID = id;
|
|
243
252
|
this._type = type;
|
|
@@ -261,6 +270,7 @@ class Device extends entity_1.default {
|
|
|
261
270
|
this._checkinInterval = checkinInterval;
|
|
262
271
|
this._pendingRequestTimeout = pendingRequestTimeout;
|
|
263
272
|
this._gpSecurityKey = gpSecurityKey;
|
|
273
|
+
this.#scheduledOta = scheduledOta;
|
|
264
274
|
}
|
|
265
275
|
createEndpoint(id) {
|
|
266
276
|
if (this.getEndpoint(id)) {
|
|
@@ -312,74 +322,85 @@ class Device extends entity_1.default {
|
|
|
312
322
|
// prevent race conditions where device gets deleted during processing
|
|
313
323
|
return;
|
|
314
324
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
// Response to read requests
|
|
322
|
-
if (frame.header.isGlobal && frame.isCommand("read") && !this._customReadResponse?.(frame, endpoint)) {
|
|
323
|
-
const attributes = {
|
|
324
|
-
...endpoint.clusters,
|
|
325
|
-
};
|
|
326
|
-
const isTimeReadRequest = dataPayload.clusterID === Zcl.Clusters.genTime.ID;
|
|
327
|
-
if (isTimeReadRequest) {
|
|
328
|
-
attributes.genTime = {
|
|
329
|
-
attributes: timeService.getTimeClusterAttributes(),
|
|
325
|
+
if (frame.header.isGlobal) {
|
|
326
|
+
// Response to read requests
|
|
327
|
+
if (frame.command.name === "read" && !this._customReadResponse?.(frame, endpoint)) {
|
|
328
|
+
const attributes = {
|
|
329
|
+
...endpoint.clusters,
|
|
330
330
|
};
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (name && name in attributes[frame.cluster.name].attributes) {
|
|
337
|
-
response[name] = attributes[frame.cluster.name].attributes[name];
|
|
338
|
-
}
|
|
331
|
+
const isTimeReadRequest = dataPayload.clusterID === Zcl.Clusters.genTime.ID;
|
|
332
|
+
if (isTimeReadRequest) {
|
|
333
|
+
attributes.genTime = {
|
|
334
|
+
attributes: timeService.getTimeClusterAttributes(),
|
|
335
|
+
};
|
|
339
336
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
337
|
+
if (frame.cluster.name in attributes) {
|
|
338
|
+
const response = {};
|
|
339
|
+
for (const entry of frame.payload) {
|
|
340
|
+
const name = frame.cluster.getAttribute(entry.attrId)?.name;
|
|
341
|
+
if (name && name in attributes[frame.cluster.name].attributes) {
|
|
342
|
+
response[name] = attributes[frame.cluster.name].attributes[name];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
await endpoint.readResponse(frame.cluster.ID, frame.header.transactionSequenceNumber, response, {
|
|
347
|
+
srcEndpoint: dataPayload.destinationEndpoint,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
logger_1.logger.error(`Read response to ${this.ieeeAddr} failed (${error.message})`, NS);
|
|
352
|
+
}
|
|
347
353
|
}
|
|
348
354
|
}
|
|
349
355
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
fastPollTimeout: 0,
|
|
358
|
-
}, { sendPolicy: "immediate" });
|
|
359
|
-
// This is a good time to read the checkin interval if we haven't stored it previously
|
|
360
|
-
if (this._checkinInterval === undefined) {
|
|
361
|
-
const pollPeriod = await endpoint.read("genPollCtrl", ["checkinInterval"], { sendPolicy: "immediate" });
|
|
362
|
-
this._checkinInterval = pollPeriod.checkinInterval / 4; // convert to seconds
|
|
363
|
-
this.resetPendingRequestTimeout();
|
|
364
|
-
logger_1.logger.debug(`Request Queue (${this.ieeeAddr}): default expiration timeout set to ${this.pendingRequestTimeout}`, NS);
|
|
356
|
+
else if (frame.header.isSpecific) {
|
|
357
|
+
switch (frame.cluster.name) {
|
|
358
|
+
case "ssIasZone": {
|
|
359
|
+
if (frame.command.name === "enrollReq") {
|
|
360
|
+
// Respond to enroll requests
|
|
361
|
+
logger_1.logger.debug(`IAS - '${this.ieeeAddr}' responding to enroll response`, NS);
|
|
362
|
+
await endpoint.command("ssIasZone", "enrollRsp", { enrollrspcode: 0, zoneid: 23 }, { disableDefaultResponse: true });
|
|
365
363
|
}
|
|
366
|
-
|
|
367
|
-
// We *must* end fast-poll when we're done sending things. Otherwise
|
|
368
|
-
// we cause undue power-drain.
|
|
369
|
-
logger_1.logger.debug(`check-in from ${this.ieeeAddr}: stopping fast-poll`, NS);
|
|
370
|
-
await endpoint.command(frame.cluster.name, "fastPollStop", {}, { sendPolicy: "immediate" });
|
|
364
|
+
break;
|
|
371
365
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
366
|
+
case "genPollCtrl": {
|
|
367
|
+
if (frame.command.name === "checkin") {
|
|
368
|
+
// Handle check-in from sleeping end devices
|
|
369
|
+
try {
|
|
370
|
+
if (this.hasPendingRequests() || this._checkinInterval === undefined) {
|
|
371
|
+
logger_1.logger.debug(`check-in from ${this.ieeeAddr}: accepting fast-poll`, NS);
|
|
372
|
+
await endpoint.command(frame.cluster.name, "checkinRsp", {
|
|
373
|
+
startFastPolling: 1,
|
|
374
|
+
fastPollTimeout: 0,
|
|
375
|
+
}, { sendPolicy: "immediate" });
|
|
376
|
+
// This is a good time to read the checkin interval if we haven't stored it previously
|
|
377
|
+
if (this._checkinInterval === undefined) {
|
|
378
|
+
const pollPeriod = await endpoint.read("genPollCtrl", ["checkinInterval"], { sendPolicy: "immediate" });
|
|
379
|
+
this._checkinInterval = pollPeriod.checkinInterval / 4; // convert to seconds
|
|
380
|
+
this.resetPendingRequestTimeout();
|
|
381
|
+
logger_1.logger.debug(`Request Queue (${this.ieeeAddr}): default expiration timeout set to ${this.pendingRequestTimeout}`, NS);
|
|
382
|
+
}
|
|
383
|
+
await Promise.all(this.endpoints.map(async (e) => await e.sendPendingRequests(true)));
|
|
384
|
+
// We *must* end fast-poll when we're done sending things. Otherwise
|
|
385
|
+
// we cause undue power-drain.
|
|
386
|
+
logger_1.logger.debug(`check-in from ${this.ieeeAddr}: stopping fast-poll`, NS);
|
|
387
|
+
await endpoint.command(frame.cluster.name, "fastPollStop", {}, { sendPolicy: "immediate" });
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
logger_1.logger.debug(`check-in from ${this.ieeeAddr}: declining fast-poll`, NS);
|
|
391
|
+
await endpoint.command(frame.cluster.name, "checkinRsp", {
|
|
392
|
+
startFastPolling: 0,
|
|
393
|
+
fastPollTimeout: 0,
|
|
394
|
+
}, { sendPolicy: "immediate" });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
logger_1.logger.error(`Handling of poll check-in from ${this.ieeeAddr} failed (${error.message})`, NS);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
378
402
|
}
|
|
379
403
|
}
|
|
380
|
-
catch (error) {
|
|
381
|
-
logger_1.logger.error(`Handling of poll check-in from ${this.ieeeAddr} failed (${error.message})`, NS);
|
|
382
|
-
}
|
|
383
404
|
}
|
|
384
405
|
// Send a default response if necessary.
|
|
385
406
|
const isDefaultResponse = frame.header.isGlobal && frame.command.name === "defaultRsp";
|
|
@@ -456,7 +477,7 @@ class Device extends entity_1.default {
|
|
|
456
477
|
entry.interviewState = entry.interviewCompleted ? InterviewState.Successful : InterviewState.Failed;
|
|
457
478
|
logger_1.logger.debug(`Migrated interviewState for '${ieeeAddr}': ${entry.interviewCompleted} -> ${entry.interviewState}`, NS);
|
|
458
479
|
}
|
|
459
|
-
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);
|
|
480
|
+
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, entry.scheduledOta);
|
|
460
481
|
}
|
|
461
482
|
toDatabaseEntry() {
|
|
462
483
|
const epList = this.endpoints.map((e) => e.ID);
|
|
@@ -488,6 +509,7 @@ class Device extends entity_1.default {
|
|
|
488
509
|
lastSeen: this.lastSeen,
|
|
489
510
|
checkinInterval: this.checkinInterval,
|
|
490
511
|
gpSecurityKey: this.gpSecurityKey,
|
|
512
|
+
scheduledOta: this.scheduledOta,
|
|
491
513
|
};
|
|
492
514
|
}
|
|
493
515
|
save(writeDatabase = true) {
|
|
@@ -565,7 +587,7 @@ class Device extends entity_1.default {
|
|
|
565
587
|
throw new Error(`Device with IEEE address '${ieeeAddr}' already exists`);
|
|
566
588
|
}
|
|
567
589
|
const ID = entity_1.default.database.newID();
|
|
568
|
-
const device = new Device(ID, type, ieeeAddr, networkAddress, manufacturerID, [], manufacturerName, powerSource, modelID, undefined, undefined, undefined, undefined, undefined, undefined, interviewState, {}, undefined, undefined, 0, gpSecurityKey);
|
|
590
|
+
const device = new Device(ID, type, ieeeAddr, networkAddress, manufacturerID, [], manufacturerName, powerSource, modelID, undefined, undefined, undefined, undefined, undefined, undefined, interviewState, {}, undefined, undefined, 0, gpSecurityKey, undefined);
|
|
569
591
|
entity_1.default.database.insert(device.toDatabaseEntry());
|
|
570
592
|
Device.devices.set(device.ieeeAddr, device);
|
|
571
593
|
Device.nwkToIeeeCache.set(device.networkAddress, device.ieeeAddr);
|
|
@@ -1054,6 +1076,269 @@ class Device extends entity_1.default {
|
|
|
1054
1076
|
}
|
|
1055
1077
|
this._customClusters[name] = cluster;
|
|
1056
1078
|
}
|
|
1079
|
+
#waitForOtaCommand(endpointId, commandId, transactionSequenceNumber, timeout) {
|
|
1080
|
+
const waiter = entity_1.default.adapter.waitFor(this.networkAddress, endpointId, Zcl.FrameType.SPECIFIC, Zcl.Direction.CLIENT_TO_SERVER, transactionSequenceNumber, Zcl.Clusters.genOta.ID, commandId, timeout);
|
|
1081
|
+
const promise = new Promise((resolve, reject) => {
|
|
1082
|
+
waiter.promise.then((payload) => {
|
|
1083
|
+
try {
|
|
1084
|
+
const frame = Zcl.Frame.fromBuffer(payload.clusterID, payload.header, payload.data, this.customClusters);
|
|
1085
|
+
resolve(frame);
|
|
1086
|
+
}
|
|
1087
|
+
catch (error) {
|
|
1088
|
+
reject(error);
|
|
1089
|
+
}
|
|
1090
|
+
}, (error) => reject(error));
|
|
1091
|
+
});
|
|
1092
|
+
return { promise, cancel: waiter.cancel };
|
|
1093
|
+
}
|
|
1094
|
+
async findMatchingOtaImage(source, current, extraMetas) {
|
|
1095
|
+
logger_1.logger.debug(() => `Getting image metadata for ${this.ieeeAddr}...`, NS);
|
|
1096
|
+
const images = await (0, ota_1.getOtaIndex)(source);
|
|
1097
|
+
// NOTE: Officially an image can be determined with a combination of manufacturerCode and imageType.
|
|
1098
|
+
// However several manufacturers do not follow the spec properly.
|
|
1099
|
+
// The index provides the needed extra metadata to prevent mismatches.
|
|
1100
|
+
// e.g. Tuya must match on manufacturerName, Gledopto on modelId...
|
|
1101
|
+
return images.find((i) => i.imageType === current.imageType &&
|
|
1102
|
+
i.manufacturerCode === current.manufacturerCode &&
|
|
1103
|
+
(i.minFileVersion === undefined || current.fileVersion >= i.minFileVersion) &&
|
|
1104
|
+
(i.maxFileVersion === undefined || current.fileVersion <= i.maxFileVersion) &&
|
|
1105
|
+
// let extra metas override the match from this.modelID, same for manufacturerName
|
|
1106
|
+
(!i.modelId || i.modelId === this.modelID || i.modelId === extraMetas.modelId) &&
|
|
1107
|
+
(!i.manufacturerName ||
|
|
1108
|
+
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
|
|
1109
|
+
i.manufacturerName.includes(this.manufacturerName) ||
|
|
1110
|
+
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
|
|
1111
|
+
i.manufacturerName.includes(extraMetas.manufacturerName)) &&
|
|
1112
|
+
(!extraMetas.otaHeaderString || i.otaHeaderString === extraMetas.otaHeaderString) &&
|
|
1113
|
+
(i.hardwareVersionMin === undefined ||
|
|
1114
|
+
(current.hardwareVersion !== undefined && current.hardwareVersion >= i.hardwareVersionMin) ||
|
|
1115
|
+
(extraMetas.hardwareVersionMin !== undefined && extraMetas.hardwareVersionMin >= i.hardwareVersionMin)) &&
|
|
1116
|
+
(i.hardwareVersionMax === undefined ||
|
|
1117
|
+
(current.hardwareVersion !== undefined && current.hardwareVersion <= i.hardwareVersionMax) ||
|
|
1118
|
+
(extraMetas.hardwareVersionMax !== undefined && extraMetas.hardwareVersionMax <= i.hardwareVersionMax)));
|
|
1119
|
+
}
|
|
1120
|
+
async #notifyOta(endpoint) {
|
|
1121
|
+
// Some devices (e.g. Insta) take a very long trying to discover the correct coordinator EP for OTA
|
|
1122
|
+
const queryNextImageRequest = this.#waitForOtaCommand(endpoint.ID, Zcl.Clusters.genOta.commands.queryNextImageRequest.ID, undefined, 60000);
|
|
1123
|
+
try {
|
|
1124
|
+
await endpoint.commandResponse("genOta", "imageNotify", { payloadType: 0, queryJitter: 100 }, { sendPolicy: "immediate" });
|
|
1125
|
+
const response = await queryNextImageRequest.promise;
|
|
1126
|
+
return [response.payload, response.header.transactionSequenceNumber];
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
queryNextImageRequest.cancel();
|
|
1130
|
+
throw new Error(`Device didn't respond to OTA request`);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* If `current` is undefined, will automatically notify and reply to query with `NO_IMAGE_AVAILABLE` (stops device from doing further requests).
|
|
1135
|
+
*/
|
|
1136
|
+
async checkOta(source, current, extraMetas, endpoint = this.endpoints.find((e) => e.supportsOutputCluster("genOta"))) {
|
|
1137
|
+
(0, node_assert_1.default)(endpoint !== undefined, `No endpoint found with OTA cluster support for ${this.ieeeAddr}`);
|
|
1138
|
+
if (this.modelID === "PP-WHT-US") {
|
|
1139
|
+
// see https://github.com/Koenkk/zigbee-OTA/pull/14
|
|
1140
|
+
const scenesEndpoint = this.endpoints.find((e) => e.supportsOutputCluster("genScenes"));
|
|
1141
|
+
if (scenesEndpoint !== undefined) {
|
|
1142
|
+
await scenesEndpoint.write("genScenes", { currentGroup: 49502 });
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
if (current === undefined) {
|
|
1146
|
+
let queryTsn;
|
|
1147
|
+
[current, queryTsn] = await this.#notifyOta(endpoint);
|
|
1148
|
+
await endpoint.commandResponse("genOta", "queryNextImageResponse", { status: Zcl.Status.NO_IMAGE_AVAILABLE }, undefined, queryTsn);
|
|
1149
|
+
}
|
|
1150
|
+
logger_1.logger.debug(() => `Checking OTA ${this.ieeeAddr} ${source.downgrade ? "downgrade" : "upgrade"} image availability, current=${JSON.stringify(current)}`, NS);
|
|
1151
|
+
if (this.meta.lumiFileVersion &&
|
|
1152
|
+
(this.modelID === "lumi.airrtc.agl001" || this.modelID === "lumi.curtain.acn003" || this.modelID === "lumi.curtain.agl001")) {
|
|
1153
|
+
// The current.fileVersion which comes from the device is wrong.
|
|
1154
|
+
// Use the `lumiFileVersion` which comes from the manuSpecificLumi.attributeReport instead.
|
|
1155
|
+
// https://github.com/Koenkk/zigbee2mqtt/issues/16345#issuecomment-1454835056
|
|
1156
|
+
// https://github.com/Koenkk/zigbee2mqtt/issues/16345 doesn't seem to be needed for all
|
|
1157
|
+
// https://github.com/Koenkk/zigbee2mqtt/issues/15745
|
|
1158
|
+
current = { ...current, fileVersion: this.meta.lumiFileVersion };
|
|
1159
|
+
}
|
|
1160
|
+
const meta = await this.findMatchingOtaImage(source, current, extraMetas);
|
|
1161
|
+
if (!meta) {
|
|
1162
|
+
// no image in repo/URL for specified device
|
|
1163
|
+
return {
|
|
1164
|
+
available: 0,
|
|
1165
|
+
current,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
logger_1.logger.debug(() => `OTA ${source.downgrade ? "downgrade" : "upgrade"} image availability for ${this.ieeeAddr}, available=${JSON.stringify(meta)}`, NS);
|
|
1169
|
+
return {
|
|
1170
|
+
available: meta.force ? -1 : Math.sign(current.fileVersion - meta.fileVersion),
|
|
1171
|
+
current,
|
|
1172
|
+
availableMeta: meta,
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
async updateOta(source, requestPayload, requestTsn, extraMetas, onProgress, dataSettings, endpoint = this.endpoints.find((e) => e.supportsOutputCluster("genOta"))) {
|
|
1176
|
+
(0, node_assert_1.default)(this.#otaInProgress === false, `OTA already in progress for ${this.ieeeAddr}`);
|
|
1177
|
+
(0, node_assert_1.default)(endpoint !== undefined, `No endpoint found with OTA cluster support for ${this.ieeeAddr}`);
|
|
1178
|
+
if (source === undefined) {
|
|
1179
|
+
(0, node_assert_1.default)(this.#scheduledOta !== undefined, `No currently scheduled OTA for ${this.ieeeAddr}`);
|
|
1180
|
+
source = this.#scheduledOta;
|
|
1181
|
+
}
|
|
1182
|
+
this.#otaInProgress = true;
|
|
1183
|
+
// always expected both undefined if one is, but just in case
|
|
1184
|
+
if (requestPayload === undefined || requestTsn === undefined) {
|
|
1185
|
+
try {
|
|
1186
|
+
[requestPayload, requestTsn] = await this.#notifyOta(endpoint);
|
|
1187
|
+
}
|
|
1188
|
+
finally {
|
|
1189
|
+
this.#otaInProgress = false;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
let available = 0;
|
|
1193
|
+
let image;
|
|
1194
|
+
if (source.url && !source.url.endsWith(".json")) {
|
|
1195
|
+
// firmware file at `source.url`
|
|
1196
|
+
try {
|
|
1197
|
+
const downloadedFile = await (0, ota_1.getOtaFirmware)(source.url, undefined);
|
|
1198
|
+
image = (0, ota_1.parseOtaImage)(downloadedFile);
|
|
1199
|
+
available = Math.sign(requestPayload.fileVersion - image.header.fileVersion);
|
|
1200
|
+
logger_1.logger.debug(() =>
|
|
1201
|
+
// biome-ignore lint/style/noNonNullAssertion: valid from above, won't change after assignment
|
|
1202
|
+
`Parsed image from '${source.url}' for ${this.ieeeAddr}, header=${JSON.stringify(image.header)}`, NS);
|
|
1203
|
+
}
|
|
1204
|
+
catch (error) {
|
|
1205
|
+
logger_1.logger.error(`Failed to parse OTA image from '${source.url}' for ${this.ieeeAddr}, aborting (${error.message})`, NS);
|
|
1206
|
+
// biome-ignore lint/style/noNonNullAssertion: expected valid
|
|
1207
|
+
logger_1.logger.debug(error.stack, NS);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
else {
|
|
1211
|
+
let availableMeta;
|
|
1212
|
+
try {
|
|
1213
|
+
// index file at `source.url` (or undefined to use defaults)
|
|
1214
|
+
({ available, availableMeta } = await this.checkOta(source, requestPayload, extraMetas, endpoint));
|
|
1215
|
+
}
|
|
1216
|
+
finally {
|
|
1217
|
+
this.#otaInProgress = false;
|
|
1218
|
+
}
|
|
1219
|
+
if ((source.downgrade ? available === 1 : available === -1) && availableMeta) {
|
|
1220
|
+
try {
|
|
1221
|
+
const downloadedFile = await (0, ota_1.getOtaFirmware)(availableMeta.url, availableMeta.sha512);
|
|
1222
|
+
image = (0, ota_1.parseOtaImage)(downloadedFile);
|
|
1223
|
+
logger_1.logger.debug(() =>
|
|
1224
|
+
// biome-ignore lint/style/noNonNullAssertion: valid from above, won't change after assignment
|
|
1225
|
+
`Parsed image from '${availableMeta.url}' for ${this.ieeeAddr}, header=${JSON.stringify(image.header)}`, NS);
|
|
1226
|
+
}
|
|
1227
|
+
catch (error) {
|
|
1228
|
+
logger_1.logger.error(`Failed to parse OTA image for ${this.ieeeAddr}, aborting (${error.message})`, NS);
|
|
1229
|
+
// biome-ignore lint/style/noNonNullAssertion: expected valid
|
|
1230
|
+
logger_1.logger.debug(error.stack, NS);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
else {
|
|
1234
|
+
logger_1.logger.info(() => `No OTA ${source.downgrade ? "downgrade" : "upgrade"} image currently available for ${this.ieeeAddr}`, NS);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
// reply to `queryNextImageRequest` now that we have the data for it, should trigger image block/page request from device
|
|
1238
|
+
// NOTE: previous code had try/catch wrapping with ignored error, but that doesn't look good (would fail to start OTA from device side)
|
|
1239
|
+
try {
|
|
1240
|
+
await endpoint.commandResponse("genOta", "queryNextImageResponse", image && available !== 0
|
|
1241
|
+
? {
|
|
1242
|
+
status: Zcl.Status.SUCCESS,
|
|
1243
|
+
manufacturerCode: image.header.manufacturerCode,
|
|
1244
|
+
imageType: image.header.imageType,
|
|
1245
|
+
fileVersion: image.header.fileVersion,
|
|
1246
|
+
imageSize: image.header.totalImageSize,
|
|
1247
|
+
}
|
|
1248
|
+
: { status: Zcl.Status.NO_IMAGE_AVAILABLE }, undefined, requestTsn);
|
|
1249
|
+
}
|
|
1250
|
+
finally {
|
|
1251
|
+
this.#otaInProgress = false;
|
|
1252
|
+
}
|
|
1253
|
+
if (!image || available === 0) {
|
|
1254
|
+
this.#otaInProgress = false;
|
|
1255
|
+
return [requestPayload, undefined];
|
|
1256
|
+
}
|
|
1257
|
+
logger_1.logger.debug(() => `Starting OTA update for ${this.ieeeAddr}`, NS);
|
|
1258
|
+
const session = new ota_1.OtaSession(this.ieeeAddr, endpoint, image, onProgress, dataSettings, this.#waitForOtaCommand.bind(this));
|
|
1259
|
+
let endResult;
|
|
1260
|
+
try {
|
|
1261
|
+
endResult = await session.run();
|
|
1262
|
+
}
|
|
1263
|
+
finally {
|
|
1264
|
+
this.#otaInProgress = false;
|
|
1265
|
+
}
|
|
1266
|
+
logger_1.logger.debug(() => `Received upgrade end request for ${this.ieeeAddr}: ${JSON.stringify(endResult.payload)}`, NS);
|
|
1267
|
+
if (endResult.payload.status === Zcl.Status.SUCCESS) {
|
|
1268
|
+
try {
|
|
1269
|
+
const currentTime = timeService.timestampToZigbeeUtcTime(Date.now());
|
|
1270
|
+
await endpoint.commandResponse("genOta", "upgradeEndResponse", {
|
|
1271
|
+
manufacturerCode: image.header.manufacturerCode,
|
|
1272
|
+
imageType: image.header.imageType,
|
|
1273
|
+
fileVersion: image.header.fileVersion,
|
|
1274
|
+
currentTime,
|
|
1275
|
+
upgradeTime: currentTime + 1, // TODO: could this tiny offset be a problem for some stacks?
|
|
1276
|
+
}, undefined, endResult.header.transactionSequenceNumber);
|
|
1277
|
+
onProgress(100, 0);
|
|
1278
|
+
logger_1.logger.info(() => `Update of ${this.ieeeAddr} successful (${Math.round((performance.now() - session.startTime) / 1000)} seconds). Waiting for device announce...`, NS);
|
|
1279
|
+
let timer;
|
|
1280
|
+
await new Promise((resolve) => {
|
|
1281
|
+
const onDeviceAnnounce = () => {
|
|
1282
|
+
clearTimeout(timer);
|
|
1283
|
+
logger_1.logger.debug(() => `Received device announce for ${this.ieeeAddr}, OTA update finished.`, NS);
|
|
1284
|
+
resolve();
|
|
1285
|
+
};
|
|
1286
|
+
// force "finished" after given time
|
|
1287
|
+
timer = setTimeout(() => {
|
|
1288
|
+
this.removeListener("deviceAnnounce", onDeviceAnnounce);
|
|
1289
|
+
logger_1.logger.debug(() => `Timed out waiting for device announce for ${this.ieeeAddr}, OTA update considered finished.`, NS);
|
|
1290
|
+
resolve();
|
|
1291
|
+
}, 120000 /** consider "done" after timeout even if no announce seen */);
|
|
1292
|
+
this.once("deviceAnnounce", onDeviceAnnounce);
|
|
1293
|
+
});
|
|
1294
|
+
// only "cancel" possible scheduled OTA when successful
|
|
1295
|
+
this.#scheduledOta = undefined;
|
|
1296
|
+
this.#otaInProgress = false;
|
|
1297
|
+
return [
|
|
1298
|
+
requestPayload,
|
|
1299
|
+
{
|
|
1300
|
+
fieldControl: 0,
|
|
1301
|
+
manufacturerCode: image.header.manufacturerCode,
|
|
1302
|
+
imageType: image.header.imageType,
|
|
1303
|
+
fileVersion: image.header.fileVersion,
|
|
1304
|
+
},
|
|
1305
|
+
];
|
|
1306
|
+
}
|
|
1307
|
+
catch (error) {
|
|
1308
|
+
this.#otaInProgress = false;
|
|
1309
|
+
throw new Error(`OTA upgrade end response failed: ${error.message}`);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
/**
|
|
1314
|
+
* For other status value received such as INVALID_IMAGE, REQUIRE_MORE_IMAGE, or ABORT,
|
|
1315
|
+
* the upgrade server SHALL not send Upgrade End Response command but it SHALL send default
|
|
1316
|
+
* response command with status of success and it SHALL wait for the client to reinitiate the upgrade process.
|
|
1317
|
+
*/
|
|
1318
|
+
try {
|
|
1319
|
+
await endpoint.defaultResponse(Zcl.Clusters.genOta.commands.upgradeEndRequest.ID, Zcl.Status.SUCCESS, Zcl.Clusters.genOta.ID, endResult.header.transactionSequenceNumber);
|
|
1320
|
+
}
|
|
1321
|
+
catch (error) {
|
|
1322
|
+
logger_1.logger.debug(() => `OTA upgrade end request default response for ${this.ieeeAddr} failed: ${error.message}`, NS);
|
|
1323
|
+
}
|
|
1324
|
+
this.#otaInProgress = false;
|
|
1325
|
+
throw new Error(`OTA update of ${this.ieeeAddr} failed with reason: ${Zcl.Status[endResult.payload.status]}`);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
scheduleOta(source) {
|
|
1329
|
+
(0, node_assert_1.default)(this.endpoints.some((e) => e.supportsOutputCluster("genOta")), `No endpoint found with OTA cluster support for ${this.ieeeAddr}`);
|
|
1330
|
+
if (this.#scheduledOta) {
|
|
1331
|
+
logger_1.logger.info(`Previously scheduled OTA update for '${this.ieeeAddr}' was cancelled in favor of new schedule request`, NS);
|
|
1332
|
+
}
|
|
1333
|
+
this.#scheduledOta = source;
|
|
1334
|
+
logger_1.logger.info(`Scheduled OTA update for '${this.ieeeAddr}' on next request from device`, NS);
|
|
1335
|
+
}
|
|
1336
|
+
unscheduleOta() {
|
|
1337
|
+
if (this.#scheduledOta !== undefined) {
|
|
1338
|
+
this.#scheduledOta = undefined;
|
|
1339
|
+
logger_1.logger.info(`Previously scheduled OTA update for '${this.ieeeAddr}' was cancelled`, NS);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1057
1342
|
}
|
|
1058
1343
|
exports.Device = Device;
|
|
1059
1344
|
exports.default = Device;
|