@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.
- package/README.md +14 -1
- package/docs/ESP32-WAVESHARE-AMOLED-QUICKSTART.md +2 -2
- package/docs/NPX-COMMANDS.md +3 -1
- package/docs/SETUP.md +81 -15
- package/docs/SPEC.md +8 -5
- 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 +114 -0
- package/src/boards/custom-target.mjs +309 -0
- package/src/boards/resolve.mjs +145 -0
- package/src/boards/targets.mjs +45 -0
- package/src/boards/usb.mjs +252 -0
- package/src/chips.mjs +1 -1
- package/src/commands/apps.mjs +204 -0
- package/src/commands/board.mjs +402 -0
- package/src/commands/boards.mjs +334 -0
- package/src/commands/doctor.mjs +91 -0
- package/src/context.mjs +50 -54
- 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 +89 -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/serial-devices.mjs +32 -20
- package/src/setup-wizard.mjs +11 -12
- package/src/taurus/adapter.mjs +52 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import zlib from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
// The one decoder for every screenshot wire format the firmware emits:
|
|
6
|
+
// rgb565-raw-v1 (GEADEV SCREENSHOTBIN and GET /screenshot) and the older
|
|
7
|
+
// rgb565-rle-v1 text protocol. Output is packed 8-bit RGB.
|
|
8
|
+
|
|
9
|
+
function expand565(value, rgb, out) {
|
|
10
|
+
const r5 = (value >> 11) & 0x1f
|
|
11
|
+
const g6 = (value >> 5) & 0x3f
|
|
12
|
+
const b5 = value & 0x1f
|
|
13
|
+
rgb[out] = (r5 << 3) | (r5 >> 2)
|
|
14
|
+
rgb[out + 1] = (g6 << 2) | (g6 >> 4)
|
|
15
|
+
rgb[out + 2] = (b5 << 3) | (b5 >> 2)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function decodeRgb565Raw(payload, expectedPixels) {
|
|
19
|
+
if (payload.length !== expectedPixels * 2) {
|
|
20
|
+
throw new Error(`raw screenshot payload has ${payload.length} bytes, expected ${expectedPixels * 2}`)
|
|
21
|
+
}
|
|
22
|
+
const rgb = Buffer.alloc(expectedPixels * 3)
|
|
23
|
+
let out = 0
|
|
24
|
+
for (let pos = 0; pos < payload.length; pos += 2) {
|
|
25
|
+
expand565(payload[pos] | (payload[pos + 1] << 8), rgb, out)
|
|
26
|
+
out += 3
|
|
27
|
+
}
|
|
28
|
+
return rgb
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function decodeRgb565Rle(payload, expectedPixels) {
|
|
32
|
+
const rgb = Buffer.alloc(expectedPixels * 3)
|
|
33
|
+
let out = 0
|
|
34
|
+
let pos = 0
|
|
35
|
+
const pixel = Buffer.alloc(3)
|
|
36
|
+
while (pos < payload.length) {
|
|
37
|
+
if (pos + 4 > payload.length) throw new Error('truncated RLE screenshot payload')
|
|
38
|
+
const count = payload[pos] | (payload[pos + 1] << 8)
|
|
39
|
+
const value = payload[pos + 2] | (payload[pos + 3] << 8)
|
|
40
|
+
pos += 4
|
|
41
|
+
expand565(value, pixel, 0)
|
|
42
|
+
for (let i = 0; i < count; i += 1) {
|
|
43
|
+
if (out + 3 > rgb.length) throw new Error('RLE screenshot payload exceeds expected size')
|
|
44
|
+
pixel.copy(rgb, out)
|
|
45
|
+
out += 3
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (out !== rgb.length) throw new Error(`RLE screenshot decoded ${out / 3} pixels, expected ${expectedPixels}`)
|
|
49
|
+
return rgb
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const crcTable = new Uint32Array(256)
|
|
53
|
+
for (let i = 0; i < 256; i += 1) {
|
|
54
|
+
let c = i
|
|
55
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
56
|
+
crcTable[i] = c >>> 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function crc32(buffer, seed = 0) {
|
|
60
|
+
let c = (seed ^ 0xffffffff) >>> 0
|
|
61
|
+
for (const byte of buffer) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8)
|
|
62
|
+
return (c ^ 0xffffffff) >>> 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pngChunk(kind, data) {
|
|
66
|
+
const body = Buffer.concat([Buffer.from(kind, 'ascii'), data])
|
|
67
|
+
const length = Buffer.alloc(4)
|
|
68
|
+
length.writeUInt32BE(data.length)
|
|
69
|
+
const crc = Buffer.alloc(4)
|
|
70
|
+
crc.writeUInt32BE(crc32(body))
|
|
71
|
+
return Buffer.concat([length, body, crc])
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function encodePng(width, height, rgb) {
|
|
75
|
+
const stride = width * 3
|
|
76
|
+
const raw = Buffer.alloc((stride + 1) * height)
|
|
77
|
+
for (let y = 0; y < height; y += 1) {
|
|
78
|
+
raw[y * (stride + 1)] = 0
|
|
79
|
+
rgb.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride)
|
|
80
|
+
}
|
|
81
|
+
const ihdr = Buffer.alloc(13)
|
|
82
|
+
ihdr.writeUInt32BE(width, 0)
|
|
83
|
+
ihdr.writeUInt32BE(height, 4)
|
|
84
|
+
ihdr[8] = 8
|
|
85
|
+
ihdr[9] = 2
|
|
86
|
+
ihdr[10] = 0
|
|
87
|
+
ihdr[11] = 0
|
|
88
|
+
ihdr[12] = 0
|
|
89
|
+
return Buffer.concat([
|
|
90
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
91
|
+
pngChunk('IHDR', ihdr),
|
|
92
|
+
pngChunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
|
|
93
|
+
pngChunk('IEND', Buffer.alloc(0))
|
|
94
|
+
])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function encodePpm(width, height, rgb) {
|
|
98
|
+
return Buffer.concat([Buffer.from(`P6\n${width} ${height}\n255\n`, 'ascii'), rgb])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function writeImage(file, width, height, rgb) {
|
|
102
|
+
mkdirSync(path.dirname(path.resolve(file)), { recursive: true })
|
|
103
|
+
writeFileSync(file, file.toLowerCase().endsWith('.png') ? encodePng(width, height, rgb) : encodePpm(width, height, rgb))
|
|
104
|
+
return file
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function nonblackRatio(rgb) {
|
|
108
|
+
const total = rgb.length / 3
|
|
109
|
+
if (total === 0) return 0
|
|
110
|
+
let nonblack = 0
|
|
111
|
+
for (let i = 0; i < rgb.length; i += 3) {
|
|
112
|
+
if (rgb[i] || rgb[i + 1] || rgb[i + 2]) nonblack += 1
|
|
113
|
+
}
|
|
114
|
+
return nonblack / total
|
|
115
|
+
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { crc32, decodeRgb565Raw, decodeRgb565Rle } from './image.mjs'
|
|
6
|
+
|
|
7
|
+
// The USB transport: the firmware's GEADEV line protocol over the board's
|
|
8
|
+
// serial console. Requests are `GEADEV <VERB> ...` lines; replies are lines
|
|
9
|
+
// starting with `GEADEV:` (an ordinary log line may carry one after a
|
|
10
|
+
// timestamp prefix, so replies are located by substring, not by column).
|
|
11
|
+
//
|
|
12
|
+
// The port is opened without ever changing DTR/RTS: native USB-Serial-JTAG
|
|
13
|
+
// boards resample GPIO0 on a modem-line pulse and drop into ROM download
|
|
14
|
+
// mode, which is exactly what idf_monitor/pyserial's default open does to a
|
|
15
|
+
// freshly flashed board. serialport with hupcl off leaves the lines alone.
|
|
16
|
+
|
|
17
|
+
let serialportModule = null
|
|
18
|
+
async function loadSerialport() {
|
|
19
|
+
if (serialportModule) return serialportModule
|
|
20
|
+
try {
|
|
21
|
+
serialportModule = await import('serialport')
|
|
22
|
+
} catch (error) {
|
|
23
|
+
throw new Error(`The 'serialport' package is required for USB device access (npm i serialport): ${error.message}`)
|
|
24
|
+
}
|
|
25
|
+
return serialportModule
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function geadevFragment(line) {
|
|
29
|
+
const index = line.indexOf('GEADEV:')
|
|
30
|
+
return index >= 0 ? line.slice(index) : line
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseKeyValues(line) {
|
|
34
|
+
const values = {}
|
|
35
|
+
for (const token of line.split(/\s+/).slice(1)) {
|
|
36
|
+
const eq = token.indexOf('=')
|
|
37
|
+
if (eq < 0) continue
|
|
38
|
+
values[token.slice(0, eq)] = token.slice(eq + 1)
|
|
39
|
+
}
|
|
40
|
+
return values
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class SerialDevice {
|
|
44
|
+
constructor(port, { path: devicePath, trace = false, stderr = () => {} }) {
|
|
45
|
+
this.port = port
|
|
46
|
+
this.path = devicePath
|
|
47
|
+
this.trace = trace
|
|
48
|
+
this.stderr = stderr
|
|
49
|
+
this.buffer = Buffer.alloc(0)
|
|
50
|
+
this.waiters = []
|
|
51
|
+
this.closed = false
|
|
52
|
+
port.on('data', (chunk) => {
|
|
53
|
+
this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : Buffer.from(chunk)
|
|
54
|
+
this.notify()
|
|
55
|
+
})
|
|
56
|
+
port.on('close', () => {
|
|
57
|
+
this.closed = true
|
|
58
|
+
this.notify()
|
|
59
|
+
})
|
|
60
|
+
port.on('error', (error) => {
|
|
61
|
+
this.error = error
|
|
62
|
+
this.notify()
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static async open({ path: devicePath, baudRate = 115200, trace = false, stderr }) {
|
|
67
|
+
const { SerialPort } = await loadSerialport()
|
|
68
|
+
const port = new SerialPort({ path: devicePath, baudRate, autoOpen: false, hupcl: false, lock: false })
|
|
69
|
+
await new Promise((resolve, reject) => port.open((error) => (error ? reject(error) : resolve())))
|
|
70
|
+
return new SerialDevice(port, { path: devicePath, trace, stderr })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
notify() {
|
|
74
|
+
const waiters = this.waiters
|
|
75
|
+
this.waiters = []
|
|
76
|
+
for (const waiter of waiters) waiter()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
waitForData(timeoutMs) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
this.waiters = this.waiters.filter((waiter) => waiter !== done)
|
|
83
|
+
resolve(false)
|
|
84
|
+
}, Math.max(0, timeoutMs))
|
|
85
|
+
const done = () => {
|
|
86
|
+
clearTimeout(timer)
|
|
87
|
+
resolve(true)
|
|
88
|
+
}
|
|
89
|
+
this.waiters.push(done)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async writeLine(line) {
|
|
94
|
+
const data = `${line.replace(/[\r\n]+$/, '')}\n`
|
|
95
|
+
await new Promise((resolve, reject) => this.port.write(data, (error) => (error ? reject(error) : resolve())))
|
|
96
|
+
await new Promise((resolve) => this.port.drain(() => resolve()))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async writeRaw(data) {
|
|
100
|
+
await new Promise((resolve, reject) => this.port.write(data, (error) => (error ? reject(error) : resolve())))
|
|
101
|
+
await new Promise((resolve) => this.port.drain(() => resolve()))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async readLine(timeoutMs) {
|
|
105
|
+
const deadline = Date.now() + timeoutMs
|
|
106
|
+
while (true) {
|
|
107
|
+
const newline = this.buffer.indexOf(0x0a)
|
|
108
|
+
if (newline >= 0) {
|
|
109
|
+
const raw = this.buffer.subarray(0, newline)
|
|
110
|
+
this.buffer = this.buffer.subarray(newline + 1)
|
|
111
|
+
return raw.toString('utf8').replace(/\r$/, '')
|
|
112
|
+
}
|
|
113
|
+
if (this.error) throw this.error
|
|
114
|
+
if (this.closed) return null
|
|
115
|
+
const remaining = deadline - Date.now()
|
|
116
|
+
if (remaining <= 0) return null
|
|
117
|
+
await this.waitForData(remaining)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async readExact(size, timeoutMs) {
|
|
122
|
+
const deadline = Date.now() + timeoutMs
|
|
123
|
+
while (this.buffer.length < size) {
|
|
124
|
+
if (this.error) throw this.error
|
|
125
|
+
const remaining = deadline - Date.now()
|
|
126
|
+
if (remaining <= 0 || this.closed) throw new Error(`timed out reading ${size} bytes`)
|
|
127
|
+
await this.waitForData(remaining)
|
|
128
|
+
}
|
|
129
|
+
const out = Buffer.from(this.buffer.subarray(0, size))
|
|
130
|
+
this.buffer = this.buffer.subarray(size)
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async drainInput(quietMs = 80, maxMs = 600) {
|
|
135
|
+
this.buffer = Buffer.alloc(0)
|
|
136
|
+
const deadline = Date.now() + maxMs
|
|
137
|
+
let quietDeadline = Date.now() + quietMs
|
|
138
|
+
while (Date.now() < deadline && Date.now() < quietDeadline) {
|
|
139
|
+
const got = await this.waitForData(Math.min(deadline, quietDeadline) - Date.now())
|
|
140
|
+
if (got) {
|
|
141
|
+
this.buffer = Buffer.alloc(0)
|
|
142
|
+
quietDeadline = Date.now() + quietMs
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async command(line, prefixes, timeoutMs = 5000) {
|
|
148
|
+
await this.writeLine(line)
|
|
149
|
+
const deadline = Date.now() + timeoutMs
|
|
150
|
+
while (true) {
|
|
151
|
+
const remaining = deadline - Date.now()
|
|
152
|
+
if (remaining <= 0) throw new Error(`timed out waiting for response to ${JSON.stringify(line)}`)
|
|
153
|
+
const received = await this.readLine(remaining)
|
|
154
|
+
if (received === null) throw new Error(`timed out waiting for response to ${JSON.stringify(line)}`)
|
|
155
|
+
if (this.trace) this.stderr(received)
|
|
156
|
+
const frame = geadevFragment(received)
|
|
157
|
+
if (frame.startsWith('GEADEV:ERR')) throw new Error(frame)
|
|
158
|
+
if (prefixes.some((prefix) => frame.startsWith(prefix))) return frame
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async collect(line, { begin = null, data = 'GEADEV:DATA ', end, error = ['GEADEV:ERR'], timeoutMs = 8000 }) {
|
|
163
|
+
await this.writeLine(line)
|
|
164
|
+
const deadline = Date.now() + timeoutMs
|
|
165
|
+
const chunks = []
|
|
166
|
+
const lines = []
|
|
167
|
+
let beginFrame = null
|
|
168
|
+
while (true) {
|
|
169
|
+
const remaining = deadline - Date.now()
|
|
170
|
+
if (remaining <= 0) throw new Error(`timed out waiting for ${line}`)
|
|
171
|
+
const received = await this.readLine(remaining)
|
|
172
|
+
if (received === null) throw new Error(`timed out waiting for ${line}`)
|
|
173
|
+
const frame = geadevFragment(received)
|
|
174
|
+
if (this.trace && !frame.startsWith(data)) this.stderr(received)
|
|
175
|
+
if (error.some((prefix) => frame.startsWith(prefix))) throw new Error(frame)
|
|
176
|
+
if (begin && beginFrame === null) {
|
|
177
|
+
if (frame.startsWith(begin)) beginFrame = frame
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
if (frame.startsWith(data)) {
|
|
181
|
+
chunks.push(frame.slice(data.length).trim())
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
lines.push(frame)
|
|
185
|
+
if (frame.startsWith(end)) return { begin: beginFrame, end: frame, chunks, lines }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async close() {
|
|
190
|
+
if (this.closed) return
|
|
191
|
+
await new Promise((resolve) => this.port.close(() => resolve()))
|
|
192
|
+
this.closed = true
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---- GEADEV verbs -----------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
export const geadev = {
|
|
199
|
+
ping: (d) => d.command('GEADEV PING', ['GEADEV:PONG']),
|
|
200
|
+
app: async (d) => parseKeyValues(await d.command('GEADEV APP', ['GEADEV:APP'])).id || '',
|
|
201
|
+
state: (d) => d.command('GEADEV STATE', ['GEADEV:STATE']),
|
|
202
|
+
mem: (d) => d.command('GEADEV MEM', ['GEADEV:MEM']),
|
|
203
|
+
node: (d, className) => d.command(`GEADEV NODE ${className}`, ['GEADEV:NODE']),
|
|
204
|
+
hit: (d, x, y) => d.command(`GEADEV HITTEST ${x} ${y}`, ['GEADEV:HITTEST', 'GEADEV:ERR HITTEST']),
|
|
205
|
+
tap: (d, x, y, holdMs = 80) => d.command(`GEADEV TAP ${x} ${y} ${holdMs}`, ['GEADEV:OK TAP'], 8000),
|
|
206
|
+
drag: (d, x1, y1, x2, y2, steps = 6, delayMs = 24) =>
|
|
207
|
+
d.command(`GEADEV DRAG ${x1} ${y1} ${x2} ${y2} ${steps} ${delayMs}`, ['GEADEV:OK DRAG'], 8000),
|
|
208
|
+
swipe: (d, x, y1, y2) => d.command(`GEADEV SWIPE ${x} ${y1} ${y2}`, ['GEADEV:OK SWIPE', 'GEADEV:ERR SWIPE'], 8000),
|
|
209
|
+
back: (d) => d.command('GEADEV BACK', ['GEADEV:OK BACK'], 8000),
|
|
210
|
+
key: (d, keyCode) => d.command(`GEADEV KEY ${keyCode}`, ['GEADEV:OK KEY', 'GEADEV:ERR KEY']),
|
|
211
|
+
storageSet: (d, key, value) => d.command(`GEADEV STORAGE SET ${key} ${value}`, ['GEADEV:OK STORAGE', 'GEADEV:ERR STORAGE']),
|
|
212
|
+
setDefault: (d, appId) => d.command(`GEADEV SETDEFAULT ${appId}`, ['GEADEV:OK SETDEFAULT', 'GEADEV:ERR SETDEFAULT']),
|
|
213
|
+
setTime: (d, epoch) => d.command(`GEADEV SETTIME ${epoch}`, ['GEADEV:OK SETTIME', 'GEADEV:ERR SETTIME']),
|
|
214
|
+
reboot: (d) => d.command('GEADEV REBOOT', ['GEADEV:OK REBOOT']),
|
|
215
|
+
notify: (d, text) => d.command(`GEADEV NOTIFY ${text}`, ['GEADEV:OK NOTIFY']),
|
|
216
|
+
rm: (d, devicePath) => d.command(`GEADEV RM ${devicePath}`, ['GEADEV:RM OK', 'GEADEV:RM ERR']),
|
|
217
|
+
playFile: (d, devicePath) => d.command(`GEADEV PLAYFILE ${devicePath}`, ['GEADEV:PLAYFILE OK', 'GEADEV:PLAYFILE ERR'], 30000),
|
|
218
|
+
|
|
219
|
+
async brightness(d, value) {
|
|
220
|
+
const raw = await d.command(`GEADEV BRIGHTNESS${value === undefined ? '' : ` ${value}`}`, ['GEADEV:OK BRIGHTNESS', 'GEADEV:ERR BRIGHTNESS'])
|
|
221
|
+
return value === undefined ? parseKeyValues(raw).value ?? '' : raw
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async i2cScan(d) {
|
|
225
|
+
const { lines } = await d.collect('GEADEV I2CSCAN', { data: '', end: 'GEADEV:I2CSCAN END', timeoutMs: 8000 })
|
|
226
|
+
return lines.filter((line) => line.startsWith('GEADEV:I2CSCAN')).join('\n')
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
async storageGet(d, key) {
|
|
230
|
+
const { chunks, lines } = await d.collect(`GEADEV STORAGE GET ${key}`, {
|
|
231
|
+
end: 'GEADEV:STORAGE GET END',
|
|
232
|
+
error: ['GEADEV:STORAGE GET ERR', 'GEADEV:ERR STORAGE'],
|
|
233
|
+
timeoutMs: 5000
|
|
234
|
+
})
|
|
235
|
+
return { value: Buffer.from(chunks.join(''), 'base64').toString('utf8'), lines }
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
async ls(d, devicePath = '/sdcard') {
|
|
239
|
+
const { lines } = await d.collect(`GEADEV LS ${devicePath}`, { data: '', end: 'GEADEV:LS END', error: ['GEADEV:LS ERR'], timeoutMs: 5000 })
|
|
240
|
+
return lines.join('\n')
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async summary(d) {
|
|
244
|
+
const app = await geadev.app(d)
|
|
245
|
+
const state = parseKeyValues(await geadev.state(d))
|
|
246
|
+
const mem = parseKeyValues(await geadev.mem(d))
|
|
247
|
+
const kb = (v) => (Number.isFinite(Number(v)) && v !== undefined ? `${Math.floor(Number(v) / 1024)} KB` : '?')
|
|
248
|
+
const mb = (v) => (Number.isFinite(Number(v)) && v !== undefined ? `${(Number(v) / 1048576).toFixed(1)} MB` : '?')
|
|
249
|
+
return [
|
|
250
|
+
`app ${app || '?'}`,
|
|
251
|
+
`${state.nodes ?? '?'} nodes`,
|
|
252
|
+
`${state.width ?? '?'}x${state.height ?? '?'}`,
|
|
253
|
+
`int ${kb(mem.internal_free)} free`,
|
|
254
|
+
`psram ${mb(mem.psram_free)} free`,
|
|
255
|
+
`batt ${state.battery ?? '?'}%`
|
|
256
|
+
].join(' · ')
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async waitForApp(d, appId, timeoutMs) {
|
|
260
|
+
const deadline = Date.now() + timeoutMs
|
|
261
|
+
let last = ''
|
|
262
|
+
while (Date.now() < deadline) {
|
|
263
|
+
last = await geadev.app(d)
|
|
264
|
+
if (last === appId) return true
|
|
265
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
266
|
+
}
|
|
267
|
+
throw new Error(`timed out waiting for app ${JSON.stringify(appId)}; last app was ${JSON.stringify(last)}`)
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
async screenshotBinary(d, timeoutMs = 12000) {
|
|
271
|
+
await d.writeLine('GEADEV SCREENSHOTBIN')
|
|
272
|
+
const deadline = Date.now() + timeoutMs
|
|
273
|
+
let begin = null
|
|
274
|
+
while (true) {
|
|
275
|
+
const remaining = deadline - Date.now()
|
|
276
|
+
if (remaining <= 0) throw new Error('timed out waiting for binary screenshot')
|
|
277
|
+
const line = await d.readLine(remaining)
|
|
278
|
+
if (line === null) throw new Error('timed out waiting for binary screenshot')
|
|
279
|
+
if (d.trace) d.stderr(line)
|
|
280
|
+
const frame = geadevFragment(line)
|
|
281
|
+
if (frame.startsWith('GEADEV:ERR') || frame.startsWith('GEADEV:SCREENSHOTBIN ERR')) throw new Error(frame)
|
|
282
|
+
if (frame.startsWith('GEADEV:SCREENSHOTBIN BEGIN')) {
|
|
283
|
+
begin = frame
|
|
284
|
+
break
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const meta = parseKeyValues(begin)
|
|
288
|
+
const width = Number(meta.width)
|
|
289
|
+
const height = Number(meta.height)
|
|
290
|
+
const size = Number(meta.bytes)
|
|
291
|
+
if (meta.encoding !== 'rgb565-raw-v1') throw new Error(`unsupported binary screenshot encoding: ${meta.encoding}`)
|
|
292
|
+
const payload = await d.readExact(size, Math.max(100, deadline - Date.now()))
|
|
293
|
+
let end = null
|
|
294
|
+
while (true) {
|
|
295
|
+
const remaining = deadline - Date.now()
|
|
296
|
+
if (remaining <= 0) throw new Error('timed out waiting for binary screenshot footer')
|
|
297
|
+
const line = await d.readLine(remaining)
|
|
298
|
+
if (line === null) throw new Error('timed out waiting for binary screenshot footer')
|
|
299
|
+
const frame = geadevFragment(line)
|
|
300
|
+
if (frame.startsWith('GEADEV:ERR') || frame.startsWith('GEADEV:SCREENSHOTBIN ERR')) throw new Error(frame)
|
|
301
|
+
if (frame.startsWith('GEADEV:SCREENSHOTBIN END')) {
|
|
302
|
+
end = frame
|
|
303
|
+
break
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const expectedCrc = parseKeyValues(end).crc
|
|
307
|
+
const actualCrc = crc32(payload)
|
|
308
|
+
if (expectedCrc !== undefined && actualCrc !== Number.parseInt(expectedCrc, 16)) {
|
|
309
|
+
throw new Error(`binary screenshot crc mismatch: got 0x${actualCrc.toString(16).padStart(8, '0')} want ${expectedCrc}`)
|
|
310
|
+
}
|
|
311
|
+
return { width, height, rgb: decodeRgb565Raw(payload, width * height) }
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
async screenshotRle(d, timeoutMs = 30000) {
|
|
315
|
+
const { begin, chunks } = await d.collect('GEADEV SCREENSHOT', {
|
|
316
|
+
begin: 'GEADEV:SCREENSHOT BEGIN',
|
|
317
|
+
end: 'GEADEV:SCREENSHOT END',
|
|
318
|
+
timeoutMs
|
|
319
|
+
})
|
|
320
|
+
const meta = parseKeyValues(begin)
|
|
321
|
+
const width = Number(meta.width)
|
|
322
|
+
const height = Number(meta.height)
|
|
323
|
+
if (meta.encoding !== 'rgb565-rle-v1') throw new Error(`unsupported screenshot encoding: ${meta.encoding}`)
|
|
324
|
+
return { width, height, rgb: decodeRgb565Rle(Buffer.from(chunks.join(''), 'base64'), width * height) }
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
async screenshot(d, { timeoutMs = 12000, fallbackTimeoutMs = 30000, attempts = 3, legacy = false } = {}) {
|
|
328
|
+
if (legacy) return geadev.screenshotRle(d, fallbackTimeoutMs)
|
|
329
|
+
let lastError = null
|
|
330
|
+
for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
|
|
331
|
+
try {
|
|
332
|
+
return await geadev.screenshotBinary(d, timeoutMs)
|
|
333
|
+
} catch (error) {
|
|
334
|
+
lastError = error
|
|
335
|
+
await d.drainInput()
|
|
336
|
+
if (d.trace) d.stderr(`binary screenshot attempt ${attempt + 1} failed: ${error.message}`)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (d.trace) d.stderr(`binary screenshot unavailable, falling back to RLE: ${lastError?.message}`)
|
|
340
|
+
return geadev.screenshotRle(d, fallbackTimeoutMs)
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
// Streams a file onto the device's storage over USB. Throttled bursts keep
|
|
344
|
+
// the transfer under the device's continuous drain rate: its RX buffer is
|
|
345
|
+
// tiny and has no flow control, and per-chunk ACK round-trips stalled.
|
|
346
|
+
async pushFile(d, source, destination, { timeoutMs = 300000, base64 = false, stderr = () => {} } = {}) {
|
|
347
|
+
const data = readFileSync(source)
|
|
348
|
+
const crc = crc32(data)
|
|
349
|
+
const verb = base64 ? 'PUSH64' : 'PUSH'
|
|
350
|
+
await d.writeLine(`GEADEV ${verb} ${destination} ${data.length} ${crc}`)
|
|
351
|
+
await waitFor(d, `GEADEV:${verb} READY`, `GEADEV:${verb} ERR`, 15000, `${verb} READY`)
|
|
352
|
+
const startedAt = Date.now()
|
|
353
|
+
if (base64) {
|
|
354
|
+
const encoded = data.toString('base64')
|
|
355
|
+
for (let offset = 0; offset < encoded.length; offset += 512) {
|
|
356
|
+
await d.writeLine(encoded.slice(offset, offset + 512))
|
|
357
|
+
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
361
|
+
const burst = 2048
|
|
362
|
+
for (let offset = 0; offset < data.length; offset += burst) {
|
|
363
|
+
await d.writeRaw(data.subarray(offset, Math.min(offset + burst, data.length)))
|
|
364
|
+
await new Promise((resolve) => setTimeout(resolve, 4))
|
|
365
|
+
if (((offset / burst) | 0) % 256 === 0) stderr(` push ${Math.floor((offset * 100) / data.length)}% (${offset}/${data.length})`)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const line = await waitFor(d, `GEADEV:${verb} OK`, `GEADEV:${verb} ERR`, timeoutMs, `${verb} OK`)
|
|
369
|
+
const seconds = (Date.now() - startedAt) / 1000
|
|
370
|
+
return `${line} (${data.length} bytes in ${seconds.toFixed(1)}s, ${Math.round(data.length / Math.max(seconds, 0.001) / 1024)} KiB/s)`
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
async pullFile(d, source, destination, { timeoutMs = 300000 } = {}) {
|
|
374
|
+
const { chunks, end, lines } = await d.collect(`GEADEV PULL ${source}`, {
|
|
375
|
+
begin: null,
|
|
376
|
+
end: 'GEADEV:PULL END',
|
|
377
|
+
error: ['GEADEV:PULL ERR'],
|
|
378
|
+
timeoutMs
|
|
379
|
+
})
|
|
380
|
+
const values = parseKeyValues(end)
|
|
381
|
+
const data = chunks.length ? Buffer.from(chunks.join(''), 'base64') : Buffer.alloc(0)
|
|
382
|
+
const expectedSize = values.bytes !== undefined ? Number(values.bytes) : parseKeyValues(lines.find((l) => l.startsWith('GEADEV:PULL BEGIN')) || '').bytes
|
|
383
|
+
if (expectedSize !== undefined && Number(expectedSize) !== data.length) {
|
|
384
|
+
throw new Error(`pull size mismatch for ${source}: got ${data.length} want ${expectedSize}`)
|
|
385
|
+
}
|
|
386
|
+
const actualCrc = crc32(data)
|
|
387
|
+
if (values.crc !== undefined && actualCrc !== Number.parseInt(values.crc, 16)) {
|
|
388
|
+
throw new Error(`pull crc mismatch for ${source}: got 0x${actualCrc.toString(16)} want 0x${values.crc}`)
|
|
389
|
+
}
|
|
390
|
+
mkdirSync(path.dirname(path.resolve(destination)), { recursive: true })
|
|
391
|
+
writeFileSync(destination, data)
|
|
392
|
+
return `pulled ${source} -> ${destination} bytes=${data.length} crc=0x${actualCrc.toString(16).padStart(8, '0')}`
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function waitFor(d, okPrefix, errPrefix, timeoutMs, what) {
|
|
397
|
+
const deadline = Date.now() + timeoutMs
|
|
398
|
+
while (true) {
|
|
399
|
+
const remaining = deadline - Date.now()
|
|
400
|
+
if (remaining <= 0) throw new Error(`timed out waiting for ${what}`)
|
|
401
|
+
const line = await d.readLine(remaining)
|
|
402
|
+
if (line === null) throw new Error(`timed out waiting for ${what}`)
|
|
403
|
+
if (d.trace) d.stderr(line)
|
|
404
|
+
if (line.startsWith(errPrefix)) throw new Error(line)
|
|
405
|
+
if (line.startsWith(okPrefix)) return line
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Prints every serial line until interrupted. Nothing is written to the
|
|
410
|
+
// port and the modem lines are never touched, so the app keeps running.
|
|
411
|
+
export async function streamSerialMonitor(d, { write, timestamps = false, logFile = '', signal }) {
|
|
412
|
+
let log = null
|
|
413
|
+
if (logFile) {
|
|
414
|
+
mkdirSync(path.dirname(path.resolve(logFile)), { recursive: true })
|
|
415
|
+
const { openSync, writeSync, closeSync } = await import('node:fs')
|
|
416
|
+
const fd = openSync(logFile, 'w')
|
|
417
|
+
log = { write: (text) => writeSync(fd, text), close: () => closeSync(fd) }
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
while (!signal?.aborted && !d.closed) {
|
|
421
|
+
const line = await d.readLine(1000)
|
|
422
|
+
if (line === null) continue
|
|
423
|
+
const output = timestamps ? `${new Date().toTimeString().slice(0, 8)} ${line}` : line
|
|
424
|
+
write(output)
|
|
425
|
+
if (log) log.write(`${output}\n`)
|
|
426
|
+
}
|
|
427
|
+
} finally {
|
|
428
|
+
if (log) log.close()
|
|
429
|
+
}
|
|
430
|
+
}
|