From eed69b00acfd9a708aef72604a89cd6c8a646e8b Mon Sep 17 00:00:00 2001 From: Niels de Boer Date: Fri, 13 Mar 2026 11:28:43 +0100 Subject: [PATCH 1/3] feat(firmware-updates): add support for driver.firmware-updates.compose.json --- lib/HomeyCompose.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/HomeyCompose.js b/lib/HomeyCompose.js index 8b77bc47..c235e540 100644 --- a/lib/HomeyCompose.js +++ b/lib/HomeyCompose.js @@ -19,6 +19,7 @@ /drivers//driver.compose.json (extend with "$extends": [ "" ]) /drivers//driver.settings.compose.json (array with driver settings, extend with "$extends": "")) + /drivers//driver.firmware-updates.compose.json /drivers//driver.flow.compose.json (object with flow cards, device arg is added automatically) /drivers//driver.pair.compose.json (object with pair views) /drivers//driver.repair.compose.json (object with repair views) @@ -216,6 +217,15 @@ class HomeyCompose { if (err.code !== 'ENOENT') throw new Error(err); } + // merge firmware updates + try { + driverJson.firmwareUpdates = await this._getJsonFile( + path.join(this._appPath, 'drivers', driverId, 'driver.firmware-updates.compose.json'), + ); + } catch (err) { + if (err.code !== 'ENOENT') throw new Error(err); + } + // merge template settings try { const settingsTemplates = await this._getJsonFiles( From b89ea24c97e3c410454d6593b1f24c51072a3c51 Mon Sep 17 00:00:00 2001 From: Niels de Boer Date: Fri, 13 Mar 2026 11:39:50 +0100 Subject: [PATCH 2/3] feat(firmware-updates): add firmware command for zigbee firmware updates --- bin/cmds/app/driver/firmware.mjs | 38 ++++ lib/App.js | 291 +++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 bin/cmds/app/driver/firmware.mjs diff --git a/bin/cmds/app/driver/firmware.mjs b/bin/cmds/app/driver/firmware.mjs new file mode 100644 index 00000000..c9f2f716 --- /dev/null +++ b/bin/cmds/app/driver/firmware.mjs @@ -0,0 +1,38 @@ +'use strict'; + +import path from 'path'; +import Log from '../../../../lib/Log.js'; +import App from '../../../../lib/App.js'; + +export const desc = 'Add a device firmware update to a Driver'; + +export const builder = (yargs) => { + return yargs + .option('driver', { + describe: 'Path to the driver to which the firmware update should be added', + type: 'string', + demandOption: false, + }) + .option('firmware-file', { + describe: 'Path to the firmware file', + type: 'string', + demandOption: false, + }); +}; +export const handler = async (yargs) => { + try { + const firmwareFile = yargs.firmwareFile + ? path.resolve(process.cwd(), yargs.firmwareFile) + : undefined; + + const app = new App(yargs.path); + await app.createFirmwareUpdate({ + driverPath: yargs.driver, + firmwareFile, + }); + process.exit(0); + } catch (err) { + Log.error(err); + process.exit(1); + } +}; diff --git a/lib/App.js b/lib/App.js index 0b52ac58..f07855db 100644 --- a/lib/App.js +++ b/lib/App.js @@ -3012,6 +3012,297 @@ $ sudo systemctl restart docker Log.success(`Flow created in \`${flowPath}\``); } + async createFirmwareUpdate({ driverPath, firmwareFile } = {}) { + if (App.hasHomeyCompose({ appPath: this.path }) === false) { + // Note: this checks that we are in a valid homey app folder + App.getManifest({ appPath: this.path }); + + if (await this._askComposeMigration()) { + await this.migrateToCompose(); + } else { + throw new Error('This command requires Homey compose, run `homey app compose` to migrate!'); + } + } + + // 1. Ask user to select one of the zigbee drivers + // TODO: make this command also available for zwave drivers. + const drivers = await this._getDrivers(); + const otaCapableDrivers = await Promise.all( + drivers.map(async (driverIdItem) => { + const driverJsonPath = path.join(this.path, 'drivers', driverIdItem, 'driver.compose.json'); + try { + const driverJson = await readFileAsync(driverJsonPath, 'utf8'); + const driver = JSON.parse(driverJson); + return { id: driverIdItem, ...driver }; + } catch (err) { + return null; + } + }), + ).then((results) => + results.filter( + (driver) => driver && driver.connectivity && driver.connectivity.includes('zigbee'), + ), + ); + + if (otaCapableDrivers.length === 0) { + throw new Error('No OTA-capable drivers found! Please create a Zigbee driver first.'); + } + + // If driverId is provided via CLI option, validate it + let selectedDriverId; + let driverJson; + if (driverPath) { + // driverId is actually a path to the driver folder + // Resolve it relative to the app path if it's relative + driverPath = path.isAbsolute(driverPath) ? driverPath : path.resolve(this.path, driverPath); + + // Extract the driver ID from the path (basename of the driver folder) + selectedDriverId = path.basename(driverPath); + + // Load the driver JSON and validate it's a Zigbee driver + const driverJsonPath = path.join(driverPath, 'driver.compose.json'); + try { + const driverJsonString = await readFileAsync(driverJsonPath, 'utf8'); + driverJson = JSON.parse(driverJsonString); + } catch (err) { + throw new Error(`Failed to load driver from \`${driverJsonPath}\`: ${err.message}`); + } + + // Check if it's a Zigbee driver + if (!driverJson.connectivity || !driverJson.connectivity.includes('zigbee')) { + throw new Error( + `Driver \`${selectedDriverId}\` is not a Zigbee driver. Firmware updates are only supported for Zigbee drivers.`, + ); + } + } else { + // Ask user to select a driver + const result = await inquirer.prompt([ + { + type: 'list', + name: 'driverId', + message: 'For which Zigbee driver do you want to create a firmware update?', + choices: () => { + return otaCapableDrivers.map((driver) => { + return { + name: driver.name.en + colors.grey(` (${driver.id})`), + value: driver.id, + }; + }); + }, + }, + ]); + selectedDriverId = result.driverId; + driverJson = otaCapableDrivers.find((driver) => driver.id === selectedDriverId); + } + + // If firmwareFile is provided via CLI option, use it; otherwise ask the user + let firmwarePath; + if (firmwareFile) { + // Validate the firmware file + if (!fs.existsSync(firmwareFile)) { + throw new Error(`Firmware file \`${firmwareFile}\` does not exist!`); + } + + // Check if the file is a valid zigbee OTA file by checking the header + try { + await HomeyLibUtil.validateZigbeeOTAHeader({ filePath: firmwareFile }); + } catch (err) { + throw new Error(`Invalid Zigbee OTA file: ${err.message}`); + } + + firmwarePath = firmwareFile; + } else { + // Ask user for firmware file + const result = await inquirer.prompt([ + { + type: 'file', + name: 'firmwarePath', + message: 'Select the firmware file you want to use for the update', + validate: async (input) => { + input = path.resolve(process.cwd(), input); + + if (!fs.existsSync(input)) { + return 'File does not exist!'; + } + + // Check if the file is a valid zigbee OTA file by checking the header + await HomeyLibUtil.validateZigbeeOTAHeader({ filePath: input }); + + return true; + }, + }, + ]); + firmwarePath = path.resolve(process.cwd(), result.firmwarePath); + } + + const { changelog, requireFileVersion } = await inquirer.prompt([ + { + type: 'string', + name: 'changelog', + message: 'What is the changelog for this firmware update?', + validate: (input) => input.length > 0, + }, + { + type: 'confirm', + name: 'requireFileVersion', + message: + 'Should the app check for the current firmware version on the device before updating?', + default: false, + }, + ]); + + let minFileVersion; + let maxFileVersion; + + if (requireFileVersion) { + const versions = await inquirer.prompt([ + { + type: 'string', + name: 'minFileVersion', + message: 'What is the minimum file version required on the device to perform the update?', + validate: (input) => { + input = Number(input); + + if (Number.isNaN(input)) { + return 'Minimum file version must be a number'; + } + + if (!Number.isInteger(input)) { + return 'Minimum file version must be an integer'; + } + + if (input < 0 || input > 0xffff_ffff) { + return 'Minimum file version must be a 32-bit unsigned integer'; + } + + return true; + }, + }, + { + type: 'string', + name: 'maxFileVersion', + message: 'What is the maximum file version required on the device to perform the update?', + validate: (input) => { + input = Number(input); + + if (Number.isNaN(input)) { + return 'Maximum file version must be a number'; + } + + if (!Number.isInteger(input)) { + return 'Maximum file version must be an integer'; + } + + if (input < 0 || input > 0xffff_ffff) { + return 'Maximum file version must be a 32-bit unsigned integer'; + } + + return true; + }, + }, + ]); + + minFileVersion = Number(versions.minFileVersion); + maxFileVersion = Number(versions.maxFileVersion); + } + + const manufacturerNames = Array.isArray(driverJson.zigbee.manufacturerName) + ? driverJson.zigbee.manufacturerName + : [driverJson.zigbee.manufacturerName]; + const productIds = Array.isArray(driverJson.zigbee.productId) + ? driverJson.zigbee.productId + : [driverJson.zigbee.productId]; + + const { selectedManufacturerNames, selectedProductIds } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'selectedManufacturerNames', + message: 'Which manufacturer names should this firmware update apply to?', + choices: manufacturerNames, + default: manufacturerNames, + validate: (input) => { + return input.length > 0 || 'Select at least one manufacturer name'; + }, + }, + { + type: 'checkbox', + name: 'selectedProductIds', + message: 'Which product IDs should this firmware update apply to?', + choices: productIds, + default: productIds, + validate: (input) => { + return input.length > 0 || 'Select at least one product ID'; + }, + }, + ]); + + const header = await HomeyLibUtil.parseZigbeeOTAHeader(firmwarePath); + const integrity = await HomeyLibUtil.getIntegrity(firmwarePath, 'sha256'); + + const fileName = path.basename(firmwarePath); + const firmwareDestPath = path.join( + this.path, + 'drivers', + selectedDriverId, + 'assets', + 'firmware', + fileName, + ); + + await fse.ensureDir(path.dirname(firmwareDestPath)); + await copyFileAsync(firmwarePath, firmwareDestPath); + + const updateJson = { + changelog: { en: changelog }, + device: { + manufacturerName: + selectedManufacturerNames.length === 1 + ? selectedManufacturerNames[0] + : selectedManufacturerNames, + productId: selectedProductIds.length === 1 ? selectedProductIds[0] : selectedProductIds, + }, + files: [ + { + fileVersion: header.fileVersion, + imageType: header.imageType, + manufacturerCode: header.manufacturerCode, + minFileVersion, + maxFileVersion, + maxHardwareVersion: header.maximumHardwareVersion, + minHardwareVersion: header.minimumHardwareVersion, + size: header.totalImageSize, + name: fileName, + integrity, + }, + ], + }; + + const updatesFilePath = path.join( + this.path, + 'drivers', + selectedDriverId, + 'driver.firmware-updates.compose.json', + ); + // Check if the firmware updates file already exists, if not create it + let firmwareUpdatesJson; + try { + firmwareUpdatesJson = await readFileAsync(updatesFilePath, 'utf8'); + firmwareUpdatesJson = JSON.parse(firmwareUpdatesJson); + } catch (err) { + if (err.code === 'ENOENT') { + firmwareUpdatesJson = { updates: [] }; // File not found so init JSON + } else { + throw new Error(`Error in \`firmware-updates.compose.json.\`:\n${err}`); + } + } + + firmwareUpdatesJson.updates.push(updateJson); + + await writeFileAsync(updatesFilePath, JSON.stringify(firmwareUpdatesJson, false, 2)); + + Log.success(`Zigbee firmware update created in \`${updatesFilePath}\``); + } + async createWidget() { if (App.hasHomeyCompose({ appPath: this.path }) === false) { // Note: this checks that we are in a valid homey app folder From 68658d3fad5a6942c607ac9e25e3bdbe926d9b4a Mon Sep 17 00:00:00 2001 From: Niels de Boer Date: Mon, 16 Mar 2026 08:50:34 +0100 Subject: [PATCH 3/3] fix(firmware-updates): update firmware file references and prompt message for version range --- lib/App.js | 8 ++++---- lib/HomeyCompose.js | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/App.js b/lib/App.js index f07855db..b59dd2c0 100644 --- a/lib/App.js +++ b/lib/App.js @@ -3146,7 +3146,7 @@ $ sudo systemctl restart docker type: 'confirm', name: 'requireFileVersion', message: - 'Should the app check for the current firmware version on the device before updating?', + 'Should this update only apply to devices within a certain firmware version range?', default: false, }, ]); @@ -3281,7 +3281,7 @@ $ sudo systemctl restart docker this.path, 'drivers', selectedDriverId, - 'driver.firmware-updates.compose.json', + 'driver.firmware.compose.json', ); // Check if the firmware updates file already exists, if not create it let firmwareUpdatesJson; @@ -3292,13 +3292,13 @@ $ sudo systemctl restart docker if (err.code === 'ENOENT') { firmwareUpdatesJson = { updates: [] }; // File not found so init JSON } else { - throw new Error(`Error in \`firmware-updates.compose.json.\`:\n${err}`); + throw new Error(`Error in \`driver.firmware.compose.json.\`:\n${err}`); } } firmwareUpdatesJson.updates.push(updateJson); - await writeFileAsync(updatesFilePath, JSON.stringify(firmwareUpdatesJson, false, 2)); + await writeFileAsync(updatesFilePath, JSON.stringify(firmwareUpdatesJson, null, 2)); Log.success(`Zigbee firmware update created in \`${updatesFilePath}\``); } diff --git a/lib/HomeyCompose.js b/lib/HomeyCompose.js index c235e540..0da5335b 100644 --- a/lib/HomeyCompose.js +++ b/lib/HomeyCompose.js @@ -19,7 +19,7 @@ /drivers//driver.compose.json (extend with "$extends": [ "" ]) /drivers//driver.settings.compose.json (array with driver settings, extend with "$extends": "")) - /drivers//driver.firmware-updates.compose.json + /drivers//driver.firmware.compose.json /drivers//driver.flow.compose.json (object with flow cards, device arg is added automatically) /drivers//driver.pair.compose.json (object with pair views) /drivers//driver.repair.compose.json (object with repair views) @@ -220,7 +220,7 @@ class HomeyCompose { // merge firmware updates try { driverJson.firmwareUpdates = await this._getJsonFile( - path.join(this._appPath, 'drivers', driverId, 'driver.firmware-updates.compose.json'), + path.join(this._appPath, 'drivers', driverId, 'driver.firmware.compose.json'), ); } catch (err) { if (err.code !== 'ENOENT') throw new Error(err);