@esp-vision/device-core 0.4.2 → 0.5.1

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/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
- createScriptRunCommand(path) {
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
- cancelScriptEndSentinel() {
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
- if (this.buffer.endsWith(marker + '\r')) {
205
- return marker.length + 1
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)}`, remaining, signal)
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 budget = deadline - Date.now()
592
- if (budget <= 0) throw new Error('Device file read timeout')
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/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
+ }
@@ -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 { stdout } = await promisify(execFile)(python, [fileURLToPath(new URL('./node_serial.py', import.meta.url)), '--list'], { signal, timeout: 10000, maxBuffer: 1024 * 1024, windowsHide: true })
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
@@ -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)
@@ -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 = () => { child.kill('SIGTERM'); reject(fail('CANCELLED', 'Serial connection cancelled')) }
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 error = () => { cleanup(); const code = ['SERIAL_ACCESS_DENIED', 'SERIAL_BUSY'].find(code => stderr.includes(code)) ?? 'SERIAL_ERROR'; const error = fail(code, 'Serial adapter failed; ensure Python, pyserial and port access are available'); reject(error); events.emit('error', error) }
53
- child.on('error', error)
54
- child.stdin.on('error', error)
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')) { ready = true; cleanup(); resolveConnect() }
82
+ if (stderr.split(/\r?\n/).includes('READY')) {
83
+ ready = true
84
+ settled = true
85
+ cleanup()
86
+ resolveConnect()
87
+ }
61
88
  })
62
- child.on('exit', () => { cleanup(); if (!ready) error(); events.emit('close') })
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
  })
@@ -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(/^# tests (\d+)$/m.exec(unitOutput)?.[1] ?? 0)
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')
@@ -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.