zigbee-herdsman-converters
Version:
Collection of device converters to be used with zigbee-herdsman
682 lines • 35.6 kB
JavaScript
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.UPGRADE_FILE_IDENTIFIER = exports.DEFAULT_IMAGE_BLOCK_RESPONSE_DELAY = exports.DEFAULT_MAXIMUM_DATA_SIZE = exports.ZIGBEE_OTA_PREVIOUS_URL = exports.ZIGBEE_OTA_LATEST_URL = void 0;
exports.setConfiguration = setConfiguration;
exports.isValidUrl = isValidUrl;
exports.parseImage = parseImage;
exports.isUpdateAvailable = isUpdateAvailable;
exports.update = update;
const node_assert_1 = __importDefault(require("node:assert"));
const node_crypto_1 = __importDefault(require("node:crypto"));
const node_fs_1 = require("node:fs");
const node_path_1 = __importDefault(require("node:path"));
const buffer_crc32_1 = __importDefault(require("buffer-crc32"));
const zigbee_herdsman_1 = require("zigbee-herdsman");
const logger_1 = require("./logger");
const NS = "zhc:ota";
exports.ZIGBEE_OTA_LATEST_URL = "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/index.json";
exports.ZIGBEE_OTA_PREVIOUS_URL = "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/index1.json";
/** +- 24 days */
const MAX_TIMEOUT = 2147483647;
/** When the data size is too big, OTA gets unstable, so default it to 50 bytes maximum. */
exports.DEFAULT_MAXIMUM_DATA_SIZE = 50;
/** Use to reduce network congestion by throttling response if necessary */
exports.DEFAULT_IMAGE_BLOCK_RESPONSE_DELAY = 250;
/** Consider update done after this amount of time without having seen a deviceAnnounce */
const UPDATE_END_FORCE_RESOLVE_TIME = 120 * 1000;
exports.UPGRADE_FILE_IDENTIFIER = Buffer.from([0x1e, 0xf1, 0xee, 0x0b]);
const VALID_SILABS_CRC = 0x2144df1c;
const EBL_TAG_HEADER = 0x0;
const EBL_TAG_ENC_HEADER = 0xfb05;
const EBL_TAG_END = 0xfc04;
const EBL_PADDING = 0xff;
const EBL_IMAGE_SIGNATURE = 0xe350;
const GBL_HEADER_TAG = Buffer.from([0xeb, 0x17, 0xa6, 0x03]);
/** Contains length+CRC32 and possibly padding after this. */
const GBL_END_TAG = Buffer.from([0xfc, 0x04, 0x04, 0xfc]);
// #region Configuration
let dataDir;
let overrideIndexFileName;
let imageBlockResponseDelay = exports.DEFAULT_IMAGE_BLOCK_RESPONSE_DELAY;
let initialMaximumDataSize = exports.DEFAULT_MAXIMUM_DATA_SIZE;
function setConfiguration(settings) {
dataDir = settings.dataDir;
overrideIndexFileName = settings.overrideIndexLocation;
// use || no zero values
imageBlockResponseDelay = settings.imageBlockResponseDelay || exports.DEFAULT_IMAGE_BLOCK_RESPONSE_DELAY;
initialMaximumDataSize = settings.defaultMaximumDataSize || exports.DEFAULT_MAXIMUM_DATA_SIZE;
}
// #endregion
// #region General Utils
function isValidUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
}
catch {
return false;
}
}
async function getJson(pageUrl) {
const response = await fetch(pageUrl);
if (!response.ok || !response.body) {
throw new Error(`Invalid response from ${pageUrl} status=${response.status}.`);
}
return (await response.json());
}
function readLocalFile(fileName) {
// If the file name is not a full path, then treat it as a relative to the data directory
if (!node_path_1.default.isAbsolute(fileName) && dataDir) {
// biome-ignore lint/style/noParameterAssign: ignored using `--suppress`
fileName = node_path_1.default.join(dataDir, fileName);
}
logger_1.logger.debug(`Getting local firmware file '${fileName}'`, NS);
return (0, node_fs_1.readFileSync)(fileName);
}
async function getFirmwareFile(meta) {
const urlOrName = meta.url;
// First try to download firmware file with the URL provided
if (isValidUrl(urlOrName)) {
logger_1.logger.debug(`Downloading firmware image from '${urlOrName}'`, NS);
const firmwareFileRsp = await fetch(urlOrName);
if (!firmwareFileRsp.ok || !firmwareFileRsp.body) {
throw new Error(`Invalid response from ${urlOrName} status=${firmwareFileRsp.status}.`);
}
return Buffer.from(await firmwareFileRsp.arrayBuffer());
}
logger_1.logger.debug(`Try to read firmware image from local file '${urlOrName}'`, NS);
return readLocalFile(urlOrName);
}
// #endregion
// #region OTA Utils
function parseSubElement(buffer, position) {
const tagID = buffer.readUInt16LE(position);
const length = buffer.readUInt32LE(position + 2);
const data = buffer.subarray(position + 6, position + 6 + length);
return { tagID, length, data };
}
function parseImage(buffer, suppressElementImageParseFailure = false) {
const header = {
otaUpgradeFileIdentifier: buffer.subarray(0, 4),
otaHeaderVersion: buffer.readUInt16LE(4),
otaHeaderLength: buffer.readUInt16LE(6),
otaHeaderFieldControl: buffer.readUInt16LE(8),
manufacturerCode: buffer.readUInt16LE(10),
imageType: buffer.readUInt16LE(12),
fileVersion: buffer.readUInt32LE(14),
zigbeeStackVersion: buffer.readUInt16LE(18),
otaHeaderString: buffer.toString("utf8", 20, 52),
totalImageSize: buffer.readUInt32LE(52),
};
let headerPos = 56;
let didSuppressElementImageParseFailure = false;
/* istanbul ignore next */
if (header.otaHeaderFieldControl & 1) {
header.securityCredentialVersion = buffer.readUInt8(headerPos);
headerPos += 1;
}
/* istanbul ignore next */
if (header.otaHeaderFieldControl & 2) {
header.upgradeFileDestination = buffer.subarray(headerPos, headerPos + 8);
headerPos += 8;
}
if (header.otaHeaderFieldControl & 4) {
header.minimumHardwareVersion = buffer.readUInt16LE(headerPos);
headerPos += 2;
header.maximumHardwareVersion = buffer.readUInt16LE(headerPos);
headerPos += 2;
}
const raw = buffer.subarray(0, header.totalImageSize);
// Note: in the context of this file, this can never assert, since both callers of `parseImage` already subarray to `UPGRADE_FILE_IDENTIFIER`
(0, node_assert_1.default)(exports.UPGRADE_FILE_IDENTIFIER.equals(header.otaUpgradeFileIdentifier), "Not a valid OTA file");
let position = header.otaHeaderLength;
const elements = [];
try {
while (position < header.totalImageSize) {
const element = parseSubElement(buffer, position);
elements.push(element);
position += element.data.length + 6;
}
}
catch (error) {
if (!suppressElementImageParseFailure) {
throw error;
}
didSuppressElementImageParseFailure = true;
logger_1.logger.error("Partially failed to parse the image, continuing anyway...", NS);
}
if (!didSuppressElementImageParseFailure) {
(0, node_assert_1.default)(position === header.totalImageSize, "Size mismatch");
}
return { header, elements, raw };
}
function validateImageData(image) {
for (const element of image.elements) {
const { data } = element;
if (data.indexOf(GBL_HEADER_TAG) === 0) {
validateSilabsGbl(data);
}
else {
const tag = data.readUInt16BE(0);
/* istanbul ignore next */
if ((tag === EBL_TAG_HEADER && data.readUInt16BE(6) === EBL_IMAGE_SIGNATURE) || tag === EBL_TAG_ENC_HEADER) {
validateSilabsEbl(data);
}
}
}
}
/* istanbul ignore next */
function validateSilabsEbl(data) {
const dataLength = data.length;
let position = 0;
while (position + 4 <= dataLength) {
const tag = data.readUInt16BE(position);
const len = data.readUInt16BE(position + 2);
position += 4 + len;
if (tag !== EBL_TAG_END) {
continue;
}
for (let position2 = position; position2 < dataLength; position2++) {
(0, node_assert_1.default)(data.readUInt8(position2) === EBL_PADDING, "Image padding contains invalid bytes");
}
const calculatedCrc32 = buffer_crc32_1.default.unsigned(data.subarray(0, position));
(0, node_assert_1.default)(calculatedCrc32 === VALID_SILABS_CRC, "Image CRC-32 is invalid");
return;
}
throw new Error("Image is truncated, not long enough to contain a valid tag");
}
function validateSilabsGbl(data) {
(0, node_assert_1.default)(data.indexOf(GBL_HEADER_TAG) === 0, "Not a valid GBL image");
const gblEndTagIndex = data.lastIndexOf(GBL_END_TAG);
(0, node_assert_1.default)(gblEndTagIndex > 16, "Not a valid GBL image"); // after HEADER, just because...
const gblEnd = gblEndTagIndex + 12; // tag + length + crc32 (4*3)
// TODO: nodejs >= v20.15.0, remove dep buffer-crc32
// import {crc32} from 'zlib';
// const calculatedCrc32 = crc32(data.subarray(0, gblEnd));
// ignore possible padding
const calculatedCrc32 = buffer_crc32_1.default.unsigned(data.subarray(0, gblEnd));
(0, node_assert_1.default)(calculatedCrc32 === VALID_SILABS_CRC, "Image CRC-32 is invalid");
}
function fillImageInfo(meta) {
// Web-hosted images must come with all fields filled already
if (isValidUrl(meta.url)) {
return meta;
}
// Nothing to do if needed fields were filled already
if (meta.imageType !== undefined && meta.manufacturerCode !== undefined && meta.fileVersion !== undefined) {
return meta;
}
// If no fields provided - get them from the image file
const imageFile = readLocalFile(meta.url);
const otaIdentifier = imageFile.indexOf(exports.UPGRADE_FILE_IDENTIFIER);
(0, node_assert_1.default)(otaIdentifier !== -1, "Not a valid OTA file");
// allow bypass non-spec Ledvance OTA files if proper manufacturer set
const image = parseImage(imageFile.subarray(otaIdentifier), meta.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.LEDVANCE_GMBH);
// Will fill only those fields that were absent
if (meta.imageType === undefined) {
meta.imageType = image.header.imageType;
}
if (meta.manufacturerCode === undefined) {
meta.manufacturerCode = image.header.manufacturerCode;
}
if (meta.fileVersion === undefined) {
meta.fileVersion = image.header.fileVersion;
}
return meta;
}
async function getIndex(previous) {
const mainIndex = await getJson(previous ? exports.ZIGBEE_OTA_PREVIOUS_URL : exports.ZIGBEE_OTA_LATEST_URL);
logger_1.logger.debug("Downloaded main index", NS);
if (overrideIndexFileName) {
logger_1.logger.debug(`Loading override index '${overrideIndexFileName}'`, NS);
const localIndex = isValidUrl(overrideIndexFileName)
? await getJson(overrideIndexFileName)
: JSON.parse((0, node_fs_1.readFileSync)(overrideIndexFileName, "utf-8"));
// Resulting index will have overridden items first
return localIndex.map((image) => fillImageInfo(image)).concat(mainIndex);
}
return mainIndex;
}
function deviceLogString(device) {
return `[${device.ieeeAddr} | ${device.modelID}]`;
}
// #endregion
// #region OTA
function cancelWaiters(waiters) {
waiters.imageBlockOrPageRequest?.cancel();
waiters.upgradeEndRequest?.cancel();
}
function getOTAEndpoint(device) {
return device.endpoints.find((e) => e.supportsOutputCluster("genOta"));
}
async function sendQueryNextImageResponse(device, endpoint, image, requestTransactionSequenceNumber) {
const payload = image
? {
status: zigbee_herdsman_1.Zcl.Status.SUCCESS,
manufacturerCode: image.header.manufacturerCode,
imageType: image.header.imageType,
fileVersion: image.header.fileVersion,
imageSize: image.header.totalImageSize,
}
: { status: zigbee_herdsman_1.Zcl.Status.NO_IMAGE_AVAILABLE };
try {
await endpoint.commandResponse("genOta", "queryNextImageResponse", payload, undefined, requestTransactionSequenceNumber);
}
catch (error) {
logger_1.logger.debug(() => `${deviceLogString(device)} Failed to send queryNextImageResponse: ${error.message}`, NS);
}
}
async function imageNotify(endpoint) {
await endpoint.commandResponse("genOta", "imageNotify", { payloadType: 0, queryJitter: 100 }, { sendPolicy: "immediate" });
}
async function requestOTA(endpoint) {
// Some devices (e.g. Insta) take very long trying to discover the correct coordinator EP for OTA.
const queryNextImageRequest = endpoint.waitForCommand("genOta", "queryNextImageRequest", undefined, 60000);
try {
await imageNotify(endpoint);
const response = await queryNextImageRequest.promise;
return [response.header.transactionSequenceNumber, response.payload];
}
catch {
queryNextImageRequest.cancel();
throw new Error(`Device didn't respond to OTA request`);
}
}
// this is not significant for tests, skipping coverage
/* istanbul ignore next */
function getInitialMaximumDataSize(imageBlockRequest) {
if (imageBlockRequest.payload.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.INSTA_GMBH) {
// Insta devices, OTA only works for data sizes 40 and smaller (= manufacturerCode 4474).
return 40;
}
if (imageBlockRequest.payload.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.LEGRAND_GROUP) {
// Legrand devices (newer firmware) require up to 64 bytes (= manufacturerCode 4129).
return Number.POSITIVE_INFINITY;
}
return initialMaximumDataSize;
}
function getImageBlockResponsePayload(device, image, imageBlockRequest, pageOffset, pageSize) {
let dataSize = Math.min(getInitialMaximumDataSize(imageBlockRequest), imageBlockRequest.payload.maximumDataSize);
let start = imageBlockRequest.payload.fileOffset + pageOffset;
// Hack for https://github.com/Koenkk/zigbee-OTA/issues/328 (Legrand OTA not working)
/* istanbul ignore next */
if (imageBlockRequest.payload.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.LEGRAND_GROUP &&
imageBlockRequest.payload.fileOffset === 50 &&
imageBlockRequest.payload.maximumDataSize === 12) {
logger_1.logger.info(() => `${deviceLogString(device)} Detected Legrand firmware issue, attempting to reset the OTA stack`, NS);
// The following vector seems to buffer overflow the device to reset the OTA stack!
start = 78;
dataSize = 64;
}
if (pageSize) {
dataSize = Math.min(dataSize, pageSize - pageOffset);
}
let end = start + dataSize;
if (end > image.raw.length) {
end = image.raw.length;
}
logger_1.logger.debug(() => `${deviceLogString(device)} Request offsets: fileOffset=${imageBlockRequest.payload.fileOffset} pageOffset=${pageOffset} maximumDataSize=${imageBlockRequest.payload.maximumDataSize}`, NS);
logger_1.logger.debug(() => `${deviceLogString(device)} Payload offsets: start=${start} end=${end} dataSize=${dataSize}`, NS);
return {
status: zigbee_herdsman_1.Zcl.Status.SUCCESS,
manufacturerCode: imageBlockRequest.payload.manufacturerCode,
imageType: imageBlockRequest.payload.imageType,
fileVersion: imageBlockRequest.payload.fileVersion,
fileOffset: start,
dataSize: end - start,
data: image.raw.subarray(start, end),
};
}
function callOnProgress(device, startTime, lastUpdate, imageBlockRequest, image, onProgress) {
const now = Date.now();
// Call on progress every +- 30 seconds
if (lastUpdate === undefined || now - lastUpdate > 30000) {
const totalDuration = (now - startTime) / 1000; // in seconds
const bytesPerSecond = imageBlockRequest.payload.fileOffset / totalDuration;
const remaining = (image.header.totalImageSize - imageBlockRequest.payload.fileOffset) / bytesPerSecond;
let percentage = imageBlockRequest.payload.fileOffset / image.header.totalImageSize;
percentage = Math.round(percentage * 10000) / 100;
logger_1.logger.debug(() => `${deviceLogString(device)} Update at ${percentage}%, remaining ${remaining} seconds`, NS);
onProgress(percentage, remaining === Number.POSITIVE_INFINITY ? undefined : remaining);
return now;
}
return lastUpdate;
}
async function getImageMeta(current, device, extraMetas, previous) {
logger_1.logger.debug(() => `${deviceLogString(device)} Getting image metadata...`, NS);
const images = await getIndex(previous);
// NOTE: Officially an image can be determined with a combination of manufacturerCode and imageType.
// However Gledopto pro products use the same imageType (0) for every device while the image is different.
// For this case additional identification through the modelId is done.
// In the case of Tuya and Moes, additional identification is carried out through the manufacturerName.
return images.find((i) => i.imageType === current.imageType &&
i.manufacturerCode === current.manufacturerCode &&
(i.minFileVersion === undefined || current.fileVersion >= i.minFileVersion) &&
(i.maxFileVersion === undefined || current.fileVersion <= i.maxFileVersion) &&
// let extra metas override the match from device.modelID, same for manufacturerName
(!i.modelId || i.modelId === device.modelID || i.modelId === extraMetas.modelId) &&
(!i.manufacturerName ||
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
i.manufacturerName.includes(device.manufacturerName) ||
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
i.manufacturerName.includes(extraMetas.manufacturerName)) &&
(!extraMetas.otaHeaderString || i.otaHeaderString === extraMetas.otaHeaderString) &&
(i.hardwareVersionMin === undefined ||
(current.hardwareVersion !== undefined && current.hardwareVersion >= i.hardwareVersionMin) ||
(extraMetas.hardwareVersionMin !== undefined && extraMetas.hardwareVersionMin >= i.hardwareVersionMin)) &&
(i.hardwareVersionMax === undefined ||
(current.hardwareVersion !== undefined && current.hardwareVersion <= i.hardwareVersionMax) ||
(extraMetas.hardwareVersionMax !== undefined && extraMetas.hardwareVersionMax <= i.hardwareVersionMax)));
}
async function isImageAvailable(current, device, extraMetas, previous) {
const imageSet = previous ? "previous" : "latest";
logger_1.logger.debug(() => `${deviceLogString(device)} Checking ${imageSet} image availability, current: ${JSON.stringify(current)}`, NS);
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
if (["lumi.airrtc.agl001", "lumi.curtain.acn003", "lumi.curtain.agl001"].includes(device.modelID)) {
// The current.fileVersion which comes from the device is wrong.
// Use the `lumiFileVersion` which comes from the manuSpecificLumi.attributeReport instead.
// https://github.com/Koenkk/zigbee2mqtt/issues/16345#issuecomment-1454835056
// https://github.com/Koenkk/zigbee2mqtt/issues/16345 doesn't seem to be needed for all
// https://github.com/Koenkk/zigbee2mqtt/issues/15745
if (device.meta.lumiFileVersion) {
// biome-ignore lint/style/noParameterAssign: ignored using `--suppress`
current = { ...current, fileVersion: device.meta.lumiFileVersion };
}
}
const meta = await getImageMeta(current, device, extraMetas, previous);
// Soft-fail because no images in repo/URL for specified device
if (!meta) {
logger_1.logger.debug(() => `${deviceLogString(device)} No ${imageSet} image currently available, current: ${JSON.stringify(current)}'`, NS);
return {
available: 0,
currentFileVersion: current.fileVersion,
otaFileVersion: current.fileVersion,
};
}
logger_1.logger.debug(() => `${deviceLogString(device)} Result for ${imageSet} image availability, meta: '${JSON.stringify(meta)}'`, NS);
/* istanbul ignore next */
if (meta.releaseNotes) {
logger_1.logger.info(() => `${deviceLogString(device)} Firmware release notes: ${
// biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
meta.releaseNotes.replace(/[\r\n]/g, "")}`, NS);
}
// Negative number means the firmware is 'newer' than current one
// Positive number means the firmware is 'older' than current one
return {
available: meta.force ? -1 : Math.sign(current.fileVersion - meta.fileVersion),
currentFileVersion: current.fileVersion,
otaFileVersion: meta.fileVersion,
};
}
async function getImage(current, device, extraMetas, previous) {
const meta = await getImageMeta(current, device, extraMetas, previous);
if (!meta) {
throw new Error(`${deviceLogString(device)} No image currently available`);
}
const imageSet = previous ? "previous" : "latest";
logger_1.logger.info(() => `${deviceLogString(device)} Getting ${imageSet} image, meta: ${JSON.stringify(meta)}`, NS);
if (previous) {
(0, node_assert_1.default)(meta.fileVersion < current.fileVersion || meta.force, "No previous image available");
}
else {
(0, node_assert_1.default)(meta.fileVersion > current.fileVersion || meta.force, "No new image available");
}
const downloadedFile = await getFirmwareFile(meta);
if (meta.sha512) {
const hash = node_crypto_1.default.createHash("sha512");
hash.update(downloadedFile);
(0, node_assert_1.default)(hash.digest("hex") === meta.sha512, "File checksum validation failed");
logger_1.logger.debug(() => `${deviceLogString(device)} Image checksum validation succeeded.`, NS);
}
const otaIdentifier = downloadedFile.indexOf(exports.UPGRADE_FILE_IDENTIFIER);
(0, node_assert_1.default)(otaIdentifier !== -1, "Not a valid OTA file");
const image = parseImage(downloadedFile.subarray(otaIdentifier), extraMetas.suppressElementImageParseFailure || false);
logger_1.logger.debug(() => `${deviceLogString(device)} Got ${imageSet} image, header: ${JSON.stringify(image.header)}`, NS);
(0, node_assert_1.default)(image.header.fileVersion === meta.fileVersion, "File version mismatch");
(0, node_assert_1.default)(!meta.fileSize || image.header.totalImageSize === meta.fileSize, "Image size mismatch");
(0, node_assert_1.default)(image.header.manufacturerCode === current.manufacturerCode, "Manufacturer code mismatch");
(0, node_assert_1.default)(image.header.imageType === current.imageType, "Image type mismatch");
// this is only reachable if manifest is missing hardwareVersionMin/Max
if ("minimumHardwareVersion" in image.header &&
image.header.minimumHardwareVersion !== undefined &&
"maximumHardwareVersion" in image.header &&
image.header.maximumHardwareVersion !== undefined) {
(0, node_assert_1.default)(current.hardwareVersion !== undefined, "Hardware version required");
(0, node_assert_1.default)(image.header.minimumHardwareVersion <= current.hardwareVersion && current.hardwareVersion <= image.header.maximumHardwareVersion, "Hardware version mismatch");
}
validateImageData(image);
return image;
}
async function isUpdateAvailable(device, extraMetas, requestPayload, previous) {
logger_1.logger.debug(() => `${deviceLogString(device)} Checking if an update is available`, NS);
if (device.modelID === "PP-WHT-US") {
// see https://github.com/Koenkk/zigbee-OTA/pull/14
const scenesEndpoint = device.endpoints.find((e) => e.supportsOutputCluster("genScenes"));
if (scenesEndpoint) {
await scenesEndpoint.write("genScenes", { currentGroup: 49502 });
}
}
if (requestPayload === undefined) {
const endpoint = getOTAEndpoint(device);
(0, node_assert_1.default)(endpoint !== undefined, `${deviceLogString(device)} Failed to find an endpoint which supports the OTA cluster`);
logger_1.logger.debug(() => `${deviceLogString(device)} Using endpoint '${endpoint.ID}'`, NS);
const [, payload] = await requestOTA(endpoint);
logger_1.logger.debug(() => `${deviceLogString(device)} Got request '${JSON.stringify(payload)}'`, NS);
// biome-ignore lint/style/noParameterAssign: ignored using `--suppress`
requestPayload = payload;
}
const availableResult = await isImageAvailable(requestPayload, device, extraMetas, previous);
let available = false;
if (previous) {
available = availableResult.available > 0;
logger_1.logger.debug(() => `${deviceLogString(device)} Downgrade available: ${available ? "YES" : "NO"}`, NS);
}
else {
available = availableResult.available < 0;
logger_1.logger.debug(() => `${deviceLogString(device)} Upgrade available: ${available ? "YES" : "NO"}`, NS);
if (availableResult.available > 0) {
logger_1.logger.warning(() => `${deviceLogString(device)} Firmware is newer than latest available firmware.`, NS);
}
}
return { ...availableResult, available };
}
// this is not significant for tests, skipping coverage
/* istanbul ignore next */
function getImageBlockOrPageRequestTimeoutMs(requestPayload) {
// increase the upgradeEndReq wait time to solve the problem of OTA timeout failure of Sonoff Devices
// (https://github.com/Koenkk/zigbee-herdsman-converters/issues/6657)
if (requestPayload.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.SHENZHEN_COOLKIT_TECHNOLOGY_CO_LTD && requestPayload.imageType === 8199) {
return 3600000;
}
// Bosch transmits the firmware updates in the background in their native implementation.
// According to the app, this can take up to 2 days. Therefore, we assume to get at least
// one package request per hour from the device here.
if (requestPayload.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH) {
return 60 * 60 * 1000;
}
// Increase the timeout for Legrand devices, so that they will re-initiate and update themselves
// Newer firmwares have awkward behaviours when it comes to the handling of the last bytes of OTA updates
if (requestPayload.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.LEGRAND_GROUP) {
return 30 * 60 * 1000;
}
return 150000;
}
async function update(device, extraMetas, previous, onProgress, requestPayload, reqTransNum) {
const imageSet = previous ? "previous" : "latest";
logger_1.logger.debug(() => `${deviceLogString(device)} Updating to ${imageSet}`, NS);
const endpoint = getOTAEndpoint(device);
(0, node_assert_1.default)(endpoint !== undefined, `${deviceLogString(device)} Failed to find an endpoint which supports the OTA cluster`);
logger_1.logger.debug(() => `${deviceLogString(device)} Using endpoint '${endpoint.ID}'`, NS);
if (device.modelID === "PP-WHT-US") {
// see https://github.com/Koenkk/zigbee-OTA/pull/14
const scenesEndpoint = device.endpoints.find((e) => e.supportsOutputCluster("genScenes"));
if (scenesEndpoint) {
await scenesEndpoint.write("genScenes", { currentGroup: 49502 });
}
}
if (!requestPayload) {
// biome-ignore lint/style/noParameterAssign: ignored using `--suppress`
[reqTransNum, requestPayload] = await requestOTA(endpoint);
logger_1.logger.debug(() => `${deviceLogString(device)} Got request payload '${JSON.stringify(requestPayload)}'`, NS);
}
let image;
try {
image = await getImage(requestPayload, device, extraMetas, previous);
logger_1.logger.debug(() => `${deviceLogString(device)} Got ${imageSet} image`, NS);
}
catch (error) {
logger_1.logger.info(() => `${deviceLogString(device)} No image currently available (${error.message})`, NS);
}
// reply to `queryNextImageRequest` in `requestOTA` now that we have the data for it,
// should trigger image block/page request from device
await sendQueryNextImageResponse(device, endpoint, image, reqTransNum);
if (!image) {
return undefined;
}
const waiters = {};
let lastBlockResponseTime = 0;
let lastBlockTimeout;
let lastUpdate = undefined;
const startTime = Date.now();
const sendImageBlockResponse = async (imageBlockRequest, pageOffset, pageSize) => {
// Reduce network congestion by throttling response if necessary
{
clearTimeout(lastBlockTimeout);
const now = Date.now();
const timeSinceLast = now - lastBlockResponseTime;
const delay = imageBlockResponseDelay - timeSinceLast;
if (delay <= 0) {
lastBlockResponseTime = now;
}
else {
await new Promise((resolve) => {
lastBlockTimeout = setTimeout(() => {
lastBlockResponseTime = Date.now();
resolve();
}, delay);
});
}
}
try {
const blockPayload = getImageBlockResponsePayload(device, image, imageBlockRequest, pageOffset, pageSize);
await endpoint.commandResponse("genOta", "imageBlockResponse", blockPayload, undefined, imageBlockRequest.header.transactionSequenceNumber);
// biome-ignore lint/style/noParameterAssign: ignored using `--suppress`
pageOffset += blockPayload.dataSize;
}
catch (error) {
// Shit happens, device will probably do a new imageBlockRequest so don't care.
logger_1.logger.debug(() => `${deviceLogString(device)} Image block response failed: ${error.message}`, NS);
}
lastUpdate = callOnProgress(device, startTime, lastUpdate, imageBlockRequest, image, onProgress);
return pageOffset;
};
let done = false;
const imageBlockOrPageRequestTimeoutMs = getImageBlockOrPageRequestTimeoutMs(requestPayload);
/** recursive, endless (expects `upgradeEndRequest` to stop it, or anything that sets done=true) */
const sendImageChunks = async () => {
while (!done) {
const imageBlockRequest = endpoint.waitForCommand("genOta", "imageBlockRequest", undefined, imageBlockOrPageRequestTimeoutMs);
const imagePageRequest = endpoint.waitForCommand("genOta", "imagePageRequest", undefined, imageBlockOrPageRequestTimeoutMs);
waiters.imageBlockOrPageRequest = {
promise: Promise.race([imageBlockRequest.promise, imagePageRequest.promise]),
cancel: () => {
imageBlockRequest.cancel();
imagePageRequest.cancel();
},
};
try {
const result = await waiters.imageBlockOrPageRequest.promise;
let pageSize = 0;
let pageOffset = 0;
if ("pageSize" in result.payload) {
// TODO: `result.payload.responseSpacing` support?
// imagePageRequest
pageSize = result.payload.pageSize;
while (pageOffset < pageSize) {
// in case upgradeEndRequest resolves, bail early (quirks)
if (done) {
return;
}
pageOffset = await sendImageBlockResponse(result, pageOffset, pageSize);
}
}
else {
// imageBlockRequest
pageOffset = await sendImageBlockResponse(result, pageOffset, pageSize);
}
}
catch (error) {
cancelWaiters(waiters);
throw new Error(`${deviceLogString(device)} Timeout. Device did not start/finish firmware download after being notified. (${error.message})`);
}
}
};
logger_1.logger.debug(() => `${deviceLogString(device)} Starting update`, NS);
waiters.upgradeEndRequest = endpoint.waitForCommand("genOta", "upgradeEndRequest", undefined, MAX_TIMEOUT);
await Promise.race([
sendImageChunks(),
waiters.upgradeEndRequest.promise.finally(() => {
clearTimeout(lastBlockResponseTime);
// always clear state
cancelWaiters(waiters);
done = true;
}),
]);
// already resolved when this is reached
const endResult = await waiters.upgradeEndRequest.promise;
logger_1.logger.debug(() => `${deviceLogString(device)} Got upgrade end request: ${JSON.stringify(endResult.payload)}`, NS);
if (endResult.payload.status === zigbee_herdsman_1.Zcl.Status.SUCCESS) {
const payload = {
manufacturerCode: image.header.manufacturerCode,
imageType: image.header.imageType,
fileVersion: image.header.fileVersion,
currentTime: 0,
upgradeTime: 1,
};
try {
await endpoint.commandResponse("genOta", "upgradeEndResponse", payload, undefined, endResult.header.transactionSequenceNumber);
logger_1.logger.debug(() => `${deviceLogString(device)} Update successful. Waiting for device announce...`, NS);
onProgress(100, undefined);
let timer;
const newFileVersion = image.header.fileVersion;
return await new Promise((resolve) => {
// XXX: annoying to test since using fake timers, same result anyway
/* istanbul ignore next */
const onDeviceAnnounce = () => {
clearTimeout(timer);
logger_1.logger.debug(() => `${deviceLogString(device)} Received device announce, update finished.`, NS);
resolve(newFileVersion);
};
// force "finished" after given time
timer = setTimeout(() => {
device.removeListener("deviceAnnounce", onDeviceAnnounce);
logger_1.logger.debug(() => `${deviceLogString(device)} Timed out waiting for device announce, update considered finished.`, NS);
resolve(newFileVersion);
}, UPDATE_END_FORCE_RESOLVE_TIME);
device.once("deviceAnnounce", onDeviceAnnounce);
});
}
catch (error) {
throw new Error(`Upgrade end response failed: ${error.message}`);
}
}
else {
/**
* For other status value received such as INVALID_IMAGE, REQUIRE_MORE_IMAGE, or ABORT,
* the upgrade server SHALL not send Upgrade End Response command but it SHALL send default
* response command with status of success and it SHALL wait for the client to reinitiate the upgrade process.
*/
try {
await endpoint.defaultResponse(zigbee_herdsman_1.Zcl.Clusters.genOta.commands.upgradeEndRequest.ID, zigbee_herdsman_1.Zcl.Status.SUCCESS, zigbee_herdsman_1.Zcl.Clusters.genOta.ID, endResult.header.transactionSequenceNumber);
}
catch (error) {
logger_1.logger.debug(() => `${deviceLogString(device)} Upgrade end request default response failed: ${error.message}`, NS);
}
throw new Error(`Update failed with reason: ${zigbee_herdsman_1.Zcl.Status[endResult.payload.status]}`);
}
}
// #endregion
//# sourceMappingURL=ota.js.map
;