@geastack/cli 0.1.51 → 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,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
|
+
}
|