@geastack/cli 0.1.52 → 0.1.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/SPEC.md +2 -2
- package/package.json +2 -3
- package/src/apps/app-index-writer.mjs +15 -0
- package/src/apps/apple-icons.mjs +147 -0
- package/src/apps/bundle-writer.mjs +156 -0
- package/src/apps/launcher-catalog.mjs +114 -0
- package/src/apps/openai-icons.mjs +356 -0
- package/src/apps/zip-writer.mjs +104 -0
- package/src/ble/ble-ota.swift +291 -0
- package/src/boards/config.mjs +32 -0
- package/src/boards/custom-target.mjs +309 -0
- package/src/boards/resolve.mjs +143 -0
- package/src/boards/targets.mjs +45 -0
- package/src/boards/usb.mjs +211 -0
- package/src/commands/apps.mjs +236 -0
- package/src/commands/board.mjs +402 -0
- package/src/commands/doctor.mjs +91 -0
- package/src/context.mjs +45 -52
- package/src/device/device.mjs +96 -0
- package/src/device/image.mjs +115 -0
- package/src/device/serial.mjs +430 -0
- package/src/device/wifi.mjs +190 -0
- package/src/esp32/build.mjs +321 -0
- package/src/esp32/capabilities.mjs +54 -0
- package/src/esp32/flash.mjs +181 -0
- package/src/esp32/idf-env.mjs +171 -0
- package/src/esp32/ota.mjs +80 -0
- package/src/esp32/partitions.mjs +59 -0
- package/src/esp32/sdkconfig.mjs +103 -0
- package/src/esp32/wifi-config.mjs +72 -0
- package/src/gea.mjs +86 -427
- package/src/geaos/adapter.mjs +119 -0
- package/src/heap-report.mjs +1276 -0
- package/src/manifest.mjs +135 -36
- package/src/rp2350/adapter.mjs +155 -0
- package/src/setup-wizard.mjs +6 -10
- package/src/taurus/adapter.mjs +52 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, statSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
import { waitForSerialPort } from '../boards/usb.mjs'
|
|
5
|
+
import { CliError, ExitCode, fail } from '../errors.mjs'
|
|
6
|
+
import { formatCommand } from '../run.mjs'
|
|
7
|
+
import { esptoolCommand } from './idf-env.mjs'
|
|
8
|
+
import { flashOffsetForBuildImage, loadPartitions, normalizeOtaSlot, partitionByName, sizeToBytes } from './partitions.mjs'
|
|
9
|
+
|
|
10
|
+
// USB flashing through esptool, with the board addressed by its USB serial:
|
|
11
|
+
// a board re-enumerates after every reset, so the port is resolved again on
|
|
12
|
+
// every attempt rather than cached.
|
|
13
|
+
|
|
14
|
+
export function flashOptions(env, { manualBoot = false, noReset = false, baud = '' } = {}) {
|
|
15
|
+
const flashBaud = String(baud || env.GEA_ESP32_FLASH_BAUD || '921600')
|
|
16
|
+
if (!/^[1-9]\d*$/.test(flashBaud)) fail(`--flash-baud must be a positive integer (got '${flashBaud}').`, ExitCode.usage)
|
|
17
|
+
return {
|
|
18
|
+
before: manualBoot ? 'no_reset' : 'default_reset',
|
|
19
|
+
after: noReset ? 'no_reset' : 'hard_reset',
|
|
20
|
+
baud: flashBaud,
|
|
21
|
+
retrySeconds: Number(env.GEA_ESP32_FLASH_RETRY_SECONDS ?? 300),
|
|
22
|
+
manualBootGraceSeconds: Number(env.GEA_ESP32_MANUAL_BOOT_GRACE_SECONDS ?? 4)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function esptoolPrefix(selection, options) {
|
|
27
|
+
return ['--chip', selection.esptoolChip || selection.idfTarget || 'esp32s3', '--before', options.before, '--after', options.after, '-b', options.baud]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeFlashArgs(selection, options, pairs) {
|
|
31
|
+
return [...esptoolPrefix(selection, options), 'write_flash', '--flash_mode', 'dio', '--flash_freq', '80m', '--flash_size', selection.flashSize, ...pairs]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runEsptoolOverUsb({ idf, selection, options, args, port = '', env, dryRun = false, stdout = console.log, stderr = console.error }) {
|
|
35
|
+
const startedAt = Date.now()
|
|
36
|
+
let attempt = 1
|
|
37
|
+
let status = 1
|
|
38
|
+
for (;;) {
|
|
39
|
+
let remaining = options.retrySeconds
|
|
40
|
+
if (options.retrySeconds > 0) {
|
|
41
|
+
remaining = options.retrySeconds - (Date.now() - startedAt) / 1000
|
|
42
|
+
if (remaining <= 0) {
|
|
43
|
+
throw new CliError('ERROR: Timed out waiting for USB flash connection.', ExitCode.deployFailed)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const flashPort = dryRun
|
|
47
|
+
? port || `<usb serial ${selection.usbSerial}>`
|
|
48
|
+
: await waitForSerialPort({ port, serial: selection.usbSerial, timeoutSeconds: remaining, label: 'ESP32 USB flash port', log: stderr })
|
|
49
|
+
if (options.before === 'no_reset') {
|
|
50
|
+
stdout('Manual boot mode: hold BOOT/IO0, reset or power-cycle the board, then keep BOOT held until esptool connects.')
|
|
51
|
+
stdout('Manual boot mode: esptool will not toggle reset before connecting.')
|
|
52
|
+
if (options.manualBootGraceSeconds > 0 && !dryRun) {
|
|
53
|
+
stdout(`Manual boot mode: waiting ${options.manualBootGraceSeconds}s before attempt ${attempt}...`)
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, options.manualBootGraceSeconds * 1000))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
stdout(`USB flash attempt ${attempt} on ${flashPort}...`)
|
|
58
|
+
const { command, args: fullArgs } = esptoolCommand(idf, ['-p', flashPort, ...args])
|
|
59
|
+
if (dryRun) {
|
|
60
|
+
stdout(formatCommand([command, ...fullArgs]))
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
const result = spawnSync(command, fullArgs, { cwd: selection.targetDir, env, stdio: 'inherit' })
|
|
64
|
+
if (result.error) throw result.error
|
|
65
|
+
status = result.status ?? 1
|
|
66
|
+
if (status === 0) return 0
|
|
67
|
+
if (status === 130 || status === 143) throw new CliError('ERROR: USB flash interrupted.', ExitCode.deployFailed)
|
|
68
|
+
if (options.retrySeconds > 0 && (Date.now() - startedAt) / 1000 >= options.retrySeconds) {
|
|
69
|
+
throw new CliError(`ERROR: USB flash failed after ${attempt} attempt(s).`, ExitCode.deployFailed)
|
|
70
|
+
}
|
|
71
|
+
stderr(`USB flash attempt ${attempt} failed with status ${status}; waiting for board and retrying...`)
|
|
72
|
+
attempt += 1
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function requireImage(file, what) {
|
|
78
|
+
if (!existsSync(file)) fail(`${what} not found: ${file}`, ExitCode.deployFailed)
|
|
79
|
+
return file
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function assertFits(image, slot, slotSize) {
|
|
83
|
+
const imageSize = statSync(image).size
|
|
84
|
+
if (imageSize > slotSize) {
|
|
85
|
+
fail(`App image is ${imageSize} bytes but ${slot} only has ${slotSize} bytes.\nRegenerate a partition plan with fewer apps or larger slots.`, ExitCode.deployFailed)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function slotGeometry(selection, slot) {
|
|
90
|
+
const name = normalizeOtaSlot(slot)
|
|
91
|
+
const partition = partitionByName(loadPartitions(selection.targetDir), name)
|
|
92
|
+
return { name, offset: partition.offset, size: sizeToBytes(partition.size) }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Bootloader + partition table + otadata + app in ota_0: a full provisioning
|
|
96
|
+
// of the board from one build directory.
|
|
97
|
+
export async function flashFirmware({ idf, selection, images, appImage = images.app, appLabel = 'prebuilt image', options, port, env, dryRun, stdout, stderr }) {
|
|
98
|
+
const image = requireImage(appImage, 'App image')
|
|
99
|
+
for (const required of [images.bootloader, images.partitionTable, images.otaData]) {
|
|
100
|
+
if (!existsSync(required)) fail(`${required} was not found. Build the launcher once first.`, ExitCode.deployFailed)
|
|
101
|
+
}
|
|
102
|
+
const partitions = loadPartitions(selection.targetDir)
|
|
103
|
+
const app = partitionByName(partitions, 'ota_0')
|
|
104
|
+
const otadata = partitionByName(partitions, 'otadata')
|
|
105
|
+
assertFits(image, 'ota_0', sizeToBytes(app.size))
|
|
106
|
+
const buildDir = images.buildDir
|
|
107
|
+
const pairs = [
|
|
108
|
+
flashOffsetForBuildImage(buildDir, images.bootloader, '0x0'), images.bootloader,
|
|
109
|
+
app.offset, image,
|
|
110
|
+
flashOffsetForBuildImage(buildDir, images.partitionTable, '0x8000'), images.partitionTable,
|
|
111
|
+
otadata.offset, images.otaData
|
|
112
|
+
]
|
|
113
|
+
stdout(`Flashing '${appLabel}' from ${image} to ota_0 (${app.offset}) over USB...`)
|
|
114
|
+
stdout('Writing bootloader, partition table, default OTA boot metadata, and app image.')
|
|
115
|
+
await runEsptoolOverUsb({ idf, selection, options, args: writeFlashArgs(selection, options, pairs), port, env, dryRun, stdout, stderr })
|
|
116
|
+
stdout(`Flashed '${appLabel}' in ota_0 and reset OTA boot metadata to ota_0.`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function flashImageSet({ idf, selection, images, slotImages, options, port, env, dryRun, stdout, stderr }) {
|
|
120
|
+
for (const required of [images.bootloader, images.partitionTable, images.otaData]) {
|
|
121
|
+
if (!existsSync(required)) fail(`${required} was not found. Build the launcher once first.`, ExitCode.deployFailed)
|
|
122
|
+
}
|
|
123
|
+
const partitions = loadPartitions(selection.targetDir)
|
|
124
|
+
const otadata = partitionByName(partitions, 'otadata')
|
|
125
|
+
const buildDir = images.buildDir
|
|
126
|
+
const pairs = [
|
|
127
|
+
flashOffsetForBuildImage(buildDir, images.bootloader, '0x0'), images.bootloader,
|
|
128
|
+
flashOffsetForBuildImage(buildDir, images.partitionTable, '0x8000'), images.partitionTable,
|
|
129
|
+
otadata.offset, images.otaData
|
|
130
|
+
]
|
|
131
|
+
for (const entry of slotImages) {
|
|
132
|
+
const eq = entry.indexOf('=')
|
|
133
|
+
if (eq <= 0 || eq === entry.length - 1) fail(`Invalid --slot-image value '${entry}'. Use --slot-image=ota_<n>=<bin>.`, ExitCode.usage)
|
|
134
|
+
const slot = slotGeometry(selection, entry.slice(0, eq))
|
|
135
|
+
const image = requireImage(entry.slice(eq + 1), `App image for ${slot.name}`)
|
|
136
|
+
assertFits(image, slot.name, slot.size)
|
|
137
|
+
pairs.push(slot.offset, image)
|
|
138
|
+
}
|
|
139
|
+
stdout(`Flashing ${slotImages.length} prebuilt app image(s) over USB...`)
|
|
140
|
+
stdout('Writing bootloader, partition table, default OTA boot metadata, and app images.')
|
|
141
|
+
await runEsptoolOverUsb({ idf, selection, options, args: writeFlashArgs(selection, options, pairs), port, env, dryRun, stdout, stderr })
|
|
142
|
+
stdout(options.after === 'no_reset' ? 'Flashed app images. Device was not reset after flashing.' : 'Flashed app images. Device was reset after flashing.')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// App image only, into a chosen OTA slot; boot selection is untouched.
|
|
146
|
+
export async function stageImage({ idf, selection, image, slot, appLabel = 'prebuilt image', options, port, env, dryRun, stdout, stderr }) {
|
|
147
|
+
const geometry = slotGeometry(selection, slot)
|
|
148
|
+
requireImage(image, 'App image')
|
|
149
|
+
assertFits(image, geometry.name, geometry.size)
|
|
150
|
+
stdout(`Staging '${appLabel}' from ${image} to ${geometry.name} (${geometry.offset}) over USB...`)
|
|
151
|
+
stdout('Writing app image only; bootloader, partition table, and otadata are unchanged.')
|
|
152
|
+
await runEsptoolOverUsb({ idf, selection, options, args: writeFlashArgs(selection, options, [geometry.offset, image]), port, env, dryRun, stdout, stderr })
|
|
153
|
+
stdout(`Staged '${appLabel}' in ${geometry.name}. Boot selection was not changed.`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function restoreBootMetadata({ idf, selection, images, options, port, env, dryRun, stdout, stderr }) {
|
|
157
|
+
if (!existsSync(images.otaData)) fail(`${images.otaData} was not found. Flash the launcher once first.`, ExitCode.deployFailed)
|
|
158
|
+
const otadata = partitionByName(loadPartitions(selection.targetDir), 'otadata')
|
|
159
|
+
stdout(`Restoring OTA boot metadata at ${otadata.offset}...`)
|
|
160
|
+
await runEsptoolOverUsb({ idf, selection, options, args: writeFlashArgs(selection, options, [otadata.offset, images.otaData]), port, env, dryRun, stdout, stderr })
|
|
161
|
+
stdout('Launcher OTA boot metadata restored.')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function eraseSlot({ idf, selection, slot, options, port, env, dryRun, stdout, stderr }) {
|
|
165
|
+
const geometry = slotGeometry(selection, slot)
|
|
166
|
+
stdout(`Erasing ${geometry.name} (${geometry.offset}, ${geometry.size} bytes) over USB...`)
|
|
167
|
+
await runEsptoolOverUsb({ idf, selection, options, args: [...esptoolPrefix(selection, options), 'erase_region', geometry.offset, String(geometry.size)], port, env, dryRun, stdout, stderr })
|
|
168
|
+
stdout(`Erased ${geometry.name}.`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function postFlashRestartNote(selection, stderr) {
|
|
172
|
+
if (selection.usbRestartAfterFlash !== 'manual') return
|
|
173
|
+
stderr(`
|
|
174
|
+
┌──────────────────────────────────────────────────────────────────────────┐
|
|
175
|
+
│ Flash complete. This board does NOT auto-restart after a USB flash. │
|
|
176
|
+
│ Tap the RESET button (or unplug/replug power) to launch the app. │
|
|
177
|
+
│ │
|
|
178
|
+
│ Why: its USB-Serial-JTAG re-enters ROM download mode when the flash port │
|
|
179
|
+
│ is closed. It boots normally from a reset/power-cycle (no host involved). │
|
|
180
|
+
└──────────────────────────────────────────────────────────────────────────┘`)
|
|
181
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
// ESP-IDF activation without `source export.sh`. export.sh re-checks Python,
|
|
7
|
+
// dependencies, outdated tools and shell completion on every invocation; the
|
|
8
|
+
// installation itself already knows the exact tool paths (idf_tools.py
|
|
9
|
+
// export) and which Python env it installed, so the CLI reads those and runs
|
|
10
|
+
// `<venv>/bin/python $IDF_PATH/tools/idf.py` directly.
|
|
11
|
+
|
|
12
|
+
const idfVersionFile = 'tools/cmake/version.cmake'
|
|
13
|
+
|
|
14
|
+
export function espIdfVersion(idfDir) {
|
|
15
|
+
const file = path.join(idfDir, idfVersionFile)
|
|
16
|
+
if (existsSync(file)) {
|
|
17
|
+
const text = readFileSync(file, 'utf8')
|
|
18
|
+
const major = text.match(/set\(IDF_VERSION_MAJOR\s+(\d+)\)/)?.[1]
|
|
19
|
+
const minor = text.match(/set\(IDF_VERSION_MINOR\s+(\d+)\)/)?.[1]
|
|
20
|
+
const patch = text.match(/set\(IDF_VERSION_PATCH\s+(\d+)\)/)?.[1]
|
|
21
|
+
if (major && minor) return { majorMinor: `${major}.${minor}`, full: `${major}.${minor}.${patch || 0}` }
|
|
22
|
+
}
|
|
23
|
+
const named = path.basename(idfDir).match(/^esp-idf-v(\d+\.\d+)(?:\.(\d+))?/)
|
|
24
|
+
if (named) return { majorMinor: named[1], full: `${named[1]}.${named[2] || 0}` }
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isIdfDir(dir) {
|
|
29
|
+
return Boolean(dir) && existsSync(path.join(dir, 'tools', 'idf.py'))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function versionKey(dir) {
|
|
33
|
+
const version = espIdfVersion(dir)?.full || '0.0.0'
|
|
34
|
+
return version.split('.').map((part) => Number(part) || 0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function compareVersions(a, b) {
|
|
38
|
+
for (let i = 0; i < 3; i += 1) {
|
|
39
|
+
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) - (b[i] || 0)
|
|
40
|
+
}
|
|
41
|
+
return 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Where an ESP-IDF checkout may live. Explicit settings win; otherwise the
|
|
45
|
+
// newest install under the conventional directories is used, because the
|
|
46
|
+
// firmware tracks the current IDF major (older ones do not compile it).
|
|
47
|
+
export function findEspIdf(env = process.env, home = os.homedir()) {
|
|
48
|
+
const explicit = [env.IDF_PATH, envExportDir(env.GEA_EMBEDDED_IDF_EXPORT), envExportDir(env.ESP_IDF_EXPORT)]
|
|
49
|
+
for (const candidate of explicit) {
|
|
50
|
+
if (isIdfDir(candidate)) return path.resolve(candidate)
|
|
51
|
+
}
|
|
52
|
+
const roots = [path.join(home, 'esp'), path.join(home, 'esp32'), home]
|
|
53
|
+
const found = []
|
|
54
|
+
for (const root of roots) {
|
|
55
|
+
for (const name of ['esp-idf']) {
|
|
56
|
+
const dir = path.join(root, name)
|
|
57
|
+
if (isIdfDir(dir)) found.push(dir)
|
|
58
|
+
}
|
|
59
|
+
let entries = []
|
|
60
|
+
try {
|
|
61
|
+
entries = readdirSync(root)
|
|
62
|
+
} catch {
|
|
63
|
+
entries = []
|
|
64
|
+
}
|
|
65
|
+
for (const name of entries) {
|
|
66
|
+
if (!/^esp-idf-v\d/.test(name)) continue
|
|
67
|
+
const dir = path.join(root, name)
|
|
68
|
+
if (isIdfDir(dir)) found.push(dir)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (found.length === 0) return ''
|
|
72
|
+
found.sort((a, b) => compareVersions(versionKey(b), versionKey(a)))
|
|
73
|
+
return found[0]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function envExportDir(exportScript) {
|
|
77
|
+
return exportScript ? path.dirname(exportScript) : ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function findIdfPythonEnv(idfDir, env = process.env, home = os.homedir()) {
|
|
81
|
+
const explicit = env.IDF_PYTHON_ENV_PATH
|
|
82
|
+
if (explicit && existsSync(path.join(explicit, 'bin', 'python'))) return explicit
|
|
83
|
+
const version = espIdfVersion(idfDir)?.majorMinor
|
|
84
|
+
if (!version) return ''
|
|
85
|
+
const envRoot = path.join(env.IDF_TOOLS_PATH || path.join(home, '.espressif'), 'python_env')
|
|
86
|
+
let entries = []
|
|
87
|
+
try {
|
|
88
|
+
entries = readdirSync(envRoot)
|
|
89
|
+
} catch {
|
|
90
|
+
return ''
|
|
91
|
+
}
|
|
92
|
+
const prefix = `idf${version}_py`
|
|
93
|
+
const candidates = entries
|
|
94
|
+
.filter((name) => name.startsWith(prefix) && name.endsWith('_env'))
|
|
95
|
+
.sort()
|
|
96
|
+
.map((name) => path.join(envRoot, name))
|
|
97
|
+
.filter((dir) => existsSync(path.join(dir, 'bin', 'python')))
|
|
98
|
+
return candidates[0] || ''
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseKeyValueExport(output) {
|
|
102
|
+
const values = {}
|
|
103
|
+
for (const line of output.split(/\r?\n/)) {
|
|
104
|
+
const eq = line.indexOf('=')
|
|
105
|
+
if (eq <= 0) continue
|
|
106
|
+
values[line.slice(0, eq)] = line.slice(eq + 1)
|
|
107
|
+
}
|
|
108
|
+
return values
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const activationCache = new Map()
|
|
112
|
+
|
|
113
|
+
// Returns the environment and command prefixes for one ESP-IDF install, or
|
|
114
|
+
// null when none is installed. `python` is the IDF venv interpreter, which
|
|
115
|
+
// is also where esptool lives.
|
|
116
|
+
// The tool export is cached per installation; the caller's environment is
|
|
117
|
+
// layered on fresh every time so per-invocation settings (jobs, variants,
|
|
118
|
+
// flash baud) are never frozen into a cached activation.
|
|
119
|
+
export function activateEspIdf({ env = process.env, home = os.homedir(), log = () => {} } = {}) {
|
|
120
|
+
const idfDir = findEspIdf(env, home)
|
|
121
|
+
if (!idfDir) return null
|
|
122
|
+
const pythonEnv = findIdfPythonEnv(idfDir, env, home)
|
|
123
|
+
if (!pythonEnv) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`ESP-IDF at ${idfDir} has no installed Python environment under ${env.IDF_TOOLS_PATH || path.join(home, '.espressif')}/python_env. Run its install.sh once.`
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
const python = path.join(pythonEnv, 'bin', 'python')
|
|
129
|
+
const idfToolsPy = path.join(idfDir, 'tools', 'idf_tools.py')
|
|
130
|
+
const cacheKey = `${idfDir}\n${pythonEnv}`
|
|
131
|
+
let exported = activationCache.get(cacheKey)
|
|
132
|
+
if (!exported) {
|
|
133
|
+
try {
|
|
134
|
+
exported = parseKeyValueExport(
|
|
135
|
+
execFileSync(python, [idfToolsPy, 'export', '--format', 'key-value'], {
|
|
136
|
+
encoding: 'utf8',
|
|
137
|
+
env: { ...env, IDF_PATH: idfDir },
|
|
138
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
139
|
+
})
|
|
140
|
+
)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new Error(`ESP-IDF tool export failed for ${idfDir}: ${error.message}`)
|
|
143
|
+
}
|
|
144
|
+
activationCache.set(cacheKey, exported)
|
|
145
|
+
log(`Using ESP-IDF ${espIdfVersion(idfDir)?.full || ''} at ${idfDir} (python env ${pythonEnv})`)
|
|
146
|
+
}
|
|
147
|
+
const exportedPath = (exported.PATH || '').replace(/\$PATH|%PATH%/g, env.PATH || '')
|
|
148
|
+
return {
|
|
149
|
+
idfDir,
|
|
150
|
+
version: espIdfVersion(idfDir),
|
|
151
|
+
pythonEnv,
|
|
152
|
+
python,
|
|
153
|
+
idfPy: path.join(idfDir, 'tools', 'idf.py'),
|
|
154
|
+
env: {
|
|
155
|
+
...env,
|
|
156
|
+
...Object.fromEntries(Object.entries(exported).filter(([key]) => key !== 'PATH' && key !== 'IDF_DEACTIVATE_FILE_PATH')),
|
|
157
|
+
IDF_PATH: idfDir,
|
|
158
|
+
IDF_PYTHON_ENV_PATH: pythonEnv,
|
|
159
|
+
VIRTUAL_ENV: pythonEnv,
|
|
160
|
+
PATH: [path.join(pythonEnv, 'bin'), exportedPath || env.PATH || ''].filter(Boolean).join(path.delimiter)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function esptoolCommand(idf, args) {
|
|
166
|
+
return { command: idf.python, args: ['-m', 'esptool', ...args] }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function idfPyCommand(idf, args) {
|
|
170
|
+
return { command: idf.python, args: [idf.idfPy, ...args] }
|
|
171
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, statSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { otaErase, otaUpload, waitForOtaServer } from '../device/wifi.mjs'
|
|
6
|
+
import { CliError, ExitCode, fail } from '../errors.mjs'
|
|
7
|
+
import { formatCommand } from '../run.mjs'
|
|
8
|
+
import { slotGeometry } from './flash.mjs'
|
|
9
|
+
|
|
10
|
+
// Over-the-air delivery: WiFi through the board's OTA server, or BLE through
|
|
11
|
+
// the CoreBluetooth helper. Neither touches the bootloader or partition table.
|
|
12
|
+
|
|
13
|
+
function requireImage(image) {
|
|
14
|
+
if (!existsSync(image)) fail(`App image not found: ${image}`, ExitCode.deployFailed)
|
|
15
|
+
return image
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function otaFlash({ host, image, dryRun = false, stdout = console.log }) {
|
|
19
|
+
requireImage(image)
|
|
20
|
+
stdout(`Sending OTA update to ${host}...`)
|
|
21
|
+
if (dryRun) {
|
|
22
|
+
stdout(`POST http://${host}:8080/ota <- ${image}`)
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
await otaUpload({ host, image, stdout })
|
|
26
|
+
stdout('OTA complete. Board is rebooting.')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function otaStage({ selection, host, image, slot, boot = false, reboot = false, appLabel = 'prebuilt image', dryRun = false, stdout = console.log }) {
|
|
30
|
+
const geometry = slotGeometry(selection, slot)
|
|
31
|
+
requireImage(image)
|
|
32
|
+
const size = statSync(image).size
|
|
33
|
+
if (size > geometry.size) {
|
|
34
|
+
fail(`App image is ${size} bytes but ${geometry.name} only has ${geometry.size} bytes.\nRegenerate a partition plan with fewer apps or larger slots.`, ExitCode.deployFailed)
|
|
35
|
+
}
|
|
36
|
+
stdout(`Staging '${appLabel}' from ${image} to ${geometry.name} over WiFi OTA...`)
|
|
37
|
+
if (dryRun) {
|
|
38
|
+
stdout(`POST http://${host}:8080/ota?slot=${geometry.name}&boot=${boot ? 1 : 0}&reboot=${reboot ? 1 : 0} <- ${image}`)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
await otaUpload({ host, image, slot: geometry.name, boot, reboot, stdout })
|
|
42
|
+
stdout(`Staged '${appLabel}' in ${geometry.name} over OTA. Boot selection was not changed.`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function otaEraseSlot({ selection, host, slot, dryRun = false, stdout = console.log }) {
|
|
46
|
+
const geometry = slotGeometry(selection, slot)
|
|
47
|
+
stdout(`Erasing ${geometry.name} over WiFi OTA...`)
|
|
48
|
+
if (dryRun) {
|
|
49
|
+
stdout(`POST http://${host}:8080/ota/erase?slot=${geometry.name}`)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
const reply = await otaErase({ host, slot: geometry.name })
|
|
53
|
+
if (reply) stdout(reply)
|
|
54
|
+
stdout(`Erased ${geometry.name} over OTA.`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function waitForReboot({ host, stdout = console.log, timeoutMs = 120000 }) {
|
|
58
|
+
stdout('OTA sent. Waiting for reboot + WiFi reconnect...')
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 8000))
|
|
60
|
+
await waitForOtaServer({ host, timeoutMs })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function bleOtaHelperPath(cliPackageRoot) {
|
|
64
|
+
return path.join(cliPackageRoot, 'src', 'ble', 'ble-ota.swift')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function bleOta({ cliPackageRoot, image, deviceName, env, dryRun = false, stdout = console.log }) {
|
|
68
|
+
requireImage(image)
|
|
69
|
+
const helper = bleOtaHelperPath(cliPackageRoot)
|
|
70
|
+
const args = [helper, image, deviceName || env.GEA_BLE_OTA_DEVICE || 'Geastack OTA']
|
|
71
|
+
if (dryRun) {
|
|
72
|
+
stdout(formatCommand(['swift', ...args]))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
const probe = spawnSync('swift', ['--version'], { env, stdio: 'ignore' })
|
|
76
|
+
if (probe.error || probe.status !== 0) fail('BLE OTA currently requires Swift/CoreBluetooth on macOS.', ExitCode.missingDependency)
|
|
77
|
+
const result = spawnSync('swift', args, { env, stdio: 'inherit' })
|
|
78
|
+
if (result.error) throw result.error
|
|
79
|
+
if (result.status !== 0) throw new CliError(`ERROR: BLE OTA failed (${result.status}).`, ExitCode.deployFailed)
|
|
80
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// partitions.csv is the target project's flash layout; OTA slots and otadata
|
|
5
|
+
// are looked up there so images are written where the bootloader expects them.
|
|
6
|
+
|
|
7
|
+
export function parsePartitionsCsv(text) {
|
|
8
|
+
const rows = []
|
|
9
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
10
|
+
const line = rawLine.trim()
|
|
11
|
+
if (!line || line.startsWith('#')) continue
|
|
12
|
+
const fields = line.split(',').map((field) => field.trim())
|
|
13
|
+
if (fields.length < 5) continue
|
|
14
|
+
rows.push({ name: fields[0], type: fields[1], subtype: fields[2], offset: fields[3], size: fields[4], flags: fields[5] || '' })
|
|
15
|
+
}
|
|
16
|
+
return rows
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function loadPartitions(targetDir, file = 'partitions.csv') {
|
|
20
|
+
const csvPath = path.join(targetDir, file)
|
|
21
|
+
if (!existsSync(csvPath)) throw new Error(`Partition table not found: ${csvPath}`)
|
|
22
|
+
return parsePartitionsCsv(readFileSync(csvPath, 'utf8'))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeOtaSlot(slot) {
|
|
26
|
+
const value = String(slot || '')
|
|
27
|
+
if (value.startsWith('ota_')) return value
|
|
28
|
+
if (/^\d+$/.test(value)) return `ota_${value}`
|
|
29
|
+
return value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function partitionByName(partitions, name) {
|
|
33
|
+
const found = partitions.find((partition) => partition.name === name)
|
|
34
|
+
if (!found) throw new Error(`Partition '${name}' was not found in partitions.csv.`)
|
|
35
|
+
return found
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sizeToBytes(value) {
|
|
39
|
+
const text = String(value).trim()
|
|
40
|
+
const match = text.match(/^(0x[0-9a-fA-F]+|\d+)\s*([KkMm]?)$/)
|
|
41
|
+
if (!match) throw new Error(`Unrecognised partition size '${value}'.`)
|
|
42
|
+
const base = match[1].toLowerCase().startsWith('0x') ? Number.parseInt(match[1], 16) : Number.parseInt(match[1], 10)
|
|
43
|
+
const unit = match[2].toUpperCase()
|
|
44
|
+
return unit === 'K' ? base * 1024 : unit === 'M' ? base * 1024 * 1024 : base
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// IDF writes the real offsets of bootloader/partition-table images into
|
|
48
|
+
// flash_args once a build has configured; fall back to the chip defaults.
|
|
49
|
+
export function flashOffsetForBuildImage(buildDir, imagePath, fallback) {
|
|
50
|
+
const flashArgs = path.join(buildDir, 'flash_args')
|
|
51
|
+
const relative = imagePath.startsWith(`${buildDir}${path.sep}`) ? imagePath.slice(buildDir.length + 1) : imagePath
|
|
52
|
+
if (existsSync(flashArgs)) {
|
|
53
|
+
for (const line of readFileSync(flashArgs, 'utf8').split(/\r?\n/)) {
|
|
54
|
+
const fields = line.trim().split(/\s+/)
|
|
55
|
+
if (fields.length >= 2 && fields[fields.length - 1] === relative) return fields[fields.length - 2]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return fallback
|
|
59
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// ESP-IDF's generated sdkconfig is mutable build state. It lives beside the
|
|
5
|
+
// CMake cache that owns it (inside the app's own build directory), so
|
|
6
|
+
// configuring one app can never rewrite another app's configuration or the
|
|
7
|
+
// stale target-root sdkconfig old builds left behind. The file starts empty:
|
|
8
|
+
// a partial sdkconfig is valid Kconfig input and IDF fills everything else
|
|
9
|
+
// from sdkconfig.defaults on the first configure.
|
|
10
|
+
export function prepareBuildLocalSdkconfig(targetDir, buildDir) {
|
|
11
|
+
const normalized = path.normalize(buildDir)
|
|
12
|
+
if (!buildDir || normalized === '..' || normalized.startsWith(`..${path.sep}`) || normalized.split(path.sep).includes('..')) {
|
|
13
|
+
throw new Error(`Invalid ESP32 build directory: ${buildDir}`)
|
|
14
|
+
}
|
|
15
|
+
const file = path.isAbsolute(buildDir) ? path.join(buildDir, 'sdkconfig') : path.join(targetDir, buildDir, 'sdkconfig')
|
|
16
|
+
const defaultsFile = path.join(targetDir, 'sdkconfig.defaults')
|
|
17
|
+
if (!existsSync(defaultsFile)) throw new Error(`ESP32 sdkconfig defaults not found: ${defaultsFile}`)
|
|
18
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
19
|
+
if (!existsSync(file)) writeFileSync(file, '')
|
|
20
|
+
return { file, defaultsFile }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function splitLines(text) {
|
|
24
|
+
const lines = text.split('\n')
|
|
25
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
26
|
+
return lines
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function withSdkconfigValue(text, key, value) {
|
|
30
|
+
const lines = splitLines(text)
|
|
31
|
+
const wanted = `${key}=${value}`
|
|
32
|
+
if (lines.includes(wanted)) return text
|
|
33
|
+
const out = []
|
|
34
|
+
let written = false
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
if (line.startsWith(`${key}=`) || line === `# ${key} is not set`) {
|
|
37
|
+
if (!written) {
|
|
38
|
+
out.push(wanted)
|
|
39
|
+
written = true
|
|
40
|
+
}
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
out.push(line)
|
|
44
|
+
}
|
|
45
|
+
if (!written) out.push(wanted)
|
|
46
|
+
return `${out.join('\n')}\n`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function withSdkconfigUnset(text, key) {
|
|
50
|
+
const lines = splitLines(text)
|
|
51
|
+
const unset = `# ${key} is not set`
|
|
52
|
+
if (lines.includes(unset) && !lines.some((line) => line.startsWith(`${key}=`))) return text
|
|
53
|
+
const out = []
|
|
54
|
+
let written = false
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
if (line.startsWith(`${key}=`) || line === unset) {
|
|
57
|
+
if (!written) {
|
|
58
|
+
out.push(unset)
|
|
59
|
+
written = true
|
|
60
|
+
}
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
out.push(line)
|
|
64
|
+
}
|
|
65
|
+
if (!written) out.push(unset)
|
|
66
|
+
return `${out.join('\n')}\n`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class Sdkconfig {
|
|
70
|
+
constructor(file, defaultsFile) {
|
|
71
|
+
this.file = file
|
|
72
|
+
this.defaultsFile = defaultsFile
|
|
73
|
+
this.text = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
74
|
+
this.defaults = existsSync(defaultsFile) ? readFileSync(defaultsFile, 'utf8') : ''
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
set(key, value) {
|
|
78
|
+
this.text = withSdkconfigValue(this.text, key, value)
|
|
79
|
+
return this
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
unset(key) {
|
|
83
|
+
this.text = withSdkconfigUnset(this.text, key)
|
|
84
|
+
return this
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
has(pattern) {
|
|
88
|
+
return pattern.test(this.text)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
defaultValue(key) {
|
|
92
|
+
return this.defaults.match(new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}=(.*)$`, 'm'))?.[1] ?? ''
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
defaultIsSet(key) {
|
|
96
|
+
return this.defaultValue(key) === 'y'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
save() {
|
|
100
|
+
const current = existsSync(this.file) ? readFileSync(this.file, 'utf8') : null
|
|
101
|
+
if (current !== this.text) writeFileSync(this.file, this.text)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// WiFi credentials come from the app's .env (GEA_WIFI_SSID / GEA_WIFI_PASSWORD)
|
|
5
|
+
// and are compiled in as a generated header inside the build directory.
|
|
6
|
+
|
|
7
|
+
export function readDotEnv(file) {
|
|
8
|
+
let text = ''
|
|
9
|
+
try {
|
|
10
|
+
text = fs.readFileSync(file, 'utf8')
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error?.code === 'ENOENT') return {}
|
|
13
|
+
throw error
|
|
14
|
+
}
|
|
15
|
+
const values = {}
|
|
16
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
17
|
+
const line = rawLine.trim()
|
|
18
|
+
if (!line || line.startsWith('#')) continue
|
|
19
|
+
const equals = line.indexOf('=')
|
|
20
|
+
if (equals < 0) continue
|
|
21
|
+
let key = line.slice(0, equals).trim()
|
|
22
|
+
if (key.startsWith('export ')) key = key.slice(7).trim()
|
|
23
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
|
|
24
|
+
let value = line.slice(equals + 1).trim()
|
|
25
|
+
if (value.length >= 2 && ((value[0] === '"' && value.endsWith('"')) || (value[0] === "'" && value.endsWith("'")))) {
|
|
26
|
+
value = value.slice(1, -1)
|
|
27
|
+
}
|
|
28
|
+
values[key] = value
|
|
29
|
+
}
|
|
30
|
+
return values
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function quoteCString(value) {
|
|
34
|
+
let output = '"'
|
|
35
|
+
for (const character of String(value)) {
|
|
36
|
+
const code = character.codePointAt(0)
|
|
37
|
+
if (character === '\\') output += '\\\\'
|
|
38
|
+
else if (character === '"') output += '\\"'
|
|
39
|
+
else if (character === '\n') output += '\\n'
|
|
40
|
+
else if (character === '\r') output += '\\r'
|
|
41
|
+
else if (character === '\t') output += '\\t'
|
|
42
|
+
else if (code < 0x20 || code === 0x7f) output += `\\x${code.toString(16).padStart(2, '0')}""`
|
|
43
|
+
else output += character
|
|
44
|
+
}
|
|
45
|
+
return `${output}"`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function wifiConfigContents(values) {
|
|
49
|
+
const ssid = values.GEA_WIFI_SSID || ''
|
|
50
|
+
const password = values.GEA_WIFI_PASSWORD || ''
|
|
51
|
+
return [
|
|
52
|
+
'#pragma once',
|
|
53
|
+
`#define GEA_EMBEDDED_WIFI_SSID ${quoteCString(ssid)}`,
|
|
54
|
+
`#define GEA_EMBEDDED_WIFI_PASSWORD ${quoteCString(password)}`,
|
|
55
|
+
`#define GEA_EMBEDDED_WIFI_EARLY_CONNECT ${ssid ? 1 : 0}`,
|
|
56
|
+
''
|
|
57
|
+
].join('\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function generateWifiConfig(appDir, outputFile) {
|
|
61
|
+
const contents = wifiConfigContents(readDotEnv(path.join(appDir, '.env')))
|
|
62
|
+
fs.mkdirSync(path.dirname(outputFile), { recursive: true })
|
|
63
|
+
let current = ''
|
|
64
|
+
try {
|
|
65
|
+
current = fs.readFileSync(outputFile, 'utf8')
|
|
66
|
+
} catch {
|
|
67
|
+
current = ''
|
|
68
|
+
}
|
|
69
|
+
if (current !== contents) fs.writeFileSync(outputFile, contents, { mode: 0o600 })
|
|
70
|
+
fs.chmodSync(outputFile, 0o600)
|
|
71
|
+
return outputFile
|
|
72
|
+
}
|