import assert from "node:assert"; import {exec} from "node:child_process"; import {createHash} from "node:crypto"; import {existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync} from "node:fs"; import path from "node:path"; import type {ExtraMetas, ExtraMetasWithFileName, ImageHeader, RepoImageMeta} from "./types.js"; export const UPGRADE_FILE_IDENTIFIER = Buffer.from([0x1e, 0xf1, 0xee, 0x0b]); export const BASE_REPO_URL = "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/"; export const REPO_BRANCH = "master"; /** Images used by OTA upgrade process */ export const BASE_IMAGES_DIR = "images"; /** Images used by OTA downgrade process */ export const PREV_IMAGES_DIR = "images1"; /** Manifest used by OTA upgrade process */ export const BASE_INDEX_MANIFEST_FILENAME = "index.json"; /** Manifest used by OTA downgrade process */ export const PREV_INDEX_MANIFEST_FILENAME = "index1.json"; export const CACHE_DIR = ".cache"; export const TMP_DIR = "tmp"; export const PR_ARTIFACT_DIR = "pr"; export const PR_DIFF_FILENAME = "PR_DIFF"; export const PR_ERROR_FILENAME = "PR_ERROR"; export const PR_NUMBER_FILENAME = "PR_NUMBER"; export const PR_ARTIFACT_DIFF_FILEPATH = path.join(PR_ARTIFACT_DIR, PR_DIFF_FILENAME); export const PR_ARTIFACT_ERROR_FILEPATH = path.join(PR_ARTIFACT_DIR, PR_ERROR_FILENAME); export const PR_ARTIFACT_NUMBER_FILEPATH = path.join(PR_ARTIFACT_DIR, PR_NUMBER_FILENAME); /** * 'ikea_new' first, to prioritize downloads from new URL */ export const ALL_AUTODL_MANUFACTURERS = [ "gammatroniques", "hue", "ikea_new", "ikea", "inovelli", "jethome", "ledvance", "lixee", "salus", "sonoff", "ubisys", "xyzroe", ]; export async function execute(command: string): Promise { return await new Promise((resolve, reject) => { exec(command, (error, stdout) => { if (error) { reject(error); } else { resolve(stdout); } }); }); } export function primitivesArrayEquals(a: (string | number | boolean)[], b: (string | number | boolean)[]): boolean { return a.length === b.length && a.every((val, index) => val === b[index]); } export function computeSHA512(buffer: Buffer): string { const hash = createHash("sha512"); hash.update(buffer); return hash.digest("hex"); } export function getOutDir(folderName: string, basePath: string = BASE_IMAGES_DIR): string { const outDir = path.join(basePath, folderName); if (!existsSync(outDir)) { mkdirSync(outDir, {recursive: true}); } return outDir; } export function getRepoFirmwareFileUrl(folderName: string, fileName: string, basePath: string = BASE_IMAGES_DIR): string { return BASE_REPO_URL + path.posix.join(REPO_BRANCH, basePath, folderName, encodeURIComponent(fileName)); } export function writeManifest(fileName: string, firmwareList: RepoImageMeta[]): void { writeFileSync(fileName, JSON.stringify(firmwareList, undefined, 2), "utf8"); } export function readManifest(fileName: string): RepoImageMeta[] { return JSON.parse(readFileSync(fileName, "utf8")); } export function writeCacheJson(fileName: string, contents: T, basePath: string = CACHE_DIR): void { writeFileSync(path.join(basePath, `${fileName}.json`), JSON.stringify(contents), "utf8"); } export function readCacheJson(fileName: string, basePath: string = CACHE_DIR): T | undefined { const filePath = path.join(basePath, `${fileName}.json`); return existsSync(filePath) ? JSON.parse(readFileSync(filePath, "utf8")) : undefined; } export function parseImageHeader(buffer: Buffer): ImageHeader { try { const header: ImageHeader = { 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; 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; header.maximumHardwareVersion = buffer.readUInt16LE(headerPos); headerPos += 2; } assert(UPGRADE_FILE_IDENTIFIER.equals(header.otaUpgradeFileIdentifier), "Invalid upgrade file identifier"); return header; } catch (error) { throw new Error(`Not a valid OTA file (${(error as Error).message}).`); } } /** * Adapted from zigbee-herdsman-converters logic */ export function findMatchImage( image: ImageHeader, imageList: RepoImageMeta[], extraMetas: ExtraMetas, ): [index: number, image: RepoImageMeta | undefined] { const imageIndex = imageList.findIndex( (i) => i.imageType === image.imageType && i.manufacturerCode === image.manufacturerCode && extraMetas.minFileVersion === i.minFileVersion && extraMetas.maxFileVersion === i.maxFileVersion && extraMetas.hardwareVersionMin === i.hardwareVersionMin && extraMetas.hardwareVersionMax === i.hardwareVersionMax && i.modelId === extraMetas.modelId && (!i.manufacturerName || extraMetas.manufacturerName?.some((v) => i.manufacturerName!.includes(v))), ); return [imageIndex, imageIndex === -1 ? undefined : imageList[imageIndex]]; } export function changeRepoUrl(repoUrl: string, fromDir: string, toDir: string): string { return repoUrl.replace(path.posix.join(REPO_BRANCH, fromDir), path.posix.join(REPO_BRANCH, toDir)); } export async function getJson(manufacturer: string, pageUrl: string): Promise { const response = await fetch(pageUrl); if (!response.ok || !response.body) { console.error(`[${manufacturer}] Invalid response from ${pageUrl} status=${response.status}.`); return; } return (await response.json()) as T; } export async function getText(manufacturer: string, pageUrl: string): Promise { const response = await fetch(pageUrl); if (!response.ok || !response.body) { console.error(`[${manufacturer}] Invalid response from ${pageUrl} status=${response.status}.`); return; } return await response.text(); } export function getLatestImage(list: T[] | undefined, compareFn: (a: T, b: T) => number): T | undefined { if (!list) { return undefined; } const sortedList = list.sort(compareFn); return sortedList.slice(0, sortedList.length > 1 && process.env.PREV ? -1 : undefined).pop(); } /** Check if has image in same base path (manufacturer), either by SHA or by spec matching */ export function hasManufacturerImage(list: RepoImageMeta[], image: RepoImageMeta, extraManufacturerName: string[] | undefined): boolean { const imageBasePath = path.posix.dirname(image.url); return list.some( (i) => path.posix.dirname(i.url) === imageBasePath && ((i.sha512 === image.sha512 && !(i.modelId && image.modelId && i.modelId !== image.modelId)) || (i.fileVersion === image.fileVersion && i.imageType === image.imageType && i.manufacturerCode === image.manufacturerCode && (!i.manufacturerName || extraManufacturerName?.some((v) => i.manufacturerName!.includes(v))) && ((!i.modelId && !image.modelId) || (i.modelId && image.modelId && i.modelId === image.modelId)))), ); } export enum ParsedImageStatus { New = 0, Newer = 1, Older = 2, Identical = 3, } export function getParsedImageStatus(parsedImage: ImageHeader, match?: RepoImageMeta): ParsedImageStatus { if (match) { if (match.fileVersion > parsedImage.fileVersion) { return ParsedImageStatus.Older; } if (match.fileVersion < parsedImage.fileVersion) { return ParsedImageStatus.Newer; } return ParsedImageStatus.Identical; } return ParsedImageStatus.New; } /** * Prevent irrelevant metas from being added to manifest. * * NOTE: fileName should be deleted before adding to manifest for consistency (always use original file name). * @param metas * @returns */ export function getValidMetas(metas: Partial, ignoreFileName: boolean): ExtraMetasWithFileName { const validMetas: ExtraMetasWithFileName = {}; if (!ignoreFileName) { if (metas.fileName != null) { if (typeof metas.fileName !== "string") { throw new Error(`Invalid format for 'fileName', expected 'string' type.`); } validMetas.fileName = metas.fileName; } } if (metas.originalUrl != null) { if (typeof metas.originalUrl !== "string") { throw new Error(`Invalid format for 'originalUrl', expected 'string' type.`); } validMetas.originalUrl = metas.originalUrl; } if (metas.force != null) { if (typeof metas.force !== "boolean") { throw new Error(`Invalid format for 'force', expected 'boolean' type.`); } validMetas.force = metas.force; } if (metas.hardwareVersionMax != null) { if (typeof metas.hardwareVersionMax !== "number") { throw new Error(`Invalid format for 'hardwareVersionMax', expected 'number' type.`); } validMetas.hardwareVersionMax = metas.hardwareVersionMax; } if (metas.hardwareVersionMin != null) { if (typeof metas.hardwareVersionMin !== "number") { throw new Error(`Invalid format for 'hardwareVersionMin', expected 'number' type.`); } validMetas.hardwareVersionMin = metas.hardwareVersionMin; } if (metas.manufacturerName != null) { if ( !Array.isArray(metas.manufacturerName) || metas.manufacturerName.length < 1 || metas.manufacturerName.some((m) => typeof m !== "string") ) { throw new Error(`Invalid format for 'manufacturerName', expected 'array of string' type.`); } validMetas.manufacturerName = metas.manufacturerName; } if (metas.maxFileVersion != null) { if (typeof metas.maxFileVersion !== "number") { throw new Error(`Invalid format for 'maxFileVersion', expected 'number' type.`); } validMetas.maxFileVersion = metas.maxFileVersion; } if (metas.minFileVersion != null) { if (typeof metas.minFileVersion !== "number") { throw new Error(`Invalid format for 'minFileVersion', expected 'number' type.`); } validMetas.minFileVersion = metas.minFileVersion; } if (metas.modelId != null) { if (typeof metas.modelId !== "string") { throw new Error(`Invalid format for 'modelId', expected 'string' type.`); } validMetas.modelId = metas.modelId; } if (metas.releaseNotes != null) { if (typeof metas.releaseNotes !== "string") { throw new Error(`Invalid format for 'releaseNotes', expected 'string' type.`); } validMetas.releaseNotes = metas.releaseNotes; } return validMetas; } export function addImageToPrev( logPrefix: string, isNewer: boolean, prevManifest: RepoImageMeta[], prevMatchIndex: number, prevMatch: RepoImageMeta, prevOutDir: string, firmwareFileName: string, manufacturer: string, parsedImage: ImageHeader, firmwareBuffer: Buffer, originalUrl: string | undefined, extraMetas: ExtraMetas, onBeforeManifestPush: () => void, ): void { console.log(`${logPrefix} Base manifest has higher version. Adding to prev instead.`); const newMetas: RepoImageMeta = { fileName: firmwareFileName, fileVersion: parsedImage.fileVersion, fileSize: parsedImage.totalImageSize, originalUrl, url: getRepoFirmwareFileUrl(manufacturer, firmwareFileName, PREV_IMAGES_DIR), imageType: parsedImage.imageType, manufacturerCode: parsedImage.manufacturerCode, sha512: computeSHA512(firmwareBuffer), otaHeaderString: parsedImage.otaHeaderString.replaceAll("\u0000", ""), ...extraMetas, }; if (hasManufacturerImage(prevManifest, newMetas, extraMetas.manufacturerName)) { throw new Error("Image already present for manufacturer"); } if (isNewer) { console.log(`${logPrefix} Removing prev image.`); prevManifest.splice(prevMatchIndex, 1); // make sure fileName exists for migration from old system const prevFileName = prevMatch.fileName ? prevMatch.fileName : prevMatch.url.split("/").pop()!; rmSync(path.join(prevOutDir, prevFileName), {force: true}); } onBeforeManifestPush(); prevManifest.push(newMetas); } export function addImageToBase( logPrefix: string, isNewer: boolean, prevManifest: RepoImageMeta[], prevOutDir: string, baseManifest: RepoImageMeta[], baseMatchIndex: number, baseMatch: RepoImageMeta, baseOutDir: string, firmwareFileName: string, manufacturer: string, parsedImage: ImageHeader, firmwareBuffer: Buffer, originalUrl: string | undefined, extraMetas: ExtraMetas, onBeforeManifestPush: () => void, ): void { const newMetas: RepoImageMeta = { fileName: firmwareFileName, fileVersion: parsedImage.fileVersion, fileSize: parsedImage.totalImageSize, originalUrl, url: getRepoFirmwareFileUrl(manufacturer, firmwareFileName, BASE_IMAGES_DIR), imageType: parsedImage.imageType, manufacturerCode: parsedImage.manufacturerCode, sha512: computeSHA512(firmwareBuffer), otaHeaderString: parsedImage.otaHeaderString.replaceAll("\u0000", ""), ...extraMetas, }; if (hasManufacturerImage(baseManifest, newMetas, extraMetas.manufacturerName)) { throw new Error("Image already present for manufacturer"); } if (isNewer) { console.log(`${logPrefix} Base manifest has older version ${baseMatch.fileVersion}. Replacing with ${parsedImage.fileVersion}.`); const [prevMatchIndex, prevMatch] = findMatchImage(parsedImage, prevManifest, extraMetas); const prevStatus = getParsedImageStatus(parsedImage, prevMatch); if (prevStatus !== ParsedImageStatus.Older && prevStatus !== ParsedImageStatus.New) { console.warn(`${logPrefix} Base image is new/newer but prev image is not older/non-existing.`); } if (prevStatus !== ParsedImageStatus.New) { console.log(`${logPrefix} Removing prev image.`); prevManifest.splice(prevMatchIndex, 1); // make sure fileName exists for migration from old system const prevFileName = prevMatch!.fileName ? prevMatch!.fileName : prevMatch!.url.split("/").pop()!; rmSync(path.join(prevOutDir, prevFileName), {force: true}); } // relocate base to prev // make sure fileName exists for migration from old system const baseFileName = baseMatch.fileName ? baseMatch.fileName : baseMatch.url.split("/").pop()!; const baseFilePath = path.join(baseOutDir, baseFileName); // if for some reason the file is no longer present (should not happen), don't add it to prev since link is broken if (existsSync(baseFilePath)) { renameSync(baseFilePath, path.join(prevOutDir, baseFileName)); baseMatch!.url = changeRepoUrl(baseMatch.url, BASE_IMAGES_DIR, PREV_IMAGES_DIR); prevManifest.push(baseMatch); } else { console.error(`${logPrefix} Image file '${baseFilePath}' does not exist. Not moving to prev.`); } baseManifest.splice(baseMatchIndex, 1); } else { console.log(`${logPrefix} Base manifest does not have version ${parsedImage.fileVersion}. Adding.`); } onBeforeManifestPush(); baseManifest.push(newMetas); }