diff --git a/src/lib/ota/common.ts b/src/lib/ota/common.ts index 7a2c6c6ff4691..a2b8ccb588351 100644 --- a/src/lib/ota/common.ts +++ b/src/lib/ota/common.ts @@ -1,84 +1,137 @@ import crypto from 'crypto'; import {HttpsProxyAgent} from 'https-proxy-agent'; -import {Zh, Ota, KeyValueAny, KeyValue, KeyValueNumberString} from '../types'; +import {Zh, Ota, KeyValueAny, KeyValue, OtaUpdateAvailableResult} from '../types'; import assert from 'assert'; import crc32 from 'buffer-crc32'; import axios from 'axios'; import * as URI from 'uri-js'; -import fs from 'fs'; +import {readFileSync} from 'fs'; import path from 'path'; import {Zcl} from 'zigbee-herdsman'; import {logger} from '../logger'; import https from 'https'; import tls from 'tls'; -const NS = 'zhc:ota:common'; -let dataDir: string = null; -const maxTimeout = 2147483647; // +- 24 days -const imageBlockResponseDelay = 250; -const endRequestCodeLookup: KeyValueNumberString = { - 0x00: 'success', - 0x95: 'aborted by device', - 0x7E: 'not authorized', - 0x96: 'invalid image', - 0x97: 'no data available', - 0x98: 'no image available', - 0x80: 'malformed command', - 0x81: 'unsupported cluster command', - 0x99: 'requires more image files', -}; -export const upgradeFileIdentifier = Buffer.from([0x1E, 0xF1, 0xEE, 0x0B]); - interface Request {cancel: () => void, promise: Promise<{header: Zh.ZclHeader, payload: KeyValue}>} -interface Waiters {imageBlockOrPageRequest?: Request, nextImageRequest?: Request, upgradeEndRequest?: Request} +interface Waiters {imageBlockOrPageRequest?: Request, upgradeEndRequest?: Request} +type CommandResult = {header: Zcl.Header; payload: KeyValueAny}; type IsNewImageAvailable = (current: Ota.ImageInfo, device: Zh.Device, getImageMeta: Ota.GetImageMeta) => Promise<{available: number, currentFileVersion: number, otaFileVersion: number}> type DownloadImage = (meta: Ota.ImageMeta) => Promise<{data: Buffer}>; type GetNewImage = (current: Ota.Version, device: Zh.Device, getImageMeta: Ota.GetImageMeta, downloadImage: DownloadImage, suppressElementImageParseFailure: boolean) => Promise; +type ImageBlockResponsePayload = { + status: number; + manufacturerCode: Zcl.ManufacturerCode; + imageType: number; + fileVersion: number; + fileOffset: number; + dataSize: number; + data: Buffer; +}; + +const NS = 'zhc:ota:common'; -const validSilabsCrc = 0x2144DF1C; +let dataDir: string = null; -const eblTagHeader = 0x0; -const eblTagEncHeader = 0xfb05; -const eblTagEnd = 0xfc04; -const eblPadding = 0xff; -const eblImageSignature = 0xe350; +const MAX_TIMEOUT = 2147483647; // +- 24 days +const IMAGE_BLOCK_RESPONSE_DELAY = 250; +export const UPGRADE_FILE_IDENTIFIER = Buffer.from([0x1E, 0xF1, 0xEE, 0x0B]); -const gblTagHeader = 0xeb17a603; -const gblTagEnd = 0xfc0404fc; +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_TAG_HEADER = 0xeb17a603; +const GBL_TAG_END = 0xfc0404fc; -/** - * Helper functions - */ +// ---- +// Helper functions +// ---- + +export function getAxios(caBundle: string[] = null) { + let config = {}; + const httpsAgentOptions: https.AgentOptions = {}; + + if (caBundle !== null) { + // We also include all system default CAs, as setting custom CAs fully replaces the default list + httpsAgentOptions.ca = [...tls.rootCertificates, ...caBundle]; + } + + const proxy = process.env.HTTPS_PROXY; + + if (proxy) { + config = { + proxy: false, + httpsAgent: new HttpsProxyAgent(proxy, httpsAgentOptions), + headers: { + 'Accept-Encoding': '*', + }, + }; + } else { + config = { + httpsAgent: new https.Agent(httpsAgentOptions), + }; + } + + const axiosInstance = axios.create(config); + axiosInstance.defaults.maxRedirects = 0; // Set to 0 to prevent automatic redirects + // Add work with 302 redirects without hostname in Location header + axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + // get domain from basic url + if (error.response && [301, 302].includes(error.response.status)) { + let redirectUrl = error.response.headers.location; + + try { + const parsedUrl = new URL(redirectUrl); + + if (!parsedUrl.protocol || !parsedUrl.host) { + throw new Error(`Get Axios, no scheme or domain`); + } + } catch { + // Prepend scheme and domain from the original request's base URL + const baseURL = new URL(error.config.url); + redirectUrl = `${baseURL.origin}${redirectUrl}`; + } + + return axiosInstance.get(redirectUrl, {responseType: error.config.responseType || 'arraybuffer'}); + } + }, + ); + return axiosInstance; +} -export const setDataDir = (dir: string) => { +export function setDataDir(dir: string): void { dataDir = dir; -}; +} -export function isValidUrl(url: string) { - let parsed; +export function isValidUrl(url: string): boolean { try { - parsed = URI.parse(url); - } catch (_) { + const parsed = URI.parse(url); + + return parsed.scheme === 'http' || parsed.scheme === 'https'; + } catch { return false; } - return parsed.scheme === 'http' || parsed.scheme === 'https'; } -export function readLocalFile(fileName: string) { +export function readLocalFile(fileName: string): Buffer { // If the file name is not a full path, then treat it as a relative to the data directory if (!path.isAbsolute(fileName) && dataDir) { fileName = path.join(dataDir, fileName); } logger.debug(`Getting local firmware file '${fileName}'`, NS); - return fs.readFileSync(fileName); + return readFileSync(fileName); } -export async function getFirmwareFile(image: KeyValueAny) { +export async function getFirmwareFile(image: KeyValueAny): Promise<{ data: Buffer; }> { const urlOrName = image.url; // First try to download firmware file with the URL provided @@ -93,13 +146,15 @@ export async function getFirmwareFile(image: KeyValueAny) { export async function processCustomCaBundle(uri: string) { let rawCaBundle = ''; + if (isValidUrl(uri)) { rawCaBundle = (await axios.get(uri)).data; } else { if (!path.isAbsolute(uri) && dataDir) { uri = path.join(dataDir, uri); } - rawCaBundle = fs.readFileSync(uri, {encoding: 'utf-8'}); + + rawCaBundle = readFileSync(uri, {encoding: 'utf-8'}); } // Parse the raw CA bundle into clean, separate CA certs @@ -107,13 +162,16 @@ export async function processCustomCaBundle(uri: string) { const caBundle = []; let inCert = false; let currentCert = ''; + for (const line of lines) { if (line === '-----BEGIN CERTIFICATE-----') { inCert = true; } + if (inCert) { currentCert = currentCert + line + '\n'; } + if (line === '-----END CERTIFICATE-----') { inCert = false; caBundle.push(currentCert); @@ -129,28 +187,27 @@ export async function getOverrideIndexFile(urlOrName: string) { const {data: index} = await getAxios().get(urlOrName); if (!index) { - throw new Error(`OTA: Error getting override index file from '${urlOrName}'`); + throw new Error(`Error getting override index file from '${urlOrName}'`); } return index; } - return JSON.parse(fs.readFileSync(urlOrName, 'utf-8')); + return JSON.parse(readFileSync(urlOrName, 'utf-8')); } +// ---- +// OTA functions +// ---- -/** - * OTA functions - */ - -function getOTAEndpoint(device: Zh.Device) { +function getOTAEndpoint(device: Zh.Device): Zh.Endpoint { return device.endpoints.find((e) => e.supportsOutputCluster('genOta')); } function parseSubElement(buffer: Buffer, position: number): Ota.ImageElement { const tagID = buffer.readUInt16LE(position); const length = buffer.readUInt32LE(position + 2); - const data = buffer.slice(position + 6, position + 6 + length); + const data = buffer.subarray(position + 6, position + 6 + length); return {tagID, length, data}; } @@ -168,14 +225,17 @@ export function parseImage(buffer: Buffer, suppressElementImageParseFailure: boo totalImageSize: buffer.readUInt32LE(52), }; let headerPos = 56; + if (header.otaHeaderFieldControl & 1) { header.securityCredentialVersion = buffer.readUInt8(headerPos); headerPos += 1; } + if (header.otaHeaderFieldControl & 2) { header.upgradeFileDestination = buffer.subarray(headerPos, headerPos + 8); headerPos += 8; } + if (header.otaHeaderFieldControl & 4) { header.minimumHardwareVersion = buffer.readUInt16LE(headerPos); headerPos += 2; @@ -183,12 +243,13 @@ export function parseImage(buffer: Buffer, suppressElementImageParseFailure: boo headerPos += 2; } - const raw = buffer.slice(0, header.totalImageSize); + const raw = buffer.subarray(0, header.totalImageSize); - assert(Buffer.compare(header.otaUpgradeFileIdentifier, upgradeFileIdentifier) === 0, `Not an OTA file`); + assert(UPGRADE_FILE_IDENTIFIER.equals(header.otaUpgradeFileIdentifier), `Not an OTA file`); let position = header.otaHeaderLength; const elements = []; + try { while (position < header.totalImageSize) { const element = parseSubElement(buffer, position); @@ -199,6 +260,7 @@ export function parseImage(buffer: Buffer, suppressElementImageParseFailure: boo if (!suppressElementImageParseFailure) { throw error; } + logger.debug('Partially failed to parse the image, continuing anyway...', NS); } @@ -206,23 +268,23 @@ export function parseImage(buffer: Buffer, suppressElementImageParseFailure: boo return {header, elements, raw}; } -function validateImageData(image: Ota.Image) { +export function validateImageData(image: Ota.Image): void { for (const element of image.elements) { const {data} = element; - if (data.readUInt32BE(0) === gblTagHeader) { + if (data.readUInt32BE(0) === GBL_TAG_HEADER) { validateSilabsGbl(data); } else { const tag = data.readUInt16BE(0); - if ((tag === eblTagHeader && data.readUInt16BE(6) === eblImageSignature) || tag === eblTagEncHeader ) { + if ((tag === EBL_TAG_HEADER && data.readUInt16BE(6) === EBL_IMAGE_SIGNATURE) || tag === EBL_TAG_ENC_HEADER) { validateSilabsEbl(data); } } } } -function validateSilabsEbl(data: Buffer) { +function validateSilabsEbl(data: Buffer): void { const dataLength = data.length; let position = 0; @@ -233,25 +295,25 @@ function validateSilabsEbl(data: Buffer) { position += 4 + len; - if (tag !== eblTagEnd) { + if (tag !== EBL_TAG_END) { continue; } for (let position2 = position; position2 < dataLength; position2++) { - assert(data.readUInt8(position2) === eblPadding, `Image padding contains invalid bytes`); + assert(data.readUInt8(position2) === EBL_PADDING, `Image padding contains invalid bytes`); } - const calculatedCrc32 = crc32.unsigned(data.slice(0, position)); + const calculatedCrc32 = crc32.unsigned(data.subarray(0, position)); - assert(calculatedCrc32 === validSilabsCrc, `Image CRC-32 is invalid`); + assert(calculatedCrc32 === VALID_SILABS_CRC, `Image CRC-32 is invalid`); return; } - throw new Error(`OTA: Image is truncated, not long enough to contain a valid tag`); + throw new Error(`Image is truncated, not long enough to contain a valid tag`); } -function validateSilabsGbl(data: Buffer) { +function validateSilabsGbl(data: Buffer): void { const dataLength = data.length; let position = 0; @@ -262,73 +324,79 @@ function validateSilabsGbl(data: Buffer) { position += 8 + len; - if (tag !== gblTagEnd) { + if (tag !== GBL_TAG_END) { continue; } - const calculatedCrc32 = crc32.unsigned(data.slice(0, position)); + const calculatedCrc32 = crc32.unsigned(data.subarray(0, position)); - assert(calculatedCrc32 === validSilabsCrc, `Image CRC-32 is invalid`); + assert(calculatedCrc32 === VALID_SILABS_CRC, `Image CRC-32 is invalid`); return; } - throw new Error(`OTA: Image is truncated, not long enough to contain a valid tag`); + throw new Error(`Image is truncated, not long enough to contain a valid tag`); } -function cancelWaiters(waiters: Waiters) { - for (const waiter of Object.values(waiters)) { - if (waiter) { - waiter.cancel(); - } - } +function cancelWaiters(waiters: Waiters): void { + waiters.imageBlockOrPageRequest?.cancel(); + waiters.upgradeEndRequest?.cancel(); } -function sendQueryNextImageResponse(endpoint: Zh.Endpoint, image: Ota.Image, requestTransactionSequenceNumber: number) { +async function sendQueryNextImageResponse(endpoint: Zh.Endpoint, image: Ota.Image, requestTransactionSequenceNumber: number): Promise { const payload = { - status: 0, + status: Zcl.Status.SUCCESS, manufacturerCode: image.header.manufacturerCode, imageType: image.header.imageType, fileVersion: image.header.fileVersion, imageSize: image.header.totalImageSize, }; - endpoint.commandResponse('genOta', 'queryNextImageResponse', payload, null, requestTransactionSequenceNumber).catch((e) => { - logger.debug(`Failed to send queryNextImageResponse (${e.message})`, NS); - }); + try { + await endpoint.commandResponse('genOta', 'queryNextImageResponse', payload, null, requestTransactionSequenceNumber); + } catch (error) { + logger.debug(`Failed to send queryNextImageResponse: ${error}`, NS); + } } -function imageNotify(endpoint: Zh.Endpoint) { - return endpoint.commandResponse('genOta', 'imageNotify', {payloadType: 0, queryJitter: 100}, {sendPolicy: 'immediate'}); +async function imageNotify(endpoint: Zh.Endpoint): Promise { + await endpoint.commandResponse('genOta', 'imageNotify', {payloadType: 0, queryJitter: 100}, {sendPolicy: 'immediate'}); } -async function requestOTA(endpoint: Zh.Endpoint): Promise<{payload: Ota.ImageInfo}> { +async function requestOTA(endpoint: Zh.Endpoint): Promise<[transNum: number, Ota.ImageInfo]> { // Some devices (e.g. Insta) take very long trying to discover the correct coordinator EP for OTA. const queryNextImageRequest = endpoint.waitForCommand('genOta', 'queryNextImageRequest', null, 60000); + try { await imageNotify(endpoint); - // @ts-expect-error - return await queryNextImageRequest.promise; + const response = await queryNextImageRequest.promise; + + return [response.header.transactionSequenceNumber, response.payload as Ota.ImageInfo]; } catch (e) { queryNextImageRequest.cancel(); - throw new Error(`OTA: Device didn't respond to OTA request`); + + throw new Error(`Device didn't respond to OTA request`); } } -function getImageBlockResponsePayload(image: Ota.Image, imageBlockRequest: KeyValueAny, pageOffset: number, pageSize: number) { +function getImageBlockResponsePayload(image: Ota.Image, imageBlockRequest: CommandResult, pageOffset: number, pageSize: number) + : ImageBlockResponsePayload { let start = imageBlockRequest.payload.fileOffset + pageOffset; // When the data size is too big, OTA gets unstable, so default it to 50 bytes maximum. // - Insta devices, OTA only works for data sizes 40 and smaller (= manufacturerCode 4474). // - Legrand devices (newer firmware) require up to 64 bytes (= manufacturerCode 4129). let maximumDataSize = 50; - if (imageBlockRequest.payload.manufacturerCode === 4474) maximumDataSize = 40; - else if (imageBlockRequest.payload.manufacturerCode === 4129) maximumDataSize = Infinity; + + if (imageBlockRequest.payload.manufacturerCode === Zcl.ManufacturerCode.INSTA_GMBH) { + maximumDataSize = 40; + } else if (imageBlockRequest.payload.manufacturerCode === Zcl.ManufacturerCode.LEGRAND_GROUP) { + maximumDataSize = Infinity; + } let dataSize = Math.min(maximumDataSize, imageBlockRequest.payload.maximumDataSize); // Hack for https://github.com/Koenkk/zigbee-OTA/issues/328 (Legrand OTA not working) - if (imageBlockRequest.payload.manufacturerCode === 4129 && - imageBlockRequest.payload.fileOffset === 50 && + if (imageBlockRequest.payload.manufacturerCode === Zcl.ManufacturerCode.LEGRAND_GROUP && imageBlockRequest.payload.fileOffset === 50 && imageBlockRequest.payload.maximumDataSize === 12) { logger.info(`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! @@ -339,70 +407,83 @@ function getImageBlockResponsePayload(image: Ota.Image, imageBlockRequest: KeyVa if (pageSize) { dataSize = Math.min(dataSize, pageSize - pageOffset); } + let end = start + dataSize; + if (end > image.raw.length) { end = image.raw.length; } - logger.debug(`Request offsets: fileOffset=${imageBlockRequest.payload.fileOffset} pageOffset=${pageOffset} \ - dataSize=${imageBlockRequest.payload.maximumDataSize}`, NS); + logger.debug(`Request offsets: fileOffset=${imageBlockRequest.payload.fileOffset} pageOffset=${pageOffset} ` + + `maximumDataSize=${imageBlockRequest.payload.maximumDataSize}`, NS); logger.debug(`Payload offsets: start=${start} end=${end} dataSize=${dataSize}`, NS); return { - status: 0, + status: Zcl.Status.SUCCESS, manufacturerCode: imageBlockRequest.payload.manufacturerCode, imageType: imageBlockRequest.payload.imageType, fileVersion: imageBlockRequest.payload.fileVersion, fileOffset: start, dataSize: end - start, - data: image.raw.slice(start, end), + data: image.raw.subarray(start, end), }; } -function callOnProgress(startTime: number, lastUpdate: number, imageBlockRequest: KeyValueAny, - image: Ota.Image, onProgress: Ota.OnProgress) { +function callOnProgress(startTime: number, lastUpdate: number, imageBlockRequest: CommandResult, image: Ota.Image, onProgress: Ota.OnProgress) + : number { const now = Date.now(); // Call on progress every +- 30 seconds if (lastUpdate === null || (now - lastUpdate) > 30000) { - const totalDuration = (now - startTime) / 1000; // in seconds + 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.debug(`Update at ${percentage}%, remaining ${remaining} seconds`, NS); onProgress(percentage, remaining === Infinity ? null : remaining); + return now; } else { return lastUpdate; } } -export async function isUpdateAvailable(device: Zh.Device, requestPayload: Ota.ImageInfo, - isNewImageAvailable: IsNewImageAvailable = null, getImageMeta: Ota.GetImageMeta = null) { - logger.debug(`Checking if an update is available for '${device.ieeeAddr}' (${device.modelID})`, NS); +export async function isUpdateAvailable(device: Zh.Device, requestPayload: Ota.ImageInfo, isNewImageAvailable: IsNewImageAvailable = null, + getImageMeta: Ota.GetImageMeta = null): Promise { + const logId = `'${device.ieeeAddr}' (${device.modelID})`; + logger.debug(`Checking if an update is available for ${logId}`, NS); - if (requestPayload === null) { + if (requestPayload == null) { const endpoint = getOTAEndpoint(device); - assert(endpoint != null, `Failed to find an endpoint which supports the OTA cluster`); + assert(endpoint != null, `Failed to find an endpoint which supports the OTA cluster for ${logId}`); + logger.debug(`Using endpoint '${endpoint.ID}'`, NS); - const request = await requestOTA(endpoint); - logger.debug(`Got request '${JSON.stringify(request.payload)}'`, NS); - requestPayload = request.payload; + const [, payload] = await requestOTA(endpoint); + + logger.debug(`Got request '${JSON.stringify(payload)}'`, NS); + + requestPayload = payload; } const availableResult = await isNewImageAvailable(requestPayload, device, getImageMeta); - logger.debug(`Update available for '${device.ieeeAddr}' (${device.modelID}): ${availableResult.available < 0 ? 'YES' : 'NO'}`, NS); + + logger.debug(`Update available for ${logId}: ${availableResult.available < 0 ? 'YES' : 'NO'}`, NS); + if (availableResult.available > 0) { - logger.warning(`Firmware on '${device.ieeeAddr}' (${device.modelID}) is newer than latest firmware online.`, NS); + logger.warning(`Firmware on ${logId} is newer than latest firmware online.`, NS); } + return {...availableResult, available: availableResult.available < 0}; } -export async function isNewImageAvailable(current: Ota.ImageInfo, device: Zh.Device, getImageMeta: Ota.GetImageMeta) { +export async function isNewImageAvailable(current: Ota.ImageInfo, device: Zh.Device, getImageMeta: Ota.GetImageMeta) + : ReturnType { const currentS = JSON.stringify(current); logger.debug(`Is new image available for '${device.ieeeAddr}' (${device.modelID}), current '${currentS}'`, NS); + const meta = await getImageMeta(current, device); // Soft-fail because no images in repo/URL for specified device @@ -427,265 +508,242 @@ export async function isNewImageAvailable(current: Ota.ImageInfo, device: Zh.Dev }; } -export async function updateToLatest(device: Zh.Device, onProgress: Ota.OnProgress, getNewImage: GetNewImage, - getImageMeta: Ota.GetImageMeta = null, downloadImage: DownloadImage = null, suppressElementImageParseFailure: boolean = false): Promise { - logger.debug(`Updating to latest '${device.ieeeAddr}' (${device.modelID})`, NS); +/** + * @see https://zigbeealliance.org/wp-content/uploads/2021/10/07-5123-08-Zigbee-Cluster-Library.pdf 11.12 + */ +export async function updateToLatest(device: Zh.Device, onProgress: Ota.OnProgress, getNewImage: GetNewImage, getImageMeta: Ota.GetImageMeta = null, + downloadImage: DownloadImage = null, suppressElementImageParseFailure: boolean = false): Promise { + const logId = `'${device.ieeeAddr}' (${device.modelID})`; + logger.debug(`Updating to latest ${logId}`, NS); + const endpoint = getOTAEndpoint(device); - assert(endpoint != null, `Failed to find an endpoint which supports the OTA cluster`); + assert(endpoint != null, `Failed to find an endpoint which supports the OTA cluster for ${logId}`); + logger.debug(`Using endpoint '${endpoint.ID}'`, NS); - const request = await requestOTA(endpoint); - logger.debug(`Got request '${JSON.stringify(request.payload)}'`, NS); - const image = await getNewImage(request.payload, device, getImageMeta, downloadImage, suppressElementImageParseFailure); - logger.debug(`Got new image for '${device.ieeeAddr}' (${device.modelID})`, NS); + + const [transNum, requestPayload] = await requestOTA(endpoint); + + logger.debug(`Got request payload '${JSON.stringify(requestPayload)}'`, NS); + + const image = await getNewImage(requestPayload, device, getImageMeta, downloadImage, suppressElementImageParseFailure); + + logger.debug(`Got new image for ${logId}`, NS); + + // reply to `queryNextImageRequest` in `requestOTA` now that we have the data for it, + // should trigger image block/page request from device + await sendQueryNextImageResponse(endpoint, image, transNum); const waiters: Waiters = {}; + let lastBlockResponseTime: number = 0; let lastUpdate: number = null; - let lastImageBlockResponse: number = null; const startTime = Date.now(); + let ended: boolean = false; - return new Promise((resolve, reject) => { - const answerNextImageBlockOrPageRequest = () => { - let imageBlockOrPageRequestTimeoutMs: number = 150000; - // 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 ( request.payload.manufacturerCode == 4742 && request.payload.imageType == 8199 ) { - imageBlockOrPageRequestTimeoutMs = 3600000; - } + const answerNextImageBlockOrPageRequest = async () => { + if (ended) { + cancelWaiters(waiters); + return; + } - // 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 (request.payload.manufacturerCode == Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH) { - imageBlockOrPageRequestTimeoutMs = 60 * 60 * 1000; - } + let imageBlockOrPageRequestTimeoutMs: number = 150000; + // 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 === Zcl.ManufacturerCode.SHENZHEN_COOLKIT_TECHNOLOGY_CO_LTD && requestPayload.imageType == 8199) { + imageBlockOrPageRequestTimeoutMs = 3600000; + } - // Increase the timeout for Legrand devices, so that they will re-initiate and update themselves - // Newer firmwares have ackward behaviours when it comes to the handling of the last bytes of OTA updates - if ( request.payload.manufacturerCode === 4129 ) { - imageBlockOrPageRequestTimeoutMs = 30 * 60 * 1000; - } + // 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 == Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH) { + imageBlockOrPageRequestTimeoutMs = 60 * 60 * 1000; + } - const imageBlockRequest = endpoint.waitForCommand('genOta', 'imageBlockRequest', null, imageBlockOrPageRequestTimeoutMs); - const imagePageRequest = endpoint.waitForCommand('genOta', 'imagePageRequest', null, imageBlockOrPageRequestTimeoutMs); - waiters.imageBlockOrPageRequest = { - promise: Promise.race([imageBlockRequest.promise, imagePageRequest.promise]), - cancel: () => { - imageBlockRequest.cancel(); - imagePageRequest.cancel(); - }, - }; + // Increase the timeout for Legrand devices, so that they will re-initiate and update themselves + // Newer firmwares have ackward behaviours when it comes to the handling of the last bytes of OTA updates + if (requestPayload.manufacturerCode === Zcl.ManufacturerCode.LEGRAND_GROUP) { + imageBlockOrPageRequestTimeoutMs = 30 * 60 * 1000; + } - waiters.imageBlockOrPageRequest.promise.then( - (imageBlockOrPageRequest) => { - let pageOffset = 0; - let pageSize = 0; - - const sendImageBlockResponse = (imageBlockRequest: KeyValueAny, thenCallback: () => void, transactionSequenceNumber: number) => { - const payload = getImageBlockResponsePayload(image, imageBlockRequest, pageOffset, pageSize); - const now = Date.now(); - const timeSinceLastImageBlockResponse = now - lastImageBlockResponse; - - // Reduce network congestion by only sending imageBlockResponse min every 250ms. - const cooldownTime = Math.max(imageBlockResponseDelay - timeSinceLastImageBlockResponse, 0); - setTimeout(() => { - endpoint.commandResponse( - 'genOta', 'imageBlockResponse', payload, null, transactionSequenceNumber, - ).then( - () => { - pageOffset += payload.dataSize; - lastImageBlockResponse = Date.now(); - thenCallback(); - }, - (e) => { - // Shit happens, device will probably do a new imageBlockRequest so don't care. - lastImageBlockResponse = Date.now(); - thenCallback(); - logger.debug(`Image block response failed (${e.message})`, NS); - }, - ); - }, cooldownTime); - - lastUpdate = callOnProgress(startTime, lastUpdate, imageBlockRequest, image, onProgress); - }; - - if ('pageSize' in imageBlockOrPageRequest.payload) { - // imagePageRequest - pageSize = imageBlockOrPageRequest.payload.pageSize as number; - const handleImagePageRequestBlocks = (imagePageRequest: KeyValueAny) => { - if (pageOffset < pageSize) { - sendImageBlockResponse(imagePageRequest, - () => handleImagePageRequestBlocks(imagePageRequest), imagePageRequest.header.transactionSequenceNumber); - } else { - answerNextImageBlockOrPageRequest(); - } - }; - handleImagePageRequestBlocks(imageBlockOrPageRequest); - } else { - // imageBlockRequest - sendImageBlockResponse(imageBlockOrPageRequest, answerNextImageBlockOrPageRequest, - imageBlockOrPageRequest.header.transactionSequenceNumber); - } - }, - () => { - cancelWaiters(waiters); - reject(new Error(`OTA: Timeout, device did not request any image blocks`)); - }, - ); + const imageBlockRequest = endpoint.waitForCommand('genOta', 'imageBlockRequest', null, imageBlockOrPageRequestTimeoutMs); + const imagePageRequest = endpoint.waitForCommand('genOta', 'imagePageRequest', null, imageBlockOrPageRequestTimeoutMs); + waiters.imageBlockOrPageRequest = { + promise: Promise.race([imageBlockRequest.promise, imagePageRequest.promise]), + cancel: () => { + imageBlockRequest.cancel(); + imagePageRequest.cancel(); + }, }; - const answerNextImageRequest = () => { - waiters.nextImageRequest = endpoint.waitForCommand('genOta', 'queryNextImageRequest', null, maxTimeout); - waiters.nextImageRequest.promise.then( - (payload) => { - answerNextImageRequest(); - sendQueryNextImageResponse(endpoint, image, payload.header.transactionSequenceNumber); - }, - () => { - cancelWaiters(waiters); - reject(new Error(`OTA: Failed queryNextImageRequest`)); + try { + const result = await waiters.imageBlockOrPageRequest.promise; + let pageOffset = 0; + let pageSize = 0; + + const sendImageBlockResponse = async (imageBlockRequest: CommandResult) => { + // Reduce network congestion by throttling response if necessary + { + const blockResponseTime = Date.now(); + const delay = (blockResponseTime - lastBlockResponseTime); + + if (delay < IMAGE_BLOCK_RESPONSE_DELAY) { + await new Promise((resolve) => setTimeout(resolve, IMAGE_BLOCK_RESPONSE_DELAY - delay)); + } + + lastBlockResponseTime = blockResponseTime; } - ); - }; - // No need to timeout here, will already be done in answerNextImageBlockRequest - waiters.upgradeEndRequest = endpoint.waitForCommand('genOta', 'upgradeEndRequest', null, maxTimeout); - waiters.upgradeEndRequest.promise.then( - (data) => { - logger.debug(`Got upgrade end request for '${device.ieeeAddr}' (${device.modelID}): ${JSON.stringify(data.payload)}`, NS); - cancelWaiters(waiters); - - if (data.payload.status === 0) { - const payload = { - manufacturerCode: image.header.manufacturerCode, imageType: image.header.imageType, - fileVersion: image.header.fileVersion, currentTime: 0, upgradeTime: 1, - }; - - endpoint.commandResponse('genOta', 'upgradeEndResponse', payload, null, data.header.transactionSequenceNumber).then( - () => { - logger.debug(`Update succeeded, waiting for device announce`, NS); - onProgress(100, null); - - let timer: ReturnType = null; - const cb = () => { - logger.debug(`Got device announce or timed out, call resolve`, NS); - clearInterval(timer); - device.removeListener('deviceAnnounce', cb); - resolve(image.header.fileVersion); - }; - timer = setTimeout(cb, 120 * 1000); // timeout after 2 minutes - device.once('deviceAnnounce', cb); - }, - (e) => { - const message = `OTA: Upgrade end response failed (${e.message})`; - logger.debug(message, NS); - reject(new Error(message)); - }, + try { + const blockPayload = getImageBlockResponsePayload(image, imageBlockRequest, pageOffset, pageSize); + + await endpoint.commandResponse( + 'genOta', + 'imageBlockResponse', + blockPayload, + null, + imageBlockRequest.header.transactionSequenceNumber, ); - } else { - // @ts-expect-error - const error = `Update failed with reason: '${endRequestCodeLookup[data.payload.status]}'`; - logger.debug(error, NS); - reject(new Error(error)); + + pageOffset += blockPayload.dataSize; + } catch (error) { + // Shit happens, device will probably do a new imageBlockRequest so don't care. + logger.debug(`Image block response failed: ${error}`, NS); } - }, - () => { - cancelWaiters(waiters); - reject(new Error(`OTA: Failed upgradeEndRequest`)); + + lastUpdate = callOnProgress(startTime, lastUpdate, imageBlockRequest, image, onProgress); + }; + + if ('pageSize' in result.payload) { + // imagePageRequest + pageSize = result.payload.pageSize as number; + + const handleImagePageRequestBlocks = async (imagePageRequest: CommandResult) => { + if (pageOffset < pageSize) { + await sendImageBlockResponse(imagePageRequest); + await handleImagePageRequestBlocks(imagePageRequest); + } else { + await answerNextImageBlockOrPageRequest(); + } + }; + + await handleImagePageRequestBlocks(result); + } else { + // imageBlockRequest + await sendImageBlockResponse(result); + await answerNextImageBlockOrPageRequest(); } - ); + } catch (error) { + cancelWaiters(waiters); + throw new Error(`Timeout. Device did not start/finish firmware download after being notified. (${error})`); + } + }; + + // No need to timeout here, will already be done in answerNextImageBlockOrPageRequest + waiters.upgradeEndRequest = endpoint.waitForCommand('genOta', 'upgradeEndRequest', null, MAX_TIMEOUT); + + logger.debug(`Starting upgrade`, NS); + + // `answerNextImageBlockOrPageRequest` is recursive and never resolves, so will only stop before `upgradeEndRequest` resolves if it throws + await Promise.race([answerNextImageBlockOrPageRequest(), waiters.upgradeEndRequest.promise]); + + ended = true; + // already resolved when this is reached + const endResult = await waiters.upgradeEndRequest.promise; + logger.debug(`Got upgrade end request for ${logId}: ${JSON.stringify(endResult.payload)}`, NS); + + cancelWaiters(waiters); + + if (endResult.payload.status === 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, null, endResult.header.transactionSequenceNumber); - logger.debug(`Starting upgrade`, NS); - answerNextImageBlockOrPageRequest(); - answerNextImageRequest(); + logger.debug(`Update successful. Waiting for device announce...`, NS); - // Notify client once more about new image, client should start sending queryNextImageRequest now - imageNotify(endpoint).catch((e) => logger.debug(`Image notify failed (${e})`, NS)); - }); + onProgress(100, null); + + let timer: NodeJS.Timeout = null; + + return new Promise((resolve) => { + const onDeviceAnnounce = () => { + clearTimeout(timer); + logger.debug(`Received device announce, update finished.`, NS); + resolve(image.header.fileVersion); + }; + + // force "finished" after 2 minutes + timer = setTimeout(() => { + device.removeListener('deviceAnnounce', onDeviceAnnounce); + logger.debug(`Timed out waiting for device announce, update considered finished.`, NS); + resolve(image.header.fileVersion); + }, 120 * 1000); + + device.once('deviceAnnounce', onDeviceAnnounce); + }); + } catch (error) { + throw new Error(`Upgrade end response failed: ${error}`); + } + } else { + /** + * TODO: + * 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. + */ + throw new Error(`Update failed with reason: '${Zcl.Status[endResult.payload.status as number]}'`); + } } -export async function getNewImage(current: Ota.ImageInfo, device: Zh.Device, - getImageMeta: Ota.GetImageMeta, downloadImage: DownloadImage, suppressElementImageParseFailure: boolean): Promise { +export async function getNewImage(current: Ota.ImageInfo, device: Zh.Device, getImageMeta: Ota.GetImageMeta, downloadImage: DownloadImage, + suppressElementImageParseFailure: boolean): Promise { + // TODO: better errors (these are reported in frontend notifies) + const logId = `'${device.ieeeAddr}' (${device.modelID})`; const meta = await getImageMeta(current, device); - assert(meta, `Images for '${device.ieeeAddr}' (${device.modelID}) currently unavailable`); - logger.debug(`Getting new image for '${device.ieeeAddr}' (${device.modelID}), latest meta ${JSON.stringify(meta)}`, NS); + assert(!!meta, `Images for ${logId} currently unavailable`); + + logger.debug(`Getting new image for ${logId}, latest meta ${JSON.stringify(meta)}`, NS); + assert(meta.fileVersion > current.fileVersion || meta.force, `No new image available`); - const download = downloadImage ? await downloadImage(meta) : - await getAxios().get(meta.url, {responseType: 'arraybuffer'}); + const download = downloadImage ? await downloadImage(meta) : await getAxios().get(meta.url, {responseType: 'arraybuffer'}); const checksum = (meta.sha512 || meta.sha256); + if (checksum) { const hash = crypto.createHash(meta.sha512 ? 'sha512' : 'sha256'); hash.update(download.data); + assert(hash.digest('hex') === checksum, `File checksum validation failed`); - logger.debug(`Update checksum validation succeeded for '${device.ieeeAddr}' (${device.modelID})`, NS); + logger.debug(`Update checksum validation succeeded for ${logId}`, NS); } - const start = download.data.indexOf(upgradeFileIdentifier); + const start = download.data.indexOf(UPGRADE_FILE_IDENTIFIER); const image = parseImage(download.data.slice(start), suppressElementImageParseFailure); - logger.debug(`Get new image for '${device.ieeeAddr}' (${device.modelID}), image header ${JSON.stringify(image.header)}`, NS); + + logger.debug(`Get new image for ${logId}, image header ${JSON.stringify(image.header)}`, NS); + assert(image.header.fileVersion === meta.fileVersion, `File version mismatch`); assert(!meta.fileSize || image.header.totalImageSize === meta.fileSize, `Image size mismatch`); assert(image.header.manufacturerCode === current.manufacturerCode, `Manufacturer code mismatch`); assert(image.header.imageType === current.imageType, `Image type mismatch`); - if ('minimumHardwareVersion' in image.header && 'maximumHardwareVersion' in image.header) { - assert(image.header.minimumHardwareVersion <= device.hardwareVersion && - device.hardwareVersion <= image.header.maximumHardwareVersion, `Hardware version mismatch`); - } - validateImageData(image); - return image; -} -export function getAxios(caBundle: string[] = null) { - let config = {}; - const httpsAgentOptions: https.AgentOptions = {}; - if (caBundle !== null) { - // We also include all system default CAs, as setting custom CAs fully replaces the default list - httpsAgentOptions.ca = [...tls.rootCertificates, ...caBundle]; + if ('minimumHardwareVersion' in image.header && 'maximumHardwareVersion' in image.header) { + assert( + image.header.minimumHardwareVersion <= device.hardwareVersion && device.hardwareVersion <= image.header.maximumHardwareVersion, + `Hardware version mismatch`, + ); } - const proxy = process.env.HTTPS_PROXY; - if (proxy) { - config = { - proxy: false, - httpsAgent: new HttpsProxyAgent(proxy, httpsAgentOptions), - headers: { - 'Accept-Encoding': '*', - }, - }; - } else { - config = { - httpsAgent: new https.Agent(httpsAgentOptions), - }; - } + validateImageData(image); - const axiosInstance = axios.create(config); - axiosInstance.defaults.maxRedirects = 0; // Set to 0 to prevent automatic redirects - // Add work with 302 redirects without hostname in Location header - axiosInstance.interceptors.response.use( - (response) => response, - (error) => { - // get domain from basic url - if (error.response && [301, 302].includes(error.response.status)) { - let redirectUrl = error.response.headers.location; - try { - const parsedUrl = new URL(redirectUrl); - if (!parsedUrl.protocol || !parsedUrl.host) { - throw new Error(`OTA: Get Axios, no scheme or domain`); - } - } catch { - // Prepend scheme and domain from the original request's base URL - const baseURL = new URL(error.config.url); - redirectUrl = `${baseURL.origin}${redirectUrl}`; - } - return axiosInstance.get(redirectUrl, {responseType: error.config.responseType || 'arraybuffer'}); - } - }, - ); - return axiosInstance; + return image; } -exports.upgradeFileIdentifier = upgradeFileIdentifier; +exports.UPGRADE_FILE_IDENTIFIER = UPGRADE_FILE_IDENTIFIER; exports.isUpdateAvailable = isUpdateAvailable; exports.parseImage = parseImage; exports.validateImageData = validateImageData; diff --git a/src/lib/ota/zigbeeOTA.ts b/src/lib/ota/zigbeeOTA.ts index 55ae331d4a11f..1b81725ff29d1 100644 --- a/src/lib/ota/zigbeeOTA.ts +++ b/src/lib/ota/zigbeeOTA.ts @@ -28,8 +28,8 @@ function fillImageInfo(meta: KeyValueAny) { // If no fields provided - get them from the image file const buffer = common.readLocalFile(meta.url); - const start = buffer.indexOf(common.upgradeFileIdentifier); - const image = common.parseImage(buffer.slice(start)); + const start = buffer.indexOf(common.UPGRADE_FILE_IDENTIFIER); + const image = common.parseImage(buffer.subarray(start)); // Will fill only those fields that were absent if (!meta.hasOwnProperty('imageType')) meta.imageType = image.header.imageType; diff --git a/test/ota.test.ts b/test/ota.test.ts new file mode 100644 index 0000000000000..c9f0197cb302f --- /dev/null +++ b/test/ota.test.ts @@ -0,0 +1,234 @@ +import {join} from 'path'; +import {readFileSync} from 'fs'; +import {EventEmitter} from 'stream'; +import {updateToLatest, parseImage, UPGRADE_FILE_IDENTIFIER, getNewImage} from '../src/lib/ota/common'; +import {KeyValueAny, Ota} from '../src/lib/types'; +import {Zcl} from 'zigbee-herdsman'; +import Waitress from 'zigbee-herdsman/dist/utils/waitress'; +import ZclTransactionSequenceNumber from 'zigbee-herdsman/dist/controller/helpers/zclTransactionSequenceNumber'; + +interface WaitressMatcher { + clusterID: number; + commandIdentifier: number; + transactionSequenceNumber?: number; +}; +type CommandResult = { + clusterID: number; + header: { + commandIdentifier: number; + transactionSequenceNumber: number; + }; + payload: KeyValueAny +}; + +// NOTE: takes too long to run this with CI, can enable locally as needed +describe.skip('OTA', () => { + const TX_MAX_DELAY = 20000;// arbitrary, but less than min timeout involved (queryNextImageRequest === 60000) + const waitressValidator = (payload: CommandResult, matcher: WaitressMatcher): boolean => { + return payload.header && (payload.clusterID === matcher.clusterID) && (payload.header.commandIdentifier === matcher.commandIdentifier) + && (!matcher.transactionSequenceNumber || (payload.header.transactionSequenceNumber === matcher.transactionSequenceNumber)) + }; + const waitressTimeoutFormatter = (matcher: WaitressMatcher, timeout: number) => `Timeout - ${matcher.clusterID} - ${matcher.commandIdentifier} - ${matcher.transactionSequenceNumber}`; + const waitress: Waitress = new Waitress(waitressValidator, waitressTimeoutFormatter); + const mockWaitressResolve = async (payload: CommandResult, maxDelay: number = TX_MAX_DELAY): Promise => { + // rnd wait time to trigger throttling randomly (min 25ms, can't be instant) + await new Promise((resolve) => setTimeout(resolve, Math.floor(Math.random() * maxDelay) + 25)); + + return waitress.resolve(payload); + }; + + class MockOTAEndpoint extends EventEmitter { + public ID: number; + public waiters: []; + public manufacturerCode: Zcl.ManufacturerCode; + public currentImageType: number; + public currentImageVersion: number; + private endFileOffset: number; + private reqFileOffset: number; + public downloadedImage: Buffer; + + constructor(ID: number, newImageHeader: Ota.ImageHeader) { + super(); + + this.ID = ID; + this.waiters = []; + this.manufacturerCode = newImageHeader.manufacturerCode; + this.currentImageType = newImageHeader.imageType; + this.currentImageVersion = (newImageHeader.fileVersion - 1); + this.endFileOffset = newImageHeader.totalImageSize; + this.reqFileOffset = 0; + this.downloadedImage = Buffer.alloc(0); + } + + public supportsOutputCluster(clusterKey: number | string): boolean { + return true; + } + + public async commandResponse(clusterKey: number | string, commandKey: number | string, payload: KeyValueAny, options?: unknown, + transactionSequenceNumber?: number): Promise { + // just because... + if (clusterKey !== 'genOta') { + return; + } + + transactionSequenceNumber = transactionSequenceNumber || ZclTransactionSequenceNumber.next(); + + switch (commandKey) { + case 'imageNotify': { + // trigger queryNextImageRequest + mockWaitressResolve({ + clusterID: Zcl.Clusters.genOta.ID, + header: {commandIdentifier: Zcl.Clusters.genOta.commands.queryNextImageRequest.ID, transactionSequenceNumber}, + payload: { + fieldControl: 0, + manufacturerCode: this.manufacturerCode, + imageType: this.currentImageType, + fileVersion: this.currentImageVersion,// version currently installed on the device + }, + }); + break; + } + case 'queryNextImageResponse': { + // trigger first `imageBlockRequest` + if (payload.status === Zcl.Status.SUCCESS) { + // payload.fileVersion is version client is required to install + if (payload.fileVersion === this.currentImageVersion) { + console.log('Cannot perform a re-install, not supported by Zigbee spec.'); + return; + } + + if (payload.fileVersion < this.currentImageVersion) { + console.log('Performing downgrade.'); + } else { + console.log('Performing upgrade.'); + } + + // first imageBlockRequest can take a good long while before triggering in practice, fake it + await new Promise((resolve) => setTimeout(resolve, 300000)); + + this.reqFileOffset = 0;// starting at zero + + mockWaitressResolve({ + clusterID: Zcl.Clusters.genOta.ID, + header: {commandIdentifier: Zcl.Clusters.genOta.commands.imageBlockRequest.ID, transactionSequenceNumber}, + payload: { + fieldControl: 0, + manufacturerCode: payload.manufacturerCode, + imageType: payload.imageType, + fileVersion: payload.fileVersion, + fileOffset: this.reqFileOffset, + maximumDataSize: 64, + }, + }); + } + break; + } + case 'imageBlockResponse': { + if (this.reqFileOffset >= this.endFileOffset) { + // trigger `upgradeEndRequest` + mockWaitressResolve({ + clusterID: Zcl.Clusters.genOta.ID, + header: {commandIdentifier: Zcl.Clusters.genOta.commands.upgradeEndRequest.ID, transactionSequenceNumber}, + payload: { + status: Zcl.Status.SUCCESS, + manufacturerCode: payload.manufacturerCode, + imageType: payload.imageType, + fileVersion: payload.fileVersion, + }, + }, 150); + } else { + // trigger n `imageBlockRequest` + this.reqFileOffset += payload.dataSize; + + mockWaitressResolve({ + clusterID: Zcl.Clusters.genOta.ID, + header: {commandIdentifier: Zcl.Clusters.genOta.commands.imageBlockRequest.ID, transactionSequenceNumber}, + payload: { + fieldControl: 0, + manufacturerCode: payload.manufacturerCode, + imageType: payload.imageType, + fileVersion: payload.fileVersion, + fileOffset: this.reqFileOffset, + maximumDataSize: 64, + }, + }); + } + + this.downloadedImage = Buffer.concat([this.downloadedImage, payload.data]); + break; + } + case 'upgradeEndResponse': { + // trigger deviceAnnounce event / timeout 2min + break; + } + } + } + + public waitForCommand(clusterKey: number | string, commandKey: number | string, transactionSequenceNumber: number, timeout: number) + : {promise: Promise; cancel: () => void} { + const cluster = Zcl.Utils.getCluster(clusterKey, null, {}); + const command = cluster.getCommand(commandKey); + const waiter = waitress.waitFor({clusterID: cluster.ID, commandIdentifier: command.ID, transactionSequenceNumber}, timeout); + + return {cancel: (): void => waitress.remove(waiter.ID), promise: waiter.start().promise}; + } + } + + class MockDevice extends EventEmitter { + public modelID: string; + public endpoints: MockOTAEndpoint[]; + public hardwareVersion: number; + + constructor(filename: string, otaEndpoint: MockOTAEndpoint, hardwareVersion: number) { + super(); + + this.modelID = filename; + this.endpoints = [otaEndpoint]; + this.hardwareVersion = hardwareVersion; + } + + get ieeeAddr(): string { + return '0x1234acdb1234abcd'; + } + } + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }) + + it.each([ + // ['10F2-7B09-0000-0004-01090206-spo-fmi4.ota.zigbee', {hardwareVersionMin: 0, hardwareVersionMax: 4, fileVersion: 17367558}, false], + // ['100B-0112-01002400-ConfLightBLE-Lamps-EFR32MG13.zigbee', {fileVersion: 16786432, fileSize: 439622}, false], + // ['10005778-10.1-TRADFRI-onoff-shortcut-control-2.2.010.ota.ota.signed', {fileVersion: 570492465}, false], + // ['A60_DIM_Z3_IM003D_00103101-encrypted_11_20_2018_Tue_122925_01_withoutMF.ota', {fileVersion: 1061121, fileSize: 182876}, true], + ])('Updates to latest for %s', async (filename, imageMeta, suppressElementImageParseFailure) => { + const data = readFileSync(join(__dirname, 'stub', 'otaImageFiles', filename)); + const start = data.indexOf(UPGRADE_FILE_IDENTIFIER); + const newImage = parseImage(data.subarray(start)); + console.log(JSON.stringify(newImage.header)); + + const endpoint = new MockOTAEndpoint(0, newImage.header); + const device = new MockDevice(filename, endpoint, newImage.header.maximumHardwareVersion ?? 0); + const onProgress = jest.fn(); + + const update = updateToLatest( + // @ts-expect-error mock + device, + onProgress, + () => newImage, + () => imageMeta, + () => ({data}), + suppressElementImageParseFailure, + ); + + await jest.runAllTimersAsync(); + const fileVersion = await update; + + expect(fileVersion).toStrictEqual(newImage.header.fileVersion); + expect(newImage.raw).toStrictEqual(endpoint.downloadedImage); + }, 60000); +}); diff --git a/test/otaCommon.test.js b/test/otaCommon.test.js deleted file mode 100644 index aece072901fec..0000000000000 --- a/test/otaCommon.test.js +++ /dev/null @@ -1,65 +0,0 @@ -const crypto = require('crypto'); -const fs = require('fs'); -const path = require("path"); -const common = require('../src/lib/ota/common'); -const otaImages = require('./stub/otaImages'); - -describe("ota/common.js", () => { - it.each(otaImages)("Can correctly parse OTA image file %s", (_, otaImage) => { - const start = otaImage.data.indexOf(common.upgradeFileIdentifier); - - const image = common.parseImage(otaImage.data.slice(start)); - - expect(common.validateImageData(image)).toBeUndefined(); - - expect(image.header.otaHeaderFieldControl).toBe(otaImage.headerField); - - expect(image.header.minimumHardwareVersion).toBe(otaImage.minimumHardwareVersion); - expect(image.header.maximumHardwareVersion).toBe(otaImage.maximumHardwareVersion); - - expect(image.elements.length).toBe(otaImage.elements); - }); - - describe('Image checksum validation', () => { - const data = fs.readFileSync( - path.join(__dirname, "stub", "otaImageFiles", "SAL2PU1_02015120_OTA.ota") - ); - const hash = crypto.createHash('sha512'); - hash.update(data); - - const start = data.indexOf(common.upgradeFileIdentifier); - const image = common.parseImage(data.slice(start)); - - const mockGetImageMeta = jest.fn().mockResolvedValue({ - fileVersion: image.header.fileVersion, - sha512: hash.digest('hex'), - }); - const device = { ieeeAddr: '0x000000000000000' }; - - it("Valid OTA image file passes checksum verification", async () => { - const mockDownloadImage = jest.fn().mockResolvedValue({ data }); - await expect(common.getNewImage( - { - manufacturerCode: image.header.manufacturerCode, - imageType: image.header.imageType, - fileVersion: image.header.fileVersion - 1, - }, - device, - mockGetImageMeta, - mockDownloadImage - )).resolves.toBeInstanceOf(Object); - }); - - it("Invalid OTA image file fails checksum verification", async () => { - const mockDownloadImage = jest.fn().mockResolvedValue({ data: 'invalid data' }); - - await expect(common.getNewImage( - { fileVersion: image.header.fileVersion - 1 }, - { ieeeAddr: '0x000000000000000' }, - mockGetImageMeta, - mockDownloadImage - )).rejects.toThrow(/File checksum validation failed/); - }); - }); - -}); diff --git a/test/otaCommon.test.ts b/test/otaCommon.test.ts new file mode 100644 index 0000000000000..82b7463a2957d --- /dev/null +++ b/test/otaCommon.test.ts @@ -0,0 +1,120 @@ +import crypto from 'crypto'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import * as common from '../src/lib/ota/common'; + +describe('OTA Common', () => { + it.each([ + ['10005777-4.1-TRADFRI-control-outlet-2.0.022.ota.ota.signed', { + manufacturer: 'Ikea', + headerField: 0, + elements: 1 + }], + ['A60_DIM_Z3_IM003D_00103101-encrypted_11_20_2018_Tue_122925_01_withoutMF.ota', { + manufacturer: 'Ledvance', + headerField: 0, + elements: 1 + }], + ['ZLL_MK_0x01020509_CLA60_TW.ota', { + manufacturer: 'Ledvance', + headerField: 0, + elements: 4 + }], + ['ZLL_MK_0x01020510_CLASSIC_A60_RGBW.ota', { + manufacturer: 'Ledvance', + headerField: 0, + elements: 6 + }], + ['SAL2PU1_02015120_OTA.ota', { + manufacturer: 'Salus', + headerField: 0, + elements: 1 + }], + ['10F2-7B09-0000-0004-01090206-spo-fmi4.ota.zigbee', { + manufacturer: 'Ubisys', + headerField: 4, + minimumHardwareVersion: 0, + maximumHardwareVersion: 4, + elements: 1 + }], + ['10005778-10.1-TRADFRI-onoff-shortcut-control-2.2.010.ota.ota.signed', { + manufacturer: '', + headerField: 0, + elements: 1 + }], + ['100B-0112-01001500-ConfLightBLE-Lamps-EFR32MG13.zigbee', { + manufacturer: '', + headerField: 0, + elements: 26 + }], + ['100B-0112-01002400-ConfLightBLE-Lamps-EFR32MG13.zigbee', { + headerField: 0, + elements: 33 + }], + ])("Can correctly parse OTA image file %s", (filename, meta) => { + const data = readFileSync(join(__dirname, 'stub', 'otaImageFiles', filename)); + const start = data.indexOf(common.UPGRADE_FILE_IDENTIFIER); + + const image = common.parseImage(data.subarray(start)); + + expect(common.validateImageData(image)).toBeUndefined(); + + expect(image.header.otaHeaderFieldControl).toBe(meta.headerField); + + expect(image.header.minimumHardwareVersion).toBe( + // @ts-expect-error can be undefined + meta.minimumHardwareVersion + ); + expect(image.header.maximumHardwareVersion).toBe( + // @ts-expect-error can be undefined + meta.maximumHardwareVersion + ); + + expect(image.elements.length).toBe(meta.elements); + }); + + describe('Image checksum validation', () => { + const data = readFileSync(join(__dirname, 'stub', 'otaImageFiles', 'SAL2PU1_02015120_OTA.ota')); + const hash = crypto.createHash('sha512'); + hash.update(data); + + const start = data.indexOf(common.UPGRADE_FILE_IDENTIFIER); + const image = common.parseImage(data.subarray(start)); + + const mockGetImageMeta = jest.fn().mockResolvedValue({ + fileVersion: image.header.fileVersion, + sha512: hash.digest('hex'), + }); + const device = { ieeeAddr: '0x000000000000000' }; + + it('Valid OTA image file passes checksum verification', async () => { + const mockDownloadImage = jest.fn().mockResolvedValue({ data }); + await expect(common.getNewImage( + { + manufacturerCode: image.header.manufacturerCode, + imageType: image.header.imageType, + fileVersion: image.header.fileVersion - 1, + }, + // @ts-expect-error mock + device, + mockGetImageMeta, + mockDownloadImage, + false + )).resolves.toBeInstanceOf(Object); + }); + + it('Invalid OTA image file fails checksum verification', async () => { + const mockDownloadImage = jest.fn().mockResolvedValue({ data: 'invalid data' }); + + await expect(common.getNewImage( + // @ts-expect-error mock + { fileVersion: image.header.fileVersion - 1 }, + { ieeeAddr: '0x000000000000000' }, + mockGetImageMeta, + mockDownloadImage, + false + )).rejects.toThrow(/File checksum validation failed/); + }); + }); + +}); diff --git a/test/stub/otaImages.js b/test/stub/otaImages.js deleted file mode 100644 index 7e8747fc0b274..0000000000000 --- a/test/stub/otaImages.js +++ /dev/null @@ -1,65 +0,0 @@ -const fs = require("fs"); -const path = require("path"); - -const otaImagesFilesWithMeta = { - "10005777-4.1-TRADFRI-control-outlet-2.0.022.ota.ota.signed": { - manufacturer: "Ikea", - headerField: 0, - elements: 1 - }, - "A60_DIM_Z3_IM003D_00103101-encrypted_11_20_2018_Tue_122925_01_withoutMF.ota": { - manufacturer: "Ledvance", - headerField: 0, - elements: 1 - }, - "ZLL_MK_0x01020509_CLA60_TW.ota": { - manufacturer: "Ledvance", - headerField: 0, - elements: 4 - }, - "ZLL_MK_0x01020510_CLASSIC_A60_RGBW.ota": { - manufacturer: "Ledvance", - headerField: 0, - elements: 6 - }, - "SAL2PU1_02015120_OTA.ota": { - manufacturer: "Salus", - headerField: 0, - elements: 1 - }, - "10F2-7B09-0000-0004-01090206-spo-fmi4.ota.zigbee": { - manufacturer: "Ubisys", - headerField: 4, - minimumHardwareVersion: 0, - maximumHardwareVersion: 4, - elements: 1 - }, - "10005778-10.1-TRADFRI-onoff-shortcut-control-2.2.010.ota.ota.signed": { - manufacturer: "", - headerField: 0, - elements: 1 - }, - "100B-0112-01001500-ConfLightBLE-Lamps-EFR32MG13.zigbee": { - manufacturer: "", - headerField: 0, - elements: 26 - }, - "100B-0112-01002400-ConfLightBLE-Lamps-EFR32MG13.zigbee": { - headerField: 0, - elements: 33 - } -}; - -const otaImages = Object.entries(otaImagesFilesWithMeta).map( - ([filename, meta]) => [ - filename, - { - ...meta, - data: fs.readFileSync( - path.join(__dirname, "otaImageFiles", filename) - ) - } - ] -); - -module.exports = otaImages;