@geastack/cli 0.1.52 → 0.1.54

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.
Files changed (44) hide show
  1. package/README.md +14 -1
  2. package/docs/ESP32-WAVESHARE-AMOLED-QUICKSTART.md +2 -2
  3. package/docs/NPX-COMMANDS.md +3 -1
  4. package/docs/SETUP.md +81 -15
  5. package/docs/SPEC.md +8 -5
  6. package/package.json +2 -3
  7. package/src/apps/app-index-writer.mjs +15 -0
  8. package/src/apps/apple-icons.mjs +147 -0
  9. package/src/apps/bundle-writer.mjs +156 -0
  10. package/src/apps/launcher-catalog.mjs +114 -0
  11. package/src/apps/openai-icons.mjs +356 -0
  12. package/src/apps/zip-writer.mjs +104 -0
  13. package/src/ble/ble-ota.swift +291 -0
  14. package/src/boards/config.mjs +114 -0
  15. package/src/boards/custom-target.mjs +309 -0
  16. package/src/boards/resolve.mjs +145 -0
  17. package/src/boards/targets.mjs +45 -0
  18. package/src/boards/usb.mjs +252 -0
  19. package/src/chips.mjs +1 -1
  20. package/src/commands/apps.mjs +204 -0
  21. package/src/commands/board.mjs +402 -0
  22. package/src/commands/boards.mjs +334 -0
  23. package/src/commands/doctor.mjs +91 -0
  24. package/src/context.mjs +50 -54
  25. package/src/device/device.mjs +96 -0
  26. package/src/device/image.mjs +115 -0
  27. package/src/device/serial.mjs +430 -0
  28. package/src/device/wifi.mjs +190 -0
  29. package/src/esp32/build.mjs +321 -0
  30. package/src/esp32/capabilities.mjs +54 -0
  31. package/src/esp32/flash.mjs +181 -0
  32. package/src/esp32/idf-env.mjs +171 -0
  33. package/src/esp32/ota.mjs +80 -0
  34. package/src/esp32/partitions.mjs +59 -0
  35. package/src/esp32/sdkconfig.mjs +103 -0
  36. package/src/esp32/wifi-config.mjs +72 -0
  37. package/src/gea.mjs +89 -427
  38. package/src/geaos/adapter.mjs +119 -0
  39. package/src/heap-report.mjs +1276 -0
  40. package/src/manifest.mjs +135 -36
  41. package/src/rp2350/adapter.mjs +155 -0
  42. package/src/serial-devices.mjs +32 -20
  43. package/src/setup-wizard.mjs +11 -12
  44. 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
+ }