@esp-vision/device-core 0.4.1 → 0.5.0
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/CHANGELOG.md +24 -1
- package/README.md +23 -13
- package/package.json +1 -5
- package/schemas/device-actions-v1.json +1 -1
- package/src/browser_rom_compat.js +8 -0
- package/src/device.js +17 -6
- package/src/device_files.js +5 -4
- package/src/device_python.js +4 -3
- package/src/device_session.js +127 -43
- package/src/evrepl.js +196 -22
- package/src/firmware_flash.js +4 -1
- package/src/node.js +10 -0
- package/src/node_inventory.js +7 -4
- package/src/node_serial.py +9 -1
- package/src/node_transport.js +36 -7
- package/tests/check-browser.mjs +25 -1
- package/tests/check-package.mjs +6 -9
- package/tests/device-checks.mjs +2 -2
- package/tests/fixtures/device-checks.py +205 -1
- package/tests/test-device-api.mjs +96 -6
- package/tests/test-device-execution.mjs +205 -13
- package/tests/test-device-files-api.mjs +85 -1
- package/tests/test-device-firmware.mjs +4 -1
- package/tests/test-device-functional-checks.mjs +9 -0
- package/tests/test-device-hardware-cli.mjs +8 -0
- package/tests/test-device-python-api.mjs +29 -2
- package/tests/test-firmware-install.mjs +0 -17
- package/tests/test-node-adapter-assets.mjs +16 -0
- package/tests/test-node-inventory.mjs +136 -0
- package/tests/test-node-serial.mjs +104 -0
- package/src/cli.js +0 -185
- package/tests/test-cli-output.mjs +0 -31
- package/tests/test-cli-ports.mjs +0 -50
- package/tests/test-cli.mjs +0 -98
package/src/evrepl.js
CHANGED
|
@@ -74,9 +74,13 @@ export class EvRepl {
|
|
|
74
74
|
this.buffer = ''
|
|
75
75
|
this.waiters = []
|
|
76
76
|
this.onScriptEnd = null
|
|
77
|
+
this.scriptStartMarker = null
|
|
78
|
+
this.scriptStartSeen = false
|
|
79
|
+
this.scriptStartWaiter = null
|
|
77
80
|
this.scriptEndMarker = null
|
|
78
81
|
this.scriptEndCommand = null
|
|
79
82
|
this.scriptSentinelSeq = 0
|
|
83
|
+
this.scriptEndPromptOffset = null
|
|
80
84
|
this.execQueue = Promise.resolve()
|
|
81
85
|
this.closedError = null
|
|
82
86
|
this.inputMaxBytes = EV_REPL_INPUT_MAX_BYTES
|
|
@@ -94,11 +98,70 @@ export class EvRepl {
|
|
|
94
98
|
return `${this.scriptEndCommand}\r\n`
|
|
95
99
|
}
|
|
96
100
|
|
|
97
|
-
|
|
101
|
+
createScriptStartSentinel() {
|
|
102
|
+
const randomId = globalThis.crypto?.randomUUID?.() ||
|
|
103
|
+
`${Date.now().toString(36)}-${++this.scriptSentinelSeq}`
|
|
104
|
+
this.scriptStartMarker = `~~EVSCRIPT-START:${randomId}~~`
|
|
105
|
+
this.scriptStartSeen = false
|
|
106
|
+
return `print(${pythonString(this.scriptStartMarker)})`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
waitForScriptStart({ timeout = 5000, signal = null } = {}) {
|
|
110
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
111
|
+
throw new RangeError('Script start acknowledgement timeout must be positive')
|
|
112
|
+
}
|
|
113
|
+
if (this.scriptStartSeen) {
|
|
114
|
+
this.scriptStartSeen = false
|
|
115
|
+
return Promise.resolve()
|
|
116
|
+
}
|
|
117
|
+
if (!this.scriptStartMarker) {
|
|
118
|
+
return Promise.reject(Object.assign(new Error('Script start acknowledgement is unavailable'), {
|
|
119
|
+
code: 'SCRIPT_START_MISSING',
|
|
120
|
+
}))
|
|
121
|
+
}
|
|
122
|
+
if (signal?.aborted) {
|
|
123
|
+
return Promise.reject(abortReason(signal))
|
|
124
|
+
}
|
|
125
|
+
if (this.scriptStartWaiter) return this.scriptStartWaiter.promise
|
|
126
|
+
|
|
127
|
+
let waiter
|
|
128
|
+
const promise = new Promise((resolve, reject) => {
|
|
129
|
+
const cleanup = () => {
|
|
130
|
+
clearTimeout(waiter.timer)
|
|
131
|
+
signal?.removeEventListener('abort', onAbort)
|
|
132
|
+
if (this.scriptStartWaiter === waiter) this.scriptStartWaiter = null
|
|
133
|
+
}
|
|
134
|
+
const settle = (callback, value) => {
|
|
135
|
+
cleanup()
|
|
136
|
+
callback(value)
|
|
137
|
+
}
|
|
138
|
+
const onAbort = () => settle(reject, abortReason(signal))
|
|
139
|
+
waiter = {
|
|
140
|
+
promise: null,
|
|
141
|
+
resolve: () => {
|
|
142
|
+
this.scriptStartSeen = false
|
|
143
|
+
settle(resolve)
|
|
144
|
+
},
|
|
145
|
+
reject: error => settle(reject, error),
|
|
146
|
+
timer: setTimeout(() => settle(reject, Object.assign(
|
|
147
|
+
new Error(`script start acknowledgement timeout after ${timeout}ms`),
|
|
148
|
+
{ code: 'SCRIPT_START_TIMEOUT' },
|
|
149
|
+
)), timeout),
|
|
150
|
+
}
|
|
151
|
+
this.scriptStartWaiter = waiter
|
|
152
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
153
|
+
if (signal?.aborted) onAbort()
|
|
154
|
+
})
|
|
155
|
+
waiter.promise = promise
|
|
156
|
+
return promise
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
createScriptRunCommand(path, { acknowledgeStart = false } = {}) {
|
|
160
|
+
const start = acknowledgeStart ? this.createScriptStartSentinel() : null
|
|
98
161
|
const end = this.createScriptEndSentinel().trim()
|
|
99
162
|
// Generate completion inside the running statement. A second queued
|
|
100
163
|
// stdin command would otherwise be consumed by Python input().
|
|
101
|
-
const source = target => `try:\n execfile(${target})\nexcept BaseException as __evcore_error:\n __import__('sys').print_exception(__evcore_error)\nfinally:\n ${end}`
|
|
164
|
+
const source = target => `try:\n${start ? ` ${start}\n` : ''} execfile(${target})\nexcept BaseException as __evcore_error:\n __import__('sys').print_exception(__evcore_error)\nfinally:\n ${end}`
|
|
102
165
|
let command = `exec("${escapeForExec(source(pythonString(path)))}")\r\n`
|
|
103
166
|
this.scriptRunPrelude = null
|
|
104
167
|
if (new TextEncoder().encode(command).length > this.inputMaxBytes) {
|
|
@@ -115,11 +178,59 @@ export class EvRepl {
|
|
|
115
178
|
return command
|
|
116
179
|
}
|
|
117
180
|
|
|
118
|
-
|
|
181
|
+
createInlineSourceAssignments(source, variableName) {
|
|
182
|
+
if (typeof source !== 'string' || !/^__[A-Za-z0-9_]+$/.test(variableName)) {
|
|
183
|
+
throw new TypeError('Inline Python source requires a string and a private variable name')
|
|
184
|
+
}
|
|
185
|
+
const characters = Array.from(source)
|
|
186
|
+
const statements = []
|
|
187
|
+
let chunk = ''
|
|
188
|
+
const statementFor = (value, first) => `${variableName}${first ? '=' : '+='}${JSON.stringify(value)}`
|
|
189
|
+
for (const character of characters) {
|
|
190
|
+
const candidate = chunk + character
|
|
191
|
+
const candidateStatement = statementFor(candidate, statements.length === 0)
|
|
192
|
+
if (this.execInputBytes(candidateStatement) <= this.inputMaxBytes) {
|
|
193
|
+
chunk = candidate
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
if (!chunk) throw new Error('Inline Python source character exceeds the REPL input limit')
|
|
197
|
+
statements.push(statementFor(chunk, statements.length === 0))
|
|
198
|
+
chunk = character
|
|
199
|
+
if (this.execInputBytes(statementFor(chunk, false)) > this.inputMaxBytes) {
|
|
200
|
+
throw new Error('Inline Python source character exceeds the REPL input limit')
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (chunk || statements.length === 0) statements.push(statementFor(chunk, statements.length === 0))
|
|
204
|
+
return statements
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
createInlineScriptRunCommand(variableName) {
|
|
208
|
+
if (!/^__[A-Za-z0-9_]+$/.test(variableName)) {
|
|
209
|
+
throw new TypeError('Inline Python run requires a private variable name')
|
|
210
|
+
}
|
|
211
|
+
const end = this.createScriptEndSentinel().trim()
|
|
212
|
+
const source = `try:\n exec(${variableName})\nexcept BaseException as __evcore_error:\n __import__('sys').print_exception(__evcore_error)\nfinally:\n try:\n del ${variableName}\n except NameError:\n pass\n ${end}`
|
|
213
|
+
const command = `exec("${escapeForExec(source)}")\r\n`
|
|
214
|
+
if (new TextEncoder().encode(command).length > this.inputMaxBytes) {
|
|
215
|
+
this.cancelScriptEndSentinel()
|
|
216
|
+
throw new Error('Inline Python runner exceeds the REPL input limit')
|
|
217
|
+
}
|
|
218
|
+
this.scriptEndCommand = command.trimEnd()
|
|
219
|
+
this.scriptRunEcho = true
|
|
220
|
+
return command
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
cancelScriptEndSentinel(error = null) {
|
|
224
|
+
this.scriptStartMarker = null
|
|
225
|
+
this.scriptStartSeen = false
|
|
226
|
+
this.scriptStartWaiter?.reject(error || Object.assign(new Error('Script ended before its start acknowledgement'), {
|
|
227
|
+
code: 'SCRIPT_START_MISSING',
|
|
228
|
+
}))
|
|
119
229
|
this.scriptEndMarker = null
|
|
120
230
|
this.scriptEndCommand = null
|
|
121
231
|
this.scriptRunEcho = false
|
|
122
232
|
this.scriptRunPrelude = null
|
|
233
|
+
this.scriptEndPromptOffset = null
|
|
123
234
|
}
|
|
124
235
|
|
|
125
236
|
/**
|
|
@@ -138,6 +249,7 @@ export class EvRepl {
|
|
|
138
249
|
this.scriptRunEcho = false
|
|
139
250
|
}
|
|
140
251
|
}
|
|
252
|
+
this.drainScriptStart()
|
|
141
253
|
const marker = this.scriptEndMarker
|
|
142
254
|
if (!marker) {
|
|
143
255
|
return
|
|
@@ -180,11 +292,43 @@ export class EvRepl {
|
|
|
180
292
|
}
|
|
181
293
|
this.buffer = before + this.buffer.slice(end)
|
|
182
294
|
this.cancelScriptEndSentinel()
|
|
295
|
+
this.scriptEndPromptOffset = before.length
|
|
183
296
|
this.onScriptEnd?.()
|
|
184
297
|
return
|
|
185
298
|
}
|
|
186
299
|
}
|
|
187
300
|
|
|
301
|
+
drainScriptStart() {
|
|
302
|
+
const marker = this.scriptStartMarker
|
|
303
|
+
if (!marker) return
|
|
304
|
+
let searchFrom = 0
|
|
305
|
+
for (;;) {
|
|
306
|
+
const at = this.buffer.indexOf(marker, searchFrom)
|
|
307
|
+
if (at < 0) return
|
|
308
|
+
let end = at + marker.length
|
|
309
|
+
const tail = this.buffer[end]
|
|
310
|
+
if (tail === undefined) return
|
|
311
|
+
if (tail !== '\r' && tail !== '\n') {
|
|
312
|
+
// The friendly-REPL echo includes the marker inside the
|
|
313
|
+
// runner command. Only its standalone printed line is proof
|
|
314
|
+
// that the VM entered the runner.
|
|
315
|
+
searchFrom = end
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
if (tail === '\r') {
|
|
319
|
+
if (end + 1 >= this.buffer.length) return
|
|
320
|
+
end += this.buffer[end + 1] === '\n' ? 2 : 1
|
|
321
|
+
} else {
|
|
322
|
+
end += 1
|
|
323
|
+
}
|
|
324
|
+
this.buffer = this.buffer.slice(0, at) + this.buffer.slice(end)
|
|
325
|
+
this.scriptStartMarker = null
|
|
326
|
+
this.scriptStartSeen = true
|
|
327
|
+
this.scriptStartWaiter?.resolve()
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
188
332
|
/**
|
|
189
333
|
* Length of the buffer suffix that could grow into the sentinel marker
|
|
190
334
|
* (starts with at least '~~'). Held back from passthrough so a marker
|
|
@@ -201,11 +345,13 @@ export class EvRepl {
|
|
|
201
345
|
// until its output marker arrives and both can be removed.
|
|
202
346
|
return this.buffer.length - commandAt
|
|
203
347
|
}
|
|
204
|
-
|
|
205
|
-
|
|
348
|
+
for (const activeMarker of [this.scriptStartMarker, marker]) {
|
|
349
|
+
if (activeMarker && this.buffer.endsWith(activeMarker + '\r')) {
|
|
350
|
+
return activeMarker.length + 1
|
|
351
|
+
}
|
|
206
352
|
}
|
|
207
353
|
let hold = 0
|
|
208
|
-
for (const candidate of [marker, this.scriptEndCommand]) {
|
|
354
|
+
for (const candidate of [this.scriptStartMarker, marker, this.scriptEndCommand]) {
|
|
209
355
|
if (!candidate) {
|
|
210
356
|
continue
|
|
211
357
|
}
|
|
@@ -234,12 +380,29 @@ export class EvRepl {
|
|
|
234
380
|
}
|
|
235
381
|
this.buffer += text
|
|
236
382
|
this.drainScriptEnd()
|
|
383
|
+
let passthrough = ''
|
|
384
|
+
let holdPromptTail = false
|
|
385
|
+
if (this.scriptEndPromptOffset !== null) {
|
|
386
|
+
const prefix = this.buffer.slice(0, this.scriptEndPromptOffset)
|
|
387
|
+
const tail = this.buffer.slice(this.scriptEndPromptOffset)
|
|
388
|
+
if (tail.length < PROMPT.length && PROMPT.startsWith(tail)) {
|
|
389
|
+
this.buffer = tail
|
|
390
|
+
this.scriptEndPromptOffset = 0
|
|
391
|
+
passthrough = prefix
|
|
392
|
+
holdPromptTail = true
|
|
393
|
+
} else if (tail.startsWith(PROMPT)) {
|
|
394
|
+
this.buffer = prefix + tail.slice(PROMPT.length)
|
|
395
|
+
this.scriptEndPromptOffset = null
|
|
396
|
+
} else {
|
|
397
|
+
this.scriptEndPromptOffset = null
|
|
398
|
+
}
|
|
399
|
+
}
|
|
237
400
|
this.drain()
|
|
238
401
|
if (this.waiters.length === 0) {
|
|
239
|
-
const hold = this.partialMarkerTail()
|
|
402
|
+
const hold = holdPromptTail ? this.buffer.length : this.partialMarkerTail()
|
|
240
403
|
const out = this.buffer.slice(0, this.buffer.length - hold)
|
|
241
404
|
this.buffer = this.buffer.slice(this.buffer.length - hold)
|
|
242
|
-
return out
|
|
405
|
+
return passthrough + out
|
|
243
406
|
}
|
|
244
407
|
return ''
|
|
245
408
|
}
|
|
@@ -278,7 +441,7 @@ export class EvRepl {
|
|
|
278
441
|
waiter.removeAbortListener?.()
|
|
279
442
|
waiter.reject(error)
|
|
280
443
|
}
|
|
281
|
-
this.cancelScriptEndSentinel()
|
|
444
|
+
this.cancelScriptEndSentinel(error)
|
|
282
445
|
this.buffer = ''
|
|
283
446
|
}
|
|
284
447
|
|
|
@@ -305,11 +468,11 @@ export class EvRepl {
|
|
|
305
468
|
return result
|
|
306
469
|
}
|
|
307
470
|
|
|
308
|
-
writeStdin(data) {
|
|
471
|
+
writeStdin(data, { signal = null } = {}) {
|
|
309
472
|
return this.enqueue(async () => {
|
|
310
473
|
const bytes = data instanceof Uint8Array ? data : new TextEncoder().encode(data)
|
|
311
474
|
if (bytes.length <= this.inputMaxBytes) {
|
|
312
|
-
return this.writeEvmux('repl.stdin', 'data', null, data)
|
|
475
|
+
return this.writeEvmux('repl.stdin', 'data', null, data, { signal })
|
|
313
476
|
}
|
|
314
477
|
let result
|
|
315
478
|
for (let offset = 0; offset < bytes.length; offset += this.inputMaxBytes) {
|
|
@@ -318,10 +481,11 @@ export class EvRepl {
|
|
|
318
481
|
'data',
|
|
319
482
|
null,
|
|
320
483
|
bytes.subarray(offset, Math.min(offset + this.inputMaxBytes, bytes.length)),
|
|
484
|
+
{ signal },
|
|
321
485
|
)
|
|
322
486
|
}
|
|
323
487
|
return result
|
|
324
|
-
})
|
|
488
|
+
}, { signal })
|
|
325
489
|
}
|
|
326
490
|
|
|
327
491
|
execLine(line, { timeout = 8000, signal = null } = {}) {
|
|
@@ -353,7 +517,7 @@ export class EvRepl {
|
|
|
353
517
|
reject(error)
|
|
354
518
|
}
|
|
355
519
|
const timer = setTimeout(() => {
|
|
356
|
-
fail(new Error(`repl exec timeout: ${line.slice(0, 60)}`))
|
|
520
|
+
fail(Object.assign(new Error(`repl exec timeout: ${line.slice(0, 60)}`), { code: 'TIMEOUT' }))
|
|
357
521
|
}, timeout)
|
|
358
522
|
const abort = () => {
|
|
359
523
|
fail(abortReason(signal))
|
|
@@ -385,7 +549,7 @@ export class EvRepl {
|
|
|
385
549
|
completion.catch(() => {})
|
|
386
550
|
if (signal?.aborted) return completion
|
|
387
551
|
try {
|
|
388
|
-
await this.writeEvmux('repl.stdin', 'data', null, line + '\r\n')
|
|
552
|
+
await this.writeEvmux('repl.stdin', 'data', null, line + '\r\n', { signal })
|
|
389
553
|
commandWritten = true
|
|
390
554
|
} catch (err) {
|
|
391
555
|
const index = this.waiters.indexOf(waiter)
|
|
@@ -553,14 +717,26 @@ walk('')
|
|
|
553
717
|
return result
|
|
554
718
|
}
|
|
555
719
|
|
|
556
|
-
async readFile(fn, { timeout = 30000, signal = null, maxBytes = null } = {}) {
|
|
720
|
+
async readFile(fn, { timeout = null, chunkTimeout = 30000, signal = null, maxBytes = null } = {}) {
|
|
721
|
+
if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
|
|
722
|
+
throw new RangeError('Device file read timeout must be positive or null')
|
|
723
|
+
}
|
|
724
|
+
if (!Number.isFinite(chunkTimeout) || chunkTimeout <= 0) {
|
|
725
|
+
throw new RangeError('Device file read chunk timeout must be positive')
|
|
726
|
+
}
|
|
557
727
|
if (maxBytes !== null && (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes > 16 * 1024 * 1024)) {
|
|
558
728
|
throw Object.assign(new Error('Invalid bounded file read limit'), { code: 'DEVICE_READ_LIMIT_INVALID' })
|
|
559
729
|
}
|
|
560
730
|
// Keep each stdout burst bounded, allowing the transport task to send
|
|
561
731
|
// keepalives between chunks. Serialize the whole read with REPL work.
|
|
562
732
|
return this.enqueue(async () => {
|
|
563
|
-
const deadline = Date.now() + timeout
|
|
733
|
+
const deadline = timeout === null ? null : Date.now() + timeout
|
|
734
|
+
const commandBudget = () => {
|
|
735
|
+
if (deadline === null) return chunkTimeout
|
|
736
|
+
const remaining = deadline - Date.now()
|
|
737
|
+
if (remaining <= 0) throw Object.assign(new Error('Device file read timeout'), { code: 'TIMEOUT' })
|
|
738
|
+
return Math.min(chunkTimeout, remaining)
|
|
739
|
+
}
|
|
564
740
|
const chunks = []
|
|
565
741
|
let offset = 0
|
|
566
742
|
let size = null
|
|
@@ -568,8 +744,6 @@ walk('')
|
|
|
568
744
|
const incomplete = () => Object.assign(new Error(`Incomplete file response for ${fn}`), { code: 'DEVICE_FILE_READ_INCOMPLETE' })
|
|
569
745
|
for (;;) {
|
|
570
746
|
throwIfAborted(signal)
|
|
571
|
-
const remaining = deadline - Date.now()
|
|
572
|
-
if (remaining <= 0) throw new Error('Device file read timeout')
|
|
573
747
|
const count = maxBytes === null ? 1024 : Math.min(1024, maxBytes - offset + 1)
|
|
574
748
|
let source = `
|
|
575
749
|
import binascii
|
|
@@ -584,13 +758,13 @@ except Exception as e:
|
|
|
584
758
|
print('__EVREAD_ERROR__'+repr(e))
|
|
585
759
|
`
|
|
586
760
|
if (this.execInputBytes(source) > this.inputMaxBytes) {
|
|
587
|
-
await this._execPy(`__evcore_read_path=${pythonString(fn)}`,
|
|
761
|
+
await this._execPy(`__evcore_read_path=${pythonString(fn)}`, commandBudget(), signal)
|
|
762
|
+
commandBudget()
|
|
588
763
|
source = source.replace(filePath, '__evcore_read_path')
|
|
589
764
|
filePath = '__evcore_read_path'
|
|
590
765
|
}
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
const rsp = await this._execPy(source, budget, signal)
|
|
766
|
+
const rsp = await this._execPy(source, commandBudget(), signal)
|
|
767
|
+
commandBudget()
|
|
594
768
|
const errorAt = rsp.indexOf('__EVREAD_ERROR__')
|
|
595
769
|
if (errorAt >= 0) throw new Error(`Unable to read ${fn}: ${rsp.slice(errorAt + 16).trim()}`)
|
|
596
770
|
const match = /(?:^|\n)__EVREAD_OK__(\d+):(\d+):([0-9a-fA-F]*)\s*$/.exec(rsp)
|
package/src/firmware_flash.js
CHANGED
|
@@ -112,7 +112,10 @@ export class FirmwareFlashSession {
|
|
|
112
112
|
}
|
|
113
113
|
await this.reset()
|
|
114
114
|
}
|
|
115
|
-
catch
|
|
115
|
+
catch (error) {
|
|
116
|
+
onPhase('Firmware verified. Automatic reset did not complete; wait for runtime reconnection or press RESET.')
|
|
117
|
+
return { verified: true, needsReset: true, resetError: { code: error?.code ?? 'RESET_FAILED', message: error?.message ?? String(error) } }
|
|
118
|
+
}
|
|
116
119
|
onPhase('Firmware verified and reset. Reconnect in the IDE to verify EV-MUX v4.')
|
|
117
120
|
return { verified: true, needsReset: false }
|
|
118
121
|
} catch (error) { this.state = 'error'; throw error }
|
package/src/node.js
CHANGED
|
@@ -3,3 +3,13 @@
|
|
|
3
3
|
// Node-only adapters. Creating them does not acquire a session or serial port.
|
|
4
4
|
export { tcpTransport, serialTransport } from './node_transport.js'
|
|
5
5
|
export { openNodeRom } from './node_rom.js'
|
|
6
|
+
export { defaultPython, filterSerialPorts, inventory, listSerial, serialEntry, transportFor } from './node_inventory.js'
|
|
7
|
+
|
|
8
|
+
// Bundlers copy these exact published adapters beside their bundled Node entry.
|
|
9
|
+
// Returning URLs is passive and keeps transport assets owned by the SDK.
|
|
10
|
+
export function nodeAdapterAssets() {
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
serial: new URL('./node_serial.py', import.meta.url),
|
|
13
|
+
rom: new URL('./node_rom.py', import.meta.url),
|
|
14
|
+
})
|
|
15
|
+
}
|
package/src/node_inventory.js
CHANGED
|
@@ -8,14 +8,17 @@ import { createDevice } from './index.js'
|
|
|
8
8
|
import { serialTransport, tcpTransport } from './node_transport.js'
|
|
9
9
|
|
|
10
10
|
export const defaultPython = process.platform === 'win32' ? 'python' : 'python3'
|
|
11
|
-
export async function listSerial({ python = defaultPython, signal } = {}) {
|
|
11
|
+
export async function listSerial({ python = defaultPython, signal, timeout = 10000 } = {}, dependencies = {}) {
|
|
12
|
+
if (!Number.isFinite(timeout) || timeout <= 0) throw new RangeError('Serial enumeration timeout must be positive')
|
|
12
13
|
try {
|
|
13
|
-
const
|
|
14
|
+
const run = dependencies.execFile ?? promisify(execFile)
|
|
15
|
+
const { stdout } = await run(python, [fileURLToPath(new URL('./node_serial.py', import.meta.url)), '--list'], { signal, timeout, maxBuffer: 1024 * 1024, windowsHide: true })
|
|
14
16
|
const ports = JSON.parse(stdout)
|
|
15
17
|
if (!Array.isArray(ports)) throw new Error('Invalid serial list')
|
|
16
18
|
return ports
|
|
17
19
|
} catch (cause) {
|
|
18
20
|
if (signal?.aborted) throw signal.reason
|
|
21
|
+
if (cause?.code === 'ETIMEDOUT') throw Object.assign(new Error('Serial enumeration timed out'), { code: 'TIMEOUT', cause })
|
|
19
22
|
throw Object.assign(new Error('Serial enumeration unavailable: install Python and pyserial, or select --python'), { code: 'SERIAL_UNAVAILABLE', cause })
|
|
20
23
|
}
|
|
21
24
|
}
|
|
@@ -53,7 +56,7 @@ export function serialEntry(port) {
|
|
|
53
56
|
: usb ? 'serial' : 'uart'
|
|
54
57
|
return { interface: kind, address: port.path, status: 'unprobed', identity: null, usb: { vendorId: vid ?? null, productId: pid ?? null, serialNumber: port.serialNumber ?? null, product: port.product ?? null, manufacturer: port.manufacturer ?? null }, interfaceSource: 'descriptor-candidate' }
|
|
55
58
|
}
|
|
56
|
-
export function transportFor(entry, options) {
|
|
59
|
+
export function transportFor(entry, options = {}) {
|
|
57
60
|
return entry.interface === 'tcp' ? tcpTransport({ host: entry.host, port: entry.port })
|
|
58
61
|
: serialTransport({ serial: entry.address, role: entry.interface, python: options.python ?? defaultPython })
|
|
59
62
|
}
|
|
@@ -84,7 +87,7 @@ export async function inventory(options = {}, dependencies = {}) {
|
|
|
84
87
|
signal?.throwIfAborted()
|
|
85
88
|
const device = (dependencies.createDevice ?? createDevice)({ transport: (dependencies.transportFor ?? transportFor)(row, options) })
|
|
86
89
|
try {
|
|
87
|
-
const result = await device.connect({ role: row.interface === 'serial' ? 'unknown' : row.interface, timeout: options.probeTimeout ?? 5000, signal, ...(row.advertisement ? { expectedDevice: row.advertisement } : {}) })
|
|
90
|
+
const result = await device.connect({ role: row.interface === 'serial' ? 'unknown' : row.interface, timeout: options.probeTimeout ?? 5000, signal, probeRepl: false, ...(row.advertisement ? { expectedDevice: row.advertisement } : {}) })
|
|
88
91
|
row.identity = result.identity; row.inspection = await device.inspect({ fields: options.fields, signal }); row.status = 'identified'
|
|
89
92
|
} catch (error) {
|
|
90
93
|
if (signal?.aborted) throw error
|
package/src/node_serial.py
CHANGED
|
@@ -10,6 +10,13 @@ import threading
|
|
|
10
10
|
import serial
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
def _configure_serial_exclusive(port, platform=os.name):
|
|
14
|
+
# pyserial implements this as a non-blocking advisory lock on POSIX. The
|
|
15
|
+
# Windows backend does not support this option, so leave its behavior alone.
|
|
16
|
+
if platform == 'posix':
|
|
17
|
+
port.exclusive = True
|
|
18
|
+
|
|
19
|
+
|
|
13
20
|
def main():
|
|
14
21
|
if sys.argv[1] == '--list':
|
|
15
22
|
from serial.tools.list_ports import comports
|
|
@@ -23,6 +30,7 @@ def main():
|
|
|
23
30
|
port.rts = False
|
|
24
31
|
port.dtr = True
|
|
25
32
|
port.port = sys.argv[1]
|
|
33
|
+
_configure_serial_exclusive(port)
|
|
26
34
|
stopped = threading.Event()
|
|
27
35
|
|
|
28
36
|
def terminate(*_args):
|
|
@@ -64,6 +72,6 @@ if __name__ == '__main__':
|
|
|
64
72
|
main()
|
|
65
73
|
except Exception as error:
|
|
66
74
|
text = str(error).lower()
|
|
67
|
-
code = 'SERIAL_ACCESS_DENIED' if ('permission denied' in text or 'access is denied' in text or 'access denied' in text) else 'SERIAL_BUSY' if ('resource busy' in text or 'exclusively lock' in text) else 'SERIAL_ERROR'
|
|
75
|
+
code = 'SERIAL_ACCESS_DENIED' if ('permission denied' in text or 'access is denied' in text or 'access denied' in text) else 'SERIAL_BUSY' if ('resource busy' in text or 'resource temporarily unavailable' in text or 'exclusively lock' in text) else 'SERIAL_ERROR'
|
|
68
76
|
print(code, file=sys.stderr, flush=True)
|
|
69
77
|
sys.exit(2)
|
package/src/node_transport.js
CHANGED
|
@@ -46,20 +46,49 @@ export function serialTransport(options) {
|
|
|
46
46
|
return new Promise((resolveConnect, reject) => {
|
|
47
47
|
const adapter = fileURLToPath(new URL('./node_serial.py', import.meta.url))
|
|
48
48
|
child = spawn(options.python, ['-u', adapter, options.serial], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
49
|
-
let ready = false, stderr = ''
|
|
50
|
-
const abort = () => {
|
|
49
|
+
let ready = false, settled = false, runtimeErrorReported = false, stderr = ''
|
|
50
|
+
const abort = () => {
|
|
51
|
+
child.kill('SIGTERM')
|
|
52
|
+
if (!settled) {
|
|
53
|
+
settled = true
|
|
54
|
+
reject(fail('CANCELLED', 'Serial connection cancelled'))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
51
57
|
const cleanup = () => signal.removeEventListener('abort', abort)
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
const serialError = () => {
|
|
59
|
+
const code = ['SERIAL_ACCESS_DENIED', 'SERIAL_BUSY'].find(code => stderr.includes(code)) ?? 'SERIAL_ERROR'
|
|
60
|
+
return fail(code, 'Serial adapter failed; ensure Python, pyserial and port access are available')
|
|
61
|
+
}
|
|
62
|
+
const failBeforeReady = () => {
|
|
63
|
+
if (settled) return
|
|
64
|
+
settled = true
|
|
65
|
+
cleanup()
|
|
66
|
+
const error = serialError()
|
|
67
|
+
reject(error)
|
|
68
|
+
events.emit('error', error)
|
|
69
|
+
}
|
|
70
|
+
const reportRuntimeError = () => {
|
|
71
|
+
if (runtimeErrorReported) return
|
|
72
|
+
runtimeErrorReported = true
|
|
73
|
+
events.emit('error', serialError())
|
|
74
|
+
}
|
|
75
|
+
child.on('error', () => { if (ready) reportRuntimeError() })
|
|
76
|
+
child.stdin.on('error', () => { if (ready) reportRuntimeError() })
|
|
55
77
|
child.stdout.on('data', bytes => events.emit('data', bytes))
|
|
56
78
|
child.stderr.on('data', bytes => {
|
|
57
79
|
if (ready) return
|
|
58
80
|
// Do not print Python errors: they can contain host paths or device data.
|
|
59
81
|
stderr = (stderr + bytes.toString()).slice(-128)
|
|
60
|
-
if (stderr.split(/\r?\n/).includes('READY')) {
|
|
82
|
+
if (stderr.split(/\r?\n/).includes('READY')) {
|
|
83
|
+
ready = true
|
|
84
|
+
settled = true
|
|
85
|
+
cleanup()
|
|
86
|
+
resolveConnect()
|
|
87
|
+
}
|
|
61
88
|
})
|
|
62
|
-
|
|
89
|
+
// Wait for close rather than exit: stdout/stderr may still have buffered
|
|
90
|
+
// data after exit, including the adapter's stable SERIAL_BUSY marker.
|
|
91
|
+
child.on('close', () => { cleanup(); if (!ready) failBeforeReady(); events.emit('close') })
|
|
63
92
|
signal.addEventListener('abort', abort, { once: true })
|
|
64
93
|
if (signal.aborted) abort()
|
|
65
94
|
})
|
package/tests/check-browser.mjs
CHANGED
|
@@ -23,7 +23,7 @@ try {
|
|
|
23
23
|
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32p4.json')))
|
|
24
24
|
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32s31.json')))
|
|
25
25
|
assert.ok(!Object.keys(result.metafile.inputs).some(path => /src\/(node|cli)/.test(path)))
|
|
26
|
-
const context = vm.createContext({ EventTarget, URL, TextDecoder, TextEncoder, Uint8Array, AbortController, setTimeout, clearTimeout, console, crypto: webcrypto, structuredClone })
|
|
26
|
+
const context = vm.createContext({ atob, btoa, EventTarget, URL, TextDecoder, TextEncoder, Uint8Array, AbortController, setTimeout, clearTimeout, console, crypto: webcrypto, structuredClone })
|
|
27
27
|
vm.runInContext(code, context)
|
|
28
28
|
let opens = 0, closes = 0
|
|
29
29
|
const adapter = context.DeviceCore.openBrowserRom({ port: {
|
|
@@ -46,6 +46,29 @@ try {
|
|
|
46
46
|
raw._inputBuffer.push(0xc0, 0xdb, 0x47, 0xc0)
|
|
47
47
|
await assert.rejects(raw.readPacket(100), /Invalid SLIP escape/)
|
|
48
48
|
}
|
|
49
|
+
// Exercise real upstream stub status decoding. A ROM reset consumes four
|
|
50
|
+
// status bytes while the running stub replies with two; route reset to it.
|
|
51
|
+
{
|
|
52
|
+
const raw = new context.DeviceCore.TestLoader({ getInfo: () => ({ usbVendorId: 0x303a, usbProductId: 0x1001 }) }, { log() {}, error() {}, debug() {} })
|
|
53
|
+
raw.chipFamily = 0x3280; raw.chipRevision = 302
|
|
54
|
+
raw.memBegin = raw.memBlock = raw.memFinish = async () => {}
|
|
55
|
+
raw.readPacket = async () => new Uint8Array([79, 72, 65, 73])
|
|
56
|
+
context.DeviceCore.testCompatibility(raw)
|
|
57
|
+
const stub = await raw.runStub(true)
|
|
58
|
+
let writes = 0
|
|
59
|
+
stub.detectUsbConnectionType = async () => true
|
|
60
|
+
stub.getUsbMode = async () => ({ mode: 'usb-jtag-serial' })
|
|
61
|
+
stub.sendCommand = async () => { writes++ }
|
|
62
|
+
stub.getResponse = async () => [0, new Uint8Array([0, 0])]
|
|
63
|
+
// Confirm the original ROM instance rejects the exact same stub response.
|
|
64
|
+
raw.detectUsbConnectionType = stub.detectUsbConnectionType
|
|
65
|
+
raw.getUsbMode = stub.getUsbMode
|
|
66
|
+
raw.sendCommand = async () => {}
|
|
67
|
+
raw.getResponse = stub.getResponse
|
|
68
|
+
await assert.rejects(context.DeviceCore.TestLoader.prototype.hardReset.call(raw), /status|response|enough/i)
|
|
69
|
+
await raw.hardReset()
|
|
70
|
+
assert.equal(writes, 4, 'P4 watchdog writes must use the running stub status decoder')
|
|
71
|
+
}
|
|
49
72
|
const prototype = context.DeviceCore.TestLoader.prototype
|
|
50
73
|
const originals = Object.fromEntries(['initialize', 'disconnect', 'getSecurityInfo', 'runStub', 'hardReset'].map(key => [key, prototype[key]]))
|
|
51
74
|
let initializeCalls = 0, disconnectCalls = 0
|
|
@@ -115,6 +138,7 @@ try {
|
|
|
115
138
|
async flashData() { writes++; writing.abort() },
|
|
116
139
|
async readFlash(offset) { return offset === 32768 ? table : data },
|
|
117
140
|
async readRegister() { return 0 },
|
|
141
|
+
async hardReset() { resets++ },
|
|
118
142
|
}
|
|
119
143
|
}
|
|
120
144
|
prototype.hardReset = async () => { resets++ }
|
package/tests/check-package.mjs
CHANGED
|
@@ -19,6 +19,8 @@ try {
|
|
|
19
19
|
assert.ok(packed.files.some(({ path }) => path === 'NOTICE'))
|
|
20
20
|
assert.ok(packed.files.some(({ path }) => path === 'schemas/device-actions-v1.json'))
|
|
21
21
|
assert.ok(packed.files.every(({ path }) => /^(src\/|schemas\/|tests\/|scripts\/|package\.json$|README\.md$|LICENSE$|NOTICE$|CHANGELOG\.md$)/.test(path)), 'tarball must contain only the standalone library')
|
|
22
|
+
assert.ok(!Object.hasOwn(manifest, 'bin'), 'Device Core no longer installs a management CLI')
|
|
23
|
+
assert.ok(!packed.files.some(({ path }) => path === 'src/cli.js'), 'the Core package must not contain the CLI')
|
|
22
24
|
assert.ok(!packed.files.some(({ path }) => path.startsWith('docs/')), 'Documentation is published by the ESP-VISION documentation center')
|
|
23
25
|
assert.ok(!packed.files.some(({ path }) => path.includes('__pycache__') || path.endsWith('.pyc')), 'Never ship host Python bytecode')
|
|
24
26
|
const consumer = join(temporary, 'consumer')
|
|
@@ -39,10 +41,12 @@ try {
|
|
|
39
41
|
const { DeviceSession } = await import('@esp-vision/device-core/session');
|
|
40
42
|
const { FirmwareFlashSession, loadOfficialFirmware, loadReleaseManifest } = await import('@esp-vision/device-core/firmware');
|
|
41
43
|
if (typeof loadOfficialFirmware !== 'function' || typeof loadReleaseManifest !== 'function') throw new Error('Missing public release manifest readers');
|
|
42
|
-
const { tcpTransport, serialTransport, openNodeRom } = await import('@esp-vision/device-core/node');
|
|
44
|
+
const { defaultPython, filterSerialPorts, inventory, listSerial, serialEntry, tcpTransport, serialTransport, transportFor, openNodeRom } = await import('@esp-vision/device-core/node');
|
|
43
45
|
const { openBrowserRom } = await import('@esp-vision/device-core/browser');
|
|
44
46
|
if (typeof openBrowserRom !== 'function' || typeof openBrowserRom({ port: { open() { throw new Error('Not opened'); }, close() {} } }).initialize !== 'function') throw new Error('Missing passive browser ROM adapter');
|
|
45
47
|
if (typeof tcpTransport !== 'function' || typeof serialTransport !== 'function') throw new Error('Missing public Node transports');
|
|
48
|
+
if (typeof inventory !== 'function' || typeof listSerial !== 'function' || typeof filterSerialPorts !== 'function'
|
|
49
|
+
|| typeof serialEntry !== 'function' || typeof transportFor !== 'function' || typeof defaultPython !== 'string') throw new Error('Missing public Node inventory APIs');
|
|
46
50
|
const { createNodeDiscovery } = await import('@esp-vision/device-core/discovery/node');
|
|
47
51
|
const { EventEmitter } = await import('node:events');
|
|
48
52
|
let queries = 0;
|
|
@@ -58,18 +62,11 @@ try {
|
|
|
58
62
|
api.createDevice({ transport: serialTransport({ python: 'python3', serial: 'not-opened' }) });
|
|
59
63
|
if (typeof DeviceSession !== 'function' || typeof FirmwareFlashSession !== 'function') throw new Error('Missing host adapter APIs');`
|
|
60
64
|
execFileSync(process.execPath, ['--input-type=module', '-e', script], { cwd: consumer, stdio: 'pipe' })
|
|
61
|
-
assert.equal(manifest.bin['esp-vision-device'], './src/cli.js')
|
|
62
|
-
const help = execFileSync(process.execPath, [join(consumer, 'node_modules/@esp-vision/device-core/src/cli.js'), '--help'], { encoding: 'utf8' })
|
|
63
|
-
assert.ok(help.includes('esp-vision-device inspect'))
|
|
64
|
-
if (process.platform !== 'win32') {
|
|
65
|
-
const installedHelp = execFileSync(join(consumer, 'node_modules/.bin/esp-vision-device'), ['--help'], { encoding: 'utf8' })
|
|
66
|
-
assert.ok(installedHelp.includes('esp-vision-device inspect'), 'The installed npm command must execute through its symlink')
|
|
67
|
-
}
|
|
68
65
|
for (const required of ['src/node_serial.py', 'scripts/run-tests.mjs', 'tests/hardware-check.mjs', 'tests/device-checks.mjs', 'tests/fixtures/device-checks.py']) {
|
|
69
66
|
assert.ok(packed.files.some(({ path }) => path === required), `Missing installed check asset: ${required}`)
|
|
70
67
|
}
|
|
71
68
|
const unitOutput = execFileSync('npm', ['test'], { cwd: installed, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
|
|
72
|
-
const unitCount = Number(
|
|
69
|
+
const unitCount = Number(/^(?:# tests|ℹ tests) (\d+)$/m.exec(unitOutput)?.[1] ?? 0)
|
|
73
70
|
assert.ok(unitCount > 0, 'Installed npm test must execute a nonempty suite')
|
|
74
71
|
const hardwareHelp = execFileSync('npm', ['run', 'test:hardware', '--', '--help'], { cwd: installed, encoding: 'utf8' })
|
|
75
72
|
assert.ok(hardwareHelp.includes('--serial PATH'), 'Installed hardware runner must load and show its help')
|
package/tests/device-checks.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { readFile, mkdir, writeFile } from 'node:fs/promises'
|
|
|
4
4
|
import { dirname } from 'node:path'
|
|
5
5
|
import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
|
|
6
6
|
|
|
7
|
-
export const DEVICE_CHECKS = Object.freeze(['identity', 'memory', 'camera', 'framesizes', 'pixformats', 'orientation', 'ipa_stats', 'snapshot_rate', 'preview', 'image_ops', 'display', 'display_stream'])
|
|
7
|
+
export const DEVICE_CHECKS = Object.freeze(['identity', 'memory', 'camera', 'framesizes', 'pixformats', 'orientation', 'ipa_stats', 'snapshot_rate', 'preview', 'image_ops', 'display', 'display_stream', 'camera_display_lifecycle', 'image_copy_to_fb', 'image_transform_argument', 'sensor_snapshot_buffer', 'memory_policy'])
|
|
8
8
|
const MARKER = '__EVDEVICECHECK__'
|
|
9
9
|
const failure = (code, message) => Object.assign(new Error(message), { code })
|
|
10
10
|
const unsupported = error => error?.code === 'UNSUPPORTED_OPERATION' || /ESP_ERR_NOT_SUPPORTED|ESP_ERR_NOT_FOUND|no module named|ENODEV/i.test(error?.message ?? '')
|
|
@@ -43,7 +43,7 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
43
43
|
const results = [], namespace = `_evhil_${nonce}`
|
|
44
44
|
let loaded = false, namespaceStarted = false, loadError = null, inputLimit = EV_REPL_INPUT_MAX_BYTES
|
|
45
45
|
const parameters = { frames: options.frames, framesize: options.framesize, pixformat: options.pixformat, displayHold: options.displayHold, screenCapture: Boolean(options.screenOutput) }
|
|
46
|
-
const timeoutFor = name => 1000 * (name === 'display' ? 30 + options.displayHold * 3 : name === 'ipa_stats' ? options.frameTimeout + 40 : options.frameTimeout + Math.max(30, options.frames * (name === 'display_stream' ? 2 : 1)))
|
|
46
|
+
const timeoutFor = name => 1000 * (name === 'display' ? 30 + options.displayHold * 3 : name === 'camera_display_lifecycle' ? options.frameTimeout + 80 : name === 'memory_policy' ? options.frameTimeout + 60 : name === 'sensor_snapshot_buffer' ? options.frameTimeout + 40 : name === 'image_copy_to_fb' ? 30 : name === 'ipa_stats' ? options.frameTimeout + 40 : options.frameTimeout + Math.max(30, options.frames * (name === 'display_stream' ? 2 : 1)))
|
|
47
47
|
const execute = async (source, timeout = 10000, operationSignal = signal) => {
|
|
48
48
|
// Larger python.execute() requests use temporary files in Device Core.
|
|
49
49
|
// Keep fixtures in RAM by respecting the actual encoded REPL-line budget.
|