@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.
- package/CHANGELOG.md +32 -1
- package/NOTICE +8 -0
- package/README.md +24 -7
- package/package.json +4 -3
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/release-npm.mjs +16 -0
- package/scripts/version-policy.mjs +71 -0
- package/src/browser.js +6 -2
- package/src/browser_rom.js +208 -4
- package/src/browser_rom_compat.js +86 -59
- package/src/browser_serial.js +130 -0
- package/src/device.js +5 -4
- package/src/device_bindings.js +5 -4
- package/src/device_capture.js +11 -0
- package/src/device_operations.js +3 -1
- package/src/device_session.js +263 -10
- package/src/evmux.js +43 -4
- package/src/firmware_flash.js +1 -1
- package/src/jpeg_validate.js +132 -0
- package/src/node_inventory.js +1 -1
- package/src/node_rom.js +76 -27
- package/src/node_rom.py +68 -2
- package/src/node_rom_port.js +211 -0
- package/src/node_serial.py +47 -1
- package/src/node_transport.js +76 -10
- package/src/rom_md5.js +30 -0
- package/tests/check-browser.mjs +76 -123
- package/tests/check-package.mjs +1 -1
- package/tests/device-checks.mjs +6 -2
- package/tests/fixtures/device-checks.py +11 -1
- package/tests/jpeg-fixtures.mjs +39 -0
- package/tests/test-browser-rom.mjs +204 -67
- package/tests/test-browser-serial.mjs +143 -0
- package/tests/test-device-api.mjs +40 -7
- package/tests/test-device-execution.mjs +30 -3
- package/tests/test-device-functional-checks.mjs +12 -2
- package/tests/test-device-operations-api.mjs +4 -3
- package/tests/test-device-reset.mjs +292 -0
- package/tests/test-firmware-flash.mjs +1 -1
- package/tests/test-jpeg-validation.mjs +233 -0
- package/tests/test-node-rom-port.mjs +305 -0
- package/tests/test-node-rom.mjs +160 -0
- package/tests/test-node-serial.mjs +206 -0
- package/tests/test-npm-release-policy.mjs +62 -0
- package/tests/test-observed-capture.mjs +131 -0
- package/tests/test-uart-baud.mjs +180 -0
- 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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 =
|
|
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
|
|
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
|
package/src/device_bindings.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/device_operations.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|