@esp-vision/device-core 0.5.0 → 0.6.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/NOTICE +8 -0
  3. package/README.md +24 -7
  4. package/package.json +4 -3
  5. package/schemas/device-actions-v1.json +1 -1
  6. package/scripts/release-npm.mjs +16 -0
  7. package/scripts/version-policy.mjs +71 -0
  8. package/src/browser.js +6 -2
  9. package/src/browser_rom.js +208 -4
  10. package/src/browser_rom_compat.js +86 -59
  11. package/src/browser_serial.js +130 -0
  12. package/src/device.js +5 -4
  13. package/src/device_bindings.js +5 -4
  14. package/src/device_capture.js +11 -0
  15. package/src/device_operations.js +3 -1
  16. package/src/device_session.js +263 -10
  17. package/src/evmux.js +43 -4
  18. package/src/firmware_flash.js +1 -1
  19. package/src/jpeg_validate.js +132 -0
  20. package/src/node_inventory.js +1 -1
  21. package/src/node_rom.js +76 -27
  22. package/src/node_rom.py +68 -2
  23. package/src/node_rom_port.js +211 -0
  24. package/src/node_serial.py +47 -1
  25. package/src/node_transport.js +76 -10
  26. package/src/rom_md5.js +30 -0
  27. package/tests/check-browser.mjs +76 -123
  28. package/tests/check-package.mjs +1 -1
  29. package/tests/device-checks.mjs +6 -2
  30. package/tests/fixtures/device-checks.py +11 -1
  31. package/tests/jpeg-fixtures.mjs +39 -0
  32. package/tests/test-browser-rom.mjs +204 -67
  33. package/tests/test-browser-serial.mjs +143 -0
  34. package/tests/test-device-api.mjs +40 -7
  35. package/tests/test-device-execution.mjs +30 -3
  36. package/tests/test-device-functional-checks.mjs +12 -2
  37. package/tests/test-device-operations-api.mjs +4 -3
  38. package/tests/test-device-reset.mjs +292 -0
  39. package/tests/test-firmware-flash.mjs +1 -1
  40. package/tests/test-jpeg-validation.mjs +233 -0
  41. package/tests/test-node-rom-port.mjs +305 -0
  42. package/tests/test-node-rom.mjs +160 -0
  43. package/tests/test-node-serial.mjs +206 -0
  44. package/tests/test-npm-release-policy.mjs +62 -0
  45. package/tests/test-observed-capture.mjs +131 -0
  46. package/tests/test-uart-baud.mjs +180 -0
  47. package/tests/test-version-policy.mjs +37 -0
@@ -1,73 +1,100 @@
1
1
  // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- // Compatibility for pinned tasmota-webserial-esptool 9.4.2. Keep changes per
4
- // instance: consumers need neither package-manager patches nor global prototypes.
5
- export function browserRomCompatibility(loader) {
6
- const readPacket = loader.readPacket
7
- loader.readPacket = async function (timeout) {
3
+ import { romMd5 } from './rom_md5.js'
4
+
5
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
6
+ export const romError = (code, message, cause) => Object.assign(new Error(message, { cause }), { code })
7
+ export function romWords(...values) {
8
+ const bytes = new Uint8Array(values.length * 4), view = new DataView(bytes.buffer)
9
+ values.forEach((value, index) => view.setUint32(index * 4, value, true))
10
+ return bytes
11
+ }
12
+
13
+ // Per-instance fixes for esptool-js 0.7.1, not patches to global prototypes.
14
+ // Its parser loses a whole buffered chunk (including a valid packet) when a
15
+ // boot-log prefix precedes SLIP. Keep the upstream buffer/readLoop and framing,
16
+ // while bounding a complete packet by one deadline, including slow partial data.
17
+ export function browserTransportCompatibility(transport) {
18
+ transport.read = async function (timeout) {
8
19
  const deadline = Date.now() + timeout
20
+ let started = false, escaping = false
21
+ const packet = []
9
22
  for (;;) {
10
- try { return await readPacket.call(this, Math.max(1, deadline - Date.now())) }
11
- catch (error) {
12
- // Only a byte before the SLIP header is safe to discard. Preserve invalid
13
- // escapes, mid-packet errors, I/O failures and the overall timeout.
14
- if (!/^Invalid head of packet \(/.test(error.message) || Date.now() >= deadline) throw error
23
+ if (Date.now() >= deadline) throw romError('ROM_READ_TIMEOUT', 'Timed out receiving a complete ROM packet')
24
+ if (!this.buffer.length) {
25
+ if (!this.device.readable) throw romError('ROM_PORT_LOST', 'Serial device disconnected; select its current download port again')
26
+ await sleep(Math.min(2, Math.max(1, deadline - Date.now())))
27
+ continue
15
28
  }
16
- }
17
- }
18
- // runStub returns a separate loader. Its command replies use two status bytes;
19
- // the ROM loader still expects four and cannot issue reset register writes.
20
- let activeStub
21
- const hardReset = loader.hardReset
22
- loader.hardReset = function (...args) {
23
- return activeStub ? activeStub.hardReset(...args) : hardReset.apply(this, args)
24
- }
25
- const runStub = loader.runStub
26
- loader.runStub = async function (skipFlashDetection = false) {
27
- const memFinish = this.memFinish, packet = this.readPacket
28
- const nativeUsb = this.isUsbJtagOrOtg === true
29
- if (nativeUsb) {
30
- // USB can reset before ROM sends MEM_END's reply. Do not claim success:
31
- // upstream must still receive and validate OHAI from the running stub.
32
- this.memFinish = async (...args) => {
33
- try { return await memFinish.apply(this, args) }
34
- catch (error) {
35
- if (!/timed?\s*out|timeout/i.test(error.message)) throw error
36
- }
29
+ const bytes = this.buffer
30
+ this.buffer = new Uint8Array(0)
31
+ if (!started && bytes[0] !== 0xc0) this.detectPanicHandler?.(bytes)
32
+ for (let i = 0; i < bytes.length; i++) {
33
+ const byte = bytes[i]
34
+ if (!started) {
35
+ if (byte === 0xc0) started = true
36
+ } else if (escaping) {
37
+ escaping = false
38
+ if (byte !== 0xdc && byte !== 0xdd) throw romError('ROM_SLIP_ESCAPE', `Invalid SLIP escape (0xdb, 0x${byte.toString(16)})`)
39
+ packet.push(byte === 0xdc ? 0xc0 : 0xdb)
40
+ } else if (byte === 0xdb) escaping = true
41
+ else if (byte === 0xc0) {
42
+ this.buffer = this.appendArray(bytes.slice(i + 1), this.buffer)
43
+ if (packet.length) return new Uint8Array(packet)
44
+ started = true
45
+ } else packet.push(byte)
46
+ if (packet.length > 65536) throw romError('ROM_PACKET_SIZE', 'ROM packet exceeds the bounded receive size')
37
47
  }
38
- this.readPacket = timeout => packet.call(this, Math.max(timeout, 5000))
39
48
  }
40
- try {
41
- const stub = await runStub.call(this, skipFlashDetection)
42
- if (!stub.IS_STUB) throw new Error('This device has no supported browser flash stub')
43
- browserStubCompatibility(stub)
44
- activeStub = stub
45
- return stub
46
- } finally { this.memFinish = memFinish; this.readPacket = packet }
47
49
  }
48
- return loader
50
+ const write = transport.write
51
+ transport.write = async function (bytes) {
52
+ if (!this.device.writable) throw romError('ROM_PORT_LOST', 'Serial device disconnected; select its current download port again')
53
+ if (typeof this.slipWriter !== 'function') return write.call(this, bytes)
54
+ const writer = this.device.writable.getWriter()
55
+ try { await writer.write(this.slipWriter(bytes)) }
56
+ finally { writer.releaseLock() }
57
+ }
58
+ return transport
49
59
  }
50
60
 
51
- function browserStubCompatibility(stub) {
52
- // Official esp-flasher-stub erases the entire region in FLASH_BEGIN. Its erase
53
- // duration must scale with image size rather than the short command timeout.
54
- const command = stub.checkCommand
55
- stub.checkCommand = function (opcode, buffer, checksum, timeout) {
56
- if (opcode === 0x02 && buffer?.length >= 4) {
57
- const size = (buffer[0] | buffer[1] << 8 | buffer[2] << 16 | buffer[3] << 24) >>> 0
58
- timeout = Math.max(timeout ?? 0, 3000, 30000 * size / 1000000)
59
- }
60
- return command.call(this, opcode, buffer, checksum, timeout)
61
+ // #273 remains an open timing investigation. This deliberately bounded read
62
+ // uses 1 KiB blocks and one outstanding packet, then consumes AND verifies the
63
+ // protocol's final MD5 packet. Replace this shim when a pinned upstream release
64
+ // implements these semantics and passes read -> write -> full readback coverage.
65
+ export async function readBrowserFlash(loader, address, size, progress, { timeout = 5000 } = {}) {
66
+ if (!loader.IS_STUB) throw romError('ROM_STUB_REQUIRED', 'Readback requires an initialized flasher stub')
67
+ if (!Number.isSafeInteger(address) || address < 0 || !Number.isSafeInteger(size) || size <= 0 || size > 65536 || address + size > 0x100000000) throw new RangeError('ROM read requires a positive range of at most 64 KiB')
68
+ const status = await loader.checkCommand('read flash', loader.ESP_READ_FLASH, romWords(address, size, 1024, 1), 0, 0, timeout)
69
+ if (status !== 0) throw romError('ROM_READ_STATUS', `Flash read command failed: ${status}`)
70
+ const output = new Uint8Array(size)
71
+ let received = 0
72
+ const deadline = Date.now() + Math.max(timeout, Math.ceil(size * 10 * 1000 / (loader.transport.baudrate || 115200)) + timeout)
73
+ const read = () => loader.transport.read(Math.max(1, Math.min(timeout, deadline - Date.now())))
74
+ while (received < size) {
75
+ if (Date.now() >= deadline) throw romError('ROM_READ_TIMEOUT', 'Flash readback exceeded its deadline')
76
+ const packet = await read()
77
+ if (!(packet instanceof Uint8Array) || !packet.length || packet.length > 1024 || received + packet.length > size) throw romError('ROM_READ_PACKET', 'Invalid or oversized flash readback packet')
78
+ output.set(packet, received); received += packet.length
79
+ await loader.transport.write(romWords(received))
80
+ progress?.(packet, received, size)
61
81
  }
62
- // Stub commands use their own loader instance and inherit the same SLIP issue.
63
- const readPacket = stub.readPacket
64
- stub.readPacket = async function (timeout) {
65
- const deadline = Date.now() + timeout
66
- for (;;) {
67
- try { return await readPacket.call(this, Math.max(1, deadline - Date.now())) }
68
- catch (error) {
69
- if (!/^Invalid head of packet \(/.test(error.message) || Date.now() >= deadline) throw error
70
- }
82
+ const digest = await read()
83
+ if (!(digest instanceof Uint8Array) || digest.length !== 16) throw romError('ROM_READ_DIGEST', 'Missing or malformed flash readback MD5 digest')
84
+ const expected = romMd5(output)
85
+ if (digest.some((byte, index) => byte !== expected[index])) throw romError('ROM_READ_DIGEST', 'Flash readback MD5 digest mismatch')
86
+ return output
87
+ }
88
+
89
+ export function browserRomCompatibility(loader, { nativeUsb = false } = {}) {
90
+ const finish = loader.memFinish
91
+ if (nativeUsb) loader.memFinish = async function (...args) {
92
+ try { return await finish.apply(this, args) }
93
+ catch (error) {
94
+ // Some native-USB ROMs omit MEM_END acknowledgement. runStub must still
95
+ // consume and validate the exact OHAI greeting before setting IS_STUB.
96
+ if (!/timed?\s*out|timeout|serial data stream stopped|no serial data received/i.test(error.message)) throw error
71
97
  }
72
98
  }
99
+ return loader
73
100
  }
@@ -0,0 +1,130 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ const failure = (code, message) => Object.assign(new Error(message), { code })
5
+ const cancelled = signal => signal?.reason instanceof Error ? signal.reason
6
+ : Object.assign(new Error('Serial operation cancelled'), { name: 'AbortError' })
7
+
8
+ /** Passive Web Serial byte adapter. The UI supplies its already authorized port. */
9
+ export function serialTransport({ port, signals = { dataTerminalReady: true, requestToSend: false }, bufferSize = 256 * 1024 } = {}) {
10
+ if (!port || typeof port.open !== 'function' || typeof port.close !== 'function') throw new TypeError('A browser SerialPort is required')
11
+ if (!Number.isSafeInteger(bufferSize) || bufferSize <= 0 || bufferSize > 16 * 1024 * 1024) throw new RangeError('bufferSize must be between 1 byte and 16 MiB')
12
+ let listener = null, reader = null, writer = null, reading = null
13
+ let stopped = true, reopening = false, opened = false, opening = false, generation = 0, baudRate = 115200
14
+ let queue = Promise.resolve()
15
+ const ordered = operation => {
16
+ const result = queue.then(operation)
17
+ queue = result.catch(() => {})
18
+ return result
19
+ }
20
+ const emit = (event, value) => listener?.[event]?.(value)
21
+ const check = signal => {
22
+ if (signal?.aborted) throw cancelled(signal)
23
+ if (stopped) throw failure('SERIAL_CLOSED', 'Browser serial adapter is closed')
24
+ }
25
+ const disconnected = () => { if (!stopped) emit('close') }
26
+ const stopStreams = async () => {
27
+ generation += 1
28
+ try { await reader?.cancel() } catch { /* Unplugged streams may already be errored. */ }
29
+ await reading?.catch(() => {})
30
+ reading = null
31
+ reader = null
32
+ if (writer) { writer.releaseLock(); writer = null }
33
+ }
34
+ const closePort = async () => {
35
+ await stopStreams()
36
+ if (opened) {
37
+ await port.close()
38
+ opened = false
39
+ }
40
+ }
41
+ const startStreams = () => {
42
+ if (!port.readable || !port.writable) throw failure('SERIAL_CLOSED', 'Browser serial streams are unavailable')
43
+ const active = ++generation
44
+ const input = reader = port.readable.getReader()
45
+ writer = port.writable.getWriter()
46
+ reading = (async () => {
47
+ try {
48
+ while (!stopped && active === generation) {
49
+ const { value, done } = await input.read()
50
+ if (done) {
51
+ if (!stopped && !reopening && active === generation) emit('close')
52
+ break
53
+ }
54
+ if (value?.byteLength && active === generation) emit('data', value)
55
+ }
56
+ } catch {
57
+ if (!stopped && !reopening && active === generation) emit('error', failure('SERIAL_READ', 'Browser serial read failed'))
58
+ } finally {
59
+ input.releaseLock()
60
+ if (reader === input) reader = null
61
+ }
62
+ })()
63
+ }
64
+ const open = async (speed, signal) => {
65
+ check(signal)
66
+ opening = true
67
+ try {
68
+ await port.open({ baudRate: speed, bufferSize })
69
+ opened = true
70
+ check(signal)
71
+ try { await port.setSignals?.(signals) } catch { /* Native USB interfaces may not expose modem control lines. */ }
72
+ check(signal)
73
+ baudRate = speed
74
+ startStreams()
75
+ } catch (error) { await closePort().catch(() => {}); throw error }
76
+ finally { opening = false }
77
+ }
78
+ return {
79
+ subscribe(value) {
80
+ if (listener) throw failure('TRANSPORT_BUSY', 'Already subscribed')
81
+ listener = value
82
+ return () => { if (listener === value) listener = null }
83
+ },
84
+ connect({ signal } = {}) {
85
+ if (!stopped) return Promise.reject(failure('TRANSPORT_BUSY', 'Browser serial adapter is already connected'))
86
+ stopped = false
87
+ port.addEventListener?.('disconnect', disconnected)
88
+ const connecting = ordered(async () => {
89
+ try { await open(115200, signal) }
90
+ catch (error) {
91
+ stopped = true
92
+ port.removeEventListener?.('disconnect', disconnected)
93
+ throw error
94
+ }
95
+ })
96
+ if (!signal) return connecting
97
+ return new Promise((resolve, reject) => {
98
+ const abort = () => { stopped = true; port.removeEventListener?.('disconnect', disconnected); cleanup(); reject(cancelled(signal)) }
99
+ const cleanup = () => signal.removeEventListener('abort', abort)
100
+ signal.addEventListener('abort', abort, { once: true })
101
+ connecting.then(value => { cleanup(); resolve(value) }, error => { cleanup(); reject(error) })
102
+ if (signal.aborted) abort()
103
+ })
104
+ },
105
+ writeBytes(bytes) {
106
+ return ordered(async () => { check(); if (!writer) throw failure('SERIAL_CLOSED', 'Browser serial writer is unavailable'); await writer.write(bytes) })
107
+ },
108
+ setBaudRate(speed, { signal } = {}) {
109
+ if (!Number.isSafeInteger(speed) || speed <= 0) return Promise.reject(new RangeError('baudRate must be a positive integer'))
110
+ return ordered(async () => {
111
+ check(signal)
112
+ if (opened && baudRate === speed) return
113
+ // Releasing both locks is required by Web Serial before close. Keep
114
+ // subscribers attached; this internal reopen is not a device removal.
115
+ reopening = true
116
+ try { await closePort(); await open(speed, signal) }
117
+ finally { reopening = false }
118
+ })
119
+ },
120
+ disconnect() {
121
+ stopped = true
122
+ port.removeEventListener?.('disconnect', disconnected)
123
+ // Web Serial cannot cancel open(). Do not block cancellation on it; the
124
+ // late completion checks stopped and closes its newly acquired port.
125
+ if (opening && !opened) return Promise.resolve()
126
+ return ordered(closePort)
127
+ },
128
+ getDiagnostics() { return { baudRate, connected: opened, reopening } },
129
+ }
130
+ }
package/src/device.js CHANGED
@@ -135,11 +135,12 @@ class Device {
135
135
  }
136
136
 
137
137
  async connect(options = {}) {
138
- object(options, ['transport', 'role', 'expectedDevice', 'timeout', 'signal', 'probeRepl'], 'connect')
139
- const { role = 'unknown', expectedDevice = null, timeout = 30000, signal, probeRepl = true } = options
138
+ object(options, ['transport', 'role', 'expectedDevice', 'timeout', 'signal', 'probeRepl', 'autoBaud'], 'connect')
139
+ const { role = 'unknown', expectedDevice = null, timeout = 60000, signal, probeRepl = true, autoBaud = true } = options
140
140
  if (!['unknown', 'usj', 'usb', 'uart', 'tcp'].includes(role)) throw new TypeError('role must be usj, usb, uart or tcp')
141
141
  if (!Number.isFinite(timeout) || timeout <= 0) throw new RangeError('timeout must be positive')
142
142
  if (typeof probeRepl !== 'boolean') throw new TypeError('probeRepl must be a boolean')
143
+ if (typeof autoBaud !== 'boolean') throw new TypeError('autoBaud must be a boolean')
143
144
  if (signal !== undefined && (!signal || typeof signal.addEventListener !== 'function' || typeof signal.aborted !== 'boolean')) throw new TypeError('signal must be an AbortSignal')
144
145
  if (signal?.aborted) throw cancellation(signal)
145
146
  if (this.#updating || this.#connecting || this.#disconnecting || this.#session) throw failure('DEVICE_BUSY', 'Disconnect before opening another connection')
@@ -184,7 +185,7 @@ class Device {
184
185
  this.#unsubscribe = transport.subscribe({ data: bytes => { if (this.#session === session) session.feedBytes(bytes) }, error: lost, close: () => lost() })
185
186
  if (typeof this.#unsubscribe !== 'function') throw new TypeError('Transport subscribe must synchronously return an unsubscribe function')
186
187
  if (transport.connect) await abortable(transport.connect({ signal: lifecycle.signal }), lifecycle.signal)
187
- await abortable(session.negotiate({ role, expectedDevice, probeRepl, signal: lifecycle.signal }), lifecycle.signal)
188
+ await abortable(session.negotiate({ role, expectedDevice, probeRepl, autoBaud, signal: lifecycle.signal }), lifecycle.signal)
188
189
  if (lifecycle.signal.aborted || this.#session !== session) throw cancellation(lifecycle.signal)
189
190
  this.#api = {
190
191
  python: createPythonApi({ session, state: this.#state }),
@@ -210,7 +211,7 @@ class Device {
210
211
  ++this.#generation
211
212
  this.#lifecycle?.abort(reason)
212
213
  let error
213
- try { if (session?.sessionId) await session.release() } catch (caught) { error = caught }
214
+ try { if (session && (session.sessionId || session.resetPromise)) await session.release() } catch (caught) { error = caught }
214
215
  finally {
215
216
  session?.close(reason)
216
217
  this.#session = null
@@ -3,6 +3,7 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
+ import { requireObservedCameraCapture } from './device_capture.js'
6
7
 
7
8
  export const EPHEMERAL_PYTHON_PATH = '/.espvision_agent_run.py'
8
9
 
@@ -326,14 +327,14 @@ export function bindSessionDeviceActions(registry, {
326
327
  { timeout: input.timeoutMs, channel: input.channel, signal },
327
328
  ))
328
329
  registry.bind('frame.capture', async (input, { session, signal }) => {
329
- session.lastDebugImage = null
330
- await session.rpc('debug.capture_frame', {}, {
330
+ requireObservedCameraCapture(session)
331
+ const image = await session.rpc('debug.capture_frame', {}, {
331
332
  timeout: input.timeoutMs,
332
333
  channel: 'debug.rpc',
333
334
  signal,
335
+ binaryResponse: true,
334
336
  })
335
- if (!session.lastDebugImage) throw new Error('Firmware returned no JPEG frame')
336
- return session.lastDebugImage
337
+ return { base64: bytesToBase64(image.data), metadata: image.metadata }
337
338
  })
338
339
 
339
340
  return { evidence, controllerFor }
@@ -0,0 +1,11 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ export const OBSERVED_CAMERA_FEATURE = 'debug.capture_frame.observed.v1'
5
+
6
+ /** Older capture_frame implementations can overwrite an application's Image. */
7
+ export function requireObservedCameraCapture(session) {
8
+ if (!session.hasFeature?.('debug.capture_frame') || !session.hasFeature?.(OBSERVED_CAMERA_FEATURE)) {
9
+ throw Object.assign(new Error(`Safe camera observation requires firmware feature ${OBSERVED_CAMERA_FEATURE}; update ESP-VISION firmware before capturing camera frames`), { code: 'UNSUPPORTED_OPERATION' })
10
+ }
11
+ }
@@ -2,6 +2,7 @@
2
2
  * SPDX-License-Identifier: Apache-2.0
3
3
  */
4
4
  import { withDeviceOperation } from './device_python.js'
5
+ import { requireObservedCameraCapture } from './device_capture.js'
5
6
 
6
7
  function failure(code, message) { return Object.assign(new Error(message), { code }) }
7
8
  function record(value, keys, label) {
@@ -163,7 +164,8 @@ export function createDeviceOperations({ session, state = {} }) {
163
164
  if (request.format !== undefined && request.format !== 'jpeg') throw new TypeError('Only jpeg capture is supported')
164
165
  if (request.quality !== undefined && (!Number.isInteger(request.quality) || request.quality < 1 || request.quality > 100)) throw new RangeError('quality must be an integer from 1 to 100')
165
166
  const method = request.source === 'camera' ? 'debug.capture_frame' : 'debug.capture_screen'
166
- supported(session, method)
167
+ if (request.source === 'camera') requireObservedCameraCapture(session)
168
+ else supported(session, method)
167
169
  return withDeviceOperation(state, 'capture', () => session.captureImage(method, request.quality === undefined ? {} : { quality: request.quality }, opts))
168
170
  },
169
171
  }