Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
c44ec7454This file is pinned to the selected release. Download the original to open it in your design tool.
import { spawnSync } from 'node:child_process'
import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import {
buildOutputDirectory,
firmwareDirectory,
hostApplicationName,
moddableOutputArguments,
} from './build-output.mjs'
import { prepareCoreS3IdfDependencies } from './idf-dependencies.mjs'
import { coreS3SdkconfigSourceDirectory, prepareVersionSdkconfig } from './moddable-version.mjs'
const appDirectory = path.join(firmwareDirectory, 'host', 'app')
const standardManifestPath = path.join(appDirectory, 'manifest.json')
const m5stackchanManifestPath = path.join(appDirectory, 'manifest_m5stackchan_cores3.json')
export const firmwareBundleName = 'tech.moddable.stackchan'
export const firmwareBundleSignature = firmwareBundleName.split('.').reverse().join('.')
export const firmwareBundleBinaries = ['bootloader.bin', 'partition-table.bin', 'xs_esp32.bin']
export const firmwareBundleTargetMetadataName = 'bundle-target.json'
export const firmwareBundleStagingDirectory = path.join(buildOutputDirectory, 'bundle-targets')
export const firmwareBundleTargets = [
{
name: 'm5stack',
bundleId: 'com.m5stack',
platform: 'esp32/m5stack',
outputPlatform: 'm5stack',
manifestPath: standardManifestPath,
sdkconfigSource: ['build', 'devices', 'esp32', 'xsProj-esp32'],
partitionSource: ['build', 'devices', 'esp32', 'xsProj-esp32', 'partitions.csv'],
},
{
name: 'm5stack_core2',
bundleId: 'com.m5stack.core2',
platform: 'esp32/m5stack_core2',
outputPlatform: 'm5stack_core2',
manifestPath: standardManifestPath,
sdkconfigSource: ['build', 'devices', 'esp32', 'targets', 'm5stack_core2', 'sdkconfig'],
partitionSource: ['build', 'devices', 'esp32', 'targets', 'm5stack_core2', 'sdkconfig', 'partitions.csv'],
},
{
name: 'm5stack_cores3',
bundleId: 'com.m5stack.cores3',
platform: 'esp32/m5stack_cores3',
outputPlatform: 'm5stack_cores3',
manifestPath: standardManifestPath,
sdkconfigSource: ['build', 'devices', 'esp32', 'targets', 'm5stack_cores3', 'sdkconfig'],
partitionSource: ['build', 'devices', 'esp32', 'targets', 'm5stack_cores3', 'sdkconfig', 'partitions.csv'],
idfDependencyPlatform: 'm5stack_cores3',
},
{
name: 'm5stackchan_cores3',
bundleId: 'm5stackchan_cores3',
platform: 'esp32:./host/platforms/m5stackchan_cores3',
outputPlatform: 'm5stackchan_cores3',
manifestPath: m5stackchanManifestPath,
sdkconfigSource: coreS3SdkconfigSourceDirectory,
partitionSource: ['build', 'devices', 'esp32', 'targets', 'm5stack_cores3', 'sdkconfig', 'partitions.csv'],
idfDependencyPlatform: 'm5stackchan_cores3',
},
]
/**
* Resolves one supported bundle target.
* @param {string} name - Matrix target name or final bundle directory name.
* @returns {(typeof firmwareBundleTargets)[number]} Target configuration.
*/
export function resolveFirmwareBundleTarget(name) {
const target = firmwareBundleTargets.find((candidate) => candidate.name === name || candidate.bundleId === name)
if (!target) {
throw new Error(
`Unknown firmware bundle target: ${name || 'missing'} (expected ${firmwareBundleTargets
.map((candidate) => candidate.name)
.join(', ')})`,
)
}
return target
}
/**
* Removes all staged per-target bundle artifacts.
* @param {string} stagingDirectory - Staging root to reset.
* @returns {string} Recreated staging directory.
*/
export function resetFirmwareBundleStagingDirectory(stagingDirectory = firmwareBundleStagingDirectory) {
rmSync(stagingDirectory, { recursive: true, force: true })
mkdirSync(stagingDirectory, { recursive: true })
return stagingDirectory
}
/**
* Builds and validates one release firmware target for later bundle assembly.
* @param {string} name - Supported matrix target name.
* @param {{moddableDirectory?: string, outputDirectory?: string, runCommand?: typeof executeCommand}} options - Build inputs.
* @returns {{target: (typeof firmwareBundleTargets)[number], directory: string, firmwareVersion: string}} Staged target details.
*/
export function buildFirmwareBundleTarget(
name,
{
moddableDirectory = process.env.MODDABLE,
outputDirectory = buildOutputDirectory,
runCommand = executeCommand,
} = {},
) {
if (!moddableDirectory) throw new Error('MODDABLE environment variable is required')
const target = resolveFirmwareBundleTarget(name)
const sourceDirectory = Array.isArray(target.sdkconfigSource)
? path.join(moddableDirectory, ...target.sdkconfigSource)
: target.sdkconfigSource
const partitionSourcePath = Array.isArray(target.partitionSource)
? path.join(moddableDirectory, ...target.partitionSource)
: target.partitionSource
const versionSdkconfig = prepareVersionSdkconfig({
platformName: target.outputPlatform,
moddableDirectory,
outputDirectory,
sourceDirectory,
partitionSourcePath,
})
if (target.idfDependencyPlatform) {
prepareCoreS3IdfDependencies({
outputDirectory,
platformName: target.idfDependencyPlatform,
applicationName: hostApplicationName,
mode: 'release',
})
}
mkdirSync(outputDirectory, { recursive: true })
const bundleManifestPath = prepareBundleManifest(target, versionSdkconfig.directory, outputDirectory)
try {
runCommand(
'mcconfig',
[
'-m',
'-p',
target.platform,
'-s',
firmwareBundleSignature,
'-t',
'build',
...moddableOutputArgumentsFor(outputDirectory),
bundleManifestPath,
],
firmwareDirectory,
{ ...process.env, MODDABLE: moddableDirectory },
)
} finally {
rmSync(bundleManifestPath, { force: true })
}
const buildDirectory = path.join(
outputDirectory,
'bin',
'esp32',
target.outputPlatform,
'release',
hostApplicationName,
)
const stagingDirectory = path.join(outputDirectory, 'bundle-targets', target.bundleId)
rmSync(stagingDirectory, { recursive: true, force: true })
mkdirSync(stagingDirectory, { recursive: true })
for (const binary of firmwareBundleBinaries) {
const source = path.join(buildDirectory, binary)
assertNonEmpty(source)
cpSync(source, path.join(stagingDirectory, binary))
}
validateFirmwareBundleTarget(stagingDirectory, versionSdkconfig.version)
writeFileSync(
path.join(stagingDirectory, firmwareBundleTargetMetadataName),
`${JSON.stringify(
{
format: 'tech.moddable.stackchan.bundle-target',
formatVersion: 1,
target: target.bundleId,
firmwareVersion: versionSdkconfig.version,
},
null,
2,
)}\n`,
)
console.log(`[stack-chan] bundle target staged: ${target.bundleId} (${versionSdkconfig.version})`)
return { target, directory: stagingDirectory, firmwareVersion: versionSdkconfig.version }
}
/**
* Validates staged targets and assembles the web-flash directory without creating an archive.
* @param {{inputDirectory?: string, outputDirectory?: string}} options - Assembly inputs.
* @returns {{bundleDirectory: string, bundleZipPath: string, firmwareVersion: string}} Assembled bundle details.
*/
export function assembleFirmwareBundle({
inputDirectory = firmwareBundleStagingDirectory,
outputDirectory = appDirectory,
} = {}) {
assertExactTargetSet(inputDirectory)
const validatedTargets = firmwareBundleTargets.map((target) => {
const directory = path.join(inputDirectory, target.bundleId)
const metadata = readTargetMetadata(directory)
if (metadata.target !== target.bundleId) {
throw new Error(`Bundle target metadata mismatch: ${metadata.target} != ${target.bundleId}`)
}
validateFirmwareBundleTarget(directory, metadata.firmwareVersion)
return { target, directory, firmwareVersion: metadata.firmwareVersion }
})
const firmwareVersions = new Set(validatedTargets.map(({ firmwareVersion }) => firmwareVersion))
if (firmwareVersions.size !== 1) {
throw new Error(`Bundle targets use different firmware versions: ${[...firmwareVersions].join(', ')}`)
}
const bundleDirectory = path.join(outputDirectory, firmwareBundleName)
const bundleZipPath = path.join(outputDirectory, `${firmwareBundleName}.zip`)
rmSync(bundleDirectory, { recursive: true, force: true })
mkdirSync(bundleDirectory, { recursive: true })
for (const { target, directory } of validatedTargets) {
const targetDirectory = path.join(bundleDirectory, target.bundleId)
mkdirSync(targetDirectory, { recursive: true })
for (const binary of firmwareBundleBinaries) {
cpSync(path.join(directory, binary), path.join(targetDirectory, binary))
}
}
const firmwareVersion = validatedTargets[0].firmwareVersion
console.log(`[stack-chan] firmware bundle assembled: ${firmwareBundleName} (${firmwareVersion})`)
return { bundleDirectory, bundleZipPath, firmwareVersion }
}
/**
* Assembles all staged targets and creates the distributable zip archive.
* @param {{inputDirectory?: string, outputDirectory?: string, runCommand?: typeof executeCommand}} options - Packaging inputs.
* @returns {{bundleDirectory: string, bundleZipPath: string, firmwareVersion: string}} Packaged bundle details.
*/
export function packageFirmwareBundle({
inputDirectory = firmwareBundleStagingDirectory,
outputDirectory = appDirectory,
runCommand = executeCommand,
} = {}) {
const result = assembleFirmwareBundle({ inputDirectory, outputDirectory })
rmSync(result.bundleZipPath, { force: true })
runCommand(
'zip',
['-r', path.basename(result.bundleZipPath), path.basename(result.bundleDirectory)],
outputDirectory,
process.env,
)
assertNonEmpty(result.bundleZipPath)
console.log(`[stack-chan] firmware bundle archive created: ${result.bundleZipPath}`)
return result
}
/**
* Validates binary presence, partition capacity, and embedded firmware version.
* @param {string} directory - Directory containing one target's binaries.
* @param {string} expectedVersion - Expected esp_app_desc version.
* @returns {{firmwareSize: number, factorySize: number, firmwareVersion: string}} Validation details.
*/
export function validateFirmwareBundleTarget(directory, expectedVersion) {
for (const binary of firmwareBundleBinaries) assertNonEmpty(path.join(directory, binary))
const { firmwareSize, factorySize } = assertFitsFactoryPartition(directory)
const firmwareVersion = readFirmwareVersion(path.join(directory, 'xs_esp32.bin'))
if (firmwareVersion !== expectedVersion) {
throw new Error(
`Bundle firmware version mismatch: ${path.join(directory, 'xs_esp32.bin')} ` +
`(${firmwareVersion || 'missing'} != ${expectedVersion})`,
)
}
console.log(
`[stack-chan] bundle target validated: ${path.basename(directory)} ` +
`(${firmwareSize}/${factorySize} bytes, ${firmwareVersion})`,
)
return { firmwareSize, factorySize, firmwareVersion }
}
/**
* Reads the ESP application version from a firmware image.
* @param {string} firmwarePath - Path to xs_esp32.bin.
* @returns {string} Embedded esp_app_desc version.
*/
export function readFirmwareVersion(firmwarePath) {
const firmware = readFileSync(firmwarePath)
const descriptorMagic = 0xabcd5432
if (firmware.length < 0x70 || firmware.readUInt32LE(0x20) !== descriptorMagic) {
throw new Error(`Firmware app descriptor is missing: ${firmwarePath}`)
}
const nul = firmware.indexOf(0, 0x30)
const versionEnd = nul >= 0 && nul < 0x50 ? nul : 0x50
return firmware.toString('utf8', 0x30, versionEnd).trim()
}
/**
* Verifies that a target firmware image fits its factory app partition.
* @param {string} directory - Directory containing target binaries.
* @returns {{firmwareSize: number, factorySize: number}} Size validation details.
*/
function assertFitsFactoryPartition(directory) {
const partitionTablePath = path.join(directory, 'partition-table.bin')
const firmwarePath = path.join(directory, 'xs_esp32.bin')
const partitionTable = readFileSync(partitionTablePath)
let factorySize
for (let offset = 0; offset + 32 <= partitionTable.length; offset += 32) {
if (partitionTable.readUInt16LE(offset) !== 0x50aa) break
const type = partitionTable.readUInt8(offset + 2)
const subtype = partitionTable.readUInt8(offset + 3)
if (type === 0 && subtype === 0) {
factorySize = partitionTable.readUInt32LE(offset + 8)
break
}
}
if (factorySize === undefined) {
throw new Error(`Factory app partition is missing: ${partitionTablePath}`)
}
const firmwareSize = statSync(firmwarePath).size
if (firmwareSize > factorySize) {
throw new Error(`Firmware exceeds factory app partition: ${firmwarePath} (${firmwareSize} > ${factorySize})`)
}
return { firmwareSize, factorySize }
}
/**
* Reads and validates one staged target metadata file.
* @param {string} directory - Staged target directory.
* @returns {{format: string, formatVersion: number, target: string, firmwareVersion: string}} Metadata.
*/
function readTargetMetadata(directory) {
const metadataPath = path.join(directory, firmwareBundleTargetMetadataName)
let metadata
try {
metadata = JSON.parse(readFileSync(metadataPath, 'utf8'))
} catch (error) {
throw new Error(`Bundle target metadata could not be read: ${metadataPath} (${error.message})`)
}
if (metadata.format !== 'tech.moddable.stackchan.bundle-target' || metadata.formatVersion !== 1) {
throw new Error(`Unsupported bundle target metadata: ${metadataPath}`)
}
if (typeof metadata.target !== 'string' || typeof metadata.firmwareVersion !== 'string') {
throw new Error(`Invalid bundle target metadata: ${metadataPath}`)
}
return metadata
}
/**
* Rejects missing, extra, and non-directory staging entries.
* @param {string} inputDirectory - Staged target root.
*/
function assertExactTargetSet(inputDirectory) {
const expected = firmwareBundleTargets.map(({ bundleId }) => bundleId).sort()
let actual
try {
actual = readdirSync(inputDirectory, { withFileTypes: true })
} catch (error) {
throw new Error(`Bundle target directory could not be read: ${inputDirectory} (${error.message})`)
}
if (actual.some((entry) => !entry.isDirectory())) {
throw new Error(`Bundle target root contains unexpected files: ${inputDirectory}`)
}
const actualNames = actual.map(({ name }) => name).sort()
if (actualNames.length !== expected.length || actualNames.some((name, index) => name !== expected[index])) {
throw new Error(`Bundle target set mismatch: ${actualNames.join(', ') || 'empty'} != ${expected.join(', ')}`)
}
}
/**
* Ensures a file exists and is not empty.
* @param {string} filePath - File to inspect.
*/
function assertNonEmpty(filePath) {
let size
try {
size = statSync(filePath).size
} catch (error) {
throw new Error(`Bundle binary is missing: ${filePath} (${error.message})`)
}
if (size === 0) throw new Error(`Bundle binary is empty: ${filePath}`)
}
/**
* Uses the managed output directory unless a test supplies an isolated root.
* @param {string} outputDirectory - Requested repository-local output root.
* @returns {string[]} mcconfig output arguments.
*/
function moddableOutputArgumentsFor(outputDirectory) {
return outputDirectory === buildOutputDirectory ? moddableOutputArguments() : ['-o', outputDirectory]
}
/**
* Appends a generated sdkconfig override after the application manifest.
*
* Standard Moddable target manifests assign SDKCONFIGPATH unconditionally, so
* a process environment value is overwritten while manifests are merged. A
* final included manifest keeps the target configuration intact and supplies
* the version overlay with normal manifest precedence.
*
* mcconfig resolves relative paths inside included manifests from the top-level
* manifest directory. Keep the wrapper beside the application manifest so
* resources such as characterFile continue to resolve; the caller removes it
* in a finally block.
* @param {(typeof firmwareBundleTargets)[number]} target - Bundle target configuration.
* @param {string} sdkconfigDirectory - Generated sdkconfig overlay directory.
* @param {string} outputDirectory - Repository-local build output root.
* @returns {string} Generated wrapper manifest path.
*/
function prepareBundleManifest(target, sdkconfigDirectory, outputDirectory) {
const directory = path.join(outputDirectory, 'generated', 'bundle-manifests', target.name)
const overrideManifestPath = path.join(directory, 'sdkconfig.json')
const bundleManifestPath = path.join(
path.dirname(target.manifestPath),
`${firmwareBundleName}.${target.name}.${process.pid}.manifest.json`,
)
mkdirSync(directory, { recursive: true })
writeFileSync(overrideManifestPath, `${JSON.stringify({ build: { SDKCONFIGPATH: sdkconfigDirectory } }, null, 2)}\n`)
writeFileSync(
bundleManifestPath,
`${JSON.stringify({ include: [target.manifestPath, overrideManifestPath] }, null, 2)}\n`,
)
return bundleManifestPath
}
/**
* Runs a bundle subprocess and throws on failure.
* @param {string} command - Executable name.
* @param {string[]} args - Command arguments.
* @param {string} cwd - Working directory.
* @param {NodeJS.ProcessEnv} env - Subprocess environment.
*/
function executeCommand(command, args, cwd, env) {
const result = spawnSync(command, args, { cwd, env, stdio: 'inherit' })
if (result.error) throw new Error(`Failed to run ${command}: ${result.error.message}`)
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? 'unknown'}`)
}