@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.
@@ -0,0 +1,190 @@
1
+ import { createReadStream, statSync } from 'node:fs'
2
+ import http from 'node:http'
3
+ import net from 'node:net'
4
+
5
+ import { decodeRgb565Raw } from './image.mjs'
6
+
7
+ // The cable-free transport. Two services on the board:
8
+ // TCP 8081 -- diagnostics stream, framed [channel, type, len_lo, len_hi] + payload;
9
+ // channel 1 is the console/ESP_LOG stream (8 KiB ring replayed on connect).
10
+ // HTTP 8080 -- the OTA server: POST /ota, /ota/erase, GET /ota/status,
11
+ // GET /screenshot (rgb565-raw-v1 with X-Gea-* geometry headers),
12
+ // POST /display/hbm.
13
+
14
+ export const diagnosticsPort = 8081
15
+ export const otaPort = 8080
16
+ const channelLog = 1
17
+ const headerBytes = 4
18
+
19
+ export function otaBaseUrl(host) {
20
+ const text = String(host)
21
+ if (/^https?:\/\//.test(text)) return text.replace(/\/$/, '')
22
+ if (text.includes(':')) return `http://${text}`
23
+ return `http://${text}:${otaPort}`
24
+ }
25
+
26
+ function enableKeepalive(socket) {
27
+ // A board that reboots never closes the connection; it just stops
28
+ // existing. Keepalive probes turn that into an error instead of a hang.
29
+ socket.setKeepAlive(true, 5000)
30
+ }
31
+
32
+ // Streams channel-1 payloads to `write` until the board goes away. Resolves
33
+ // { connected } so a follow loop can tell "rebooted mid-stream" apart from
34
+ // "nothing is listening".
35
+ export function streamLogsOnce({ host, port = diagnosticsPort, timeoutMs = 10000, write, onConnect }) {
36
+ return new Promise((resolve, reject) => {
37
+ const socket = net.createConnection({ host, port })
38
+ let connected = false
39
+ let buffer = Buffer.alloc(0)
40
+ socket.setTimeout(timeoutMs)
41
+ socket.on('timeout', () => {
42
+ if (!connected) {
43
+ socket.destroy()
44
+ const error = new Error(`Timed out connecting to ${host}:${port}.`)
45
+ error.code = 'ETIMEDOUT'
46
+ reject(error)
47
+ }
48
+ })
49
+ socket.on('connect', () => {
50
+ connected = true
51
+ socket.setTimeout(0)
52
+ enableKeepalive(socket)
53
+ if (onConnect) onConnect()
54
+ })
55
+ socket.on('data', (chunk) => {
56
+ buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk
57
+ let offset = 0
58
+ while (buffer.length - offset >= headerBytes) {
59
+ const channel = buffer[offset]
60
+ const payloadLength = buffer[offset + 2] | (buffer[offset + 3] << 8)
61
+ const frameLength = headerBytes + payloadLength
62
+ if (buffer.length - offset < frameLength) break
63
+ if (channel === channelLog && payloadLength > 0) write(buffer.subarray(offset + headerBytes, offset + frameLength))
64
+ offset += frameLength
65
+ }
66
+ buffer = offset ? buffer.subarray(offset) : buffer
67
+ })
68
+ socket.on('error', (error) => {
69
+ error.connected = connected
70
+ reject(error)
71
+ })
72
+ socket.on('close', () => resolve({ connected }))
73
+ })
74
+ }
75
+
76
+ const transientErrors = new Set(['ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'EHOSTDOWN'])
77
+
78
+ export async function tailLogs({ host, port = diagnosticsPort, follow = false, timeoutMs = 10000, write, stderr, signal }) {
79
+ let connectedBefore = false
80
+ let firstAttempt = true
81
+ while (true) {
82
+ let connected = false
83
+ try {
84
+ const result = await streamLogsOnce({ host, port, timeoutMs, write, onConnect: () => { connected = true } })
85
+ connected = result.connected
86
+ } catch (error) {
87
+ connected = error.connected || connected
88
+ if (error.code === 'ECONNREFUSED' && !connected && firstAttempt) {
89
+ throw new Error(
90
+ `${host}:${port} refused the connection. The board is reachable but its firmware has no diagnostics server -- rebuild the target with GEA_EMBEDDED_DIAGNOSTICS_ENABLED=1.`
91
+ )
92
+ }
93
+ if (!transientErrors.has(error.code)) throw error
94
+ if (!connected && !follow) throw new Error(`Could not reach ${host}:${port}: ${error.message}`)
95
+ }
96
+ firstAttempt = false
97
+ if (!follow || signal?.aborted) return
98
+ if (connected) {
99
+ stderr(`-- reconnecting to ${host}:${port} --`)
100
+ connectedBefore = true
101
+ }
102
+ await new Promise((resolve) => setTimeout(resolve, 1000))
103
+ if (signal?.aborted) return
104
+ }
105
+ }
106
+
107
+ function httpRequest(url, { method = 'GET', body = null, headers = {}, timeoutMs = 30000 } = {}) {
108
+ return new Promise((resolve, reject) => {
109
+ const request = http.request(url, { method, headers, timeout: timeoutMs }, (response) => {
110
+ const chunks = []
111
+ response.on('data', (chunk) => chunks.push(chunk))
112
+ response.on('end', () => resolve({ status: response.statusCode, headers: response.headers, body: Buffer.concat(chunks) }))
113
+ response.on('error', reject)
114
+ })
115
+ request.on('timeout', () => request.destroy(new Error(`Timed out after ${timeoutMs / 1000}s waiting for ${url}`)))
116
+ request.on('error', (error) => reject(new Error(`Could not reach ${url}: ${error.message}`)))
117
+ if (body && typeof body.pipe === 'function') body.pipe(request)
118
+ else request.end(body || undefined)
119
+ })
120
+ }
121
+
122
+ export async function fetchScreenshot({ host, port = otaPort, timeoutMs = 30000 }) {
123
+ const url = `http://${host}:${port}/screenshot`
124
+ const response = await httpRequest(url, { timeoutMs })
125
+ if (response.status !== 200) throw new Error(`${url} returned HTTP ${response.status}`)
126
+ const width = Number(response.headers['x-gea-width'] || 0)
127
+ const height = Number(response.headers['x-gea-height'] || 0)
128
+ const encoding = response.headers['x-gea-encoding'] || ''
129
+ const app = response.headers['x-gea-app'] || ''
130
+ if (encoding !== 'rgb565-raw-v1') throw new Error(`Unexpected screenshot encoding '${encoding}' (expected rgb565-raw-v1)`)
131
+ if (width <= 0 || height <= 0) throw new Error(`Board reported an unusable size: ${width}x${height}`)
132
+ return { width, height, app, rgb: decodeRgb565Raw(response.body, width * height) }
133
+ }
134
+
135
+ export async function otaStatus(host) {
136
+ const response = await httpRequest(`${otaBaseUrl(host)}/ota/status`, { timeoutMs: 5000 })
137
+ if (response.status !== 200) throw new Error(`OTA status returned HTTP ${response.status}`)
138
+ return JSON.parse(response.body.toString('utf8'))
139
+ }
140
+
141
+ export async function otaUpload({ host, image, slot = '', boot = false, reboot = false, timeoutMs = 600000, stdout }) {
142
+ const query = slot ? `?slot=${encodeURIComponent(slot)}&boot=${boot ? 1 : 0}&reboot=${reboot ? 1 : 0}` : ''
143
+ const url = `${otaBaseUrl(host)}/ota${query}`
144
+ const size = statSync(image).size
145
+ const startedAt = Date.now()
146
+ const response = await httpRequest(url, {
147
+ method: 'POST',
148
+ body: createReadStream(image),
149
+ headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(size) },
150
+ timeoutMs
151
+ })
152
+ const seconds = (Date.now() - startedAt) / 1000
153
+ if (response.status !== 200) {
154
+ throw new Error(`OTA upload to ${url} failed with HTTP ${response.status}: ${response.body.toString('utf8').trim()}`)
155
+ }
156
+ const text = response.body.toString('utf8').trim()
157
+ if (text) stdout(text)
158
+ stdout(`WiFi OTA transfer: ${size} bytes in ${seconds.toFixed(3)}s (${Math.round(size / Math.max(seconds, 0.001))} bytes/s)`)
159
+ return { size, seconds }
160
+ }
161
+
162
+ export async function otaErase({ host, slot }) {
163
+ const url = `${otaBaseUrl(host)}/ota/erase?slot=${encodeURIComponent(slot)}`
164
+ const response = await httpRequest(url, { method: 'POST', timeoutMs: 120000 })
165
+ if (response.status !== 200) throw new Error(`Erase via ${url} failed with HTTP ${response.status}`)
166
+ return response.body.toString('utf8').trim()
167
+ }
168
+
169
+ export async function setHighBrightnessMode({ host, enabled }) {
170
+ const url = `${otaBaseUrl(host)}/display/hbm?on=${enabled ? 1 : 0}`
171
+ const response = await httpRequest(url, { method: 'POST', timeoutMs: 10000 })
172
+ if (response.status === 501) throw new Error('This board has no high-brightness mode.')
173
+ if (response.status !== 200) throw new Error(`${url} returned HTTP ${response.status}`)
174
+ return JSON.parse(response.body.toString('utf8'))
175
+ }
176
+
177
+ export function waitForOtaServer({ host, timeoutMs = 120000, pollMs = 2000 }) {
178
+ const deadline = Date.now() + timeoutMs
179
+ return new Promise((resolve, reject) => {
180
+ const attempt = async () => {
181
+ try {
182
+ resolve(await otaStatus(host))
183
+ } catch (error) {
184
+ if (Date.now() >= deadline) reject(new Error(`Board at ${host} did not come back within ${timeoutMs / 1000}s: ${error.message}`))
185
+ else setTimeout(attempt, pollMs)
186
+ }
187
+ }
188
+ attempt()
189
+ })
190
+ }
@@ -0,0 +1,321 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ import { loadChipCatalogFromDir, writeCustomTarget } from '../boards/custom-target.mjs'
6
+ import { CliError, ExitCode, fail } from '../errors.mjs'
7
+ import { appCmakeMeta } from '../manifest.mjs'
8
+ import { formatCommand } from '../run.mjs'
9
+ import { resolveAppCapabilities } from './capabilities.mjs'
10
+ import { activateEspIdf, idfPyCommand } from './idf-env.mjs'
11
+ import { Sdkconfig, prepareBuildLocalSdkconfig } from './sdkconfig.mjs'
12
+ import { generateWifiConfig } from './wifi-config.mjs'
13
+
14
+ // The ESP-IDF build. Everything the old bash board script decided about a
15
+ // build lives here: where the build directory is, what the app-local
16
+ // sdkconfig must say, which capabilities the firmware links, and when a
17
+ // reconfigure is actually needed.
18
+
19
+ function safePathFragment(value) {
20
+ return String(value).replace(/[^A-Za-z0-9_.-]/g, '_')
21
+ }
22
+
23
+ export function esp32BuildDir(ctx, selection, appId, env = ctx.env || process.env) {
24
+ if (!appId) return path.join(ctx.buildRoot, selection.target, 'default')
25
+ let key = safePathFragment(appId)
26
+ const variant = env.GEA_IDF_BUILD_VARIANT || ''
27
+ if (variant) {
28
+ if (!/^[A-Za-z0-9._-]+$/.test(variant)) {
29
+ fail("GEA_IDF_BUILD_VARIANT may contain only letters, digits, '.', '_' and '-'.", ExitCode.usage)
30
+ }
31
+ key = `${key}__variant-${variant}`
32
+ }
33
+ return path.join(ctx.buildRoot, selection.target, 'app-builds', key)
34
+ }
35
+
36
+ export function buildImages(buildDir) {
37
+ return {
38
+ app: path.join(buildDir, 'gea_embedded.bin'),
39
+ bootloader: path.join(buildDir, 'bootloader', 'bootloader.bin'),
40
+ partitionTable: path.join(buildDir, 'partition_table', 'partition-table.bin'),
41
+ otaData: path.join(buildDir, 'ota_data_initial.bin')
42
+ }
43
+ }
44
+
45
+ export function requireEspIdf(env, log) {
46
+ let idf
47
+ try {
48
+ idf = activateEspIdf({ env, log })
49
+ } catch (error) {
50
+ fail(error.message, ExitCode.missingDependency)
51
+ }
52
+ if (!idf) {
53
+ fail('ESP-IDF was not found. Install it and set IDF_PATH (or place it under ~/esp or ~/esp32).', ExitCode.missingDependency)
54
+ }
55
+ return idf
56
+ }
57
+
58
+ // The per-app sdkconfig policy: development logging, the board's stack
59
+ // sizes, the S3 instruction cache and the BLE host exactly as the app needs.
60
+ export function applySdkconfigPolicy(sdkconfig, { selection, app, capabilities, bleOta }) {
61
+ const set = (key, value) => sdkconfig.set(key, value)
62
+ const unset = (key) => sdkconfig.unset(key)
63
+
64
+ set('CONFIG_ESP_MAIN_TASK_STACK_SIZE', String(selection.mainTaskStackSize || 32768))
65
+ set('CONFIG_ESP_IPC_TASK_STACK_SIZE', String(selection.ipcTaskStackSize || 16384))
66
+ set('CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY', 'y')
67
+ set('CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL', '0')
68
+
69
+ // The S3's default 16 KB instruction cache is too small for a frame's code
70
+ // working set; 32 KB cut a whole frame ~35% for 16 KB of internal SRAM.
71
+ // Cache config lives in the generated (sticky) sdkconfig, so defaults alone
72
+ // would never reach an existing build directory.
73
+ if ((selection.idfTarget || 'esp32s3') === 'esp32s3') {
74
+ unset('CONFIG_ESP32S3_INSTRUCTION_CACHE_16KB')
75
+ set('CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB', 'y')
76
+ }
77
+
78
+ unset('CONFIG_GEA_EMBEDDED_PRODUCTION_LOCKDOWN')
79
+ for (const level of ['NONE', 'ERROR', 'WARN', 'DEBUG', 'VERBOSE']) unset(`CONFIG_LOG_DEFAULT_LEVEL_${level}`)
80
+ set('CONFIG_LOG_DEFAULT_LEVEL_INFO', 'y')
81
+ set('CONFIG_LOG_DEFAULT_LEVEL', '3')
82
+ set('CONFIG_LOG_MAXIMUM_EQUALS_DEFAULT', 'y')
83
+ unset('CONFIG_LOG_MAXIMUM_LEVEL_DEBUG')
84
+ unset('CONFIG_LOG_MAXIMUM_LEVEL_VERBOSE')
85
+ set('CONFIG_LOG_MAXIMUM_LEVEL', '3')
86
+
87
+ // The M5StickC S3's OTA partitions are small and the binary sits at the
88
+ // ceiling; assertion strings push it over. Boards that already disable or
89
+ // silence assertions in their defaults keep that choice.
90
+ if (selection.boardName === 'sticks3' || sdkconfig.defaultIsSet('CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE')) {
91
+ unset('CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE')
92
+ unset('CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT')
93
+ set('CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE', 'y')
94
+ set('CONFIG_COMPILER_OPTIMIZATION_ASSERTION_LEVEL', '0')
95
+ } else if (sdkconfig.defaultIsSet('CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT')) {
96
+ unset('CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE')
97
+ unset('CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE')
98
+ set('CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT', 'y')
99
+ set('CONFIG_COMPILER_OPTIMIZATION_ASSERTION_LEVEL', '1')
100
+ } else {
101
+ unset('CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE')
102
+ unset('CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT')
103
+ set('CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE', 'y')
104
+ set('CONFIG_COMPILER_OPTIMIZATION_ASSERTION_LEVEL', '2')
105
+ }
106
+
107
+ for (const level of ['NONE', 'ERROR', 'WARN', 'DEBUG', 'VERBOSE']) unset(`CONFIG_BOOTLOADER_LOG_LEVEL_${level}`)
108
+ set('CONFIG_BOOTLOADER_LOG_LEVEL_INFO', 'y')
109
+ set('CONFIG_BOOTLOADER_LOG_LEVEL', '3')
110
+
111
+ // taurus-display OTAs into hardware provisioned with taurus-pedal's legacy
112
+ // partition table; its image must stay byte-compatible with the field.
113
+ if (app?.id === 'taurus-display') {
114
+ set('CONFIG_PARTITION_TABLE_CUSTOM_FILENAME', '"partitions-taurus-display.csv"')
115
+ }
116
+
117
+ if (!app) return sdkconfig
118
+ if (capabilities.ble) {
119
+ set('CONFIG_BT_ENABLED', 'y')
120
+ unset('CONFIG_BT_BLUEDROID_ENABLED')
121
+ set('CONFIG_BT_NIMBLE_ENABLED', 'y')
122
+ set('CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_EXTERNAL', 'y')
123
+ set('CONFIG_BT_NIMBLE_ROLE_BROADCASTER', 'y')
124
+ set('CONFIG_BT_NIMBLE_ROLE_PERIPHERAL', 'y')
125
+ if (bleOta && !capabilities.bleApi) {
126
+ set('CONFIG_BT_NIMBLE_MAX_CONNECTIONS', '1')
127
+ unset('CONFIG_BT_NIMBLE_ROLE_CENTRAL')
128
+ unset('CONFIG_BT_NIMBLE_ROLE_OBSERVER')
129
+ } else {
130
+ // Apps using the BLE API inherit the board policy (the amoled 2.06 uses
131
+ // four links plus central/observer for HID-host and MIDI roles).
132
+ set('CONFIG_BT_NIMBLE_MAX_CONNECTIONS', sdkconfig.defaultValue('CONFIG_BT_NIMBLE_MAX_CONNECTIONS') || '1')
133
+ if (sdkconfig.defaultIsSet('CONFIG_BT_NIMBLE_ROLE_CENTRAL')) set('CONFIG_BT_NIMBLE_ROLE_CENTRAL', 'y')
134
+ if (sdkconfig.defaultIsSet('CONFIG_BT_NIMBLE_ROLE_OBSERVER')) set('CONFIG_BT_NIMBLE_ROLE_OBSERVER', 'y')
135
+ }
136
+ } else if (sdkconfig.has(/^(CONFIG_BT_ENABLED=|# CONFIG_BT_ENABLED is not set)/m)) {
137
+ // A minimal no-BLE build excludes the whole bt component, so its Kconfig
138
+ // symbols do not exist; re-adding an "is not set" line there makes
139
+ // Kconfig delete it and invalidates CMake on every invocation.
140
+ unset('CONFIG_BT_ENABLED')
141
+ }
142
+ return sdkconfig
143
+ }
144
+
145
+ // Resolves every input of an ESP32 build without running it: build dir,
146
+ // sdkconfig, generated headers, cache arguments and the child environment.
147
+ export function prepareEsp32Build({ ctx, selection, app = null, env = ctx.env || process.env, log = () => {}, bleOta = false }) {
148
+ if (!selection.targetDir || !existsSync(path.join(selection.targetDir, 'CMakeLists.txt'))) {
149
+ fail(`ESP32 target '${selection.target}' has no project directory (${selection.targetDir || 'unset'}). Is @geastack/targets installed?`, ExitCode.missingDependency)
150
+ }
151
+ const idfTarget = selection.idfTarget || 'esp32s3'
152
+ const buildDir = esp32BuildDir(ctx, selection, app?.id, env)
153
+ const { file: sdkconfigFile, defaultsFile } = prepareBuildLocalSdkconfig(selection.targetDir, buildDir)
154
+ log(`Using ESP32 target '${selection.target}' at ${selection.targetDir} (idf=${idfTarget} chip=${selection.esptoolChip || idfTarget} flash=${selection.flashSize})`)
155
+
156
+ const childEnv = { ...env }
157
+ const idfArgs = [`-DIDF_TARGET=${idfTarget}`, `-DGEA_APPS_ROOT=${ctx.projectRoot}`]
158
+ if (selection.targetDefinition) {
159
+ // The board header and target.cmake are generated here, once; CMake only
160
+ // includes them.
161
+ const outDir = path.join(buildDir, 'gea-custom-target')
162
+ if (!ctx.chipsPackageDir) fail('@geastack/chips is not installed in this project; custom boards need its chip catalog.', ExitCode.missingDependency)
163
+ const generated = writeCustomTarget({ definitionPath: selection.targetDefinition, outDir, catalog: loadChipCatalogFromDir(ctx.chipsPackageDir) })
164
+ idfArgs.push(`-DGEA_BOARD_DEFINITION=${selection.targetDefinition}`, `-DGEA_CUSTOM_TARGET_DIR=${outDir}`)
165
+ childEnv.GEA_BOARD_DEFINITION = selection.targetDefinition
166
+ childEnv.GEA_CUSTOM_TARGET_DIR = outDir
167
+ log(`Generated custom board files in ${outDir} (${path.basename(generated.headerPath)}, ${path.basename(generated.cmakePath)})`)
168
+ }
169
+
170
+ let capabilities = { network: false, ble: false, bleApi: false, audio: false, bindings: [], features: [] }
171
+ if (app) {
172
+ capabilities = resolveAppCapabilities(ctx, app, { env })
173
+ if (bleOta) capabilities.ble = true
174
+ const meta = appCmakeMeta(ctx, app)
175
+ idfArgs.push(`-DGEA_EMBEDDED_APP=${app.id}`, `-DGEA_EMBEDDED_APP_META=${meta}`)
176
+ idfArgs.push(
177
+ `-DGEA_EMBEDDED_CAPABILITY_NETWORK=${capabilities.network ? 1 : 0}`,
178
+ `-DGEA_EMBEDDED_CAPABILITY_BLE=${capabilities.ble ? 1 : 0}`,
179
+ `-DGEA_EMBEDDED_CAPABILITY_AUDIO=${capabilities.audio ? 1 : 0}`
180
+ )
181
+ // IDF's MINIMAL_BUILD component-requirements pass runs before normal
182
+ // CMake cache propagation; environment values stay visible there, so
183
+ // conditional components such as bt enter the dependency graph.
184
+ childEnv.GEA_EMBEDDED_APP = app.id
185
+ childEnv.GEA_EMBEDDED_APP_META = meta
186
+ childEnv.GEA_EMBEDDED_CAPABILITY_NETWORK = capabilities.network ? '1' : '0'
187
+ childEnv.GEA_EMBEDDED_CAPABILITY_BLE = capabilities.ble ? '1' : '0'
188
+ childEnv.GEA_EMBEDDED_CAPABILITY_AUDIO = capabilities.audio ? '1' : '0'
189
+ if (bleOta) childEnv.GEA_EMBEDDED_BLE_OTA = '1'
190
+ log(`App capabilities: network=${capabilities.network ? 1 : 0} ble=${capabilities.ble ? 1 : 0} audio=${capabilities.audio ? 1 : 0}`)
191
+ generateWifiConfig(app.root, path.join(buildDir, 'apps', app.id, 'wifi_config.h'))
192
+ }
193
+
194
+ applySdkconfigPolicy(new Sdkconfig(sdkconfigFile, defaultsFile), { selection, app, capabilities, bleOta }).save()
195
+
196
+ return { buildDir, sdkconfigFile, defaultsFile, idfArgs, childEnv, capabilities, images: buildImages(buildDir) }
197
+ }
198
+
199
+ function commandExists(name, env) {
200
+ const dirs = String(env.PATH || '').split(path.delimiter).filter(Boolean)
201
+ return dirs.some((dir) => existsSync(path.join(dir, name)))
202
+ }
203
+
204
+ export function configureArguments(prepared, env) {
205
+ const args = []
206
+ if ((env.GEA_IDF_CCACHE || '1') !== '0' && commandExists('ccache', env)) args.push('--ccache')
207
+ // A CMake generator is immutable once a build directory has been
208
+ // configured. Prefer Ninja for new builds (its no-op dependency traversal
209
+ // is dramatically cheaper) while leaving existing Make builds untouched.
210
+ if (!existsSync(path.join(prepared.buildDir, 'CMakeCache.txt'))) {
211
+ const generator = env.GEA_IDF_GENERATOR || (commandExists('ninja', env) ? 'Ninja' : 'Unix Makefiles')
212
+ if (generator !== 'Ninja' && generator !== 'Unix Makefiles') {
213
+ fail(`GEA_IDF_GENERATOR must be 'Ninja' or 'Unix Makefiles' (got '${generator}').`, ExitCode.usage)
214
+ }
215
+ args.push('-G', generator)
216
+ }
217
+ args.push('-B', prepared.buildDir, `-DSDKCONFIG=${prepared.sdkconfigFile}`, `-DSDKCONFIG_DEFAULTS=${prepared.defaultsFile}`)
218
+ return args
219
+ }
220
+
221
+ function runInTarget(command, args, { cwd, env, dryRun, stdout, failureCode = ExitCode.buildFailed }) {
222
+ if (dryRun) {
223
+ stdout(formatCommand([command, ...args]))
224
+ return
225
+ }
226
+ const result = spawnSync(command, args, { cwd, env, stdio: 'inherit' })
227
+ if (result.error) throw result.error
228
+ if (result.status !== 0) throw new CliError(`ERROR: Command failed (${result.status ?? 1}): ${formatCommand([command, ...args])}`, failureCode)
229
+ }
230
+
231
+ // App capability flags are CMake cache entries. Reconfigure only when those
232
+ // inputs change; ordinary source/CMake dependency changes remain the build
233
+ // system's responsibility. This retains Ninja's sub-second no-op.
234
+ export function ensureConfigured({ idf, prepared, env, dryRun = false, stdout }) {
235
+ const signatureFile = path.join(prepared.buildDir, '.gea-configure-args')
236
+ const signature = [`-DSDKCONFIG=${prepared.sdkconfigFile}`, `-DSDKCONFIG_DEFAULTS=${prepared.defaultsFile}`, ...prepared.idfArgs].join('\n') + '\n'
237
+ if (existsSync(path.join(prepared.buildDir, 'CMakeCache.txt')) && existsSync(signatureFile) && readFileSync(signatureFile, 'utf8') === signature) {
238
+ return false
239
+ }
240
+ const { command, args } = idfPyCommand(idf, [...configureArguments(prepared, env), ...prepared.idfArgs, 'reconfigure'])
241
+ runInTarget(command, args, { cwd: prepared.targetDir, env, dryRun, stdout })
242
+ if (!dryRun) {
243
+ mkdirSync(prepared.buildDir, { recursive: true })
244
+ const tmp = `${signatureFile}.tmp.${process.pid}`
245
+ writeFileSync(tmp, signature)
246
+ renameSync(tmp, signatureFile)
247
+ }
248
+ return true
249
+ }
250
+
251
+ export function buildJobs(env) {
252
+ const jobs = env.GEA_IDF_JOBS || '8'
253
+ if (!/^[1-9]\d*$/.test(jobs)) fail(`GEA_IDF_JOBS must be a positive integer (got '${jobs}').`, ExitCode.usage)
254
+ return jobs
255
+ }
256
+
257
+ // Opt-in workspace-wide serialization for benchmark-grade builds
258
+ // (GEA_SERIALIZE_HEAVY_BUILDS=1). Concurrent builds are the normal workflow;
259
+ // the lock only exists so a measurement is not taken beside another compile.
260
+ export function acquireHeavyBuildLock({ ctx, env, label, stderr }) {
261
+ if (env.GEA_SERIALIZE_HEAVY_BUILDS !== '1') return () => {}
262
+ const lockPath = env.GEA_HEAVY_BUILD_LOCK_PATH || path.join(ctx.buildRoot, '.heavy-build.lock')
263
+ mkdirSync(path.dirname(lockPath), { recursive: true })
264
+ const contents = `pid=${process.pid}\nkind=esp32\nlabel=${label}\nstarted_at=${new Date().toISOString()}\n`
265
+ for (;;) {
266
+ try {
267
+ writeFileSync(lockPath, contents, { flag: 'wx', mode: 0o600 })
268
+ break
269
+ } catch (error) {
270
+ if (error.code !== 'EEXIST') throw error
271
+ const owner = readFileSync(lockPath, 'utf8')
272
+ const pid = Number(owner.match(/^pid=(\d+)$/m)?.[1])
273
+ let live = false
274
+ try {
275
+ process.kill(pid, 0)
276
+ live = Number.isFinite(pid)
277
+ } catch {
278
+ live = false
279
+ }
280
+ if (live) {
281
+ stderr(`Another heavyweight build is already using this workspace:\n${owner.trim()}\n(lock: ${lockPath})`)
282
+ fail('Heavy-build lock is held; retry when that build finishes or unset GEA_SERIALIZE_HEAVY_BUILDS.', ExitCode.buildFailed)
283
+ }
284
+ unlinkSync(lockPath)
285
+ }
286
+ }
287
+ return () => {
288
+ try {
289
+ if (readFileSync(lockPath, 'utf8') === contents) unlinkSync(lockPath)
290
+ } catch {
291
+ // already gone
292
+ }
293
+ }
294
+ }
295
+
296
+ export function buildEsp32Firmware({ ctx, selection, app = null, env = ctx.env || process.env, bleOta = false, dryRun = false, stdout = console.log, stderr = console.error, configureOnly = false }) {
297
+ const idf = requireEspIdf(env, stdout)
298
+ const prepared = prepareEsp32Build({ ctx, selection, app, env: idf.env, log: stdout, bleOta })
299
+ prepared.targetDir = selection.targetDir
300
+ const buildEnv = { ...prepared.childEnv }
301
+ const release = acquireHeavyBuildLock({ ctx, env: buildEnv, label: app ? `${selection.target} app=${app.id}` : selection.target, stderr })
302
+ try {
303
+ if (configureOnly) {
304
+ stdout(`Configuring target ${selection.idfTarget || 'esp32s3'}...`)
305
+ ensureConfigured({ idf, prepared, env: buildEnv, dryRun, stdout })
306
+ return prepared
307
+ }
308
+ stdout(app ? `Building firmware for app '${app.id}' in ${prepared.buildDir}...` : 'Building firmware...')
309
+ ensureConfigured({ idf, prepared, env: buildEnv, dryRun, stdout })
310
+ runInTarget('cmake', ['--build', prepared.buildDir, '--parallel', buildJobs(buildEnv)], { cwd: selection.targetDir, env: buildEnv, dryRun, stdout })
311
+ } finally {
312
+ release()
313
+ }
314
+ return prepared
315
+ }
316
+
317
+ export function fullCleanEsp32({ ctx, selection, app = null, env = ctx.env || process.env, stdout = console.log }) {
318
+ const dir = app ? esp32BuildDir(ctx, selection, app.id, env) : path.join(ctx.buildRoot, selection.target)
319
+ stdout(`Removing build artifacts in ${dir}...`)
320
+ spawnSync('rm', ['-rf', dir], { stdio: 'inherit' })
321
+ }
@@ -0,0 +1,54 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import path from 'node:path'
3
+
4
+ import { ExitCode, fail } from '../errors.mjs'
5
+ import { exists } from '../fs-utils.mjs'
6
+
7
+ // What the firmware must link for this app. The compiler analyses the entry
8
+ // (with the gea plugin) and reports the host bindings the program reaches;
9
+ // a network stack, the BLE host and the audio pipeline are only built when
10
+ // something actually uses them.
11
+ const networkBindings = ['wifi', 'fetch', 'http', 'websocket', 'rtc']
12
+
13
+ export function parseAnalysis(output) {
14
+ const bindings = output.match(/^bindings=(.*)$/m)?.[1]?.split(';').filter(Boolean) ?? []
15
+ const features = output.match(/^features=(.*)$/m)?.[1]?.split(';').filter(Boolean) ?? []
16
+ return { bindings, features }
17
+ }
18
+
19
+ export function manifestRequestsBleOta(packageJson) {
20
+ return packageJson?.gea?.ota?.ble === true
21
+ }
22
+
23
+ export function analyzeApp(ctx, app, { env = ctx.env || process.env } = {}) {
24
+ const compiler = path.join(ctx.compilerPackageDir || '', 'dist', 'cli.js')
25
+ const plugin = path.join(ctx.pluginPackageDir || '', 'dist', 'index.js')
26
+ if (!ctx.compilerPackageDir || !exists(compiler)) {
27
+ fail(`@geastack/compiler is not installed in this project (expected ${compiler}).`, ExitCode.missingDependency)
28
+ }
29
+ if (!ctx.pluginPackageDir || !exists(plugin)) {
30
+ fail(`@geastack/geatsc-plugin-gea is not installed in this project (expected ${plugin}).`, ExitCode.missingDependency)
31
+ }
32
+ const output = execFileSync(process.execPath, [compiler, 'analyze', path.join(app.root, app.entry), '--plugin', plugin], {
33
+ cwd: ctx.compilerPackageDir,
34
+ encoding: 'utf8',
35
+ env,
36
+ stdio: ['ignore', 'pipe', 'inherit']
37
+ })
38
+ return parseAnalysis(output)
39
+ }
40
+
41
+ export function resolveAppCapabilities(ctx, app, options = {}) {
42
+ const analysis = analyzeApp(ctx, app, options)
43
+ const bindings = new Set(analysis.bindings)
44
+ const features = new Set(analysis.features)
45
+ const manifestBleOta = manifestRequestsBleOta(app.packageJson)
46
+ return {
47
+ network: networkBindings.some((binding) => bindings.has(binding)) || features.has('https'),
48
+ ble: bindings.has('ble') || manifestBleOta,
49
+ bleApi: bindings.has('ble'),
50
+ audio: bindings.has('audio'),
51
+ bindings: [...bindings].sort(),
52
+ features: [...features].sort()
53
+ }
54
+ }