@geastack/cli 0.1.8 → 0.1.10
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 +27 -18
- package/docs/NPX-COMMANDS.md +30 -32
- package/docs/SPEC.md +19 -1
- package/package.json +2 -2
- package/src/chips.mjs +295 -0
- package/src/context.mjs +2 -2
- package/src/create-geastack.mjs +66 -6
- package/src/gea.mjs +7 -0
- package/src/setup-wizard.mjs +119 -240
package/README.md
CHANGED
|
@@ -31,8 +31,10 @@ flowchart TD
|
|
|
31
31
|
|
|
32
32
|
starter -->|"Embedded component counter"| counter["Copy the touchscreen counter used in the embedded tutorial"]
|
|
33
33
|
|
|
34
|
-
starter -->|"Blank application"|
|
|
35
|
-
|
|
34
|
+
starter -->|"Blank application"| emptyTarget{"Where should it run?"}
|
|
35
|
+
emptyTarget -->|"Web browser"| emptyWeb["Generate browser entry, HTML, and Vite config"]
|
|
36
|
+
emptyTarget -->|"ESP32 board"| emptyEsp["Generate native entry<br/>Ask whether to enable Bluetooth updates"]
|
|
37
|
+
emptyTarget -->|"Other native target"| emptyNative["Generate native entry for the selected platform"]
|
|
36
38
|
|
|
37
39
|
starter -->|"Example application"| pickExample["Pick from the example gallery<br/>web, ESP32, GeaOS, iOS, macOS, Android"]
|
|
38
40
|
pickExample --> fetchExample["Fetch selected app from GitHub"]
|
|
@@ -41,7 +43,9 @@ flowchart TD
|
|
|
41
43
|
|
|
42
44
|
counter --> projectWiring["Add @geastack/core and @geastack/cli"]
|
|
43
45
|
rewriteExample --> projectWiring["Add @geastack/core and @geastack/cli"]
|
|
44
|
-
|
|
46
|
+
emptyWeb --> projectWiring
|
|
47
|
+
emptyEsp --> projectWiring
|
|
48
|
+
emptyNative --> projectWiring
|
|
45
49
|
projectWiring --> boardConfig["Create .gea/boards.json"]
|
|
46
50
|
boardConfig --> install["Install npm dependencies<br/>(interactive default)"]
|
|
47
51
|
install --> setup["Next: npx gea setup"]
|
|
@@ -69,20 +73,13 @@ flowchart TD
|
|
|
69
73
|
ota --> reviewKnown["Review board setup"]
|
|
70
74
|
reviewKnown --> writeKnown["Write .gea/boards.json"]
|
|
71
75
|
|
|
72
|
-
interactive -->|"Custom board
|
|
73
|
-
custom -->
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
peripherals --> connection
|
|
80
|
-
connection --> notes["Notes and datasheet links"]
|
|
81
|
-
notes --> reviewCustom["Review custom profile"]
|
|
82
|
-
reviewCustom --> writeProfile["Write .gea/boards/alias.json"]
|
|
83
|
-
writeProfile --> maybeAlias{"Base target selected?"}
|
|
84
|
-
maybeAlias -->|"Yes"| writeCustomAlias["Write alias to .gea/boards.json"]
|
|
85
|
-
maybeAlias -->|"No"| profileOnly["Profile only, not flash-ready yet"]
|
|
76
|
+
interactive -->|"Custom board target"| custom["Choose MCU and compatible chips"]
|
|
77
|
+
custom --> chipConfig["Ask interface and pin questions<br/>from @geastack/chips/catalog.json"]
|
|
78
|
+
chipConfig --> features["Optional microSD and launcher button"]
|
|
79
|
+
features --> connection["Detect initial USB connection"]
|
|
80
|
+
connection --> reviewCustom["Review native composition"]
|
|
81
|
+
reviewCustom --> writeProfile["Write .gea/targets/alias.json"]
|
|
82
|
+
writeProfile --> writeCustomAlias["Write alias to .gea/boards.json"]
|
|
86
83
|
|
|
87
84
|
interactive -->|"npm dependencies only"| npmInstall["Run npm install when package.json exists"]
|
|
88
85
|
interactive -->|"ESP-IDF toolchain only"| idfOnly["Install or check ESP-IDF v6.0.1"]
|
|
@@ -90,7 +87,6 @@ flowchart TD
|
|
|
90
87
|
writeKnown --> initialize["Initialize board target"]
|
|
91
88
|
writeCustomAlias --> initialize
|
|
92
89
|
initialize --> ready["Ready: npx gea flash --board alias --monitor"]
|
|
93
|
-
profileOnly --> done["Done"]
|
|
94
90
|
npmInstall --> done
|
|
95
91
|
idfOnly --> done
|
|
96
92
|
directIdf --> done
|
|
@@ -103,6 +99,19 @@ host bindings, and the other native packages from npm. A project can use a
|
|
|
103
99
|
local CLI with `npx gea` or a global installation with `gea`; neither command
|
|
104
100
|
depends on a GeaStack source checkout.
|
|
105
101
|
|
|
102
|
+
Custom boards remain editable after setup:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
gea chips list
|
|
106
|
+
gea chips info co5300
|
|
107
|
+
gea chips add co5300 ft3168 --board my-board
|
|
108
|
+
gea chips remove ft3168 --board my-board
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
These commands update the app-local target definition. They do not copy native
|
|
112
|
+
sources into the application; the target adapter compiles the selected drivers
|
|
113
|
+
directly from the installed `@geastack/chips` package.
|
|
114
|
+
|
|
106
115
|
## Development
|
|
107
116
|
|
|
108
117
|
```sh
|
package/docs/NPX-COMMANDS.md
CHANGED
|
@@ -23,7 +23,7 @@ Use these release packages instead:
|
|
|
23
23
|
the `create-geastack` bin.
|
|
24
24
|
- `@geastack/cli`: scoped private Gea CLI package exposing the `gea` bin.
|
|
25
25
|
|
|
26
|
-
`create-geastack` should add `@geastack/cli` as a
|
|
26
|
+
`create-geastack` should add `@geastack/cli` as a dependency in generated
|
|
27
27
|
projects and install dependencies by default in interactive terminals. After
|
|
28
28
|
that, inside the project, `npx gea ...` works because npm finds the local
|
|
29
29
|
`node_modules/.bin/gea` binary. It does not require an npm package named `gea`.
|
|
@@ -31,7 +31,7 @@ that, inside the project, `npx gea ...` works because npm finds the local
|
|
|
31
31
|
If someone is wiring an existing app by hand, install the package first:
|
|
32
32
|
|
|
33
33
|
```sh
|
|
34
|
-
npm install
|
|
34
|
+
npm install @geastack/cli
|
|
35
35
|
npx gea setup
|
|
36
36
|
```
|
|
37
37
|
|
|
@@ -53,9 +53,10 @@ What do you want to build?
|
|
|
53
53
|
`Embedded component counter` is the bundled tutorial starter. It uses local
|
|
54
54
|
component state, targets ESP32, and enables BLE updates.
|
|
55
55
|
|
|
56
|
-
`Blank application`
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
`Blank application` asks where the app should run. It creates the smallest
|
|
57
|
+
project for that target and, for ESP32, asks whether to enable wireless
|
|
58
|
+
firmware updates over Bluetooth. Browser-only files such as `index.html` and
|
|
59
|
+
`vite.config.ts` are added only when the web target is selected.
|
|
59
60
|
|
|
60
61
|
`Example application` opens an example picker populated from the hard-coded
|
|
61
62
|
`examples/catalog.json` shipped inside `@geastack/cli`. The catalog contains web,
|
|
@@ -88,7 +89,7 @@ packages are scoped and configured for restricted npmjs publication.
|
|
|
88
89
|
`gea setup` with no `--board` or `--target` opens a guided setup flow:
|
|
89
90
|
|
|
90
91
|
- known supported board, with descriptions for each board;
|
|
91
|
-
- custom board
|
|
92
|
+
- custom board target composed from the installed chip catalog;
|
|
92
93
|
- npm dependency check/install only;
|
|
93
94
|
- ESP-IDF toolchain check/install only.
|
|
94
95
|
|
|
@@ -101,38 +102,35 @@ initializes the selected board target so the next command can be
|
|
|
101
102
|
.gea/boards.json
|
|
102
103
|
```
|
|
103
104
|
|
|
104
|
-
Custom-board setup
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
sensors, power, transport, and notes.
|
|
108
|
-
- `Fast profile`: core board identity, display/touch, transport, and inferred
|
|
109
|
-
defaults for optional peripherals.
|
|
110
|
-
|
|
111
|
-
Both paths show a review screen before writing a profile under:
|
|
105
|
+
Custom-board setup reads `@geastack/chips/catalog.json`, offers only drivers
|
|
106
|
+
with a compatible platform binding, and asks the configuration questions
|
|
107
|
+
declared by each selected driver. It shows a review before writing the target:
|
|
112
108
|
|
|
113
109
|
```text
|
|
114
|
-
.gea/
|
|
110
|
+
.gea/targets/<alias>.json
|
|
115
111
|
```
|
|
116
112
|
|
|
117
|
-
The
|
|
113
|
+
The target captures:
|
|
118
114
|
|
|
119
115
|
- MCU / SoC;
|
|
120
|
-
-
|
|
121
|
-
- display
|
|
122
|
-
- touch controller and
|
|
123
|
-
-
|
|
124
|
-
-
|
|
125
|
-
-
|
|
126
|
-
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
116
|
+
- the generic platform base and adapter;
|
|
117
|
+
- display controller, interface, dimensions, bus, and pins;
|
|
118
|
+
- touch controller, bus, reset, and interrupt pins;
|
|
119
|
+
- power, IMU, and audio drivers;
|
|
120
|
+
- shared buses and audio pins;
|
|
121
|
+
- optional storage and launcher-button pins;
|
|
122
|
+
- the initial USB serial connection.
|
|
123
|
+
|
|
124
|
+
The wizard also writes `.gea/boards.json`, where the physical board alias points
|
|
125
|
+
to that definition. A partial composition is saved but is not described as
|
|
126
|
+
flash-ready. It can be completed later with:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
npx gea chips list
|
|
130
|
+
npx gea chips info co5300
|
|
131
|
+
npx gea chips add co5300 --board my-board
|
|
132
|
+
npx gea chips remove co5300 --board my-board
|
|
133
|
+
```
|
|
136
134
|
|
|
137
135
|
ESP-IDF setup is available as:
|
|
138
136
|
|
package/docs/SPEC.md
CHANGED
|
@@ -87,6 +87,23 @@ The CLI should pass through board aliases from the active board config. A projec
|
|
|
87
87
|
uses its own `.gea/boards.json`; otherwise the CLI reads the board catalog shipped
|
|
88
88
|
by the installed `@geastack/targets` package.
|
|
89
89
|
|
|
90
|
+
### `gea chips`
|
|
91
|
+
|
|
92
|
+
Inspects the installed `@geastack/chips` catalog and composes a custom board
|
|
93
|
+
definition without copying native driver source into the app.
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
gea chips list
|
|
97
|
+
gea chips info co5300
|
|
98
|
+
gea chips add co5300 ft3168 --board my-board
|
|
99
|
+
gea chips remove ft3168 --board my-board
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`add` asks only the interface and pin questions declared by each chip. Automation
|
|
103
|
+
can answer them with repeated `--set chip.path=value` options. The command rejects
|
|
104
|
+
chips without a binding for the board adapter and MCU before changing the target
|
|
105
|
+
definition.
|
|
106
|
+
|
|
90
107
|
### `gea monitor`
|
|
91
108
|
|
|
92
109
|
Starts a log monitor for a configured board or target.
|
|
@@ -132,7 +149,8 @@ Interactive starter choices must explain the tradeoff in-line:
|
|
|
132
149
|
|
|
133
150
|
- `Embedded component counter`: create a touchscreen counter with local
|
|
134
151
|
component state, an ESP32 target, and BLE updates enabled.
|
|
135
|
-
- `Blank application`:
|
|
152
|
+
- `Blank application`: ask where the app should run, generate the matching
|
|
153
|
+
minimal project, and ask whether to enable Bluetooth updates for ESP32.
|
|
136
154
|
- `Example application`: fetch a selected app from `geastack/examples`, then rewrite
|
|
137
155
|
package name, app id, dependencies, and board config for the new project.
|
|
138
156
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geastack/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Command-line front door for GeaStack apps, targets, and local toolchains.",
|
|
6
6
|
"publishConfig": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@geastack/core": "0.1.4",
|
|
31
|
-
"@geastack/targets": "0.1.
|
|
31
|
+
"@geastack/targets": "0.1.5"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"check": "node --check bin/gea.mjs && node --check bin/create-geastack.mjs && find src test packages -name '*.mjs' -print0 | xargs -0 -n1 node --check",
|
package/src/chips.mjs
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { flag, option } from './args.mjs'
|
|
4
|
+
import { ExitCode, fail } from './errors.mjs'
|
|
5
|
+
import { exists, readJson, writeJson } from './fs-utils.mjs'
|
|
6
|
+
import { ask, choose, createPrompt } from './prompts.mjs'
|
|
7
|
+
|
|
8
|
+
export async function runChips(ctx, parsed, rest, io = {}) {
|
|
9
|
+
const action = rest[0] || 'list'
|
|
10
|
+
const chipIds = rest.slice(1)
|
|
11
|
+
const stdout = io.stdout || console.log
|
|
12
|
+
const catalog = loadChipCatalog(ctx)
|
|
13
|
+
|
|
14
|
+
if (action === 'list') {
|
|
15
|
+
const entries = Object.entries(catalog).sort((a, b) => a[0].localeCompare(b[0]))
|
|
16
|
+
if (flag(parsed, 'json')) stdout(JSON.stringify(Object.fromEntries(entries), null, 2))
|
|
17
|
+
else {
|
|
18
|
+
for (const [id, chip] of entries) {
|
|
19
|
+
const adapters = Object.keys(chip.adapters || {})
|
|
20
|
+
stdout(`${id}\t${chip.category}\t${chip.label}${adapters.length ? `\t${adapters.join(',')}` : '\tunbound'}`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return 0
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (action === 'info') {
|
|
27
|
+
if (chipIds.length !== 1) fail('chips info requires one chip id.', ExitCode.usage)
|
|
28
|
+
const chip = catalog[chipIds[0]]
|
|
29
|
+
if (!chip) failUnknownChip(chipIds[0], catalog)
|
|
30
|
+
if (flag(parsed, 'json')) stdout(JSON.stringify({ id: chipIds[0], ...chip }, null, 2))
|
|
31
|
+
else {
|
|
32
|
+
stdout(`${chipIds[0]}: ${chip.label}`)
|
|
33
|
+
stdout(`Category: ${chip.category}`)
|
|
34
|
+
stdout(`Interfaces: ${(chip.interfaces || []).join(', ') || 'none'}`)
|
|
35
|
+
stdout(`Adapters: ${Object.keys(chip.adapters || {}).join(', ') || 'no platform binding yet'}`)
|
|
36
|
+
for (const field of chip.configuration || []) stdout(`Configure: ${field.path} (${field.label})`)
|
|
37
|
+
}
|
|
38
|
+
return 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (action !== 'add' && action !== 'remove') {
|
|
42
|
+
fail(`Unknown chips action '${action}'. Expected list, info, add, or remove.`, ExitCode.usage)
|
|
43
|
+
}
|
|
44
|
+
if (chipIds.length === 0) fail(`chips ${action} requires at least one chip id.`, ExitCode.usage)
|
|
45
|
+
|
|
46
|
+
const location = resolveTargetDefinition(ctx, parsed)
|
|
47
|
+
const definition = readJson(location.definitionPath)
|
|
48
|
+
definition.chips ||= {}
|
|
49
|
+
|
|
50
|
+
if (action === 'remove') {
|
|
51
|
+
for (const id of chipIds) {
|
|
52
|
+
const role = Object.keys(definition.chips).find((key) => selectedDriver(definition.chips[key]) === id)
|
|
53
|
+
if (!role) fail(`Chip '${id}' is not selected by board '${location.boardName}'.`, ExitCode.usage)
|
|
54
|
+
delete definition.chips[role]
|
|
55
|
+
stdout(`Removed ${id} from ${location.boardName}.`)
|
|
56
|
+
}
|
|
57
|
+
writeJson(location.definitionPath, definition)
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const prompt = createPrompt(io)
|
|
62
|
+
try {
|
|
63
|
+
const provided = parseSetOptions(parsed)
|
|
64
|
+
for (const id of chipIds) {
|
|
65
|
+
const descriptor = catalog[id]
|
|
66
|
+
if (!descriptor) failUnknownChip(id, catalog)
|
|
67
|
+
const role = await configureChipSelection({
|
|
68
|
+
definition,
|
|
69
|
+
id,
|
|
70
|
+
descriptor,
|
|
71
|
+
provided,
|
|
72
|
+
prompt,
|
|
73
|
+
io,
|
|
74
|
+
replace: flag(parsed, 'replace'),
|
|
75
|
+
boardName: location.boardName
|
|
76
|
+
})
|
|
77
|
+
stdout(`Added ${id} as ${role} on ${location.boardName}.`)
|
|
78
|
+
}
|
|
79
|
+
if (provided.size > 0) {
|
|
80
|
+
fail(`Unused --set values: ${[...provided.keys()].join(', ')}`, ExitCode.usage)
|
|
81
|
+
}
|
|
82
|
+
validateGpioAssignments(definition)
|
|
83
|
+
writeJson(location.definitionPath, definition)
|
|
84
|
+
stdout(`Updated ${location.definitionPath}`)
|
|
85
|
+
return 0
|
|
86
|
+
} finally {
|
|
87
|
+
await prompt.close()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function configureChipSelection({ definition, id, descriptor, provided = new Map(), prompt, io = {}, replace = false, boardName = definition.id }) {
|
|
92
|
+
definition.chips ||= {}
|
|
93
|
+
const adapter = definition.adapter || 'esp32-idf'
|
|
94
|
+
const binding = descriptor.adapters?.[adapter]
|
|
95
|
+
if (!binding) fail(`Chip '${id}' has no ${adapter} binding. 'gea chips info ${id}' shows its current support.`, ExitCode.targetUnavailable)
|
|
96
|
+
if (Array.isArray(binding.mcus) && !binding.mcus.includes(definition.mcu)) {
|
|
97
|
+
fail(`Chip '${id}' does not support MCU '${definition.mcu}' through ${adapter}.`, ExitCode.targetUnavailable)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const role = descriptor.category
|
|
101
|
+
const previous = definition.chips[role]
|
|
102
|
+
if (previous && selectedDriver(previous) !== id && !replace) {
|
|
103
|
+
fail(`Board '${boardName}' already uses '${selectedDriver(previous)}' as ${role}. Pass --replace to change it.`, ExitCode.usage)
|
|
104
|
+
}
|
|
105
|
+
const selection = previous && selectedDriver(previous) === id ? structuredClone(previous) : { driver: id }
|
|
106
|
+
selection.driver = id
|
|
107
|
+
delete selection.controller
|
|
108
|
+
selection.interface = await configuredInterface({ id, descriptor, selection, provided, prompt, io })
|
|
109
|
+
if (selection.interface === 'i2c') {
|
|
110
|
+
definition.buses ||= {}
|
|
111
|
+
definition.buses.i2c ||= {}
|
|
112
|
+
await configureFields({
|
|
113
|
+
id: 'i2c',
|
|
114
|
+
target: definition.buses.i2c,
|
|
115
|
+
fields: [
|
|
116
|
+
{ path: 'sda', label: 'Shared I2C SDA pin', type: 'pin' },
|
|
117
|
+
{ path: 'scl', label: 'Shared I2C SCL pin', type: 'pin' }
|
|
118
|
+
],
|
|
119
|
+
provided,
|
|
120
|
+
prompt,
|
|
121
|
+
io
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
await configureFields({ id, target: selection, fields: descriptor.configuration || [], provided, prompt, io })
|
|
125
|
+
definition.chips[role] = selection
|
|
126
|
+
return role
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function loadChipCatalog(ctx) {
|
|
130
|
+
const catalogPath = path.join(ctx.chipsPackageDir || '', 'catalog.json')
|
|
131
|
+
if (!ctx.chipsPackageDir || !exists(catalogPath)) {
|
|
132
|
+
fail(`Missing @geastack/chips catalog: ${catalogPath || '@geastack/chips/catalog.json'}`, ExitCode.missingDependency)
|
|
133
|
+
}
|
|
134
|
+
const catalog = readJson(catalogPath)
|
|
135
|
+
if (catalog.schemaVersion !== 1 || !catalog.chips || typeof catalog.chips !== 'object') {
|
|
136
|
+
fail(`Unsupported @geastack/chips catalog schema in ${catalogPath}.`, ExitCode.missingDependency)
|
|
137
|
+
}
|
|
138
|
+
return catalog.chips
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function resolveTargetDefinition(ctx, parsed) {
|
|
142
|
+
const configPath = option(parsed, 'boards-config')
|
|
143
|
+
? path.resolve(ctx.cwd, option(parsed, 'boards-config'))
|
|
144
|
+
: ctx.projectBoardsConfig
|
|
145
|
+
if (!configPath || !exists(configPath)) fail('No project .gea/boards.json was found. Run gea setup and create a custom board first.', ExitCode.usage)
|
|
146
|
+
const boards = readJson(configPath)
|
|
147
|
+
const requested = option(parsed, 'board', '')
|
|
148
|
+
const customBoards = Object.entries(boards).filter(([, entry]) => typeof entry?.targetDefinition === 'string' && entry.targetDefinition)
|
|
149
|
+
const boardName = requested || (customBoards.length === 1 ? customBoards[0][0] : '')
|
|
150
|
+
if (!boardName) fail('Select a custom board with --board <alias>.', ExitCode.usage)
|
|
151
|
+
const board = boards[boardName]
|
|
152
|
+
if (!board) fail(`Unknown board '${boardName}' in ${configPath}.`, ExitCode.usage)
|
|
153
|
+
if (!board.targetDefinition) fail(`Board '${boardName}' is a preset and has no editable target definition.`, ExitCode.usage)
|
|
154
|
+
const definitionPath = path.resolve(path.dirname(configPath), board.targetDefinition)
|
|
155
|
+
if (!exists(definitionPath)) fail(`Target definition for '${boardName}' does not exist: ${definitionPath}`, ExitCode.usage)
|
|
156
|
+
return { boardName, board, configPath, definitionPath }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function validateGpioAssignments(definition) {
|
|
160
|
+
const pins = []
|
|
161
|
+
collectGpioAssignments(definition, [], false, pins)
|
|
162
|
+
const used = new Map()
|
|
163
|
+
for (const [label, pin] of pins) {
|
|
164
|
+
const previous = used.get(pin)
|
|
165
|
+
if (previous) fail(`GPIO ${pin} is assigned to both ${previous} and ${label}.`, ExitCode.usage)
|
|
166
|
+
used.set(pin, label)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function collectGpioAssignments(value, pathParts, pinMap, out) {
|
|
171
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return
|
|
172
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
173
|
+
const nextPath = [...pathParts, key]
|
|
174
|
+
const nextPinMap = pinMap || key === 'pins' || pathParts[0] === 'buses'
|
|
175
|
+
if (typeof entry === 'number' && entry >= 0 && (nextPinMap || key === 'pin')) {
|
|
176
|
+
out.push([nextPath.join('.'), entry])
|
|
177
|
+
} else if (entry && typeof entry === 'object') {
|
|
178
|
+
collectGpioAssignments(entry, nextPath, nextPinMap, out)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function configuredInterface({ id, descriptor, selection, provided, prompt, io }) {
|
|
184
|
+
const interfaces = Array.isArray(descriptor.interfaces) ? descriptor.interfaces : []
|
|
185
|
+
const supplied = provided.get(`${id}.interface`)
|
|
186
|
+
if (supplied !== undefined) {
|
|
187
|
+
provided.delete(`${id}.interface`)
|
|
188
|
+
if (!interfaces.includes(supplied)) fail(`Chip '${id}' does not support interface '${supplied}'.`, ExitCode.usage)
|
|
189
|
+
return supplied
|
|
190
|
+
}
|
|
191
|
+
if (selection.interface && interfaces.includes(selection.interface)) return selection.interface
|
|
192
|
+
if (interfaces.length === 1) return interfaces[0]
|
|
193
|
+
if (!canAnswer(prompt, io)) fail(`Set ${id}.interface with --set ${id}.interface=<value>.`, ExitCode.usage)
|
|
194
|
+
return choose(prompt, {
|
|
195
|
+
message: `${descriptor.label} interface`,
|
|
196
|
+
choices: interfaces.map((value) => ({ value, label: value.toUpperCase() })),
|
|
197
|
+
defaultValue: interfaces[0]
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function configureFields({ id, target, fields, provided, prompt, io }) {
|
|
202
|
+
for (const field of fields) {
|
|
203
|
+
const supplied = provided.get(`${id}.${field.path}`)
|
|
204
|
+
if (supplied !== undefined) {
|
|
205
|
+
provided.delete(`${id}.${field.path}`)
|
|
206
|
+
const error = validateFieldValue(supplied, field)
|
|
207
|
+
if (error) fail(`Invalid ${id}.${field.path}: ${error}.`, ExitCode.usage)
|
|
208
|
+
setPath(target, field.path, parseFieldValue(supplied, field))
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
const current = getPath(target, field.path)
|
|
212
|
+
if (current !== undefined && current !== null) continue
|
|
213
|
+
if (!canAnswer(prompt, io)) {
|
|
214
|
+
if (field.default !== undefined) {
|
|
215
|
+
setPath(target, field.path, field.default)
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
if (field.optional) {
|
|
219
|
+
setPath(target, field.path, null)
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
fail(`Missing ${id}.${field.path}. Pass --set ${id}.${field.path}=<value> or run interactively.`, ExitCode.usage)
|
|
223
|
+
}
|
|
224
|
+
if (field.type === 'choice') {
|
|
225
|
+
const value = await choose(prompt, {
|
|
226
|
+
message: field.label,
|
|
227
|
+
choices: field.values.map((entry) => ({ value: entry, label: entry })),
|
|
228
|
+
defaultValue: field.default || field.values[0]
|
|
229
|
+
})
|
|
230
|
+
setPath(target, field.path, value)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
const value = await ask(prompt, {
|
|
234
|
+
message: field.optional ? `${field.label} (blank for none)` : field.label,
|
|
235
|
+
defaultValue: field.default === undefined ? '' : String(field.default),
|
|
236
|
+
validate: (entry) => validateFieldValue(entry, field)
|
|
237
|
+
})
|
|
238
|
+
setPath(target, field.path, field.optional && value === '' ? null : parseFieldValue(value, field))
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseSetOptions(parsed) {
|
|
243
|
+
const raw = parsed.options.set
|
|
244
|
+
const entries = raw === undefined ? [] : Array.isArray(raw) ? raw : [raw]
|
|
245
|
+
const out = new Map()
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
const index = String(entry).indexOf('=')
|
|
248
|
+
if (index <= 0) fail(`Invalid --set '${entry}'. Expected --set chip.path=value.`, ExitCode.usage)
|
|
249
|
+
out.set(String(entry).slice(0, index), String(entry).slice(index + 1))
|
|
250
|
+
}
|
|
251
|
+
return out
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseFieldValue(value, field) {
|
|
255
|
+
if (field.optional && (value === '' || value === 'none' || value === 'null')) return null
|
|
256
|
+
if (field.type === 'integer' || field.type === 'pin') return Number.parseInt(value, 10)
|
|
257
|
+
return String(value)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function validateFieldValue(value, field) {
|
|
261
|
+
if (field.optional && value === '') return ''
|
|
262
|
+
if (field.type === 'choice') return field.values.includes(value) ? '' : `choose one of ${field.values.join(', ')}`
|
|
263
|
+
if (field.type !== 'integer' && field.type !== 'pin') return ''
|
|
264
|
+
if (!/^-?\d+$/.test(String(value))) return 'enter an integer'
|
|
265
|
+
const number = Number(value)
|
|
266
|
+
const min = field.type === 'pin' ? 0 : field.min
|
|
267
|
+
const max = field.type === 'pin' ? 48 : field.max
|
|
268
|
+
if (min !== undefined && number < min) return `minimum is ${min}`
|
|
269
|
+
if (max !== undefined && number > max) return `maximum is ${max}`
|
|
270
|
+
return ''
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function getPath(value, dottedPath) {
|
|
274
|
+
return dottedPath.split('.').reduce((current, part) => current?.[part], value)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function setPath(value, dottedPath, next) {
|
|
278
|
+
const parts = dottedPath.split('.')
|
|
279
|
+
let current = value
|
|
280
|
+
for (const part of parts.slice(0, -1)) current = current[part] ||= {}
|
|
281
|
+
current[parts.at(-1)] = next
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function selectedDriver(selection) {
|
|
285
|
+
return String(selection?.driver || selection?.controller || '')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function failUnknownChip(id, catalog) {
|
|
289
|
+
const available = Object.keys(catalog).sort().join(', ')
|
|
290
|
+
fail(`Unknown chip '${id}'. Available chips: ${available}`, ExitCode.usage)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function canAnswer(prompt, io) {
|
|
294
|
+
return Boolean(io.prompt || io.stdin?.isTTY || (!io.stdin && process.stdin.isTTY))
|
|
295
|
+
}
|
package/src/context.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export function createContext(parsed, _env = process.env, cwd = process.cwd()) {
|
|
|
13
13
|
const absoluteCwd = path.resolve(cwd)
|
|
14
14
|
const projectRoot = findNodeProjectRoot(absoluteCwd) || absoluteCwd
|
|
15
15
|
const initialAnchors = [projectRoot, cliPackageRoot]
|
|
16
|
-
const targetsRoot = resolveInstalledPackageDir('@geastack/targets', initialAnchors)
|
|
16
|
+
const targetsRoot = _env.GEA_TARGETS_ROOT || resolveInstalledPackageDir('@geastack/targets', initialAnchors)
|
|
17
17
|
const corePackageDir = resolveInstalledPackageDir('@geastack/core', initialAnchors)
|
|
18
18
|
const packageAnchors = [projectRoot, cliPackageRoot, targetsRoot, corePackageDir].filter(Boolean)
|
|
19
19
|
const compilerPackageDir = resolveInstalledPackageDir('@geastack/compiler', packageAnchors)
|
|
@@ -28,7 +28,7 @@ export function createContext(parsed, _env = process.env, cwd = process.cwd()) {
|
|
|
28
28
|
cliPackageRoot,
|
|
29
29
|
compilerPackageDir,
|
|
30
30
|
corePackageDir,
|
|
31
|
-
chipsPackageDir: resolveInstalledPackageDir('@geastack/chips', packageAnchors),
|
|
31
|
+
chipsPackageDir: _env.GEA_CHIPS_DIR || resolveInstalledPackageDir('@geastack/chips', packageAnchors),
|
|
32
32
|
elementsPackageDir: resolveInstalledPackageDir('@geastack/elements', packageAnchors),
|
|
33
33
|
enginePackageDir: resolveInstalledPackageDir('@geastack/engine', packageAnchors),
|
|
34
34
|
geaosPackageDir: resolveInstalledPackageDir('@geastack/geaos', packageAnchors),
|
package/src/create-geastack.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { flag, option, optionList, parseArgs } from './args.mjs'
|
|
|
5
5
|
import { createContext } from './context.mjs'
|
|
6
6
|
import { ExitCode, fail } from './errors.mjs'
|
|
7
7
|
import { exists, readJson, writeJson } from './fs-utils.mjs'
|
|
8
|
-
import { canPrompt, choose, createPrompt } from './prompts.mjs'
|
|
8
|
+
import { canPrompt, choose, confirm, createPrompt } from './prompts.mjs'
|
|
9
9
|
import { runExternal } from './run.mjs'
|
|
10
10
|
import {
|
|
11
11
|
copyStarterFiles,
|
|
@@ -67,12 +67,12 @@ export async function runCreateGeastack(argv, io = {}) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
const sourcePackage = readPackageJson(targetDir)
|
|
70
|
-
const sourceManifest = sourcePackage.gea || starter.starter?.manifest || starter.example?.manifest || {}
|
|
70
|
+
const sourceManifest = sourcePackage.gea || starter.manifest || starter.starter?.manifest || starter.example?.manifest || {}
|
|
71
71
|
const requestedTargets = optionList(parsed, 'targets')
|
|
72
72
|
const targets = requestedTargets.length > 0
|
|
73
73
|
? targetManifest(requestedTargets)
|
|
74
74
|
: starter.kind === 'empty'
|
|
75
|
-
? targetManifest(['web'])
|
|
75
|
+
? targetManifest(starter.targets || ['web'])
|
|
76
76
|
: targetManifestFromObject(sourceManifest.targets)
|
|
77
77
|
writeJson(path.join(targetDir, 'package.json'), packageJson({
|
|
78
78
|
appId,
|
|
@@ -121,7 +121,23 @@ async function resolveStarter(ctx, parsed, io) {
|
|
|
121
121
|
try {
|
|
122
122
|
const mode = requested || await chooseStarterMode({ interactive, prompt, bundled, examples })
|
|
123
123
|
|
|
124
|
-
if (mode === 'empty')
|
|
124
|
+
if (mode === 'empty') {
|
|
125
|
+
const requestedTargets = optionList(parsed, 'targets')
|
|
126
|
+
if (!interactive || requestedTargets.length > 0) return { kind: 'empty' }
|
|
127
|
+
|
|
128
|
+
const target = await chooseBlankTarget(prompt)
|
|
129
|
+
const enableBleOta = target === 'esp32'
|
|
130
|
+
? await confirm(prompt, {
|
|
131
|
+
message: 'Enable wireless firmware updates over Bluetooth?',
|
|
132
|
+
defaultValue: true
|
|
133
|
+
})
|
|
134
|
+
: false
|
|
135
|
+
return {
|
|
136
|
+
kind: 'empty',
|
|
137
|
+
targets: [target],
|
|
138
|
+
manifest: enableBleOta ? { ota: { ble: true } } : {}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
125
141
|
if (mode === 'bundled' || mode === 'counter') {
|
|
126
142
|
const requestedBundled = mode === 'counter' ? 'counter' : option(parsed, 'bundled') || option(parsed, 'starter-id') || ''
|
|
127
143
|
const starter = requestedBundled
|
|
@@ -191,6 +207,50 @@ async function chooseExample({ interactive, prompt, examples }) {
|
|
|
191
207
|
return examples.find((example) => example.id === id) || null
|
|
192
208
|
}
|
|
193
209
|
|
|
210
|
+
async function chooseBlankTarget(prompt) {
|
|
211
|
+
return choose(prompt, {
|
|
212
|
+
message: 'Where should this application run?',
|
|
213
|
+
choices: [
|
|
214
|
+
{
|
|
215
|
+
value: 'web',
|
|
216
|
+
label: 'Web browser',
|
|
217
|
+
description: 'A browser app with a local development server.'
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
value: 'esp32',
|
|
221
|
+
label: 'ESP32 board',
|
|
222
|
+
description: 'Native firmware for a supported ESP32 device.'
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
value: 'rp2350',
|
|
226
|
+
label: 'RP2350 board',
|
|
227
|
+
description: 'Native firmware for a supported Raspberry Pi RP2350 device.'
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
value: 'geaos',
|
|
231
|
+
label: 'GeaOS device',
|
|
232
|
+
description: 'A native application for GeaOS.'
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
value: 'macos',
|
|
236
|
+
label: 'macOS',
|
|
237
|
+
description: 'A native Mac application.'
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
value: 'ios',
|
|
241
|
+
label: 'iPhone or iPad',
|
|
242
|
+
description: 'A native iOS application.'
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
value: 'android',
|
|
246
|
+
label: 'Android',
|
|
247
|
+
description: 'A native Android application.'
|
|
248
|
+
}
|
|
249
|
+
],
|
|
250
|
+
defaultValue: 'web'
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
194
254
|
function ensureWritableTarget(targetDir, force) {
|
|
195
255
|
if (!exists(targetDir)) return
|
|
196
256
|
const entries = fs.readdirSync(targetDir).filter((entry) => entry !== '.DS_Store')
|
|
@@ -248,11 +308,11 @@ function packageJson({ appId, displayName, targets, entry, coreDependency, cliDe
|
|
|
248
308
|
dependencies: {
|
|
249
309
|
...(sourcePackage.dependencies || {}),
|
|
250
310
|
'@geajs/core': sourcePackage.dependencies?.['@geajs/core'] || '^1.3.0',
|
|
251
|
-
'@geastack/core': coreDependency
|
|
311
|
+
'@geastack/core': coreDependency,
|
|
312
|
+
'@geastack/cli': cliDependency
|
|
252
313
|
},
|
|
253
314
|
devDependencies: {
|
|
254
315
|
...(sourcePackage.devDependencies || {}),
|
|
255
|
-
'@geastack/cli': cliDependency,
|
|
256
316
|
typescript: sourcePackage.devDependencies?.typescript || 'latest',
|
|
257
317
|
...(targets.web ? { vite: sourcePackage.devDependencies?.vite || 'latest' } : {})
|
|
258
318
|
},
|
package/src/gea.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
|
|
3
3
|
|
|
4
4
|
import { flag, option, parseArgs } from './args.mjs'
|
|
5
5
|
import { runCreateGeastack } from './create-geastack.mjs'
|
|
6
|
+
import { runChips } from './chips.mjs'
|
|
6
7
|
import { createChildEnv, createContext } from './context.mjs'
|
|
7
8
|
import { ExitCode, fail } from './errors.mjs'
|
|
8
9
|
import { exists, readJson } from './fs-utils.mjs'
|
|
@@ -59,6 +60,8 @@ export async function runGea(argv, io = {}) {
|
|
|
59
60
|
return build(ctx, parsed, rest, { stdout, env })
|
|
60
61
|
case 'setup':
|
|
61
62
|
return setup(ctx, parsed, { stdout, env, stdin, output, prompt })
|
|
63
|
+
case 'chips':
|
|
64
|
+
return runChips(ctx, parsed, rest, { stdout, env, stdin, output, prompt })
|
|
62
65
|
case 'flash':
|
|
63
66
|
return flash(ctx, parsed, rest, { stdout, env })
|
|
64
67
|
case 'monitor':
|
|
@@ -432,6 +435,10 @@ function usage() {
|
|
|
432
435
|
gea create <name> [--starter counter|empty|example] [--targets <list>]
|
|
433
436
|
gea doctor [--strict] [--json]
|
|
434
437
|
gea setup --board <alias>
|
|
438
|
+
gea chips list [--json]
|
|
439
|
+
gea chips info <chip> [--json]
|
|
440
|
+
gea chips add <chip...> [--board <alias>] [--set chip.path=value]
|
|
441
|
+
gea chips remove <chip...> [--board <alias>]
|
|
435
442
|
gea dev [app] [--target web] [--port 5181]
|
|
436
443
|
gea build [app] [--target web|macos|ios|android|<target>] [--board <alias>] [--output-tag <tag>]
|
|
437
444
|
gea flash [app] --board <alias> [--monitor] [--port auto]
|
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 { configureChipSelection, loadChipCatalog, validateGpioAssignments } from './chips.mjs'
|
|
6
7
|
import { flag, option } from './args.mjs'
|
|
7
8
|
import { createChildEnv } from './context.mjs'
|
|
8
9
|
import { ExitCode, fail } from './errors.mjs'
|
|
@@ -143,7 +144,7 @@ async function setupKnownBoard(ctx, parsed, io, prompt) {
|
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
async function setupCustomBoard(ctx, parsed, io, prompt) {
|
|
146
|
-
renderStep(io, 'Board', ['
|
|
147
|
+
renderStep(io, 'Board', ['Create an app-local hardware target from the installed chip catalog.'])
|
|
147
148
|
const alias = await ask(prompt, {
|
|
148
149
|
message: 'Custom board alias',
|
|
149
150
|
defaultValue: 'custom-board',
|
|
@@ -151,230 +152,119 @@ async function setupCustomBoard(ctx, parsed, io, prompt) {
|
|
|
151
152
|
})
|
|
152
153
|
const mcu = await choose(prompt, {
|
|
153
154
|
message: 'MCU / SoC',
|
|
154
|
-
choices: [
|
|
155
|
-
{ value: 'esp32-s3', label: 'ESP32-S3' },
|
|
156
|
-
{ value: 'esp32-p4', label: 'ESP32-P4' },
|
|
157
|
-
{ value: 'esp32-c6', label: 'ESP32-C6' },
|
|
158
|
-
{ value: 'esp32', label: 'ESP32' },
|
|
159
|
-
{ value: 'other', label: 'Other / not listed' }
|
|
160
|
-
],
|
|
161
|
-
defaultValue: 'esp32-s3'
|
|
162
|
-
})
|
|
163
|
-
const mcuName = mcu === 'other' ? await ask(prompt, { message: 'MCU / SoC name', defaultValue: '' }) : mcu
|
|
164
|
-
const baseTarget = await choose(prompt, {
|
|
165
|
-
message: 'Closest existing target to start from',
|
|
166
|
-
choices: [
|
|
167
|
-
{ value: '', label: 'None yet, generate profile only' },
|
|
168
|
-
...knownBoards.map((board) => ({ value: board.target, label: `${board.label} (${board.target})` }))
|
|
169
|
-
],
|
|
170
|
-
defaultValue: ''
|
|
171
|
-
})
|
|
172
|
-
const depth = await choose(prompt, {
|
|
173
|
-
message: 'How much hardware detail do you want to enter?',
|
|
174
155
|
choices: [
|
|
175
156
|
{
|
|
176
|
-
value: '
|
|
177
|
-
label: '
|
|
178
|
-
description: '
|
|
179
|
-
},
|
|
180
|
-
{
|
|
181
|
-
value: 'quick',
|
|
182
|
-
label: 'Fast profile',
|
|
183
|
-
description: 'Core board, display/touch, transport, and sensible defaults for everything else.'
|
|
157
|
+
value: 'esp32s3',
|
|
158
|
+
label: 'ESP32-S3',
|
|
159
|
+
description: 'Composable ESP-IDF target with display, touch, power, IMU, audio, WiFi, BLE, and OTA support.'
|
|
184
160
|
}
|
|
185
161
|
],
|
|
186
|
-
defaultValue: '
|
|
162
|
+
defaultValue: 'esp32s3'
|
|
187
163
|
})
|
|
188
164
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
]
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
{ value: 'i2c', label: 'I2C' },
|
|
222
|
-
{ value: 'spi', label: 'SPI' },
|
|
223
|
-
{ value: 'gpio', label: 'GPIO buttons/interrupts' },
|
|
224
|
-
{ value: 'other', label: 'Other' }
|
|
225
|
-
],
|
|
226
|
-
defaultValue: 'i2c'
|
|
227
|
-
}) : ''
|
|
228
|
-
|
|
229
|
-
const defaults = defaultCustomPeripherals(mcuName)
|
|
230
|
-
let wifi = defaults.wifi
|
|
231
|
-
let ble = defaults.ble
|
|
232
|
-
let gpsModule = ''
|
|
233
|
-
let gpsInterface = ''
|
|
234
|
-
let audioCodec = ''
|
|
235
|
-
let audioOutput = ''
|
|
236
|
-
let audioInput = ''
|
|
237
|
-
let storage = defaults.storage
|
|
238
|
-
let sensors = ''
|
|
239
|
-
let power = 'USB'
|
|
240
|
-
|
|
241
|
-
if (depth === 'full') {
|
|
242
|
-
renderStep(io, 'Peripherals', ['Add wireless, location, audio, storage, sensors, and power details.'])
|
|
243
|
-
wifi = await choose(prompt, {
|
|
244
|
-
message: 'WiFi',
|
|
165
|
+
const definition = {
|
|
166
|
+
id: alias,
|
|
167
|
+
extends: 'esp32-s3',
|
|
168
|
+
adapter: 'esp32-idf',
|
|
169
|
+
mcu,
|
|
170
|
+
buses: {},
|
|
171
|
+
chips: {},
|
|
172
|
+
storage: {
|
|
173
|
+
microSD: {
|
|
174
|
+
interface: 'sdmmc-1bit',
|
|
175
|
+
pins: { clk: null, cmd: null, data0: null }
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
controls: {
|
|
179
|
+
launcherButton: { pin: null, activeLevel: 0 }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const catalog = loadChipCatalog(ctx)
|
|
184
|
+
const roles = [
|
|
185
|
+
['display', 'Display controller'],
|
|
186
|
+
['touch', 'Touch controller'],
|
|
187
|
+
['power', 'Power-management controller'],
|
|
188
|
+
['imu', 'Inertial measurement unit'],
|
|
189
|
+
['audio', 'Audio codec']
|
|
190
|
+
]
|
|
191
|
+
renderStep(io, 'Chips', ['Choose each controller from @geastack/chips. Pin questions come from the installed catalog.'])
|
|
192
|
+
for (const [role, label] of roles) {
|
|
193
|
+
const compatible = Object.entries(catalog)
|
|
194
|
+
.filter(([, chip]) => chip.category === role && chip.adapters?.['esp32-idf']?.mcus?.includes(mcu))
|
|
195
|
+
const selected = await choose(prompt, {
|
|
196
|
+
message: label,
|
|
245
197
|
choices: [
|
|
246
|
-
{ value:
|
|
247
|
-
{ value: '
|
|
248
|
-
{ value: 'none', label: 'None' }
|
|
198
|
+
...compatible.map(([id, chip]) => ({ value: id, label: `${id} — ${chip.label}` })),
|
|
199
|
+
{ value: '', label: 'None / configure later' }
|
|
249
200
|
],
|
|
250
|
-
defaultValue:
|
|
201
|
+
defaultValue: compatible[0]?.[0] || ''
|
|
251
202
|
})
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
203
|
+
if (!selected) continue
|
|
204
|
+
await configureChipSelection({
|
|
205
|
+
definition,
|
|
206
|
+
id: selected,
|
|
207
|
+
descriptor: catalog[selected],
|
|
208
|
+
prompt,
|
|
209
|
+
io,
|
|
210
|
+
replace: true,
|
|
211
|
+
boardName: alias
|
|
260
212
|
})
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
audioOutput = audioCodec ? await choose(prompt, {
|
|
274
|
-
message: 'Audio output',
|
|
275
|
-
choices: [
|
|
276
|
-
{ value: 'i2s-speaker', label: 'I2S speaker/output' },
|
|
277
|
-
{ value: 'dac', label: 'DAC output' },
|
|
278
|
-
{ value: 'pwm', label: 'PWM/buzzer' },
|
|
279
|
-
{ value: 'other', label: 'Other' }
|
|
280
|
-
],
|
|
281
|
-
defaultValue: 'i2s-speaker'
|
|
282
|
-
}) : ''
|
|
283
|
-
audioInput = audioCodec ? await choose(prompt, {
|
|
284
|
-
message: 'Audio input',
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
renderStep(io, 'Board features', ['Configure physical features that are not separate chip drivers.'])
|
|
216
|
+
if (await confirm(prompt, { message: 'Does this board expose a microSD slot?', defaultValue: false })) {
|
|
217
|
+
definition.storage.microSD.pins.clk = await askPin(prompt, 'microSD clock pin')
|
|
218
|
+
definition.storage.microSD.pins.cmd = await askPin(prompt, 'microSD command pin')
|
|
219
|
+
definition.storage.microSD.pins.data0 = await askPin(prompt, 'microSD data 0 pin')
|
|
220
|
+
}
|
|
221
|
+
if (await confirm(prompt, { message: 'Use a hardware button to return to the launcher?', defaultValue: false })) {
|
|
222
|
+
definition.controls.launcherButton.pin = await askPin(prompt, 'Launcher button pin')
|
|
223
|
+
definition.controls.launcherButton.activeLevel = Number(await choose(prompt, {
|
|
224
|
+
message: 'Launcher button active level',
|
|
285
225
|
choices: [
|
|
286
|
-
{ value: '
|
|
287
|
-
{ value: '
|
|
288
|
-
{ value: 'pdm-mic', label: 'PDM microphone' },
|
|
289
|
-
{ value: 'analog-mic', label: 'Analog microphone' },
|
|
290
|
-
{ value: 'other', label: 'Other' }
|
|
226
|
+
{ value: '0', label: 'Active low' },
|
|
227
|
+
{ value: '1', label: 'Active high' }
|
|
291
228
|
],
|
|
292
|
-
defaultValue: '
|
|
293
|
-
})
|
|
294
|
-
storage = await ask(prompt, { message: 'Storage chips/features, comma-separated', defaultValue: defaults.storage })
|
|
295
|
-
sensors = await ask(prompt, { message: 'Sensors, comma-separated (IMU, light, temp, etc.)', defaultValue: '' })
|
|
296
|
-
power = await ask(prompt, { message: 'Power path (USB, battery charger, PMIC, etc.)', defaultValue: 'USB' })
|
|
297
|
-
} else {
|
|
298
|
-
renderStep(io, 'Peripherals', [
|
|
299
|
-
`Using defaults: WiFi ${wifi}, BLE ${ble}, storage ${storage}, power USB.`,
|
|
300
|
-
`You can edit .gea/boards/${alias}.json later if the board has GPS, audio, or sensors.`
|
|
301
|
-
])
|
|
229
|
+
defaultValue: '0'
|
|
230
|
+
}))
|
|
302
231
|
}
|
|
303
232
|
|
|
304
|
-
renderStep(io, 'Connection', ['
|
|
305
|
-
const
|
|
306
|
-
message: '
|
|
307
|
-
choices: [
|
|
308
|
-
{ value: 'usbSerial', label: 'USB serial' },
|
|
309
|
-
{ value: 'ota', label: 'WiFi OTA' },
|
|
310
|
-
{ value: 'both', label: 'USB serial + OTA' },
|
|
311
|
-
{ value: 'custom', label: 'Custom' }
|
|
312
|
-
],
|
|
313
|
-
defaultValue: 'usbSerial'
|
|
233
|
+
renderStep(io, 'Connection', ['The first flash uses USB. BLE OTA can take over after the initial firmware is running.'])
|
|
234
|
+
const usbSerial = await selectUsbSerial(prompt, io, {
|
|
235
|
+
message: 'USB serial number (leave blank to auto-detect or pass --port)'
|
|
314
236
|
})
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const profile = compactObject({
|
|
324
|
-
alias,
|
|
325
|
-
kind: 'custom-board-profile',
|
|
326
|
-
targetFamily: 'esp32',
|
|
237
|
+
|
|
238
|
+
const configPath = boardConfigPath(ctx, parsed)
|
|
239
|
+
const definitionPath = path.join(path.dirname(configPath), 'targets', `${alias}.json`)
|
|
240
|
+
const relativeDefinition = path.relative(path.dirname(configPath), definitionPath)
|
|
241
|
+
const entry = compactObject({
|
|
242
|
+
target: alias,
|
|
243
|
+
targetDefinition: relativeDefinition,
|
|
327
244
|
adapter: 'esp32-idf',
|
|
328
|
-
|
|
329
|
-
mcu: mcuName,
|
|
330
|
-
display: compactObject({
|
|
331
|
-
kind: displayKind,
|
|
332
|
-
controller: displayController,
|
|
333
|
-
interface: displayInterface,
|
|
334
|
-
resolution
|
|
335
|
-
}),
|
|
336
|
-
touch: compactObject({
|
|
337
|
-
controller: touchController,
|
|
338
|
-
interface: touchInterface
|
|
339
|
-
}),
|
|
340
|
-
wireless: compactObject({ wifi, ble }),
|
|
341
|
-
gps: compactObject({ module: gpsModule, interface: gpsInterface }),
|
|
342
|
-
audio: compactObject({ codec: audioCodec, output: audioOutput, input: audioInput }),
|
|
343
|
-
storage: csv(storage),
|
|
344
|
-
sensors: csv(sensors),
|
|
345
|
-
power,
|
|
245
|
+
appPlatform: 'esp32',
|
|
346
246
|
transports: compactObject({
|
|
347
|
-
usbSerial: usbSerial ? { serial: usbSerial } : undefined
|
|
348
|
-
|
|
349
|
-
}),
|
|
350
|
-
notes
|
|
247
|
+
usbSerial: usbSerial ? { serial: usbSerial } : undefined
|
|
248
|
+
})
|
|
351
249
|
})
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
renderCustomBoardReview(io, { alias,
|
|
356
|
-
if (!await shouldSaveSetup(parsed, prompt, 'Save this custom board
|
|
250
|
+
const requiredRoles = roles.map(([role]) => role)
|
|
251
|
+
const missingRoles = requiredRoles.filter((role) => !definition.chips[role])
|
|
252
|
+
validateGpioAssignments(definition)
|
|
253
|
+
renderCustomBoardReview(io, { alias, definition, definitionPath, configPath, entry, missingRoles })
|
|
254
|
+
if (!await shouldSaveSetup(parsed, prompt, 'Save this custom board target?')) {
|
|
357
255
|
return { alias, cancelled: true }
|
|
358
256
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
transports: profile.transports
|
|
369
|
-
})
|
|
370
|
-
writeJsonEnsured(configPath, config)
|
|
371
|
-
io.stdout(`Wrote experimental board alias '${alias}' to ${configPath}`)
|
|
372
|
-
return { alias, flashReady: true }
|
|
373
|
-
} else {
|
|
374
|
-
io.stdout('No board alias was added because no base target was selected.')
|
|
375
|
-
io.stdout('Add a target backend before flashing this custom profile.')
|
|
376
|
-
return { alias, flashReady: false }
|
|
257
|
+
|
|
258
|
+
writeJsonEnsured(definitionPath, definition)
|
|
259
|
+
const config = readBoardConfig(configPath)
|
|
260
|
+
config[alias] = entry
|
|
261
|
+
writeJsonEnsured(configPath, config)
|
|
262
|
+
io.stdout(`Wrote custom target to ${definitionPath}`)
|
|
263
|
+
io.stdout(`Wrote board alias '${alias}' to ${configPath}`)
|
|
264
|
+
if (missingRoles.length) {
|
|
265
|
+
io.stdout(`Add the remaining roles with: npx gea chips add <chip> --board ${alias}`)
|
|
377
266
|
}
|
|
267
|
+
return { alias, flashReady: missingRoles.length === 0 }
|
|
378
268
|
}
|
|
379
269
|
|
|
380
270
|
function renderHeader(io, title, lines = []) {
|
|
@@ -405,25 +295,24 @@ function renderKnownBoardReview(io, { alias, board, configPath, entry }) {
|
|
|
405
295
|
])
|
|
406
296
|
}
|
|
407
297
|
|
|
408
|
-
function renderCustomBoardReview(io, { alias,
|
|
298
|
+
function renderCustomBoardReview(io, { alias, definition, definitionPath, configPath, entry, missingRoles }) {
|
|
409
299
|
renderStep(io, 'Review', [
|
|
410
300
|
'This is what GeaStack will save.'
|
|
411
301
|
])
|
|
412
302
|
writeRows(io, [
|
|
413
303
|
['Alias', alias],
|
|
414
|
-
['MCU / SoC',
|
|
415
|
-
['
|
|
416
|
-
['Display', describeObject(
|
|
417
|
-
['Touch', describeObject(
|
|
418
|
-
['
|
|
419
|
-
['
|
|
420
|
-
['Audio', describeObject(
|
|
421
|
-
['
|
|
422
|
-
['
|
|
423
|
-
['
|
|
424
|
-
['
|
|
425
|
-
['
|
|
426
|
-
['Board config', flashReady ? configPath : 'not written until a base target is selected']
|
|
304
|
+
['MCU / SoC', definition.mcu],
|
|
305
|
+
['Platform base', definition.extends],
|
|
306
|
+
['Display', describeObject(definition.chips.display)],
|
|
307
|
+
['Touch', describeObject(definition.chips.touch)],
|
|
308
|
+
['Power', describeObject(definition.chips.power)],
|
|
309
|
+
['IMU', describeObject(definition.chips.imu)],
|
|
310
|
+
['Audio', describeObject(definition.chips.audio)],
|
|
311
|
+
['I2C', describeObject(definition.buses.i2c)],
|
|
312
|
+
['USB serial', entry.transports?.usbSerial?.serial || 'auto / pass --port'],
|
|
313
|
+
['Missing roles', missingRoles.join(', ') || 'none'],
|
|
314
|
+
['Target definition', definitionPath],
|
|
315
|
+
['Board config', configPath]
|
|
427
316
|
])
|
|
428
317
|
}
|
|
429
318
|
|
|
@@ -461,17 +350,6 @@ function describeObject(value) {
|
|
|
461
350
|
return entries.length ? entries.join('; ') : 'not set'
|
|
462
351
|
}
|
|
463
352
|
|
|
464
|
-
function defaultCustomPeripherals(mcuName) {
|
|
465
|
-
const normalized = String(mcuName || '').toLowerCase()
|
|
466
|
-
const espWithWireless = ['esp32', 'esp32-s3', 'esp32-c3', 'esp32-c6', 'esp32-h2'].includes(normalized)
|
|
467
|
-
const hasPsramByDefault = ['esp32-s3', 'esp32-p4'].includes(normalized)
|
|
468
|
-
return {
|
|
469
|
-
wifi: espWithWireless && normalized !== 'esp32-h2' ? 'built-in' : 'none',
|
|
470
|
-
ble: espWithWireless ? 'built-in' : 'none',
|
|
471
|
-
storage: hasPsramByDefault ? 'flash, psram' : 'flash'
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
353
|
async function maybeInstallNpmDependencies(ctx, parsed, io, prompt, { force = false } = {}) {
|
|
476
354
|
const install = force || option(parsed, 'install') === true || await confirm(prompt, {
|
|
477
355
|
message: 'Install npm dependencies now if package.json exists?',
|
|
@@ -612,6 +490,18 @@ function validateAlias(value) {
|
|
|
612
490
|
return ''
|
|
613
491
|
}
|
|
614
492
|
|
|
493
|
+
async function askPin(prompt, message) {
|
|
494
|
+
return Number(await ask(prompt, {
|
|
495
|
+
message,
|
|
496
|
+
defaultValue: '',
|
|
497
|
+
validate: (value) => {
|
|
498
|
+
if (!/^\d+$/.test(value)) return 'enter a GPIO number from 0 through 48'
|
|
499
|
+
const pin = Number(value)
|
|
500
|
+
return pin >= 0 && pin <= 48 ? '' : 'enter a GPIO number from 0 through 48'
|
|
501
|
+
}
|
|
502
|
+
}))
|
|
503
|
+
}
|
|
504
|
+
|
|
615
505
|
function compactObject(value) {
|
|
616
506
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => {
|
|
617
507
|
if (entry === undefined || entry === null || entry === '') return false
|
|
@@ -620,14 +510,3 @@ function compactObject(value) {
|
|
|
620
510
|
return true
|
|
621
511
|
}))
|
|
622
512
|
}
|
|
623
|
-
|
|
624
|
-
function csv(value) {
|
|
625
|
-
return String(value || '')
|
|
626
|
-
.split(',')
|
|
627
|
-
.map((entry) => entry.trim())
|
|
628
|
-
.filter(Boolean)
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
function list(value) {
|
|
632
|
-
return Array.isArray(value) && value.length ? value.join(', ') : 'not set'
|
|
633
|
-
}
|