Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions bin/cmds/app/driver/firmware.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
};
291 changes: 291 additions & 0 deletions lib/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Comment thread
nozols marked this conversation as resolved.

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 this update only apply to devices within a certain firmware version range?',
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];
Comment thread
nozols marked this conversation as resolved.

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.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 \`driver.firmware.compose.json.\`:\n${err}`);
}
}

firmwareUpdatesJson.updates.push(updateJson);

await writeFileAsync(updatesFilePath, JSON.stringify(firmwareUpdatesJson, null, 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
Expand Down
10 changes: 10 additions & 0 deletions lib/HomeyCompose.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
/drivers/<id>/driver.compose.json (extend with "$extends": [ "<template_id>" ])
/drivers/<id>/driver.settings.compose.json
(array with driver settings, extend with "$extends": "<template_id>"))
/drivers/<id>/driver.firmware.compose.json
/drivers/<id>/driver.flow.compose.json (object with flow cards, device arg is added automatically)
/drivers/<id>/driver.pair.compose.json (object with pair views)
/drivers/<id>/driver.repair.compose.json (object with repair views)
Expand Down Expand Up @@ -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.compose.json'),
);
} catch (err) {
if (err.code !== 'ENOENT') throw new Error(err);
}

// merge template settings
try {
const settingsTemplates = await this._getJsonFiles(
Expand Down
Loading