particle-cli
Version:
Simple Node commandline application for working with your Particle devices and using the Particle Cloud
559 lines (512 loc) • 17.7 kB
JavaScript
;
const fs = require('fs-extra');
const os = require('os');
const semver = require('semver');
const { getEdlDevices } = require('particle-usb');
const { delay } = require('./utilities');
const unzip = require('unzipper');
const DEVICE_READY_WAIT_TIME = 500; // ms
const UI = require('./ui');
const QdlFlasher = require('./qdl');
const path = require('path');
const GPT = require('gpt');
const temp = require('temp').track();
const FSG_PARTITION = 'fsg';
const BOOT_A_PARTITION = 'boot_a';
const BOOT_B_PARTITION = 'boot_b';
const REGION_NA_MARKER = Buffer.from('SG560D-NA');
const REGION_ROW_MARKER = Buffer.from('SG560D-EM');
const EFS_PARTITION_HEADER = Buffer.from('EFS');
const UBUNTU_20_MARKER = Buffer.from('ANDROID');
const UBUNTU_24_MARKER = Buffer.from('UEFI');
const wifiMacScanner = require('./wifi-scanner');
const VError = require('verror');
const chalk = require('chalk');
const inquirer = require('inquirer');
const wifiScan = require('node-wifiscanner2').scan;
const { TachyonConnectionError } = require('./qdl');
function addLogHeaders({ outputLog, startTime, deviceId, commandName }) {
fs.appendFileSync(outputLog, `Tachyon Logs:${os.EOL}`);
fs.appendFileSync(outputLog, `==================${os.EOL}`);
fs.appendFileSync(outputLog, `Command: ${commandName}${os.EOL}`);
fs.appendFileSync(outputLog, `Using Device ID: ${deviceId}${os.EOL}`);
fs.appendFileSync(outputLog, `Start time: ${startTime.toISOString()}${os.EOL}`);
fs.appendFileSync(outputLog, `==================${os.EOL}`);
}
function addManifestInfoLog({ outputLog, manifest }) {
if (!manifest) {
return;
}
fs.appendFileSync(outputLog, `Manifest Info:${os.EOL}`);
fs.appendFileSync(outputLog, `==================${os.EOL}`);
fs.appendFileSync(outputLog, `Release name: ${manifest.release_name || ''}${os.EOL}`);
fs.appendFileSync(outputLog, `Version: ${manifest.version || ''}${os.EOL}`);
fs.appendFileSync(outputLog, `Region: ${manifest.region || ''}${os.EOL}`);
fs.appendFileSync(outputLog, `Variant: ${manifest.variant || ''}${os.EOL}`);
fs.appendFileSync(outputLog, `Platform: ${manifest.platform || ''}${os.EOL}`);
fs.appendFileSync(outputLog, `Board: ${manifest.board || ''}${os.EOL}`);
fs.appendFileSync(outputLog, `OS: ${manifest.os || ''}${os.EOL}`);
fs.appendFileSync(outputLog, `Distribution: ${manifest.distribution || ''}${os.EOL}`);
fs.appendFileSync(outputLog, `Distribution version: ${manifest.distribution_version || ''}${os.EOL}`);
fs.appendFileSync(outputLog, `Distribution variant: ${manifest.distribution_variant || ''}${os.EOL}`);
fs.appendFileSync(outputLog, `Build date: ${manifest.build_date || ''}${os.EOL}`);
}
function addLogFooter({ outputLog, startTime, endTime }) {
fs.appendFileSync(outputLog, `==================${os.EOL}`);
fs.appendFileSync(outputLog, `Process Done${os.EOL}`);
fs.appendFileSync(outputLog, `End Time: ${endTime.toISOString()}${os.EOL}`);
fs.appendFileSync(outputLog, `Duration: ${((endTime - startTime) / 1000).toFixed(2)}s${os.EOL}`);
fs.appendFileSync(outputLog, `==================${os.EOL}`);
fs.appendFileSync(outputLog, `Tachyon Log Ended${os.EOL}`);
fs.appendFileSync(outputLog, `==================${os.EOL}`);
fs.appendFileSync(outputLog, `${os.EOL}`);
}
async function getEDLDevice({ ui = new UI(), showSetupMessage = false } = {}) {
const devices = await getEDLModeDevices(ui, showSetupMessage);
if (devices.length === 1) {
return devices[0];
} else {
return edlModePicker({ ui: ui, devices });
}
}
async function prepareFlashFiles({ logFile, ui, partitionsList, dir = process.cwd(), device, operation, checkFiles = false, modifyPartitions = (partitions) => partitions } = {}) {
const { firehosePath, tempPath, gptXmlPath } = await initFiles();
const partitionTable = await readPartitionsFromDevice({
logFile,
ui,
tempPath,
firehosePath,
gptXmlPath,
device
});
const partitions = modifyPartitions(partitionDefinitions({
partitionList: partitionsList,
partitionTable,
deviceId: device.id,
dir
}));
const partitionFilenames = partitions.reduce((acc, partition) => {
acc[partition.label] = partition.filename;
return acc;
}, {});
if (checkFiles) {
await verifyFilesExist(partitions);
}
const xmlFile = await generateXml({ partitions, tempPath, operation });
return { firehosePath, xmlFile, partitionTable, partitionFilenames };
}
async function initFiles() {
const firehoseAsset = path.join(__dirname, '../../assets/qdl/firehose/prog_firehose_ddr.elf');
const gptXmlAsset = path.join(__dirname, '../../assets/qdl/read_gpt.xml');
const tempPath = await temp.mkdir('tachyon-init-files');
const firehosePath = path.join(tempPath, 'prog_firehose_ddr.elf');
const gptXmlPath = path.join(tempPath, 'read_gpt.xml');
await fs.copyFile(firehoseAsset, firehosePath);
await fs.copyFile(gptXmlAsset, gptXmlPath);
return { firehosePath, gptXmlPath, tempPath };
}
async function readPartitionsFromDevice({ logFile, ui, tempPath, firehosePath, gptXmlPath, device }) {
const files = [
firehosePath,
gptXmlPath
];
const qdl = new QdlFlasher({
outputLogFile: logFile,
files: files,
updateFolder: tempPath,
ui: ui,
currTask: 'Read partitions',
skipReset: true,
serialNumber: device.serialNumber
});
try {
await qdl.run();
} catch (error) {
if (error instanceof TachyonConnectionError) {
throw error;
}
// Ignore other errors as the gpt read will fail for LUN 6 for EVT devices.
// If there was an actual error reading the partitions, it will trigger an error in parsePartitions.
}
return parsePartitions({ gptPath: tempPath });
}
async function parsePartitions({ gptPath }) {
const table = [];
for (let i = 0; i <= 6; i++) {
const filename = path.join(gptPath, `gpt_main${i}.bin`);
try {
const buffer = await fs.readFile(filename);
const gpt = new GPT({ blockSize: 4096 });
const { partitions } = gpt.parse(buffer, gpt.blockSize);// partition table starts at 4096 bytes for Tachyon
partitions.forEach((partition) => {
table.push({ lun: i, partition });
});
} catch {
if (i !== 6) {
// LUN 6 does not exist on EVT devices, so ignore the error
throw new Error(`Failed to parse partition table ${i} from device`);
}
}
}
return table;
}
function partitionDefinitions({ partitionList, partitionTable, deviceId, dir }) {
return partitionList.map((name) => {
const entry = partitionTable.find(({ partition }) => partition.name === name);
if (!entry) {
throw new Error(`Partition ${name} not found in device partition table`);
}
return {
label: entry.partition.name,
physical_partition_number: entry.lun,
start_sector: Number(entry.partition.firstLBA),
num_partition_sectors: Number(entry.partition.lastLBA) - Number(entry.partition.firstLBA) + 1,
filename: path.join(dir, `${deviceId}_${entry.partition.name}.backup`)
};
});
}
async function verifyFilesExist(partitions) {
for (const partition of partitions) {
if (!await fs.exists(partition.filename)) {
throw new Error(`File ${partition.filename} does not exist`);
}
}
}
async function generateXml({ partitions, operation, tempPath }) {
const xmlContent = getXmlContent({ partitions, operation });
const xmlFile = path.join(tempPath, `partitions_${operation}.xml`);
await fs.writeFile(xmlFile, xmlContent);
return xmlFile;
}
function getXmlContent({ partitions, operation = 'read' }) {
const elements = partitions.map(partition => [
` <${operation}`,
` label="${partition.label}"`,
` physical_partition_number="${partition.physical_partition_number}"`,
` start_sector="${partition.start_sector}"`,
` num_partition_sectors="${partition.num_partition_sectors}"`,
` filename="${ partition.filename}"`,
' file_sector_offset="0"',
' SECTOR_SIZE_IN_BYTES="4096"',
' />'
].join('\n')).join('\n');
const xmlLines = [
'<?xml version="1.0" encoding="utf-8"?>',
'<data>',
' <!--NOTE: This is an ** Autogenerated file **-->',
elements,
'</data>',
''
];
return xmlLines.join('\n');
}
async function getTachyonInfo({ outputLog, ui, device }) {
if (!device) {
const _device = await getEDLDevice();
device = _device;
}
const partitionDir = await temp.mkdir();
const { firehosePath, xmlFile, partitionTable, partitionFilenames } = await prepareFlashFiles({
ui,
logFile: outputLog,
partitionsList: [FSG_PARTITION, BOOT_A_PARTITION, BOOT_B_PARTITION],
dir: partitionDir,
device: device,
operation: 'read',
modifyPartitions: (partitions) => {
return partitions.map((p) => {
// we only need the first sector of these partitions to determine distribution version
if ([BOOT_A_PARTITION, BOOT_B_PARTITION].includes(p.label)) {
p.num_partition_sectors = 1;
}
return p;
});
}
});
const files = [
firehosePath,
xmlFile
];
const qdl = new QdlFlasher({
outputLogFile: outputLog,
files: files,
ui: ui,
currTask: 'Identify',
skipReset: true,
serialNumber: device.serialNumber
});
await qdl.run();
return getIdentification({ deviceId: device.id, partitionTable, partitionFilenames });
}
async function getIdentification({ deviceId, partitionTable, partitionFilenames }) {
const fsgBuffer = await fs.readFile(partitionFilenames[FSG_PARTITION]);
const bootABuffer = await fs.readFile(partitionFilenames[BOOT_A_PARTITION]);
const bootBBuffer = await fs.readFile(partitionFilenames[BOOT_B_PARTITION]);
const regionNa = fsgBuffer.includes(REGION_NA_MARKER);
const regionRow = fsgBuffer.includes(REGION_ROW_MARKER);
let regionString;
if (regionNa) {
regionString = 'NA';
} else if (regionRow) {
regionString = 'RoW';
} else {
regionString = 'Unknown';
}
const modemDataValid = fsgBuffer.includes(EFS_PARTITION_HEADER);
let manufacturingDataString;
if (modemDataValid) {
manufacturingDataString = 'Found';
} else {
manufacturingDataString = 'Missing';
}
const nvdataLun = partitionTable.find(({ partition }) => partition.name === 'nvdata1')?.lun;
const ubuntu20 = bootABuffer.includes(UBUNTU_20_MARKER) || bootBBuffer.includes(UBUNTU_20_MARKER);
const ubuntu24 = bootABuffer.includes(UBUNTU_24_MARKER) || bootBBuffer.includes(UBUNTU_24_MARKER);
const hasVendorBoot = !!partitionTable.find(({ partition }) => partition.name.startsWith('vendor_boot'));
let osVersion = 'Unknown';
let board = 'formfactor_dvt';
if (nvdataLun === 0) {
osVersion = 'Ubuntu 20.04 EVT';
board = 'formfactor';
} else if (nvdataLun === 5) {
if (hasVendorBoot) {
osVersion = 'Android 14';
} else if (ubuntu20 && !ubuntu24) {
osVersion = 'Ubuntu 20.04';
} else if (ubuntu24 && !ubuntu20) {
osVersion = 'Ubuntu 24.04';
}
}
return {
deviceId,
region: regionString,
manufacturingData: manufacturingDataString,
osVersion,
board
};
}
async function promptWifiNetworks(ui = new UI()) {
const { ssids, networks } = await _scanNetworks(ui);
const otherNetworkLabel = '[Other Network]';
const rescanLabel = '[Rescan networks]';
let ssid;
if (networks) { // error when trying to get networks
const choices = [
...ssids,
otherNetworkLabel,
rescanLabel,
new inquirer.Separator(),
];
const question = [
{
type: 'list',
name: 'ssid',
message: chalk.bold.white('Select the Wi-Fi network with which you wish to connect your device:'),
choices
}];
const { ssid: selected } = await ui.prompt(question);
if (selected === rescanLabel) {
return promptWifiNetworks(ui);
}
ssid = selected === otherNetworkLabel
? (await _requestWifiSSID(ui)).ssid
: selected;
} else {
ssid = (await _requestWifiSSID(ui)).ssid;
}
const password = await _requestWifiPassword({ ui, ssid, networks });
return { ssid, password };
}
async function _scanNetworks(ui) {
let networks;
try {
networks = await ui.showBusySpinnerUntilResolved(
'Scanning for nearby Wi-Fi networks...',
_wifiScan()
);
} catch (_err) {
// something happened so need to call manual instead of rescanning
const message = ui.chalk.yellow('Unable to scan Wi-Fi networks.');
let description;
if (os.platform() === 'win32') {
description = ui.chalk.yellow('Make sure Location Services are enabled in ' +
'the Location page of the Privacy & security settings.');
} else {
description = ui.chalk.yellow('Ensure your system has the necessary permissions and tools to perform Wi-Fi scans.');
}
ui.write(`${message} ${description}`);
}
const ssids = networks
? [...new Set(networks.map(n => n.ssid).filter(Boolean))]
: undefined;
return { networks, ssids };
}
async function _wifiScan() {
let networks = [];
networks = await new Promise((resolve, reject) => {
wifiScan((err, networkList) => {
if (err) {
return reject(new VError('Unable to scan for Wi-Fi networks. Do you have permission to do that on this system?'));
}
resolve(networkList);
});
});
if (networks?.length === 1 && !networks[0].ssid && os.platform() === 'darwin') {
networks = await wifiMacScanner.scan();
}
return networks;
}
async function _requestWifiSSID(ui) {
const questions = [
{
type: 'input',
name: 'ssid',
message: 'Enter your WiFi SSID:'
}
];
const { ssid } = await ui.prompt(questions);
return { ssid };
}
async function _requestWifiPassword({ ui, ssid, networks }) {
const network = networks?.find((n) => n.ssid === ssid);
const isOpen = network?.security === 'none' || network?.security === '';
const isManualEntry = !network;
const annotation = isManualEntry ? ' (leave it blank for open networks)' : '';
if (isOpen) {
return '';
}
return ui.promptPasswordWithConfirmation({
customMessage: `Enter your WiFi password:${annotation}`,
customConfirmationMessage: `Re-enter your WiFi password:${annotation}`
});
}
async function getEDLModeDevices(ui, showSetupMessage) {
let edlDevices = [];
let devices;
let messageShown = false;
while (edlDevices.length === 0) {
try {
edlDevices = await getEdlDevices();
if (edlDevices.length > 0) {
devices = edlDevices;
break;
}
if (!messageShown) {
const defaultMessage = `Ensure your device is connected, then put it in system update mode...${os.EOL}`;
const setupMessage = `${ui.chalk.bold('Before we get started, we need to power on your Tachyon board')}:` +
`${os.EOL}${os.EOL}` +
`1. Plug the USB-C cable into your computer and the Tachyon board.${os.EOL}` +
` The red light should turn on!${os.EOL}${os.EOL}` +
`2. Put the Tachyon device into ${ui.chalk.bold('system update')} mode:${os.EOL}` +
` - Hold the button next to the red LED for 3 seconds.${os.EOL}` +
` - When the light starts flashing yellow, release the button.${os.EOL}`;
ui.stdout.write(showSetupMessage ? setupMessage : defaultMessage);
ui.stdout.write(os.EOL);
messageShown = true;
}
} catch (_err) {
// ignore error
}
await delay(DEVICE_READY_WAIT_TIME);
}
if (messageShown && showSetupMessage) {
ui.stdout.write(`Your device is now in ${ui.chalk.bold('system update')} mode!${os.EOL}`);
await delay(1000); // give the user a moment to read the message
}
return devices;
}
async function edlModePicker({ ui, devices }) {
const choices = devices.map(device => device.id);
const question = [
{
type: 'list',
name: 'deviceId',
message: chalk.bold.white('Select a device'),
choices
}];
const { deviceId: selected } = await ui.prompt(question);
return devices.find((device) => device.id === selected);
}
async function handleFlashError({ error, ui }) {
if (error instanceof TachyonConnectionError) {
ui.write(
ui.chalk.yellow(`Device not responding ${os.EOL}`) +
`Please turn it off completely: ${os.EOL}` +
` 1. Remove the battery and USB-C cable ${os.EOL}` +
` 2. Wait 30 seconds ${os.EOL}` +
` 3. Reconnect both ${os.EOL}${os.EOL}` +
'Press Enter once you\'re ready to retry.'
);
const question = {
type: 'confirm',
name: 'retry',
message: 'Retry?',
default: true
};
return ui.prompt([question]);
}
return false;
}
/**
*
* @param ui
* @return {Promise<Workflow>}
*/
async function promptOSSelection({ ui, workflows }) {
const choices = Object.values(workflows);
const question = [{
type: 'list',
name: 'osType',
message: ui.chalk.bold.white('Select the OS Type to setup in your device'),
choices
}];
const { osType } = await ui.prompt(question);
return workflows[osType];
}
async function isFile(version) {
const validChannels = ['latest', 'stable', 'beta', 'rc'];
const isValidChannel = validChannels.includes(version);
const isValidSemver = semver.valid(version);
const isFile = !isValidChannel && !isValidSemver;
// access(OK
if (isFile) {
try {
await fs.access(version, fs.constants.F_OK | fs.constants.R_OK);
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`The file "${version}" does not exist.`);
} else if (error.code === 'EACCES') {
throw new Error(`The file "${version}" is not accessible (permission denied).`);
}
throw error;
}
}
return isFile;
}
async function readManifestFromLocalFile(path, targetFile = 'manifest.json') {
const directory = await unzip.Open.file(path);
const entry = directory.files.find(f => f.path.endsWith(targetFile));
if (!entry) {
throw new Error(`File "${targetFile}" not found in ${path}`);
}
// Stream and parse
const content = await entry.buffer();
try {
return JSON.parse(content.toString('utf8'));
} catch (err) {
throw new Error(`Invalid JSON in ${targetFile}: ${err.message}`);
}
}
module.exports = {
addLogHeaders,
addManifestInfoLog,
addLogFooter,
getEDLDevice,
prepareFlashFiles,
getTachyonInfo,
promptWifiNetworks,
handleFlashError,
promptOSSelection,
isFile,
readManifestFromLocalFile
};