@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,96 @@
1
+ import { waitForSerialPort } from '../boards/usb.mjs'
2
+ import { ExitCode, fail } from '../errors.mjs'
3
+ import { writeImage } from './image.mjs'
4
+ import { SerialDevice, geadev, streamSerialMonitor } from './serial.mjs'
5
+ import { fetchScreenshot, setHighBrightnessMode, tailLogs } from './wifi.mjs'
6
+
7
+ // One handle for "the board", whichever cable (or no cable) reaches it. Logs
8
+ // and screenshots are written once against this interface; the transport
9
+ // decides how the bytes travel.
10
+
11
+ export const transports = Object.freeze(['auto', 'usb', 'wifi', 'ble'])
12
+
13
+ // 'auto' prefers WiFi whenever the board can be addressed by IP: that works
14
+ // whether or not a cable is attached, and a dropped USB enumeration is the
15
+ // exact situation where you most want to see the screen or the log.
16
+ export function chooseTransport(requested, selection, { host = '' } = {}) {
17
+ const wanted = requested || 'auto'
18
+ if (!transports.includes(wanted)) fail(`--transport must be one of ${transports.join(', ')}.`, ExitCode.usage)
19
+ if (wanted === 'ble') fail('BLE device access is not available yet; use --transport usb or wifi.', ExitCode.usage)
20
+ if (wanted !== 'auto') return wanted
21
+ if (host || selection.otaHost) return 'wifi'
22
+ return 'usb'
23
+ }
24
+
25
+ export function serialBaudRate(selection, env = process.env) {
26
+ if (env.GEA_SERIAL_BAUD) return Number(env.GEA_SERIAL_BAUD)
27
+ return (selection.idfTarget || '') === 'esp32p4' ? 921600 : 115200
28
+ }
29
+
30
+ export async function openDevice({ selection, transport, host = '', port = '', env = process.env, trace = false, stderr = () => {}, waitSeconds = 0 }) {
31
+ if (transport === 'wifi') {
32
+ const address = host || selection.otaHost
33
+ if (!address) fail(`Board '${selection.boardName || selection.target}' has no transports.ota.host and no --host was passed.`, ExitCode.usage)
34
+ return new WifiDevice(address, { stderr })
35
+ }
36
+ if (!selection.usbSerial && !port) {
37
+ fail(`Board '${selection.boardName || selection.target}' has no transports.usbSerial.serial; use --transport wifi or add the USB serial.`, ExitCode.usage)
38
+ }
39
+ const devicePath = await waitForSerialPort({ port, serial: selection.usbSerial, timeoutSeconds: waitSeconds, label: 'USB serial port', log: stderr })
40
+ const serial = await SerialDevice.open({ path: devicePath, baudRate: serialBaudRate(selection, env), trace, stderr })
41
+ return new UsbDevice(serial, { stderr })
42
+ }
43
+
44
+ export class WifiDevice {
45
+ constructor(host, { stderr }) {
46
+ this.kind = 'wifi'
47
+ this.host = host
48
+ this.stderr = stderr
49
+ this.description = `${host} (WiFi)`
50
+ }
51
+
52
+ async logs({ follow = false, write, timeoutMs, signal }) {
53
+ await tailLogs({ host: this.host, follow, timeoutMs, write, stderr: this.stderr, signal })
54
+ }
55
+
56
+ async screenshot() {
57
+ return fetchScreenshot({ host: this.host })
58
+ }
59
+
60
+ async hbm(enabled) {
61
+ return setHighBrightnessMode({ host: this.host, enabled })
62
+ }
63
+
64
+ async close() {}
65
+ }
66
+
67
+ export class UsbDevice {
68
+ constructor(serial, { stderr }) {
69
+ this.kind = 'usb'
70
+ this.serial = serial
71
+ this.stderr = stderr
72
+ this.description = `${serial.path} (USB)`
73
+ }
74
+
75
+ async logs({ write, timestamps = false, logFile = '', signal }) {
76
+ await streamSerialMonitor(this.serial, { write, timestamps, logFile, signal })
77
+ }
78
+
79
+ async screenshot(options = {}) {
80
+ return geadev.screenshot(this.serial, options)
81
+ }
82
+
83
+ async hbm() {
84
+ fail('High-brightness mode is toggled over WiFi (POST /display/hbm); use --transport wifi.', ExitCode.usage)
85
+ }
86
+
87
+ async close() {
88
+ await this.serial.close()
89
+ }
90
+ }
91
+
92
+ export async function saveScreenshot(device, file, options = {}) {
93
+ const shot = await device.screenshot(options)
94
+ writeImage(file, shot.width, shot.height, shot.rgb)
95
+ return shot
96
+ }
@@ -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
+ }