@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.
@@ -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
+ }