@geastack/cli 0.1.52 → 0.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +14 -1
  2. package/docs/ESP32-WAVESHARE-AMOLED-QUICKSTART.md +2 -2
  3. package/docs/NPX-COMMANDS.md +3 -1
  4. package/docs/SETUP.md +81 -15
  5. package/docs/SPEC.md +8 -5
  6. package/package.json +2 -3
  7. package/src/apps/app-index-writer.mjs +15 -0
  8. package/src/apps/apple-icons.mjs +147 -0
  9. package/src/apps/bundle-writer.mjs +156 -0
  10. package/src/apps/launcher-catalog.mjs +114 -0
  11. package/src/apps/openai-icons.mjs +356 -0
  12. package/src/apps/zip-writer.mjs +104 -0
  13. package/src/ble/ble-ota.swift +291 -0
  14. package/src/boards/config.mjs +114 -0
  15. package/src/boards/custom-target.mjs +309 -0
  16. package/src/boards/resolve.mjs +145 -0
  17. package/src/boards/targets.mjs +45 -0
  18. package/src/boards/usb.mjs +252 -0
  19. package/src/chips.mjs +1 -1
  20. package/src/commands/apps.mjs +204 -0
  21. package/src/commands/board.mjs +402 -0
  22. package/src/commands/boards.mjs +334 -0
  23. package/src/commands/doctor.mjs +91 -0
  24. package/src/context.mjs +50 -54
  25. package/src/device/device.mjs +96 -0
  26. package/src/device/image.mjs +115 -0
  27. package/src/device/serial.mjs +430 -0
  28. package/src/device/wifi.mjs +190 -0
  29. package/src/esp32/build.mjs +321 -0
  30. package/src/esp32/capabilities.mjs +54 -0
  31. package/src/esp32/flash.mjs +181 -0
  32. package/src/esp32/idf-env.mjs +171 -0
  33. package/src/esp32/ota.mjs +80 -0
  34. package/src/esp32/partitions.mjs +59 -0
  35. package/src/esp32/sdkconfig.mjs +103 -0
  36. package/src/esp32/wifi-config.mjs +72 -0
  37. package/src/gea.mjs +89 -427
  38. package/src/geaos/adapter.mjs +119 -0
  39. package/src/heap-report.mjs +1276 -0
  40. package/src/manifest.mjs +135 -36
  41. package/src/rp2350/adapter.mjs +155 -0
  42. package/src/serial-devices.mjs +32 -20
  43. package/src/setup-wizard.mjs +11 -12
  44. 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,114 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ import { ExitCode, fail } from '../errors.mjs'
5
+ import { exists, readJson, writeJson } from '../fs-utils.mjs'
6
+
7
+ // Board aliases are machine-local configuration: which physical board answers
8
+ // to `--board amoled`, its USB serial, its IP. They live in two tiers that
9
+ // are merged, project over home:
10
+ //
11
+ // ~/.geastack/boards.json every board on this machine (GEA_HOME overrides
12
+ // the directory)
13
+ // <project>/.gea/boards.json aliases specific to one project, overriding a
14
+ // home alias of the same name
15
+ //
16
+ // An explicit --boards-config (or GEA_BOARDS_CONFIG) replaces both tiers: a
17
+ // caller naming a file wants exactly that file. Nothing is ever read from an
18
+ // installed package -- a board catalog shipped in @geastack/targets was a
19
+ // development convenience that described one developer's bench, and every
20
+ // npm install would have to overwrite it.
21
+
22
+ export function homeBoardsConfigPath(env = process.env) {
23
+ const home = env.GEA_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), '.geastack')
24
+ return path.join(home, 'boards.json')
25
+ }
26
+
27
+ // Every file that contributes aliases, lowest precedence first. The list is
28
+ // the same whether or not the files exist so writers can target a tier that
29
+ // has not been created yet.
30
+ export function boardConfigTiers(ctx) {
31
+ if (ctx.boardsConfig) return [{ scope: 'explicit', file: ctx.boardsConfig }]
32
+ const tiers = []
33
+ if (ctx.homeBoardsConfig) tiers.push({ scope: 'home', file: ctx.homeBoardsConfig })
34
+ if (ctx.projectBoardsConfig) tiers.push({ scope: 'project', file: ctx.projectBoardsConfig })
35
+ return tiers
36
+ }
37
+
38
+ // The single path older callers print or check: the explicit file, else the
39
+ // highest-precedence tier that exists, else where `gea boards add` would
40
+ // write (project when the project already has a config, else home).
41
+ export function boardConfigPath(ctx) {
42
+ const tiers = boardConfigTiers(ctx)
43
+ const existing = [...tiers].reverse().find((tier) => exists(tier.file))
44
+ return existing ? existing.file : boardConfigWritePath(ctx)
45
+ }
46
+
47
+ // Where a write goes when the caller does not say: `--global` / `--local`
48
+ // pick a tier, an alias that already exists is edited in place, a new alias
49
+ // joins the project config if the project has one and the home config
50
+ // otherwise.
51
+ export function boardConfigWritePath(ctx, { scope = '', alias = '' } = {}) {
52
+ const tiers = boardConfigTiers(ctx)
53
+ if (ctx.boardsConfig) return ctx.boardsConfig
54
+ if (scope === 'global' || scope === 'home') return ctx.homeBoardsConfig
55
+ if (scope === 'project') return ctx.projectBoardsConfig
56
+ if (scope) fail(`Unknown board config scope '${scope}'. Expected --global or --local.`, ExitCode.usage)
57
+ if (alias) {
58
+ const origin = boardConfigOrigins(ctx).get(alias)
59
+ if (origin) return origin
60
+ }
61
+ const project = tiers.find((tier) => tier.scope === 'project')
62
+ if (project && exists(project.file)) return project.file
63
+ return ctx.homeBoardsConfig || project?.file || path.join(ctx.cwd || process.cwd(), '.gea', 'boards.json')
64
+ }
65
+
66
+ function readTier(file) {
67
+ if (!exists(file)) return {}
68
+ try {
69
+ return normalizeBoardConfig(readJson(file))
70
+ } catch (error) {
71
+ fail(`Could not read board config ${file}: ${error.message}`, ExitCode.usage)
72
+ }
73
+ }
74
+
75
+ // Merged aliases plus, for each, the file it came from: a `targetDefinition`
76
+ // is relative to its own file, and `gea boards list` says which tier an alias
77
+ // lives in.
78
+ export function loadBoardConfigWithOrigins(ctx) {
79
+ const boards = {}
80
+ const origins = new Map()
81
+ for (const tier of boardConfigTiers(ctx)) {
82
+ for (const [alias, board] of Object.entries(readTier(tier.file))) {
83
+ boards[alias] = board
84
+ origins.set(alias, tier.file)
85
+ }
86
+ }
87
+ return { boards, origins }
88
+ }
89
+
90
+ export function loadBoardConfig(ctx) {
91
+ return loadBoardConfigWithOrigins(ctx).boards
92
+ }
93
+
94
+ export function boardConfigOrigins(ctx) {
95
+ return loadBoardConfigWithOrigins(ctx).origins
96
+ }
97
+
98
+ export function readBoardConfigFile(file) {
99
+ return readTier(file)
100
+ }
101
+
102
+ export function writeBoardConfigFile(file, boards) {
103
+ writeJson(file, boards)
104
+ }
105
+
106
+ export function normalizeBoardConfig(raw) {
107
+ if (!raw || typeof raw !== 'object') return {}
108
+ if (raw.boards && typeof raw.boards === 'object') return raw.boards
109
+ const out = {}
110
+ for (const [key, value] of Object.entries(raw)) {
111
+ if (!key.startsWith('$') && value && typeof value === 'object') out[key] = value
112
+ }
113
+ return out
114
+ }