@geastack/cli 0.1.52 → 0.1.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/SPEC.md +2 -2
- package/package.json +2 -3
- package/src/apps/app-index-writer.mjs +15 -0
- package/src/apps/apple-icons.mjs +147 -0
- package/src/apps/bundle-writer.mjs +156 -0
- package/src/apps/launcher-catalog.mjs +114 -0
- package/src/apps/openai-icons.mjs +356 -0
- package/src/apps/zip-writer.mjs +104 -0
- package/src/ble/ble-ota.swift +291 -0
- package/src/boards/config.mjs +32 -0
- package/src/boards/custom-target.mjs +309 -0
- package/src/boards/resolve.mjs +143 -0
- package/src/boards/targets.mjs +45 -0
- package/src/boards/usb.mjs +211 -0
- package/src/commands/apps.mjs +236 -0
- package/src/commands/board.mjs +402 -0
- package/src/commands/doctor.mjs +91 -0
- package/src/context.mjs +45 -52
- package/src/device/device.mjs +96 -0
- package/src/device/image.mjs +115 -0
- package/src/device/serial.mjs +430 -0
- package/src/device/wifi.mjs +190 -0
- package/src/esp32/build.mjs +321 -0
- package/src/esp32/capabilities.mjs +54 -0
- package/src/esp32/flash.mjs +181 -0
- package/src/esp32/idf-env.mjs +171 -0
- package/src/esp32/ota.mjs +80 -0
- package/src/esp32/partitions.mjs +59 -0
- package/src/esp32/sdkconfig.mjs +103 -0
- package/src/esp32/wifi-config.mjs +72 -0
- package/src/gea.mjs +86 -427
- package/src/geaos/adapter.mjs +119 -0
- package/src/heap-report.mjs +1276 -0
- package/src/manifest.mjs +135 -36
- package/src/rp2350/adapter.mjs +155 -0
- package/src/setup-wizard.mjs +6 -10
- package/src/taurus/adapter.mjs +52 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env swift
|
|
2
|
+
|
|
3
|
+
import CoreBluetooth
|
|
4
|
+
import Darwin
|
|
5
|
+
import Foundation
|
|
6
|
+
|
|
7
|
+
// `gea` runs this helper with stdout connected to a pipe. Disable stdio
|
|
8
|
+
// buffering so discovery, transport selection, and percentage updates remain
|
|
9
|
+
// visible during a long first update from older firmware.
|
|
10
|
+
setbuf(stdout, nil)
|
|
11
|
+
|
|
12
|
+
private let otaService = CBUUID(string: "7F2E1001-6D8F-4A4F-A0E9-5B8892140001")
|
|
13
|
+
private let otaControl = CBUUID(string: "7F2E1001-6D8F-4A4F-A0E9-5B8892140002")
|
|
14
|
+
private let otaData = CBUUID(string: "7F2E1001-6D8F-4A4F-A0E9-5B8892140003")
|
|
15
|
+
|
|
16
|
+
private func u32le(_ value: UInt32) -> [UInt8] {
|
|
17
|
+
[
|
|
18
|
+
UInt8(truncatingIfNeeded: value),
|
|
19
|
+
UInt8(truncatingIfNeeded: value >> 8),
|
|
20
|
+
UInt8(truncatingIfNeeded: value >> 16),
|
|
21
|
+
UInt8(truncatingIfNeeded: value >> 24),
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private func readU32le(_ bytes: [UInt8], _ offset: Int) -> UInt32 {
|
|
26
|
+
UInt32(bytes[offset])
|
|
27
|
+
| (UInt32(bytes[offset + 1]) << 8)
|
|
28
|
+
| (UInt32(bytes[offset + 2]) << 16)
|
|
29
|
+
| (UInt32(bytes[offset + 3]) << 24)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private func controlPacket(opcode: UInt8, imageSize: UInt32? = nil) -> Data {
|
|
33
|
+
var bytes = [opcode]
|
|
34
|
+
if let imageSize { bytes.append(contentsOf: u32le(imageSize)) }
|
|
35
|
+
return Data(bytes)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private final class BleOtaUpdater: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
|
|
39
|
+
private let image: Data
|
|
40
|
+
private let deviceName: String
|
|
41
|
+
private let statusOnly: Bool
|
|
42
|
+
private var central: CBCentralManager!
|
|
43
|
+
private var peripheral: CBPeripheral?
|
|
44
|
+
private var controlCharacteristic: CBCharacteristic?
|
|
45
|
+
private var dataCharacteristic: CBCharacteristic?
|
|
46
|
+
private var offset = 0
|
|
47
|
+
private var lastPrintedPercent = -1
|
|
48
|
+
private var sentFinish = false
|
|
49
|
+
private var finished = false
|
|
50
|
+
private var timeout: Timer?
|
|
51
|
+
private var transferStartedAt: Date?
|
|
52
|
+
|
|
53
|
+
init(image: Data, deviceName: String, statusOnly: Bool = false) {
|
|
54
|
+
self.image = image
|
|
55
|
+
self.deviceName = deviceName
|
|
56
|
+
self.statusOnly = statusOnly
|
|
57
|
+
super.init()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func start() {
|
|
61
|
+
print("Looking for \(deviceName)…")
|
|
62
|
+
central = CBCentralManager(delegate: self, queue: .main)
|
|
63
|
+
// Give discovery, transfer, flash validation, and reboot a bounded
|
|
64
|
+
// window. Data always uses CoreBluetooth's no-response flow control.
|
|
65
|
+
let timeoutSeconds = statusOnly ? 30 : max(300, Double(image.count) / 2048)
|
|
66
|
+
timeout = Timer.scheduledTimer(withTimeInterval: timeoutSeconds, repeats: false) { [weak self] _ in
|
|
67
|
+
self?.fail("Timed out waiting for the BLE update to complete.")
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
72
|
+
guard central.state == .poweredOn else {
|
|
73
|
+
if central.state == .unsupported || central.state == .unauthorized || central.state == .poweredOff {
|
|
74
|
+
fail("Bluetooth is unavailable (state \(central.state.rawValue)).")
|
|
75
|
+
}
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
// Bonded BLE peripherals may already be connected by macOS (for example
|
|
79
|
+
// because the firmware exposes a battery service). Connected peripherals
|
|
80
|
+
// no longer produce scan results, so claim an existing OTA connection
|
|
81
|
+
// before starting discovery.
|
|
82
|
+
if let connected = central.retrieveConnectedPeripherals(withServices: [otaService]).first {
|
|
83
|
+
connect(connected, advertisedName: connected.name)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func connect(_ peripheral: CBPeripheral, advertisedName: String?) {
|
|
90
|
+
central.stopScan()
|
|
91
|
+
self.peripheral = peripheral
|
|
92
|
+
peripheral.delegate = self
|
|
93
|
+
print("Connecting to \(advertisedName ?? peripheral.name ?? deviceName)…")
|
|
94
|
+
central.connect(peripheral)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
|
98
|
+
advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
|
99
|
+
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
|
|
100
|
+
let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
|
101
|
+
let nameMatches = peripheral.name?.localizedCaseInsensitiveContains(deviceName) == true
|
|
102
|
+
|| advertisedName?.localizedCaseInsensitiveContains(deviceName) == true
|
|
103
|
+
guard nameMatches || advertisedServices.contains(otaService) else { return }
|
|
104
|
+
|
|
105
|
+
connect(peripheral, advertisedName: advertisedName)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral,
|
|
109
|
+
error: Error?) {
|
|
110
|
+
fail("Could not connect: \(error?.localizedDescription ?? "unknown error")")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral,
|
|
114
|
+
error: Error?) {
|
|
115
|
+
if !finished { fail("The board disconnected before the update completed: \(error?.localizedDescription ?? "no reason reported")") }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
119
|
+
print("Connected. Discovering the OTA service…")
|
|
120
|
+
peripheral.discoverServices([otaService])
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
124
|
+
if let error { return fail("Service discovery failed: \(error.localizedDescription)") }
|
|
125
|
+
guard let service = peripheral.services?.first(where: { $0.uuid == otaService }) else {
|
|
126
|
+
return fail("The board does not expose the Geastack OTA service. Flash a BLE-OTA-enabled app over USB first.")
|
|
127
|
+
}
|
|
128
|
+
peripheral.discoverCharacteristics([otaControl, otaData], for: service)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService,
|
|
132
|
+
error: Error?) {
|
|
133
|
+
if let error { return fail("Characteristic discovery failed: \(error.localizedDescription)") }
|
|
134
|
+
for characteristic in service.characteristics ?? [] {
|
|
135
|
+
if characteristic.uuid == otaControl { controlCharacteristic = characteristic }
|
|
136
|
+
if characteristic.uuid == otaData { dataCharacteristic = characteristic }
|
|
137
|
+
}
|
|
138
|
+
guard let controlCharacteristic, dataCharacteristic != nil else {
|
|
139
|
+
return fail("The OTA service is incomplete on this firmware.")
|
|
140
|
+
}
|
|
141
|
+
peripheral.setNotifyValue(true, for: controlCharacteristic)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
|
|
145
|
+
error: Error?) {
|
|
146
|
+
if let error { return fail("Could not subscribe to OTA status: \(error.localizedDescription)") }
|
|
147
|
+
guard characteristic.uuid == otaControl, characteristic.isNotifying else { return }
|
|
148
|
+
if statusOnly {
|
|
149
|
+
peripheral.writeValue(controlPacket(opcode: 0x05), for: characteristic, type: .withResponse)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
guard image.count <= Int(UInt32.max) else { return fail("Firmware image is too large for the OTA protocol.") }
|
|
153
|
+
print("Starting encrypted transfer of \(image.count) bytes…")
|
|
154
|
+
transferStartedAt = Date()
|
|
155
|
+
peripheral.writeValue(controlPacket(opcode: 0x01, imageSize: UInt32(image.count)),
|
|
156
|
+
for: characteristic, type: .withResponse)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,
|
|
160
|
+
error: Error?) {
|
|
161
|
+
if let error { return fail("OTA status read failed: \(error.localizedDescription)") }
|
|
162
|
+
guard characteristic.uuid == otaControl, let value = characteristic.value else { return }
|
|
163
|
+
let bytes = [UInt8](value)
|
|
164
|
+
guard bytes.count == 10 || bytes.count == 18 else { return fail("The board returned a malformed OTA status packet.") }
|
|
165
|
+
let opcode = bytes[0]
|
|
166
|
+
if opcode == 0x85 {
|
|
167
|
+
let error = Int32(bitPattern: readU32le(bytes, 2))
|
|
168
|
+
finished = true
|
|
169
|
+
timeout?.invalidate()
|
|
170
|
+
var details = "Wi-Fi BLE status: state=\(bytes[1]) error=\(error) enabled=\(bytes[6]) connected=\(bytes[7])"
|
|
171
|
+
if bytes.count == 18 && bytes[9] == 0xa5 {
|
|
172
|
+
let otaError = Int32(bitPattern: readU32le(bytes, 10))
|
|
173
|
+
details += " ota-started=\(bytes[8]) ota-error=\(otaError) live-dma-free=\(readU32le(bytes, 14))"
|
|
174
|
+
} else if bytes.count == 18 {
|
|
175
|
+
details += " dma-free=\(readU32le(bytes, 10)) dma-largest=\(readU32le(bytes, 14))"
|
|
176
|
+
}
|
|
177
|
+
print(details)
|
|
178
|
+
exit(EXIT_SUCCESS)
|
|
179
|
+
}
|
|
180
|
+
let result = bytes[1]
|
|
181
|
+
let received = readU32le(bytes, 2)
|
|
182
|
+
let expected = readU32le(bytes, 6)
|
|
183
|
+
guard result == 0 else {
|
|
184
|
+
return fail("The board rejected OTA command 0x\(String(opcode & 0x7f, radix: 16)) (result \(result), \(received)/\(expected) bytes).")
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if opcode == 0x81 {
|
|
188
|
+
sendNextChunk(peripheral)
|
|
189
|
+
} else if opcode == 0x82 {
|
|
190
|
+
guard received == 0 && expected == 0 else {
|
|
191
|
+
return fail("The board completed OTA with unexpected residual state \(received)/\(expected).")
|
|
192
|
+
}
|
|
193
|
+
finished = true
|
|
194
|
+
timeout?.invalidate()
|
|
195
|
+
let elapsed = max(0.001, Date().timeIntervalSince(transferStartedAt ?? Date()))
|
|
196
|
+
let kibPerSecond = Double(image.count) / elapsed / 1024
|
|
197
|
+
let mibPerSecond = kibPerSecond / 1024
|
|
198
|
+
print(String(format: "BLE OTA complete in %.2fs (%.3f MiB/s, %.1f KiB/s). The board is rebooting into the new firmware.", elapsed, mibPerSecond, kibPerSecond))
|
|
199
|
+
exit(EXIT_SUCCESS)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic,
|
|
204
|
+
error: Error?) {
|
|
205
|
+
if let error { return fail("BLE write failed: \(error.localizedDescription)") }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
|
209
|
+
sendNextChunk(peripheral)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private func sendNextChunk(_ peripheral: CBPeripheral) {
|
|
213
|
+
guard let dataCharacteristic, let controlCharacteristic else { return }
|
|
214
|
+
if offset == image.count {
|
|
215
|
+
guard !sentFinish else { return }
|
|
216
|
+
sentFinish = true
|
|
217
|
+
print("\nValidating firmware…")
|
|
218
|
+
peripheral.writeValue(controlPacket(opcode: 0x02), for: controlCharacteristic, type: .withResponse)
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
// OTA has its own BEGIN/FINISH control acknowledgements. Sending DATA as
|
|
222
|
+
// ATT write commands is correct even when a bonded CoreBluetooth cache
|
|
223
|
+
// still reports the flags from older firmware; waiting for an ATT write
|
|
224
|
+
// response per chunk makes a full image take many minutes.
|
|
225
|
+
let writeType: CBCharacteristicWriteType = .withoutResponse
|
|
226
|
+
let maximum = min(512, peripheral.maximumWriteValueLength(for: writeType))
|
|
227
|
+
guard maximum > 0 else { return fail("CoreBluetooth reported an invalid write size.") }
|
|
228
|
+
if offset == 0 {
|
|
229
|
+
print("BLE OTA data: write without response, \(maximum)-byte chunks")
|
|
230
|
+
}
|
|
231
|
+
while offset < image.count && peripheral.canSendWriteWithoutResponse {
|
|
232
|
+
let length = min(maximum, image.count - offset)
|
|
233
|
+
let chunk = image.subdata(in: offset..<(offset + length))
|
|
234
|
+
peripheral.writeValue(chunk, for: dataCharacteristic, type: writeType)
|
|
235
|
+
offset += length
|
|
236
|
+
printProgress()
|
|
237
|
+
}
|
|
238
|
+
if offset == image.count { sendNextChunk(peripheral) }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private func printProgress() {
|
|
242
|
+
let percent = image.isEmpty ? 100 : (offset * 100 / image.count)
|
|
243
|
+
guard percent != lastPrintedPercent else { return }
|
|
244
|
+
lastPrintedPercent = percent
|
|
245
|
+
print("Transferred \(offset)/\(image.count) bytes (\(percent)%)")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private func fail(_ message: String) {
|
|
249
|
+
guard !finished else { return }
|
|
250
|
+
finished = true
|
|
251
|
+
timeout?.invalidate()
|
|
252
|
+
if let peripheral, let controlCharacteristic {
|
|
253
|
+
peripheral.writeValue(controlPacket(opcode: 0x03), for: controlCharacteristic, type: .withResponse)
|
|
254
|
+
}
|
|
255
|
+
fputs("ERROR: \(message)\n", stderr)
|
|
256
|
+
exit(EXIT_FAILURE)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if CommandLine.arguments.dropFirst().first == "--self-test" {
|
|
261
|
+
precondition([UInt8](controlPacket(opcode: 0x01, imageSize: 0x78563412)) == [1, 0x12, 0x34, 0x56, 0x78])
|
|
262
|
+
precondition(readU32le([0, 0, 0x12, 0x34, 0x56, 0x78], 2) == 0x78563412)
|
|
263
|
+
print("BLE OTA protocol self-test passed")
|
|
264
|
+
exit(EXIT_SUCCESS)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private let updater: BleOtaUpdater = {
|
|
268
|
+
let arguments = Array(CommandLine.arguments.dropFirst())
|
|
269
|
+
if arguments.first == "--wifi-status" {
|
|
270
|
+
guard arguments.count <= 2 else {
|
|
271
|
+
fputs("Usage: ble-ota.swift --wifi-status [device name]\n", stderr)
|
|
272
|
+
exit(EXIT_FAILURE)
|
|
273
|
+
}
|
|
274
|
+
return BleOtaUpdater(
|
|
275
|
+
image: Data(),
|
|
276
|
+
deviceName: arguments.count == 2 ? arguments[1] : "Geastack OTA",
|
|
277
|
+
statusOnly: true)
|
|
278
|
+
}
|
|
279
|
+
guard arguments.count == 1 || arguments.count == 2 else {
|
|
280
|
+
fputs("Usage: ble-ota.swift <firmware.bin> [device name]\n", stderr)
|
|
281
|
+
exit(EXIT_FAILURE)
|
|
282
|
+
}
|
|
283
|
+
let imagePath = arguments[0]
|
|
284
|
+
guard let image = FileManager.default.contents(atPath: imagePath), !image.isEmpty else {
|
|
285
|
+
fputs("ERROR: Could not read firmware image at \(imagePath)\n", stderr)
|
|
286
|
+
exit(EXIT_FAILURE)
|
|
287
|
+
}
|
|
288
|
+
return BleOtaUpdater(image: image, deviceName: arguments.count == 2 ? arguments[1] : "Geastack OTA")
|
|
289
|
+
}()
|
|
290
|
+
updater.start()
|
|
291
|
+
RunLoop.main.run()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { ExitCode, fail } from '../errors.mjs'
|
|
4
|
+
import { exists, readJson } from '../fs-utils.mjs'
|
|
5
|
+
|
|
6
|
+
// Board aliases: an explicit --boards-config, else the project's
|
|
7
|
+
// .gea/boards.json, else the boards.json shipped by @geastack/targets.
|
|
8
|
+
export function boardConfigPath(ctx) {
|
|
9
|
+
if (ctx.boardsConfig) return ctx.boardsConfig
|
|
10
|
+
if (ctx.projectBoardsConfig && exists(ctx.projectBoardsConfig)) return ctx.projectBoardsConfig
|
|
11
|
+
return ctx.targetsRoot ? path.join(ctx.targetsRoot, 'boards.json') : ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loadBoardConfig(ctx) {
|
|
15
|
+
const file = boardConfigPath(ctx)
|
|
16
|
+
if (!file || !exists(file)) return {}
|
|
17
|
+
try {
|
|
18
|
+
return normalizeBoardConfig(readJson(file))
|
|
19
|
+
} catch (error) {
|
|
20
|
+
fail(`Could not read board config ${file}: ${error.message}`, ExitCode.usage)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeBoardConfig(raw) {
|
|
25
|
+
if (!raw || typeof raw !== 'object') return {}
|
|
26
|
+
if (raw.boards && typeof raw.boards === 'object') return raw.boards
|
|
27
|
+
const out = {}
|
|
28
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
29
|
+
if (!key.startsWith('$') && value && typeof value === 'object') out[key] = value
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// A composed board: a JSON definition (chips from @geastack/chips plus pins
|
|
5
|
+
// and buses) that extends a built-in base target. The CLI turns it into a
|
|
6
|
+
// generated board.h + target.cmake inside the app's build directory before
|
|
7
|
+
// IDF configures, so the target project only ever includes generated files.
|
|
8
|
+
|
|
9
|
+
const supportedBase = 'esp32-s3'
|
|
10
|
+
|
|
11
|
+
function object(value, label) {
|
|
12
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`)
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function text(value, label) {
|
|
17
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} must be a non-empty string.`)
|
|
18
|
+
return value.trim()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function integer(value, label, { min = 0, max = 48 } = {}) {
|
|
22
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
23
|
+
throw new Error(`${label} must be an integer from ${min} through ${max}.`)
|
|
24
|
+
}
|
|
25
|
+
return value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pin(value, label, { optional = false } = {}) {
|
|
29
|
+
if (optional && (value === null || value === undefined || value === 'none')) return -1
|
|
30
|
+
return integer(value, label)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function exact(value, expected, label) {
|
|
34
|
+
const actual = text(value, label).toLowerCase()
|
|
35
|
+
if (actual !== expected) {
|
|
36
|
+
throw new Error(`${label} '${actual}' is not supported by the ${supportedBase} base. Supported value: ${expected}.`)
|
|
37
|
+
}
|
|
38
|
+
return actual
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadChipCatalogFromDir(chipsDir) {
|
|
42
|
+
const packageDir = text(chipsDir, 'chips package directory')
|
|
43
|
+
const catalogPath = path.join(packageDir, 'catalog.json')
|
|
44
|
+
if (!existsSync(catalogPath)) throw new Error(`Chip catalog not found: ${catalogPath}`)
|
|
45
|
+
const catalog = object(JSON.parse(readFileSync(catalogPath, 'utf8')), 'Chip catalog')
|
|
46
|
+
if (catalog.schemaVersion !== 1) throw new Error(`Unsupported chip catalog schema: ${catalog.schemaVersion}`)
|
|
47
|
+
return object(catalog.chips, 'Chip catalog entries')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function selectedChip(chips, role, category, adapter, mcu, catalog) {
|
|
51
|
+
const selection = object(chips[role], `chips.${role}`)
|
|
52
|
+
const driver = text(selection.driver || selection.controller, `chips.${role}.driver`).toLowerCase()
|
|
53
|
+
const descriptor = object(catalog[driver], `Catalog entry for '${driver}'`)
|
|
54
|
+
if (descriptor.category !== category) {
|
|
55
|
+
throw new Error(`Chip '${driver}' is a ${descriptor.category}, so it cannot fill the ${role} role.`)
|
|
56
|
+
}
|
|
57
|
+
const interfaceName = text(selection.interface, `chips.${role}.interface`).toLowerCase()
|
|
58
|
+
if (!Array.isArray(descriptor.interfaces) || !descriptor.interfaces.includes(interfaceName)) {
|
|
59
|
+
throw new Error(`Chip '${driver}' does not support the '${interfaceName}' interface.`)
|
|
60
|
+
}
|
|
61
|
+
const adapterInfo = descriptor.adapters?.[adapter]
|
|
62
|
+
if (!adapterInfo) throw new Error(`Chip '${driver}' has no ${adapter} binding in the installed catalog.`)
|
|
63
|
+
if (Array.isArray(adapterInfo.mcus) && !adapterInfo.mcus.includes(mcu)) {
|
|
64
|
+
throw new Error(`Chip '${driver}' does not support MCU '${mcu}' through ${adapter}.`)
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
...selection,
|
|
68
|
+
driver,
|
|
69
|
+
interface: interfaceName,
|
|
70
|
+
nativeSources: Array.isArray(descriptor.sources) ? descriptor.sources : [],
|
|
71
|
+
bindingSources: Array.isArray(adapterInfo.bindingSources) ? adapterInfo.bindingSources : []
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeCustomTarget(raw, catalog) {
|
|
76
|
+
const definition = object(raw, 'Target definition')
|
|
77
|
+
const id = text(definition.id, 'id')
|
|
78
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error('id may contain lowercase letters, digits, dots, underscores, and hyphens.')
|
|
79
|
+
const base = exact(definition.extends, supportedBase, 'extends')
|
|
80
|
+
const mcu = exact(definition.mcu, 'esp32s3', 'mcu')
|
|
81
|
+
const adapter = definition.adapter ? exact(definition.adapter, 'esp32-idf', 'adapter') : 'esp32-idf'
|
|
82
|
+
const chips = object(definition.chips, 'chips')
|
|
83
|
+
const display = object(selectedChip(chips, 'display', 'display', adapter, mcu, catalog), 'chips.display')
|
|
84
|
+
const touch = object(selectedChip(chips, 'touch', 'touch', adapter, mcu, catalog), 'chips.touch')
|
|
85
|
+
const power = object(selectedChip(chips, 'power', 'power', adapter, mcu, catalog), 'chips.power')
|
|
86
|
+
const imu = object(selectedChip(chips, 'imu', 'imu', adapter, mcu, catalog), 'chips.imu')
|
|
87
|
+
const audio = object(selectedChip(chips, 'audio', 'audio', adapter, mcu, catalog), 'chips.audio')
|
|
88
|
+
const buses = object(definition.buses, 'buses')
|
|
89
|
+
const i2c = object(buses.i2c, 'buses.i2c')
|
|
90
|
+
const storage = object(definition.storage, 'storage')
|
|
91
|
+
const microSD = object(storage.microSD, 'storage.microSD')
|
|
92
|
+
const controls = object(definition.controls, 'controls')
|
|
93
|
+
const launcherButton = object(controls.launcherButton, 'controls.launcherButton')
|
|
94
|
+
|
|
95
|
+
const displayPins = object(display.pins, 'chips.display.pins')
|
|
96
|
+
const touchPins = object(touch.pins, 'chips.touch.pins')
|
|
97
|
+
const audioPins = object(audio.pins, 'chips.audio.pins')
|
|
98
|
+
const storagePins = object(microSD.pins, 'storage.microSD.pins')
|
|
99
|
+
|
|
100
|
+
const spiHost = String(display.spiHost).toLowerCase()
|
|
101
|
+
if (!['spi2', 'spi3'].includes(spiHost)) throw new Error("chips.display.spiHost must be 'spi2' or 'spi3'.")
|
|
102
|
+
|
|
103
|
+
const target = {
|
|
104
|
+
id,
|
|
105
|
+
extends: base,
|
|
106
|
+
adapter,
|
|
107
|
+
mcu,
|
|
108
|
+
buses: {
|
|
109
|
+
i2c: {
|
|
110
|
+
sda: pin(i2c.sda, 'buses.i2c.sda'),
|
|
111
|
+
scl: pin(i2c.scl, 'buses.i2c.scl')
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
chips: {
|
|
115
|
+
display: {
|
|
116
|
+
driver: display.driver,
|
|
117
|
+
interface: exact(display.interface, 'qspi', 'chips.display.interface'),
|
|
118
|
+
width: integer(display.width, 'chips.display.width', { min: 1, max: 4096 }),
|
|
119
|
+
height: integer(display.height, 'chips.display.height', { min: 1, max: 4096 }),
|
|
120
|
+
spiHost,
|
|
121
|
+
nativeSources: display.nativeSources,
|
|
122
|
+
bindingSources: display.bindingSources,
|
|
123
|
+
pins: {
|
|
124
|
+
cs: pin(displayPins.cs, 'chips.display.pins.cs'),
|
|
125
|
+
pclk: pin(displayPins.pclk, 'chips.display.pins.pclk'),
|
|
126
|
+
data0: pin(displayPins.data0, 'chips.display.pins.data0'),
|
|
127
|
+
data1: pin(displayPins.data1, 'chips.display.pins.data1'),
|
|
128
|
+
data2: pin(displayPins.data2, 'chips.display.pins.data2'),
|
|
129
|
+
data3: pin(displayPins.data3, 'chips.display.pins.data3'),
|
|
130
|
+
reset: pin(displayPins.reset, 'chips.display.pins.reset'),
|
|
131
|
+
te: pin(displayPins.te, 'chips.display.pins.te', { optional: true })
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
touch: {
|
|
135
|
+
driver: touch.driver,
|
|
136
|
+
interface: exact(touch.interface, 'i2c', 'chips.touch.interface'),
|
|
137
|
+
nativeSources: touch.nativeSources,
|
|
138
|
+
bindingSources: touch.bindingSources,
|
|
139
|
+
pins: {
|
|
140
|
+
reset: pin(touchPins.reset, 'chips.touch.pins.reset'),
|
|
141
|
+
interrupt: pin(touchPins.interrupt, 'chips.touch.pins.interrupt')
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
power: {
|
|
145
|
+
driver: power.driver,
|
|
146
|
+
interface: exact(power.interface, 'i2c', 'chips.power.interface'),
|
|
147
|
+
nativeSources: power.nativeSources,
|
|
148
|
+
bindingSources: power.bindingSources
|
|
149
|
+
},
|
|
150
|
+
imu: {
|
|
151
|
+
driver: imu.driver,
|
|
152
|
+
interface: exact(imu.interface, 'i2c', 'chips.imu.interface'),
|
|
153
|
+
nativeSources: imu.nativeSources,
|
|
154
|
+
bindingSources: imu.bindingSources
|
|
155
|
+
},
|
|
156
|
+
audio: {
|
|
157
|
+
driver: audio.driver,
|
|
158
|
+
interface: exact(audio.interface, 'i2s', 'chips.audio.interface'),
|
|
159
|
+
nativeSources: audio.nativeSources,
|
|
160
|
+
bindingSources: audio.bindingSources,
|
|
161
|
+
pins: {
|
|
162
|
+
mclk: pin(audioPins.mclk, 'chips.audio.pins.mclk'),
|
|
163
|
+
bclk: pin(audioPins.bclk, 'chips.audio.pins.bclk'),
|
|
164
|
+
ws: pin(audioPins.ws, 'chips.audio.pins.ws'),
|
|
165
|
+
dout: pin(audioPins.dout, 'chips.audio.pins.dout'),
|
|
166
|
+
din: pin(audioPins.din, 'chips.audio.pins.din'),
|
|
167
|
+
powerAmplifier: pin(audioPins.powerAmplifier, 'chips.audio.pins.powerAmplifier')
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
storage: {
|
|
172
|
+
microSD: {
|
|
173
|
+
interface: exact(microSD.interface, 'sdmmc-1bit', 'storage.microSD.interface'),
|
|
174
|
+
pins: {
|
|
175
|
+
clk: pin(storagePins.clk, 'storage.microSD.pins.clk', { optional: true }),
|
|
176
|
+
cmd: pin(storagePins.cmd, 'storage.microSD.pins.cmd', { optional: true }),
|
|
177
|
+
data0: pin(storagePins.data0, 'storage.microSD.pins.data0', { optional: true })
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
controls: {
|
|
182
|
+
launcherButton: {
|
|
183
|
+
pin: pin(launcherButton.pin, 'controls.launcherButton.pin', { optional: true }),
|
|
184
|
+
activeLevel: integer(launcherButton.activeLevel, 'controls.launcherButton.activeLevel', { min: 0, max: 1 })
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
validatePinAssignments(target)
|
|
189
|
+
return target
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validatePinAssignments(target) {
|
|
193
|
+
const pins = [
|
|
194
|
+
['I2C SDA', target.buses.i2c.sda],
|
|
195
|
+
['I2C SCL', target.buses.i2c.scl],
|
|
196
|
+
['display CS', target.chips.display.pins.cs],
|
|
197
|
+
['display PCLK', target.chips.display.pins.pclk],
|
|
198
|
+
['display DATA0', target.chips.display.pins.data0],
|
|
199
|
+
['display DATA1', target.chips.display.pins.data1],
|
|
200
|
+
['display DATA2', target.chips.display.pins.data2],
|
|
201
|
+
['display DATA3', target.chips.display.pins.data3],
|
|
202
|
+
['display reset', target.chips.display.pins.reset],
|
|
203
|
+
['display TE', target.chips.display.pins.te],
|
|
204
|
+
['touch reset', target.chips.touch.pins.reset],
|
|
205
|
+
['touch interrupt', target.chips.touch.pins.interrupt],
|
|
206
|
+
['audio MCLK', target.chips.audio.pins.mclk],
|
|
207
|
+
['audio BCLK', target.chips.audio.pins.bclk],
|
|
208
|
+
['audio WS', target.chips.audio.pins.ws],
|
|
209
|
+
['audio DOUT', target.chips.audio.pins.dout],
|
|
210
|
+
['audio DIN', target.chips.audio.pins.din],
|
|
211
|
+
['audio amplifier', target.chips.audio.pins.powerAmplifier],
|
|
212
|
+
['microSD CLK', target.storage.microSD.pins.clk],
|
|
213
|
+
['microSD CMD', target.storage.microSD.pins.cmd],
|
|
214
|
+
['microSD DATA0', target.storage.microSD.pins.data0],
|
|
215
|
+
['launcher button', target.controls.launcherButton.pin]
|
|
216
|
+
]
|
|
217
|
+
const used = new Map()
|
|
218
|
+
for (const [label, value] of pins) {
|
|
219
|
+
if (value < 0) continue
|
|
220
|
+
const previous = used.get(value)
|
|
221
|
+
if (previous) throw new Error(`GPIO ${value} is assigned to both ${previous} and ${label}.`)
|
|
222
|
+
used.set(value, label)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function gpio(value) {
|
|
227
|
+
return value < 0 ? 'GPIO_NUM_NC' : `GPIO_NUM_${value}`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function renderBoardHeader(target) {
|
|
231
|
+
const { i2c } = target.buses
|
|
232
|
+
const { display, touch, audio } = target.chips
|
|
233
|
+
const sd = target.storage.microSD
|
|
234
|
+
const launcher = target.controls.launcherButton
|
|
235
|
+
return `#pragma once
|
|
236
|
+
|
|
237
|
+
#include "driver/gpio.h"
|
|
238
|
+
#include "driver/i2s_types.h"
|
|
239
|
+
#include "driver/spi_master.h"
|
|
240
|
+
|
|
241
|
+
namespace gea::platform::board {
|
|
242
|
+
|
|
243
|
+
struct I2cBusConfig { gpio_num_t sda; gpio_num_t scl; };
|
|
244
|
+
struct Co5300DisplayConfig { spi_host_device_t spiHost; gpio_num_t cs; gpio_num_t pclk; gpio_num_t data0; gpio_num_t data1; gpio_num_t data2; gpio_num_t data3; gpio_num_t reset; gpio_num_t te; };
|
|
245
|
+
struct Ft3168TouchConfig { gpio_num_t reset; gpio_num_t interrupt; };
|
|
246
|
+
struct Es8311AudioConfig { int i2sPort; gpio_num_t mclk; gpio_num_t bclk; gpio_num_t ws; gpio_num_t dout; gpio_num_t din; gpio_num_t powerAmplifier; };
|
|
247
|
+
struct SdMmcConfig { gpio_num_t clk; gpio_num_t cmd; gpio_num_t data0; };
|
|
248
|
+
struct LauncherButtonConfig { gpio_num_t pin; int activeLevel; };
|
|
249
|
+
|
|
250
|
+
inline constexpr I2cBusConfig i2c{ .sda = ${gpio(i2c.sda)}, .scl = ${gpio(i2c.scl)} };
|
|
251
|
+
inline constexpr Co5300DisplayConfig display{
|
|
252
|
+
.spiHost = ${display.spiHost === 'spi3' ? 'SPI3_HOST' : 'SPI2_HOST'}, .cs = ${gpio(display.pins.cs)}, .pclk = ${gpio(display.pins.pclk)},
|
|
253
|
+
.data0 = ${gpio(display.pins.data0)}, .data1 = ${gpio(display.pins.data1)},
|
|
254
|
+
.data2 = ${gpio(display.pins.data2)}, .data3 = ${gpio(display.pins.data3)},
|
|
255
|
+
.reset = ${gpio(display.pins.reset)}, .te = ${gpio(display.pins.te)}
|
|
256
|
+
};
|
|
257
|
+
inline constexpr Ft3168TouchConfig touch{ .reset = ${gpio(touch.pins.reset)}, .interrupt = ${gpio(touch.pins.interrupt)} };
|
|
258
|
+
inline constexpr Es8311AudioConfig audio{
|
|
259
|
+
.i2sPort = I2S_NUM_AUTO, .mclk = ${gpio(audio.pins.mclk)}, .bclk = ${gpio(audio.pins.bclk)},
|
|
260
|
+
.ws = ${gpio(audio.pins.ws)}, .dout = ${gpio(audio.pins.dout)}, .din = ${gpio(audio.pins.din)},
|
|
261
|
+
.powerAmplifier = ${gpio(audio.pins.powerAmplifier)}
|
|
262
|
+
};
|
|
263
|
+
inline constexpr SdMmcConfig storage{ .clk = ${gpio(sd.pins.clk)}, .cmd = ${gpio(sd.pins.cmd)}, .data0 = ${gpio(sd.pins.data0)} };
|
|
264
|
+
inline constexpr LauncherButtonConfig launcherButton{ .pin = ${gpio(launcher.pin)}, .activeLevel = ${launcher.activeLevel} };
|
|
265
|
+
|
|
266
|
+
} // namespace gea::platform::board
|
|
267
|
+
`
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function cmakeQuote(value) {
|
|
271
|
+
return `"${String(value).replace(/\\/g, '/').replace(/"/g, '\\"')}"`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function renderTargetCmake(target, includeDir) {
|
|
275
|
+
const { display, touch, power, imu, audio } = target.chips
|
|
276
|
+
const chipSource = (source) => ` "\${GEA_CHIPS}/${source}"`
|
|
277
|
+
const bindingSource = (source) => ` "\${GEA_EMBEDDED_ROOT}/targets/esp32/${source}"`
|
|
278
|
+
const displaySources = [...display.nativeSources.map(chipSource), ...display.bindingSources.map(bindingSource)]
|
|
279
|
+
const peripheralSources = [power, imu, touch, audio]
|
|
280
|
+
.flatMap((chip) => [...chip.nativeSources.map(chipSource), ...chip.bindingSources.map(bindingSource)])
|
|
281
|
+
return `set(GEA_CUSTOM_TARGET_ACTIVE 1)
|
|
282
|
+
set(GEA_CUSTOM_TARGET_INCLUDE_DIR ${cmakeQuote(includeDir)})
|
|
283
|
+
set(GEA_CUSTOM_TARGET_DISPLAY_SOURCES
|
|
284
|
+
${displaySources.join('\n')}
|
|
285
|
+
)
|
|
286
|
+
set(GEA_CUSTOM_TARGET_PERIPHERAL_SOURCES
|
|
287
|
+
${peripheralSources.join('\n')}
|
|
288
|
+
)
|
|
289
|
+
set(GEA_CUSTOM_TARGET_COMPILE_DEFINITIONS
|
|
290
|
+
GEA_EMBEDDED_DISPLAY_WIDTH=${display.width}
|
|
291
|
+
GEA_EMBEDDED_DISPLAY_HEIGHT=${display.height}
|
|
292
|
+
)
|
|
293
|
+
`
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function writeCustomTarget({ definitionPath, outDir, catalog }) {
|
|
297
|
+
const target = normalizeCustomTarget(JSON.parse(readFileSync(definitionPath, 'utf8')), catalog)
|
|
298
|
+
mkdirSync(outDir, { recursive: true })
|
|
299
|
+
const headerPath = path.join(outDir, 'board.h')
|
|
300
|
+
const cmakePath = path.join(outDir, 'target.cmake')
|
|
301
|
+
writeIfChanged(headerPath, renderBoardHeader(target))
|
|
302
|
+
writeIfChanged(cmakePath, renderTargetCmake(target, outDir))
|
|
303
|
+
return { target, headerPath, cmakePath }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function writeIfChanged(file, contents) {
|
|
307
|
+
if (existsSync(file) && readFileSync(file, 'utf8') === contents) return
|
|
308
|
+
writeFileSync(file, contents)
|
|
309
|
+
}
|