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.
Files changed (34) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/controller/controller.js +2 -2
  3. package/dist/controller/controller.js.map +1 -1
  4. package/dist/controller/helpers/ota.d.ts +52 -0
  5. package/dist/controller/helpers/ota.d.ts.map +1 -0
  6. package/dist/controller/helpers/ota.js +450 -0
  7. package/dist/controller/helpers/ota.js.map +1 -0
  8. package/dist/controller/model/device.d.ts +12 -2
  9. package/dist/controller/model/device.d.ts.map +1 -1
  10. package/dist/controller/model/device.js +347 -62
  11. package/dist/controller/model/device.js.map +1 -1
  12. package/dist/controller/model/endpoint.d.ts +0 -7
  13. package/dist/controller/model/endpoint.d.ts.map +1 -1
  14. package/dist/controller/model/endpoint.js +0 -18
  15. package/dist/controller/model/endpoint.js.map +1 -1
  16. package/dist/controller/tstype.d.ts +69 -1
  17. package/dist/controller/tstype.d.ts.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +8 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/utils/timeService.d.ts +1 -0
  23. package/dist/utils/timeService.d.ts.map +1 -1
  24. package/dist/utils/timeService.js +1 -0
  25. package/dist/utils/timeService.js.map +1 -1
  26. package/dist/zspec/zcl/definition/foundation.d.ts +2 -2
  27. package/dist/zspec/zcl/definition/foundation.d.ts.map +1 -1
  28. package/dist/zspec/zcl/definition/tstype.d.ts +16 -18
  29. package/dist/zspec/zcl/definition/tstype.d.ts.map +1 -1
  30. package/dist/zspec/zcl/zclFrame.d.ts +16 -3
  31. package/dist/zspec/zcl/zclFrame.d.ts.map +1 -1
  32. package/dist/zspec/zcl/zclFrame.js +8 -3
  33. package/dist/zspec/zcl/zclFrame.js.map +1 -1
  34. 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
- // Respond to enroll requests
316
- if (frame.header.isSpecific && frame.isCluster("ssIasZone") && frame.isCommand("enrollReq")) {
317
- logger_1.logger.debug(`IAS - '${this.ieeeAddr}' responding to enroll response`, NS);
318
- const payload = { enrollrspcode: 0, zoneid: 23 };
319
- await endpoint.command("ssIasZone", "enrollRsp", payload, { disableDefaultResponse: true });
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
- if (frame.cluster.name in attributes) {
333
- const response = {};
334
- for (const entry of frame.payload) {
335
- const name = frame.cluster.getAttribute(entry.attrId)?.name;
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
- try {
341
- await endpoint.readResponse(frame.cluster.ID, frame.header.transactionSequenceNumber, response, {
342
- srcEndpoint: dataPayload.destinationEndpoint,
343
- });
344
- }
345
- catch (error) {
346
- logger_1.logger.error(`Read response to ${this.ieeeAddr} failed (${error.message})`, NS);
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
- // Handle check-in from sleeping end devices
351
- if (frame.header.isSpecific && frame.isCluster("genPollCtrl") && frame.isCommand("checkin")) {
352
- try {
353
- if (this.hasPendingRequests() || this._checkinInterval === undefined) {
354
- logger_1.logger.debug(`check-in from ${this.ieeeAddr}: accepting fast-poll`, NS);
355
- await endpoint.command(frame.cluster.name, "checkinRsp", {
356
- startFastPolling: 1,
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
- await Promise.all(this.endpoints.map(async (e) => await e.sendPendingRequests(true)));
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
- else {
373
- logger_1.logger.debug(`check-in from ${this.ieeeAddr}: declining fast-poll`, NS);
374
- await endpoint.command(frame.cluster.name, "checkinRsp", {
375
- startFastPolling: 0,
376
- fastPollTimeout: 0,
377
- }, { sendPolicy: "immediate" });
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;