@geastack/cli 0.1.53 → 0.1.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/docs/ESP32-WAVESHARE-AMOLED-QUICKSTART.md +2 -2
- package/docs/NPX-COMMANDS.md +3 -1
- package/docs/SETUP.md +81 -15
- 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/boards.mjs +334 -0
- package/src/commands/doctor.mjs +4 -4
- package/src/context.mjs +5 -2
- package/src/gea.mjs +7 -4
- package/src/serial-devices.mjs +32 -20
- package/src/setup-wizard.mjs +7 -4
package/README.md
CHANGED
|
@@ -100,6 +100,18 @@ host bindings, and the other native packages from npm. A project can use a
|
|
|
100
100
|
local CLI with `npx gea` or a global installation with `gea`; neither command
|
|
101
101
|
depends on a GeaStack source checkout.
|
|
102
102
|
|
|
103
|
+
Boards are managed without editing JSON by hand; aliases live in
|
|
104
|
+
`~/.geastack/boards.json` (this machine) and the project's `.gea/boards.json`
|
|
105
|
+
(overrides):
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
gea boards discover # which registered board is on which USB port, its app and IP
|
|
109
|
+
gea boards list
|
|
110
|
+
gea boards set amoled host 192.168.1.100
|
|
111
|
+
gea boards rename amoled desk-amoled
|
|
112
|
+
gea boards remove desk-amoled
|
|
113
|
+
```
|
|
114
|
+
|
|
103
115
|
Custom boards remain editable after setup:
|
|
104
116
|
|
|
105
117
|
```sh
|
|
@@ -169,4 +181,5 @@ First implementation is in place:
|
|
|
169
181
|
- `list` and `inspect` helpers;
|
|
170
182
|
- `create-geastack` with a bundled counter starter, a blank application, and a
|
|
171
183
|
GitHub-backed rich example flow for web, embedded, GeaOS, iOS, macOS, and Android apps,
|
|
172
|
-
all with `.gea/boards.json
|
|
184
|
+
all with `.gea/boards.json`, plus `gea boards` for machine-wide aliases in
|
|
185
|
+
`~/.geastack/boards.json`.
|
|
@@ -90,7 +90,7 @@ You do not need to run that before every command unless `gea doctor` says ESP-ID
|
|
|
90
90
|
|
|
91
91
|
If the wizard finds a connected serial device, pick it. If no board is plugged in yet, that is fine; you can provide the port later.
|
|
92
92
|
|
|
93
|
-
The wizard writes the board
|
|
93
|
+
The wizard writes the board alias into your app's `.gea/boards.json` (or `~/.geastack/boards.json` with `--global`, for every project on this machine) and initializes the selected board target.
|
|
94
94
|
|
|
95
95
|
## 4. Flash
|
|
96
96
|
|
|
@@ -134,4 +134,4 @@ Most first-run issues are one of:
|
|
|
134
134
|
- ESP-IDF is not activated in the current shell.
|
|
135
135
|
- The USB cable is power-only.
|
|
136
136
|
- The serial port needs to be passed with `--port`.
|
|
137
|
-
- The board alias does not match
|
|
137
|
+
- The board alias does not match a name in `npx gea boards list` (`~/.geastack/boards.json` plus the app's `.gea/boards.json`). `npx gea boards discover` shows which registered board each USB port is.
|
package/docs/NPX-COMMANDS.md
CHANGED
|
@@ -94,7 +94,9 @@ packages are scoped and configured for restricted npmjs publication.
|
|
|
94
94
|
- ESP-IDF toolchain check/install only.
|
|
95
95
|
|
|
96
96
|
Known-board setup detects attached serial devices, asks for a stable USB serial,
|
|
97
|
-
shows a review screen, writes a board alias into the active boards config
|
|
97
|
+
shows a review screen, writes a board alias into the active boards config
|
|
98
|
+
(`--global` for `~/.geastack/boards.json`, `--local` for the project's
|
|
99
|
+
`.gea/boards.json`; by default the project config when it exists), then
|
|
98
100
|
initializes the selected board target so the next command can be
|
|
99
101
|
`npx gea flash --board <alias> --monitor`. In generated apps, that config is:
|
|
100
102
|
|
package/docs/SETUP.md
CHANGED
|
@@ -35,7 +35,7 @@ For ESP32 hardware:
|
|
|
35
35
|
- npm.
|
|
36
36
|
- Python 3.
|
|
37
37
|
- ESP-IDF v6.0.1.
|
|
38
|
-
-
|
|
38
|
+
- a board alias for your board (`~/.geastack/boards.json` or the project's `.gea/boards.json`, see Board Configuration).
|
|
39
39
|
|
|
40
40
|
For the Waveshare ESP32-S3 AMOLED board, use
|
|
41
41
|
[ESP32-WAVESHARE-AMOLED-QUICKSTART.md](ESP32-WAVESHARE-AMOLED-QUICKSTART.md).
|
|
@@ -224,9 +224,44 @@ manager or from python.org.
|
|
|
224
224
|
|
|
225
225
|
## Board Configuration
|
|
226
226
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
227
|
+
A board alias names one physical unit: `gea flash --board amoled` resolves
|
|
228
|
+
`amoled` to a target, a USB serial and, optionally, an IP. Aliases are
|
|
229
|
+
machine-local configuration and never come from a package. The CLI merges two
|
|
230
|
+
files, project over home:
|
|
231
|
+
|
|
232
|
+
| file | holds | written by |
|
|
233
|
+
| --- | --- | --- |
|
|
234
|
+
| `~/.geastack/boards.json` | every board on this machine (`GEA_HOME` relocates the directory) | `gea boards add --global`, `gea boards set` |
|
|
235
|
+
| `<project>/.gea/boards.json` | aliases specific to one application; overrides a home alias of the same name | `create-geastack` (empty), `gea boards add` |
|
|
236
|
+
|
|
237
|
+
`--boards-config <file>` (or `GEA_BOARDS_CONFIG`) replaces both with exactly
|
|
238
|
+
that file. By default `gea boards add` writes to the project config when the
|
|
239
|
+
project has one and to the home config otherwise; an existing alias is always
|
|
240
|
+
edited where it lives.
|
|
241
|
+
|
|
242
|
+
```sh
|
|
243
|
+
npx gea boards discover # what is plugged in: alias, app, IP
|
|
244
|
+
npx gea boards add # register a board (guided)
|
|
245
|
+
npx gea boards list # every alias and which file it lives in
|
|
246
|
+
npx gea boards set amoled host 192.168.1.100
|
|
247
|
+
npx gea boards rename amoled desk-amoled
|
|
248
|
+
npx gea boards remove desk-amoled
|
|
249
|
+
npx gea doctor
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`gea boards discover` sends one `GEADEV PING` to each serial device without
|
|
253
|
+
resetting it; a board running gea firmware answers with its app id, its IP
|
|
254
|
+
(when it has joined WiFi) and its MAC, and the CLI pairs the reply with an
|
|
255
|
+
alias by USB serial. `--save` writes a reported IP into
|
|
256
|
+
`transports.ota.host`, which is what `gea ota`, `gea logs` and
|
|
257
|
+
`gea screenshot` use over WiFi.
|
|
258
|
+
|
|
259
|
+
### Entry shapes
|
|
260
|
+
|
|
261
|
+
Every entry names a `target` (`gea targets list`) and its `adapter`, then the
|
|
262
|
+
transports the board offers. The USB serial is the stable identity: on ESP32
|
|
263
|
+
boards it is the station MAC, and the CLI resolves it to today's `/dev` port
|
|
264
|
+
at call time, so never record a port path.
|
|
230
265
|
|
|
231
266
|
```json
|
|
232
267
|
{
|
|
@@ -234,22 +269,53 @@ Example:
|
|
|
234
269
|
"target": "esp32-s3-touch-amoled-2.06",
|
|
235
270
|
"adapter": "esp32-idf",
|
|
236
271
|
"transports": {
|
|
237
|
-
"usbSerial": {
|
|
238
|
-
|
|
239
|
-
|
|
272
|
+
"usbSerial": { "serial": "80:B5:4E:DA:73:88" },
|
|
273
|
+
"ota": { "host": "192.168.1.100" }
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
"rotary": {
|
|
277
|
+
"target": "esp32-s3-elecrow-rotary-2.1",
|
|
278
|
+
"adapter": "esp32-idf",
|
|
279
|
+
"transports": {
|
|
280
|
+
"usbSerial": { "serial": "14:C1:9F:26:65:08", "restartAfterFlash": "manual" }
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
"tufty": {
|
|
284
|
+
"target": "rp2350-tufty-2350",
|
|
285
|
+
"adapter": "rp2350-pico",
|
|
286
|
+
"transports": { "usbSerial": { "serial": "fa59949adbb4802f" } }
|
|
287
|
+
},
|
|
288
|
+
"linux": {
|
|
289
|
+
"target": "geaos",
|
|
290
|
+
"adapter": "geaos-linux",
|
|
291
|
+
"transports": {
|
|
292
|
+
"telnet": { "host": "192.168.7.2", "port": 2323 },
|
|
293
|
+
"fastboot": { "serial": "geaos001" }
|
|
240
294
|
}
|
|
295
|
+
},
|
|
296
|
+
"lokmat": {
|
|
297
|
+
"target": "lokmat-applp2max",
|
|
298
|
+
"adapter": "geaos-arm64",
|
|
299
|
+
"transports": {
|
|
300
|
+
"usbSerial": { "serial": "geaos01" },
|
|
301
|
+
"fastboot": { "serial": "0123456789ABCDEF" },
|
|
302
|
+
"mtk": { "workdir": "~/lokmat-root" }
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
"my-board": {
|
|
306
|
+
"target": "my-board",
|
|
307
|
+
"targetDefinition": "targets/my-board.json",
|
|
308
|
+
"adapter": "esp32-idf",
|
|
309
|
+
"transports": { "usbSerial": { "serial": "YOUR_BOARD_USB_SERIAL" } }
|
|
241
310
|
}
|
|
242
311
|
}
|
|
243
312
|
```
|
|
244
313
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
npx gea list targets
|
|
251
|
-
npx gea doctor
|
|
252
|
-
```
|
|
314
|
+
- `restartAfterFlash: "manual"` marks a board whose USB-Serial-JTAG port
|
|
315
|
+
re-enters ROM download mode after a flash; the CLI stops and asks for a
|
|
316
|
+
power cycle instead of pulsing the reset lines.
|
|
317
|
+
- `targetDefinition` points at a custom target composed by `gea setup` /
|
|
318
|
+
`gea chips`, relative to the file the alias lives in.
|
|
253
319
|
|
|
254
320
|
## Common Verification Flow
|
|
255
321
|
|
package/docs/SPEC.md
CHANGED
|
@@ -83,9 +83,12 @@ gea flash --app bouncing-balls-jsx --board amoled --monitor
|
|
|
83
83
|
gea flash --app css-3d-cube --target android
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
The CLI
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
The CLI resolves board aliases from two machine-local files merged project over
|
|
87
|
+
home: `~/.geastack/boards.json` (every board on the machine, `GEA_HOME`
|
|
88
|
+
relocates it) and the project's `.gea/boards.json`. `--boards-config` replaces
|
|
89
|
+
both. No package ships aliases. `gea boards` manages them: `list`, `show`,
|
|
90
|
+
`add`, `set <alias> <key> <value>`, `remove`, `rename`, and `discover`, which
|
|
91
|
+
identifies connected boards over USB (`GEADEV PING` reports app, IP and MAC).
|
|
89
92
|
|
|
90
93
|
### `gea chips`
|
|
91
94
|
|
package/package.json
CHANGED
package/src/boards/config.mjs
CHANGED
|
@@ -1,19 +1,70 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
1
2
|
import path from 'node:path'
|
|
2
3
|
|
|
3
4
|
import { ExitCode, fail } from '../errors.mjs'
|
|
4
|
-
import { exists, readJson } from '../fs-utils.mjs'
|
|
5
|
+
import { exists, readJson, writeJson } from '../fs-utils.mjs'
|
|
5
6
|
|
|
6
|
-
// Board aliases
|
|
7
|
-
//
|
|
7
|
+
// Board aliases are machine-local configuration: which physical board answers
|
|
8
|
+
// to `--board amoled`, its USB serial, its IP. They live in two tiers that
|
|
9
|
+
// are merged, project over home:
|
|
10
|
+
//
|
|
11
|
+
// ~/.geastack/boards.json every board on this machine (GEA_HOME overrides
|
|
12
|
+
// the directory)
|
|
13
|
+
// <project>/.gea/boards.json aliases specific to one project, overriding a
|
|
14
|
+
// home alias of the same name
|
|
15
|
+
//
|
|
16
|
+
// An explicit --boards-config (or GEA_BOARDS_CONFIG) replaces both tiers: a
|
|
17
|
+
// caller naming a file wants exactly that file. Nothing is ever read from an
|
|
18
|
+
// installed package -- a board catalog shipped in @geastack/targets was a
|
|
19
|
+
// development convenience that described one developer's bench, and every
|
|
20
|
+
// npm install would have to overwrite it.
|
|
21
|
+
|
|
22
|
+
export function homeBoardsConfigPath(env = process.env) {
|
|
23
|
+
const home = env.GEA_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), '.geastack')
|
|
24
|
+
return path.join(home, 'boards.json')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Every file that contributes aliases, lowest precedence first. The list is
|
|
28
|
+
// the same whether or not the files exist so writers can target a tier that
|
|
29
|
+
// has not been created yet.
|
|
30
|
+
export function boardConfigTiers(ctx) {
|
|
31
|
+
if (ctx.boardsConfig) return [{ scope: 'explicit', file: ctx.boardsConfig }]
|
|
32
|
+
const tiers = []
|
|
33
|
+
if (ctx.homeBoardsConfig) tiers.push({ scope: 'home', file: ctx.homeBoardsConfig })
|
|
34
|
+
if (ctx.projectBoardsConfig) tiers.push({ scope: 'project', file: ctx.projectBoardsConfig })
|
|
35
|
+
return tiers
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The single path older callers print or check: the explicit file, else the
|
|
39
|
+
// highest-precedence tier that exists, else where `gea boards add` would
|
|
40
|
+
// write (project when the project already has a config, else home).
|
|
8
41
|
export function boardConfigPath(ctx) {
|
|
42
|
+
const tiers = boardConfigTiers(ctx)
|
|
43
|
+
const existing = [...tiers].reverse().find((tier) => exists(tier.file))
|
|
44
|
+
return existing ? existing.file : boardConfigWritePath(ctx)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Where a write goes when the caller does not say: `--global` / `--local`
|
|
48
|
+
// pick a tier, an alias that already exists is edited in place, a new alias
|
|
49
|
+
// joins the project config if the project has one and the home config
|
|
50
|
+
// otherwise.
|
|
51
|
+
export function boardConfigWritePath(ctx, { scope = '', alias = '' } = {}) {
|
|
52
|
+
const tiers = boardConfigTiers(ctx)
|
|
9
53
|
if (ctx.boardsConfig) return ctx.boardsConfig
|
|
10
|
-
if (
|
|
11
|
-
|
|
54
|
+
if (scope === 'global' || scope === 'home') return ctx.homeBoardsConfig
|
|
55
|
+
if (scope === 'project') return ctx.projectBoardsConfig
|
|
56
|
+
if (scope) fail(`Unknown board config scope '${scope}'. Expected --global or --local.`, ExitCode.usage)
|
|
57
|
+
if (alias) {
|
|
58
|
+
const origin = boardConfigOrigins(ctx).get(alias)
|
|
59
|
+
if (origin) return origin
|
|
60
|
+
}
|
|
61
|
+
const project = tiers.find((tier) => tier.scope === 'project')
|
|
62
|
+
if (project && exists(project.file)) return project.file
|
|
63
|
+
return ctx.homeBoardsConfig || project?.file || path.join(ctx.cwd || process.cwd(), '.gea', 'boards.json')
|
|
12
64
|
}
|
|
13
65
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (!file || !exists(file)) return {}
|
|
66
|
+
function readTier(file) {
|
|
67
|
+
if (!exists(file)) return {}
|
|
17
68
|
try {
|
|
18
69
|
return normalizeBoardConfig(readJson(file))
|
|
19
70
|
} catch (error) {
|
|
@@ -21,6 +72,37 @@ export function loadBoardConfig(ctx) {
|
|
|
21
72
|
}
|
|
22
73
|
}
|
|
23
74
|
|
|
75
|
+
// Merged aliases plus, for each, the file it came from: a `targetDefinition`
|
|
76
|
+
// is relative to its own file, and `gea boards list` says which tier an alias
|
|
77
|
+
// lives in.
|
|
78
|
+
export function loadBoardConfigWithOrigins(ctx) {
|
|
79
|
+
const boards = {}
|
|
80
|
+
const origins = new Map()
|
|
81
|
+
for (const tier of boardConfigTiers(ctx)) {
|
|
82
|
+
for (const [alias, board] of Object.entries(readTier(tier.file))) {
|
|
83
|
+
boards[alias] = board
|
|
84
|
+
origins.set(alias, tier.file)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { boards, origins }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function loadBoardConfig(ctx) {
|
|
91
|
+
return loadBoardConfigWithOrigins(ctx).boards
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function boardConfigOrigins(ctx) {
|
|
95
|
+
return loadBoardConfigWithOrigins(ctx).origins
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function readBoardConfigFile(file) {
|
|
99
|
+
return readTier(file)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function writeBoardConfigFile(file, boards) {
|
|
103
|
+
writeJson(file, boards)
|
|
104
|
+
}
|
|
105
|
+
|
|
24
106
|
export function normalizeBoardConfig(raw) {
|
|
25
107
|
if (!raw || typeof raw !== 'object') return {}
|
|
26
108
|
if (raw.boards && typeof raw.boards === 'object') return raw.boards
|
package/src/boards/resolve.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
|
-
import { loadBoardConfig, normalizeBoardConfig } from './config.mjs'
|
|
4
|
+
import { boardConfigOrigins, boardConfigPath, loadBoardConfig, normalizeBoardConfig } from './config.mjs'
|
|
5
5
|
import { loadTargets } from './targets.mjs'
|
|
6
6
|
import { resolveUsbSerialPort } from './usb.mjs'
|
|
7
7
|
|
|
@@ -30,14 +30,16 @@ export function resolveBoardSelection({
|
|
|
30
30
|
needs = {},
|
|
31
31
|
targets = ctx ? loadTargets(ctx) : {},
|
|
32
32
|
config = ctx ? loadBoardConfig(ctx) : {},
|
|
33
|
-
|
|
33
|
+
// A targetDefinition is relative to the file its alias came from, which
|
|
34
|
+
// differs between the home and project tiers.
|
|
35
|
+
configDir = ctx ? path.dirname((boardName && boardConfigOrigins(ctx).get(boardName)) || boardConfigPath(ctx)) : process.cwd(),
|
|
34
36
|
usbSerialResolver = resolveUsbSerialPort,
|
|
35
37
|
deferUsbPort = false
|
|
36
38
|
} = {}) {
|
|
37
39
|
const boards = normalizeBoardConfig(config)
|
|
38
40
|
const board = boardName ? boards[boardName] : null
|
|
39
41
|
if (boardName && !board) {
|
|
40
|
-
throw new Error(`Unknown board '${boardName}'.
|
|
42
|
+
throw new Error(`Unknown board '${boardName}'. Run gea boards list, or gea boards add to register it.`)
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
let target = board?.target || targetName || ''
|
package/src/boards/usb.mjs
CHANGED
|
@@ -113,6 +113,47 @@ function macUsbCalloutPortsForSerial(serial, ioreg = runIoreg) {
|
|
|
113
113
|
return [...matches].sort()
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// Every USB callout port the registry knows, with the serial and product
|
|
117
|
+
// name inherited from the enclosing USB device: the inverse of
|
|
118
|
+
// macUsbCalloutPortsForSerial, for `gea boards discover` and the setup
|
|
119
|
+
// wizard. The /dev name carries no identity (the usbmodem number changes on
|
|
120
|
+
// every enumeration and identical boards share it), so this is the only
|
|
121
|
+
// place a port's serial can come from on macOS.
|
|
122
|
+
export function listMacUsbCalloutPorts(ioreg = runIoreg) {
|
|
123
|
+
let output = ''
|
|
124
|
+
try {
|
|
125
|
+
output = ioreg(['-p', 'IOService', '-l', '-w0'], { maxBuffer: 64 * 1024 * 1024 })
|
|
126
|
+
} catch {
|
|
127
|
+
return []
|
|
128
|
+
}
|
|
129
|
+
const stack = []
|
|
130
|
+
const ports = new Map()
|
|
131
|
+
for (const line of output.split(/\r?\n/)) {
|
|
132
|
+
const node = line.match(/^([\s|]*)[+\\-]*o\s+/)
|
|
133
|
+
if (node) {
|
|
134
|
+
const depth = (node[1].match(/\|/g) || []).length
|
|
135
|
+
while (stack.length > 0 && stack[stack.length - 1].depth >= depth) stack.pop()
|
|
136
|
+
stack.push({ depth, serial: '', product: '' })
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
if (stack.length === 0) continue
|
|
140
|
+
const current = stack[stack.length - 1]
|
|
141
|
+
const serialValue = ioregStringProperty(line, 'kUSBSerialNumberString') || ioregStringProperty(line, 'USB Serial Number')
|
|
142
|
+
if (serialValue) current.serial = serialValue
|
|
143
|
+
const productValue = ioregStringProperty(line, 'kUSBProductString') || ioregStringProperty(line, 'USB Product Name')
|
|
144
|
+
if (productValue) current.product = productValue
|
|
145
|
+
const callout = ioregStringProperty(line, 'IOCalloutDevice')
|
|
146
|
+
if (!callout || !path.basename(callout).startsWith('cu.')) continue
|
|
147
|
+
// A callout with no USB serial above it is not a USB device at all
|
|
148
|
+
// (Bluetooth SPP, the debug console); probing those is a wasted timeout.
|
|
149
|
+
const serial = [...stack].reverse().find((entry) => entry.serial)?.serial || ''
|
|
150
|
+
if (!serial) continue
|
|
151
|
+
const label = [...stack].reverse().find((entry) => entry.product)?.product || ''
|
|
152
|
+
ports.set(callout, { path: callout, serial, label })
|
|
153
|
+
}
|
|
154
|
+
return [...ports.values()].sort((a, b) => a.path.localeCompare(b.path))
|
|
155
|
+
}
|
|
156
|
+
|
|
116
157
|
function runIoreg(args, options = {}) {
|
|
117
158
|
return execFileSync('ioreg', args, { encoding: 'utf8', ...options })
|
|
118
159
|
}
|
package/src/chips.mjs
CHANGED
|
@@ -142,7 +142,7 @@ export function resolveTargetDefinition(ctx, parsed) {
|
|
|
142
142
|
const configPath = option(parsed, 'boards-config')
|
|
143
143
|
? path.resolve(ctx.cwd, option(parsed, 'boards-config'))
|
|
144
144
|
: ctx.projectBoardsConfig
|
|
145
|
-
if (!configPath || !exists(configPath)) fail(
|
|
145
|
+
if (!configPath || !exists(configPath)) fail(`No board config was found at ${configPath || '.gea/boards.json'}. Run gea setup and create a custom board first.`, ExitCode.usage)
|
|
146
146
|
const boards = readJson(configPath)
|
|
147
147
|
const requested = option(parsed, 'board', '')
|
|
148
148
|
const customBoards = Object.entries(boards).filter(([, entry]) => typeof entry?.targetDefinition === 'string' && entry.targetDefinition)
|
package/src/commands/apps.mjs
CHANGED
|
@@ -2,7 +2,6 @@ import { readdirSync } from 'node:fs'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { flag, option, optionList } from '../args.mjs'
|
|
5
|
-
import { loadBoardConfig, normalizeBoardConfig, boardConfigPath } from '../boards/config.mjs'
|
|
6
5
|
import { loadTargets } from '../boards/targets.mjs'
|
|
7
6
|
import { ExitCode, fail } from '../errors.mjs'
|
|
8
7
|
import { exists } from '../fs-utils.mjs'
|
|
@@ -161,38 +160,7 @@ async function iconSheet(ctx, parsed, options) {
|
|
|
161
160
|
return 0
|
|
162
161
|
}
|
|
163
162
|
|
|
164
|
-
// gea
|
|
165
|
-
|
|
166
|
-
export async function boardsCommand(ctx, parsed, rest, options) {
|
|
167
|
-
const sub = rest[0] || 'list'
|
|
168
|
-
const boards = normalizeBoardConfig(loadBoardConfig(ctx))
|
|
169
|
-
if (sub === 'list') {
|
|
170
|
-
if (flag(parsed, 'json')) options.stdout(JSON.stringify(boards, null, 2))
|
|
171
|
-
else {
|
|
172
|
-
const names = Object.keys(boards).sort()
|
|
173
|
-
if (names.length === 0) options.stdout(`No boards configured in ${boardConfigPath(ctx)}. Run gea setup, or gea boards add.`)
|
|
174
|
-
for (const name of names) {
|
|
175
|
-
const board = boards[name]
|
|
176
|
-
const bits = [board.target]
|
|
177
|
-
if (board.transports?.usbSerial?.serial) bits.push(`usb ${board.transports.usbSerial.serial}`)
|
|
178
|
-
if (board.transports?.ota?.host) bits.push(`wifi ${board.transports.ota.host}`)
|
|
179
|
-
options.stdout(`${name}\t${bits.join(' ')}`)
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return 0
|
|
183
|
-
}
|
|
184
|
-
if (sub === 'show') {
|
|
185
|
-
const name = rest[1] || option(parsed, 'board', '')
|
|
186
|
-
if (!name || !boards[name]) fail(`Unknown board '${name}'. Run gea boards list.`, ExitCode.usage)
|
|
187
|
-
options.stdout(JSON.stringify({ [name]: boards[name] }, null, 2))
|
|
188
|
-
return 0
|
|
189
|
-
}
|
|
190
|
-
if (sub === 'add') {
|
|
191
|
-
const { runSetupWizard } = await import('../setup-wizard.mjs')
|
|
192
|
-
return runSetupWizard(ctx, parsed, options)
|
|
193
|
-
}
|
|
194
|
-
fail(`Unknown boards subcommand '${sub}'. Expected list, show, or add.`, ExitCode.usage)
|
|
195
|
-
}
|
|
163
|
+
// gea targets ...
|
|
196
164
|
|
|
197
165
|
export function targetsCommand(ctx, parsed, rest, options) {
|
|
198
166
|
const sub = rest[0] || 'list'
|
|
@@ -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) : '',
|
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,8 @@ 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
|
+
const options = { stdout, stderr, env, stdin, output, prompt, probeSerialDevice: io.probeSerialDevice }
|
|
46
48
|
|
|
47
49
|
// A platform name in --target (web, macos, ...) is not a board.
|
|
48
50
|
const target = option(parsed, 'target', '')
|
|
@@ -137,10 +139,11 @@ Device access:
|
|
|
137
139
|
|
|
138
140
|
Catalogs:
|
|
139
141
|
gea apps list|inspect|pack|index|launcher|icons|icon-sheet|apple-icons
|
|
140
|
-
gea boards list|show
|
|
142
|
+
gea boards list|show|add|set|remove|rename|discover (gea boards help)
|
|
141
143
|
gea targets list|show <id>
|
|
142
144
|
gea chips ... custom board composition from the chip catalog
|
|
143
145
|
gea heap-report [logs...] [--out file] [--map elf.map]
|
|
144
146
|
|
|
145
|
-
Global options: --project <dir> --boards-config <file> --dry-run --json
|
|
147
|
+
Global options: --project <dir> --boards-config <file> --global|--local (boards writes) --dry-run --json
|
|
148
|
+
Board aliases: ~/.geastack/boards.json (this machine) + <project>/.gea/boards.json (overrides)`
|
|
146
149
|
}
|
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
|
|
package/src/setup-wizard.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import os from 'node:os'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { knownBoards } from './board-catalog.mjs'
|
|
6
|
+
import { boardConfigWritePath } from './boards/config.mjs'
|
|
6
7
|
import { configureChipSelection, loadChipCatalog, validateGpioAssignments } from './chips.mjs'
|
|
7
8
|
import { flag, option } from './args.mjs'
|
|
8
9
|
import { ExitCode, fail } from './errors.mjs'
|
|
@@ -380,8 +381,7 @@ async function maybeInitializeBoardTarget(ctx, parsed, io, boardSetup) {
|
|
|
380
381
|
io.stdout(`Initializing board target '${boardSetup.alias}'...`)
|
|
381
382
|
const { buildCommand } = await import('./commands/board.mjs')
|
|
382
383
|
const setupParsed = { ...parsed, options: { ...parsed.options, board: boardSetup.alias, 'configure-only': true } }
|
|
383
|
-
|
|
384
|
-
return buildCommand(reloaded, setupParsed, [], io)
|
|
384
|
+
return buildCommand(ctx, setupParsed, [], io)
|
|
385
385
|
}
|
|
386
386
|
|
|
387
387
|
async function maybeSetupEspIdf(ctx, parsed, io, prompt, { force = false } = {}) {
|
|
@@ -464,10 +464,13 @@ async function selectUsbSerial(prompt, io, { message }) {
|
|
|
464
464
|
})
|
|
465
465
|
}
|
|
466
466
|
|
|
467
|
+
// --global writes the alias to ~/.geastack/boards.json, --local to the
|
|
468
|
+
// project's .gea/boards.json; otherwise the project config when it exists,
|
|
469
|
+
// else the home one (src/boards/config.mjs owns that rule).
|
|
467
470
|
function boardConfigPath(ctx, parsed) {
|
|
468
|
-
const explicit = option(parsed, 'boards-config')
|
|
471
|
+
const explicit = option(parsed, 'boards-config')
|
|
469
472
|
if (explicit) return path.resolve(ctx.cwd, explicit)
|
|
470
|
-
return ctx
|
|
473
|
+
return boardConfigWritePath(ctx, { scope: flag(parsed, 'global') ? 'global' : flag(parsed, 'local') ? 'project' : '' })
|
|
471
474
|
}
|
|
472
475
|
|
|
473
476
|
function readBoardConfig(filePath) {
|