@geastack/cli 0.1.53 → 0.1.55
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/README.md +17 -4
- package/docs/ESP32-WAVESHARE-AMOLED-QUICKSTART.md +2 -2
- package/docs/NPX-COMMANDS.md +10 -4
- package/docs/SETUP.md +99 -21
- package/docs/SPEC.md +6 -3
- package/package.json +1 -1
- package/src/boards/config.mjs +90 -8
- package/src/boards/resolve.mjs +5 -3
- package/src/boards/usb.mjs +41 -0
- package/src/chips.mjs +1 -1
- package/src/commands/apps.mjs +1 -33
- package/src/commands/board.mjs +15 -2
- package/src/commands/boards.mjs +334 -0
- package/src/commands/doctor.mjs +4 -4
- package/src/context.mjs +5 -2
- package/src/esp32/idf-version.mjs +89 -0
- package/src/gea.mjs +9 -4
- package/src/serial-devices.mjs +32 -20
- package/src/setup-wizard.mjs +140 -52
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { flag, option } from '../args.mjs'
|
|
2
|
+
import {
|
|
3
|
+
boardConfigOrigins,
|
|
4
|
+
boardConfigPath,
|
|
5
|
+
boardConfigTiers,
|
|
6
|
+
boardConfigWritePath,
|
|
7
|
+
loadBoardConfigWithOrigins,
|
|
8
|
+
readBoardConfigFile,
|
|
9
|
+
writeBoardConfigFile
|
|
10
|
+
} from '../boards/config.mjs'
|
|
11
|
+
import { normalizedSerial } from '../boards/usb.mjs'
|
|
12
|
+
import { SerialDevice, geadev } from '../device/serial.mjs'
|
|
13
|
+
import { ExitCode, fail } from '../errors.mjs'
|
|
14
|
+
import { exists } from '../fs-utils.mjs'
|
|
15
|
+
import { detectSerialDevices } from '../serial-devices.mjs'
|
|
16
|
+
|
|
17
|
+
export const boardsUsage = `gea boards <subcommand> [--global | --local]
|
|
18
|
+
|
|
19
|
+
list [--json] every alias, with the file it lives in
|
|
20
|
+
show <alias> [--json]
|
|
21
|
+
add register a board (guided; gea setup)
|
|
22
|
+
set <alias> <key> <value> edit one field; an empty value removes it
|
|
23
|
+
remove <alias>
|
|
24
|
+
rename <alias> <new-alias>
|
|
25
|
+
discover [--json] [--save] identify the boards plugged in over USB
|
|
26
|
+
|
|
27
|
+
Keys for set: host (transports.ota.host), serial (transports.usbSerial.serial),
|
|
28
|
+
restart (transports.usbSerial.restartAfterFlash), target, adapter, or any
|
|
29
|
+
dotted path such as transports.telnet.port.
|
|
30
|
+
|
|
31
|
+
Aliases are read from ~/.geastack/boards.json (every board on this machine)
|
|
32
|
+
and the project's .gea/boards.json (project overrides), or only from
|
|
33
|
+
--boards-config when given. --global / --local choose where a write goes
|
|
34
|
+
(the machine-wide file or the project's); by default an existing alias is
|
|
35
|
+
edited where it lives and a new one joins the project config when the project
|
|
36
|
+
has one.`
|
|
37
|
+
|
|
38
|
+
const keyShorthands = {
|
|
39
|
+
host: 'transports.ota.host',
|
|
40
|
+
ip: 'transports.ota.host',
|
|
41
|
+
serial: 'transports.usbSerial.serial',
|
|
42
|
+
usb: 'transports.usbSerial.serial',
|
|
43
|
+
restart: 'transports.usbSerial.restartAfterFlash',
|
|
44
|
+
restartAfterFlash: 'transports.usbSerial.restartAfterFlash'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --project is already the global "project directory" option, so the
|
|
48
|
+
// project tier is selected with --local.
|
|
49
|
+
function writeScope(parsed) {
|
|
50
|
+
if (flag(parsed, 'global') && flag(parsed, 'local')) fail('Pass either --global or --local, not both.', ExitCode.usage)
|
|
51
|
+
return flag(parsed, 'global') ? 'global' : flag(parsed, 'local') ? 'project' : ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tierLabel(ctx, file) {
|
|
55
|
+
const tier = boardConfigTiers(ctx).find((candidate) => candidate.file === file)
|
|
56
|
+
return tier ? tier.scope : file
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requireAlias(boards, alias, verb) {
|
|
60
|
+
if (!alias) fail(`gea boards ${verb} needs a board alias.\n${boardsUsage}`, ExitCode.usage)
|
|
61
|
+
if (!boards[alias]) fail(`Unknown board '${alias}'. Run gea boards list.`, ExitCode.usage)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function boardSummaryLine(alias, board, scope = '') {
|
|
65
|
+
const bits = [board.target || '(no target)']
|
|
66
|
+
if (board.transports?.usbSerial?.serial) bits.push(`usb ${board.transports.usbSerial.serial}`)
|
|
67
|
+
if (board.transports?.ota?.host) bits.push(`wifi ${board.transports.ota.host}`)
|
|
68
|
+
if (board.transports?.telnet?.host) bits.push(`telnet ${board.transports.telnet.host}`)
|
|
69
|
+
if (scope) bits.push(`[${scope}]`)
|
|
70
|
+
return `${alias}\t${bits.join(' ')}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A value typed on the command line: numbers and booleans become JSON
|
|
74
|
+
// values (transports.telnet.port is a number), everything else stays a
|
|
75
|
+
// string. IPs, MAC-style serials and hostnames never parse as JSON.
|
|
76
|
+
export function parseFieldValue(raw) {
|
|
77
|
+
if (raw === '' || raw === undefined) return undefined
|
|
78
|
+
if (/^(true|false|null|-?\d+(\.\d+)?)$/.test(raw) || /^[[{]/.test(raw)) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(raw)
|
|
81
|
+
} catch {
|
|
82
|
+
return raw
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return raw
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function setFieldPath(board, keyPath, value) {
|
|
89
|
+
const segments = keyPath.split('.').filter(Boolean)
|
|
90
|
+
if (segments.length === 0) fail('The key to set is empty.', ExitCode.usage)
|
|
91
|
+
let cursor = board
|
|
92
|
+
for (const segment of segments.slice(0, -1)) {
|
|
93
|
+
if (cursor[segment] === undefined || cursor[segment] === null || typeof cursor[segment] !== 'object') {
|
|
94
|
+
if (value === undefined) return board
|
|
95
|
+
cursor[segment] = {}
|
|
96
|
+
}
|
|
97
|
+
cursor = cursor[segment]
|
|
98
|
+
}
|
|
99
|
+
const last = segments[segments.length - 1]
|
|
100
|
+
if (value === undefined) delete cursor[last]
|
|
101
|
+
else cursor[last] = value
|
|
102
|
+
pruneEmptyObjects(board)
|
|
103
|
+
return board
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pruneEmptyObjects(value) {
|
|
107
|
+
for (const [key, child] of Object.entries(value)) {
|
|
108
|
+
if (child && typeof child === 'object' && !Array.isArray(child)) {
|
|
109
|
+
pruneEmptyObjects(child)
|
|
110
|
+
if (Object.keys(child).length === 0 && key !== 'transports') delete value[key]
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function updateBoardFile(file, mutate) {
|
|
116
|
+
const boards = readBoardConfigFile(file)
|
|
117
|
+
const next = mutate(boards)
|
|
118
|
+
writeBoardConfigFile(file, next)
|
|
119
|
+
return next
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- discover -----------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
// Asks a serial device who it is with one GEADEV PING. A board that is mid
|
|
125
|
+
// boot, running a firmware without device control, or not a gea board at
|
|
126
|
+
// all answers nothing and is reported as such; nothing here resets the port
|
|
127
|
+
// (SerialDevice.open never touches DTR/RTS).
|
|
128
|
+
export async function probeSerialDevice(device, { baudRate = 115200, timeoutMs = 1500, env = process.env } = {}) {
|
|
129
|
+
let serial = null
|
|
130
|
+
try {
|
|
131
|
+
serial = await SerialDevice.open({ path: device.path, baudRate: Number(env.GEA_SERIAL_BAUD) || baudRate })
|
|
132
|
+
await serial.drainInput(60, 200)
|
|
133
|
+
const reply = await serial.command('GEADEV PING', ['GEADEV:PONG'], timeoutMs)
|
|
134
|
+
const values = parsePong(reply)
|
|
135
|
+
return { ok: true, ...values }
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return { ok: false, error: error.message }
|
|
138
|
+
} finally {
|
|
139
|
+
if (serial) await serial.close().catch(() => {})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function parsePong(reply) {
|
|
144
|
+
const values = {}
|
|
145
|
+
for (const token of String(reply).split(/\s+/).slice(1)) {
|
|
146
|
+
const eq = token.indexOf('=')
|
|
147
|
+
if (eq > 0) values[token.slice(0, eq)] = token.slice(eq + 1)
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
app: values.app || '',
|
|
151
|
+
ip: values.ip && values.ip !== '0.0.0.0' ? values.ip : '',
|
|
152
|
+
mac: values.mac || ''
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Pure: pairs detected serial devices with configured aliases by USB serial
|
|
157
|
+
// (the MAC on ESP32 boards, so a PONG's mac= confirms the same thing), and
|
|
158
|
+
// records what each device answered.
|
|
159
|
+
export async function discoverBoards({ devices, boards, probe }) {
|
|
160
|
+
const results = []
|
|
161
|
+
for (const device of devices) {
|
|
162
|
+
const probed = await probe(device)
|
|
163
|
+
const serials = [device.serial, probed.mac].map(normalizedSerial).filter(Boolean)
|
|
164
|
+
const alias = Object.keys(boards).find((name) => {
|
|
165
|
+
const configured = normalizedSerial(boards[name]?.transports?.usbSerial?.serial)
|
|
166
|
+
return configured && serials.includes(configured)
|
|
167
|
+
})
|
|
168
|
+
results.push({
|
|
169
|
+
path: device.path,
|
|
170
|
+
label: device.label || '',
|
|
171
|
+
serial: device.serial || probed.mac || '',
|
|
172
|
+
alias: alias || '',
|
|
173
|
+
target: alias ? boards[alias].target || '' : '',
|
|
174
|
+
configuredHost: alias ? boards[alias].transports?.ota?.host || '' : '',
|
|
175
|
+
responds: probed.ok,
|
|
176
|
+
app: probed.app || '',
|
|
177
|
+
ip: probed.ip || '',
|
|
178
|
+
mac: probed.mac || '',
|
|
179
|
+
error: probed.ok ? '' : probed.error || ''
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
return results
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function formatDiscovery(result) {
|
|
186
|
+
const who = result.alias ? `${result.alias} (${result.target})` : 'not configured'
|
|
187
|
+
const bits = [result.path, who]
|
|
188
|
+
if (result.serial) bits.push(`serial ${result.serial}`)
|
|
189
|
+
if (result.responds) {
|
|
190
|
+
bits.push(result.app ? `app ${result.app}` : 'app ?')
|
|
191
|
+
bits.push(result.ip ? `ip ${result.ip}` : 'no ip')
|
|
192
|
+
} else {
|
|
193
|
+
bits.push('no GEADEV reply')
|
|
194
|
+
}
|
|
195
|
+
return bits.join(' ')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- command --------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
export async function boardsCommand(ctx, parsed, rest, options) {
|
|
201
|
+
const sub = rest[0] || 'list'
|
|
202
|
+
const json = flag(parsed, 'json')
|
|
203
|
+
const { boards, origins } = loadBoardConfigWithOrigins(ctx)
|
|
204
|
+
|
|
205
|
+
if (sub === 'help') {
|
|
206
|
+
options.stdout(boardsUsage)
|
|
207
|
+
return 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (sub === 'list') {
|
|
211
|
+
const names = Object.keys(boards).sort()
|
|
212
|
+
if (json) {
|
|
213
|
+
options.stdout(JSON.stringify(boards, null, 2))
|
|
214
|
+
return 0
|
|
215
|
+
}
|
|
216
|
+
if (names.length === 0) {
|
|
217
|
+
const tiers = boardConfigTiers(ctx).map((tier) => `${tier.file}${exists(tier.file) ? '' : ' (missing)'}`)
|
|
218
|
+
options.stdout(`No boards configured. Looked in:\n ${tiers.join('\n ')}\nRun gea boards add, or gea boards discover to see what is plugged in.`)
|
|
219
|
+
return 0
|
|
220
|
+
}
|
|
221
|
+
for (const name of names) options.stdout(boardSummaryLine(name, boards[name], tierLabel(ctx, origins.get(name))))
|
|
222
|
+
return 0
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (sub === 'show') {
|
|
226
|
+
const name = rest[1] || option(parsed, 'board', '')
|
|
227
|
+
requireAlias(boards, name, 'show')
|
|
228
|
+
if (json) options.stdout(JSON.stringify({ [name]: boards[name] }, null, 2))
|
|
229
|
+
else {
|
|
230
|
+
options.stdout(`${name}: ${origins.get(name)}`)
|
|
231
|
+
options.stdout(JSON.stringify(boards[name], null, 2))
|
|
232
|
+
}
|
|
233
|
+
return 0
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (sub === 'add') {
|
|
237
|
+
const { runSetupWizard } = await import('../setup-wizard.mjs')
|
|
238
|
+
return runSetupWizard(ctx, parsed, options)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (sub === 'set') {
|
|
242
|
+
const [, name, key, ...valueParts] = rest
|
|
243
|
+
requireAlias(boards, name, 'set')
|
|
244
|
+
if (!key) fail(`gea boards set needs a key and a value.\n${boardsUsage}`, ExitCode.usage)
|
|
245
|
+
const keyPath = keyShorthands[key] || key
|
|
246
|
+
const value = parseFieldValue(valueParts.join(' '))
|
|
247
|
+
const file = boardConfigWritePath(ctx, { scope: writeScope(parsed), alias: name })
|
|
248
|
+
const scope = writeScope(parsed)
|
|
249
|
+
updateBoardFile(file, (current) => {
|
|
250
|
+
// Moving an alias between tiers with --global/--project copies the
|
|
251
|
+
// merged entry so the edit lands on a complete board, not a fragment.
|
|
252
|
+
const base = current[name] ?? structuredClone(boards[name])
|
|
253
|
+
current[name] = setFieldPath(base, keyPath, value)
|
|
254
|
+
return current
|
|
255
|
+
})
|
|
256
|
+
if (scope && origins.get(name) && origins.get(name) !== file) {
|
|
257
|
+
options.stderr(`Note: '${name}' also exists in ${origins.get(name)}; the project entry is the one commands see.`)
|
|
258
|
+
}
|
|
259
|
+
options.stdout(value === undefined ? `Removed ${keyPath} from '${name}' in ${file}` : `Set ${keyPath}=${JSON.stringify(value)} on '${name}' in ${file}`)
|
|
260
|
+
return 0
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
264
|
+
const name = rest[1]
|
|
265
|
+
requireAlias(boards, name, 'remove')
|
|
266
|
+
const scope = writeScope(parsed)
|
|
267
|
+
const files = scope ? [boardConfigWritePath(ctx, { scope })] : boardConfigTiers(ctx).map((tier) => tier.file)
|
|
268
|
+
let removedFrom = []
|
|
269
|
+
for (const file of files) {
|
|
270
|
+
if (!exists(file)) continue
|
|
271
|
+
const current = readBoardConfigFile(file)
|
|
272
|
+
if (!current[name]) continue
|
|
273
|
+
delete current[name]
|
|
274
|
+
writeBoardConfigFile(file, current)
|
|
275
|
+
removedFrom.push(file)
|
|
276
|
+
}
|
|
277
|
+
if (removedFrom.length === 0) fail(`'${name}' is not defined in ${files.join(' or ')}.`, ExitCode.usage)
|
|
278
|
+
options.stdout(`Removed '${name}' from ${removedFrom.join(', ')}`)
|
|
279
|
+
return 0
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (sub === 'rename' || sub === 'mv') {
|
|
283
|
+
const [, from, to] = rest
|
|
284
|
+
requireAlias(boards, from, 'rename')
|
|
285
|
+
if (!to || !/^[a-z0-9][a-z0-9._-]*$/i.test(to)) fail(`gea boards rename needs a new alias made of letters, digits, dots, dashes or underscores.`, ExitCode.usage)
|
|
286
|
+
if (boards[to]) fail(`A board named '${to}' already exists. Remove it first.`, ExitCode.usage)
|
|
287
|
+
const file = boardConfigWritePath(ctx, { scope: writeScope(parsed), alias: from })
|
|
288
|
+
updateBoardFile(file, (current) => {
|
|
289
|
+
const entry = current[from] ?? structuredClone(boards[from])
|
|
290
|
+
delete current[from]
|
|
291
|
+
current[to] = entry
|
|
292
|
+
return current
|
|
293
|
+
})
|
|
294
|
+
options.stdout(`Renamed '${from}' to '${to}' in ${file}`)
|
|
295
|
+
return 0
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (sub === 'discover' || sub === 'scan') {
|
|
299
|
+
const devices = detectSerialDevices({ env: options.env || ctx.env || process.env })
|
|
300
|
+
const timeoutMs = Number(option(parsed, 'timeout', '1500')) || 1500
|
|
301
|
+
const probe = options.probeSerialDevice || ((device) => probeSerialDevice(device, { timeoutMs, env: options.env || ctx.env || process.env }))
|
|
302
|
+
const results = await discoverBoards({ devices, boards, probe: flag(parsed, 'no-probe') ? async () => ({ ok: false, error: 'not probed' }) : probe })
|
|
303
|
+
if (json) {
|
|
304
|
+
options.stdout(JSON.stringify(results, null, 2))
|
|
305
|
+
} else if (results.length === 0) {
|
|
306
|
+
options.stdout('No serial devices detected. Plug a board in over USB and retry.')
|
|
307
|
+
} else {
|
|
308
|
+
for (const result of results) options.stdout(formatDiscovery(result))
|
|
309
|
+
const unconfigured = results.filter((result) => !result.alias && result.serial)
|
|
310
|
+
for (const result of unconfigured) {
|
|
311
|
+
options.stdout(` -> register it: gea boards add (USB serial ${result.serial}${result.app ? `, running ${result.app}` : ''})`)
|
|
312
|
+
}
|
|
313
|
+
for (const result of results.filter((entry) => entry.alias && entry.ip && entry.ip !== entry.configuredHost)) {
|
|
314
|
+
options.stdout(` -> '${result.alias}' is at ${result.ip}${result.configuredHost ? ` (config says ${result.configuredHost})` : ''}: gea boards set ${result.alias} host ${result.ip}, or rerun with --save`)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (flag(parsed, 'save')) {
|
|
318
|
+
for (const result of results.filter((entry) => entry.alias && entry.ip && entry.ip !== entry.configuredHost)) {
|
|
319
|
+
const file = boardConfigWritePath(ctx, { scope: writeScope(parsed), alias: result.alias })
|
|
320
|
+
updateBoardFile(file, (current) => {
|
|
321
|
+
const base = current[result.alias] ?? structuredClone(boards[result.alias])
|
|
322
|
+
current[result.alias] = setFieldPath(base, 'transports.ota.host', result.ip)
|
|
323
|
+
return current
|
|
324
|
+
})
|
|
325
|
+
options.stdout(`Saved transports.ota.host=${result.ip} for '${result.alias}' in ${file}`)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return 0
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
fail(`Unknown boards subcommand '${sub}'.\n${boardsUsage}`, ExitCode.usage)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export { boardConfigPath, boardConfigOrigins }
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { flag } from '../args.mjs'
|
|
5
|
-
import { boardConfigPath, loadBoardConfig } from '../boards/config.mjs'
|
|
5
|
+
import { boardConfigPath, boardConfigTiers, loadBoardConfig } from '../boards/config.mjs'
|
|
6
6
|
import { ExitCode } from '../errors.mjs'
|
|
7
7
|
import { findEspIdf, findIdfPythonEnv } from '../esp32/idf-env.mjs'
|
|
8
8
|
import { exists } from '../fs-utils.mjs'
|
|
@@ -53,10 +53,10 @@ export async function doctorCommand(ctx, parsed, rest, options) {
|
|
|
53
53
|
add('picotool (RP2350)', onPath('picotool', env), onPath('picotool', env) ? 'on PATH' : 'optional', false)
|
|
54
54
|
add('swift (BLE OTA)', onPath('swift', env), onPath('swift', env) ? 'on PATH' : 'optional; macOS only', false)
|
|
55
55
|
|
|
56
|
-
const
|
|
56
|
+
const boardFiles = boardConfigTiers(ctx).filter((tier) => exists(tier.file)).map((tier) => tier.file)
|
|
57
57
|
try {
|
|
58
|
-
const boards =
|
|
59
|
-
add('boards.json', true,
|
|
58
|
+
const boards = boardFiles.length ? loadBoardConfig(ctx) : {}
|
|
59
|
+
add('boards.json', true, boardFiles.length ? `${boardFiles.join(', ')} (${Object.keys(boards).length} board(s))` : `not configured (gea boards add; looked for ${boardConfigPath(ctx)})`, false)
|
|
60
60
|
} catch (error) {
|
|
61
61
|
add('boards.json', false, error.message, true)
|
|
62
62
|
}
|
package/src/context.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import path from 'node:path'
|
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
|
|
4
4
|
import { option } from './args.mjs'
|
|
5
|
+
import { homeBoardsConfigPath } from './boards/config.mjs'
|
|
5
6
|
import { exists, findUp } from './fs-utils.mjs'
|
|
6
7
|
|
|
7
8
|
const srcDir = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -31,13 +32,15 @@ export function createContext(parsed, env = process.env, cwd = process.cwd()) {
|
|
|
31
32
|
const explicitProject = option(parsed, 'project') || env.GEA_PROJECT_ROOT || ''
|
|
32
33
|
const projectRoot = explicitProject ? path.resolve(absoluteCwd, explicitProject) : findNodeProjectRoot(absoluteCwd) || absoluteCwd
|
|
33
34
|
const projectBoardsConfig = path.join(projectRoot, '.gea', 'boards.json')
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// Only an explicit file is `boardsConfig`; the project and home tiers are
|
|
36
|
+
// merged by src/boards/config.mjs, which also decides where a write goes.
|
|
37
|
+
const boardsConfig = option(parsed, 'boards-config') || env.GEA_BOARDS_CONFIG || ''
|
|
36
38
|
|
|
37
39
|
const ctx = {
|
|
38
40
|
cwd: absoluteCwd,
|
|
39
41
|
projectRoot,
|
|
40
42
|
projectBoardsConfig,
|
|
43
|
+
homeBoardsConfig: homeBoardsConfigPath(env),
|
|
41
44
|
cliPackageRoot,
|
|
42
45
|
cliBin,
|
|
43
46
|
boardsConfig: boardsConfig ? path.resolve(absoluteCwd, boardsConfig) : '',
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// ESP-IDF target-version resolution.
|
|
2
|
+
//
|
|
3
|
+
// One pinned default in code, an env var / CLI option that overrides it
|
|
4
|
+
// outright (pin an older release or try a release candidate), and a
|
|
5
|
+
// best-effort GitHub "latest release" lookup used only when nothing
|
|
6
|
+
// overrides the pin. The network call is injected (`fetchImpl`/`fetchLatest`)
|
|
7
|
+
// so real usage hits GitHub while tests supply a fake and never touch the
|
|
8
|
+
// network.
|
|
9
|
+
|
|
10
|
+
const STABLE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/
|
|
11
|
+
const VERSION_IN_TEXT = /v?(\d+)\.(\d+)\.(\d+)/i
|
|
12
|
+
|
|
13
|
+
// The version GeaStack targets when nothing overrides it and the latest
|
|
14
|
+
// release cannot be determined (offline, GitHub unreachable, rate limited).
|
|
15
|
+
// Bump this alongside board-script updates.
|
|
16
|
+
export const DEFAULT_ESP_IDF_VERSION = 'v6.0.2'
|
|
17
|
+
|
|
18
|
+
const GITHUB_LATEST_RELEASE_URL = 'https://api.github.com/repos/espressif/esp-idf/releases/latest'
|
|
19
|
+
|
|
20
|
+
// Parses a strict `vX.Y.Z` release tag. Returns null for anything else,
|
|
21
|
+
// including release candidates and betas (`v6.1.0-rc1`), so callers can
|
|
22
|
+
// filter those out of the "latest" lookup.
|
|
23
|
+
export function parseStableIdfTag(value) {
|
|
24
|
+
const match = STABLE_TAG.exec(String(value || '').trim())
|
|
25
|
+
if (!match) return null
|
|
26
|
+
return { tag: match[0], major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pulls a `major.minor(.patch)` out of free-form text such as
|
|
30
|
+
// `idf.py --version`'s `ESP-IDF v6.0.2-dirty` output.
|
|
31
|
+
export function extractIdfVersionFromText(text) {
|
|
32
|
+
const match = VERSION_IN_TEXT.exec(String(text || ''))
|
|
33
|
+
if (!match) return null
|
|
34
|
+
return { majorMinor: `${match[1]}.${match[2]}`, full: `${match[1]}.${match[2]}.${match[3]}` }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Best-effort lookup of the latest stable ESP-IDF release tag from GitHub.
|
|
38
|
+
// Never throws: returns '' when the request fails, times out (~4s default),
|
|
39
|
+
// the machine is offline, or the latest release is not a stable vX.Y.Z tag.
|
|
40
|
+
// `fetchImpl` defaults to the global fetch; tests inject a fake instead of
|
|
41
|
+
// touching the network.
|
|
42
|
+
export async function fetchLatestEspIdfVersion({ fetchImpl = globalThis.fetch, timeoutMs = 4000, log = () => {} } = {}) {
|
|
43
|
+
if (typeof fetchImpl !== 'function') return ''
|
|
44
|
+
const controller = new AbortController()
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetchImpl(GITHUB_LATEST_RELEASE_URL, {
|
|
48
|
+
signal: controller.signal,
|
|
49
|
+
headers: { Accept: 'application/vnd.github+json' }
|
|
50
|
+
})
|
|
51
|
+
if (!response?.ok) return ''
|
|
52
|
+
const body = await response.json()
|
|
53
|
+
return parseStableIdfTag(body?.tag_name)?.tag || ''
|
|
54
|
+
} catch (error) {
|
|
55
|
+
log(`Could not determine the latest ESP-IDF release (${error.message}); using ${DEFAULT_ESP_IDF_VERSION}.`)
|
|
56
|
+
return ''
|
|
57
|
+
} finally {
|
|
58
|
+
clearTimeout(timer)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Resolves the ESP-IDF version tag to install/verify against, in order:
|
|
63
|
+
// 1. `override` (--idf-version) or GEA_ESP_IDF_VERSION -- explicit pin or
|
|
64
|
+
// a release candidate to test; used as-is (not required to be stable).
|
|
65
|
+
// 2. the latest stable GitHub release, when `fetchLatest` can determine one.
|
|
66
|
+
// 3. DEFAULT_ESP_IDF_VERSION.
|
|
67
|
+
export async function resolveEspIdfVersion({ override, env = process.env, fetchLatest = fetchLatestEspIdfVersion, log = () => {} } = {}) {
|
|
68
|
+
const requested = override || env.GEA_ESP_IDF_VERSION
|
|
69
|
+
if (requested) return requested.startsWith('v') ? requested : `v${requested}`
|
|
70
|
+
const latest = await fetchLatest({ log })
|
|
71
|
+
return latest || DEFAULT_ESP_IDF_VERSION
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// True when an installed ESP-IDF's version.cmake-derived version satisfies
|
|
75
|
+
// the resolved target: same-or-newer major.minor. This is a floor, not an
|
|
76
|
+
// exact-match: an installed 6.0.2 must not be rejected just because the
|
|
77
|
+
// resolved target moved on to, say, 6.1.0 -- reinstalling isn't required
|
|
78
|
+
// unless the installed major.minor genuinely trails the target's.
|
|
79
|
+
// An unparsable target is treated as "anything installed is acceptable".
|
|
80
|
+
export function idfVersionMeetsTarget(installedVersion, targetTag) {
|
|
81
|
+
const target = extractIdfVersionFromText(targetTag)
|
|
82
|
+
if (!target) return true
|
|
83
|
+
const [targetMajor, targetMinor] = target.majorMinor.split('.').map(Number)
|
|
84
|
+
const parts = String(installedVersion?.majorMinor || '').split('.').map(Number)
|
|
85
|
+
if (parts.length < 2 || parts.some((part) => Number.isNaN(part))) return false
|
|
86
|
+
const [installedMajor, installedMinor] = parts
|
|
87
|
+
if (installedMajor !== targetMajor) return installedMajor > targetMajor
|
|
88
|
+
return installedMinor >= targetMinor
|
|
89
|
+
}
|
package/src/gea.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import fs from 'node:fs'
|
|
|
2
2
|
|
|
3
3
|
import { flag, option, parseArgs } from './args.mjs'
|
|
4
4
|
import { runChips } from './chips.mjs'
|
|
5
|
-
import { appsCommand,
|
|
5
|
+
import { appsCommand, heapReportCommand, targetsCommand } from './commands/apps.mjs'
|
|
6
|
+
import { boardsCommand } from './commands/boards.mjs'
|
|
6
7
|
import { buildCommand, cleanCommand, devctlCommand, flashCommand, geaosDeviceCommand, logsCommand, monitorCommand, otaCommand, screenshotCommand } from './commands/board.mjs'
|
|
7
8
|
import { doctorCommand } from './commands/doctor.mjs'
|
|
8
9
|
import { createContext } from './context.mjs'
|
|
@@ -42,7 +43,10 @@ export async function runGea(argv, io = {}) {
|
|
|
42
43
|
|
|
43
44
|
const ctx = createContext(parsed, env, cwd)
|
|
44
45
|
const rest = parsed.positionals.slice(1)
|
|
45
|
-
|
|
46
|
+
// probeSerialDevice lets tests answer `gea boards discover` without a port;
|
|
47
|
+
// fetchEspIdfLatest likewise lets tests answer the setup wizard's ESP-IDF
|
|
48
|
+
// "latest release" lookup without touching the network.
|
|
49
|
+
const options = { stdout, stderr, env, stdin, output, prompt, probeSerialDevice: io.probeSerialDevice, fetchEspIdfLatest: io.fetchEspIdfLatest }
|
|
46
50
|
|
|
47
51
|
// A platform name in --target (web, macos, ...) is not a board.
|
|
48
52
|
const target = option(parsed, 'target', '')
|
|
@@ -137,10 +141,11 @@ Device access:
|
|
|
137
141
|
|
|
138
142
|
Catalogs:
|
|
139
143
|
gea apps list|inspect|pack|index|launcher|icons|icon-sheet|apple-icons
|
|
140
|
-
gea boards list|show
|
|
144
|
+
gea boards list|show|add|set|remove|rename|discover (gea boards help)
|
|
141
145
|
gea targets list|show <id>
|
|
142
146
|
gea chips ... custom board composition from the chip catalog
|
|
143
147
|
gea heap-report [logs...] [--out file] [--map elf.map]
|
|
144
148
|
|
|
145
|
-
Global options: --project <dir> --boards-config <file> --dry-run --json
|
|
149
|
+
Global options: --project <dir> --boards-config <file> --global|--local (boards writes) --dry-run --json
|
|
150
|
+
Board aliases: ~/.geastack/boards.json (this machine) + <project>/.gea/boards.json (overrides)`
|
|
146
151
|
}
|
package/src/serial-devices.mjs
CHANGED
|
@@ -2,10 +2,16 @@ import { execFileSync } from 'node:child_process'
|
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import { listMacUsbCalloutPorts } from './boards/usb.mjs'
|
|
6
|
+
|
|
7
|
+
// Serial devices with, where the OS can tell, the USB serial that identifies
|
|
8
|
+
// the board. A /dev name is never that identity -- `cu.usbmodem1101` is a
|
|
9
|
+
// slot number that changes on every enumeration -- so a device whose serial
|
|
10
|
+
// the registry does not know reports an empty one and the caller asks.
|
|
11
|
+
export function detectSerialDevices({ env = process.env, platform = process.platform, ioreg = undefined } = {}) {
|
|
6
12
|
if (env.GEA_SERIAL_DEVICES) return parseSerialDevices(env.GEA_SERIAL_DEVICES)
|
|
7
13
|
if (platform === 'win32') return detectWindowsSerialDevices(env)
|
|
8
|
-
return detectUnixSerialDevices()
|
|
14
|
+
return detectUnixSerialDevices({ platform, ioreg })
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
export function formatSerialDevice(device) {
|
|
@@ -32,13 +38,28 @@ function parseSerialDevices(value) {
|
|
|
32
38
|
.filter((device) => device.path)
|
|
33
39
|
}
|
|
34
40
|
|
|
35
|
-
function detectUnixSerialDevices() {
|
|
41
|
+
function detectUnixSerialDevices({ platform, ioreg }) {
|
|
36
42
|
const devices = new Map()
|
|
37
43
|
addLinuxByIdDevices(devices)
|
|
38
|
-
addDevPatternDevices(devices)
|
|
44
|
+
addDevPatternDevices(devices, platform)
|
|
45
|
+
if (platform === 'darwin') mergeRegistryDevices(devices, ioreg ? listMacUsbCalloutPorts(ioreg) : listMacUsbCalloutPorts())
|
|
39
46
|
return [...devices.values()].sort((a, b) => a.path.localeCompare(b.path))
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
// Overlays what the USB registry knows (serial, product name) on the ports
|
|
50
|
+
// found under /dev, and adds callout ports /dev scanning did not match.
|
|
51
|
+
export function mergeRegistryDevices(devices, registry) {
|
|
52
|
+
for (const entry of registry) {
|
|
53
|
+
const known = devices.get(entry.path)
|
|
54
|
+
devices.set(entry.path, normalizeDevice({
|
|
55
|
+
path: entry.path,
|
|
56
|
+
label: entry.label || known?.label || entry.path,
|
|
57
|
+
serial: entry.serial || known?.serial || ''
|
|
58
|
+
}))
|
|
59
|
+
}
|
|
60
|
+
return devices
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
function addLinuxByIdDevices(devices) {
|
|
43
64
|
const byId = '/dev/serial/by-id'
|
|
44
65
|
if (!isDirectory(byId)) return
|
|
@@ -52,27 +73,18 @@ function addLinuxByIdDevices(devices) {
|
|
|
52
73
|
}
|
|
53
74
|
}
|
|
54
75
|
|
|
55
|
-
|
|
76
|
+
// On macOS only the call-up (`cu.`) device is listed: `tty.` is the same
|
|
77
|
+
// port waiting for carrier, and opening it blocks.
|
|
78
|
+
function addDevPatternDevices(devices, platform) {
|
|
56
79
|
const dev = '/dev'
|
|
57
80
|
if (!isDirectory(dev)) return
|
|
58
|
-
const patterns =
|
|
59
|
-
/^cu\.usbmodem/,
|
|
60
|
-
/^
|
|
61
|
-
/^cu\.usbserial/,
|
|
62
|
-
/^tty\.usbserial/,
|
|
63
|
-
/^cu\.SLAB_USBtoUART/,
|
|
64
|
-
/^tty\.SLAB_USBtoUART/,
|
|
65
|
-
/^ttyACM/,
|
|
66
|
-
/^ttyUSB/
|
|
67
|
-
]
|
|
81
|
+
const patterns = platform === 'darwin'
|
|
82
|
+
? [/^cu\.usbmodem/, /^cu\.usbserial/, /^cu\.SLAB_USBtoUART/, /^cu\.wchusbserial/]
|
|
83
|
+
: [/^ttyACM/, /^ttyUSB/]
|
|
68
84
|
for (const name of safeReaddir(dev)) {
|
|
69
85
|
if (!patterns.some((pattern) => pattern.test(name))) continue
|
|
70
86
|
const devicePath = path.join(dev, name)
|
|
71
|
-
devices.set(devicePath, normalizeDevice({
|
|
72
|
-
path: devicePath,
|
|
73
|
-
label: name,
|
|
74
|
-
serial: serialFromDeviceName(name)
|
|
75
|
-
}))
|
|
87
|
+
devices.set(devicePath, normalizeDevice({ path: devicePath, label: name, serial: '' }))
|
|
76
88
|
}
|
|
77
89
|
}
|
|
78
90
|
|