@esp-vision/device-core 0.5.1 → 0.6.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/NOTICE +8 -0
  3. package/README.md +25 -8
  4. package/package.json +4 -3
  5. package/schemas/device-actions-v1.json +1 -1
  6. package/scripts/generate-action-schema.mjs +2 -1
  7. package/scripts/npm-command.mjs +17 -0
  8. package/scripts/release-npm.mjs +18 -1
  9. package/scripts/version-policy.mjs +71 -0
  10. package/src/browser.js +6 -2
  11. package/src/browser_rom.js +208 -4
  12. package/src/browser_rom_compat.js +86 -59
  13. package/src/browser_serial.js +130 -0
  14. package/src/device.js +5 -4
  15. package/src/device_bindings.js +5 -4
  16. package/src/device_capture.js +11 -0
  17. package/src/device_operations.js +3 -1
  18. package/src/device_session.js +263 -10
  19. package/src/evmux.js +43 -4
  20. package/src/firmware_flash.js +1 -1
  21. package/src/jpeg_validate.js +132 -0
  22. package/src/node_inventory.js +1 -1
  23. package/src/node_rom.js +92 -29
  24. package/src/node_rom.py +134 -6
  25. package/src/node_rom_port.js +219 -0
  26. package/src/node_serial.py +122 -13
  27. package/src/node_transport.js +136 -21
  28. package/src/rom_md5.js +30 -0
  29. package/tests/check-browser.mjs +76 -123
  30. package/tests/check-package.mjs +8 -7
  31. package/tests/device-checks.mjs +6 -2
  32. package/tests/fixtures/device-checks.py +11 -1
  33. package/tests/jpeg-fixtures.mjs +39 -0
  34. package/tests/test-browser-rom.mjs +204 -67
  35. package/tests/test-browser-serial.mjs +143 -0
  36. package/tests/test-device-api.mjs +40 -7
  37. package/tests/test-device-functional-checks.mjs +12 -2
  38. package/tests/test-device-operations-api.mjs +4 -3
  39. package/tests/test-device-reset.mjs +292 -0
  40. package/tests/test-firmware-flash.mjs +1 -1
  41. package/tests/test-host-checks.mjs +47 -0
  42. package/tests/test-jpeg-validation.mjs +233 -0
  43. package/tests/test-node-rom-port.mjs +385 -0
  44. package/tests/test-node-rom.mjs +231 -0
  45. package/tests/test-node-serial.mjs +436 -1
  46. package/tests/test-npm-release-policy.mjs +56 -0
  47. package/tests/test-observed-capture.mjs +131 -0
  48. package/tests/test-uart-baud.mjs +180 -0
  49. package/tests/test-version-policy.mjs +37 -0
@@ -4,12 +4,64 @@
4
4
  import json
5
5
  import os
6
6
  import signal
7
+ import struct
7
8
  import sys
8
9
  import threading
9
10
 
10
11
  import serial
11
12
 
12
13
 
14
+ def _report_serial_error(stage, error):
15
+ """Emit bounded OS diagnostics, never exception text or transferred bytes."""
16
+ number = getattr(error, 'errno', None)
17
+ winerror = getattr(error, 'winerror', None)
18
+ cause = error.__cause__ or error.__context__
19
+ if not isinstance(number, int) and cause is not None:
20
+ number = getattr(cause, 'errno', None)
21
+ if not isinstance(winerror, int) and cause is not None:
22
+ winerror = getattr(cause, 'winerror', None)
23
+ def bounded_number(value):
24
+ if isinstance(value, int) and not isinstance(value, bool) and -0x80000000 <= value <= 0xffffffff:
25
+ return value
26
+ return None
27
+ number = bounded_number(number)
28
+ winerror = bounded_number(winerror)
29
+ # Pyserial sometimes wraps a Windows API failure without preserving errno.
30
+ # Only recognize fixed API names/messages; arbitrary exception text may
31
+ # contain application bytes and must not cross this diagnostic channel.
32
+ text = str(error)[:512]
33
+ message = 'Serial ' + stage + ' failed'
34
+ if number is not None and 0 < number < 4096:
35
+ message = os.strerror(number)
36
+ else:
37
+ for needle, safe in (
38
+ ('ClearCommError', 'ClearCommError failed'),
39
+ ('GetOverlappedResult', 'GetOverlappedResult failed'),
40
+ ('ReadFile', 'ReadFile failed'),
41
+ ('WriteFile', 'WriteFile failed'),
42
+ ('readiness to read but returned no data', 'Device reports readiness to read but returned no data'),
43
+ ('write timeout', 'Serial write timed out'),
44
+ ('Zero-byte serial write', 'Serial write returned zero bytes'),
45
+ ):
46
+ if needle.lower() in text.lower():
47
+ message = safe
48
+ break
49
+ record = {'kind': 'serial.error', 'stage': stage, 'type': type(error).__name__[:64],
50
+ 'errno': number, 'winerror': winerror, 'message': message[:160]}
51
+ # One small line keeps READY/control records intact when the reader thread
52
+ # fails concurrently with the main thread. stderr failure is terminal too.
53
+ line = (json.dumps(record, ensure_ascii=True) + '\n').encode()
54
+ try:
55
+ offset = 0
56
+ while offset < len(line):
57
+ count = os.write(2, line[offset:])
58
+ if count <= 0:
59
+ break
60
+ offset += count
61
+ except OSError:
62
+ pass
63
+
64
+
13
65
  def _configure_serial_exclusive(port, platform=os.name):
14
66
  # pyserial implements this as a non-blocking advisory lock on POSIX. The
15
67
  # Windows backend does not support this option, so leave its behavior alone.
@@ -17,6 +69,18 @@ def _configure_serial_exclusive(port, platform=os.name):
17
69
  port.exclusive = True
18
70
 
19
71
 
72
+ def _read_exact(stream, size):
73
+ data = bytearray()
74
+ while len(data) < size:
75
+ chunk = stream.read(size - len(data))
76
+ if not chunk:
77
+ if data:
78
+ raise OSError('Truncated serial bridge record')
79
+ return None
80
+ data.extend(chunk)
81
+ return bytes(data)
82
+
83
+
20
84
  def main():
21
85
  if sys.argv[1] == '--list':
22
86
  from serial.tools.list_ports import comports
@@ -37,16 +101,24 @@ def main():
37
101
  raise SystemExit(0)
38
102
 
39
103
  def receive():
40
- try:
41
- while not stopped.is_set():
104
+ while not stopped.is_set():
105
+ try:
42
106
  data = port.read(8192)
43
- if data:
107
+ except (OSError, serial.SerialException) as error:
108
+ if not stopped.is_set():
109
+ _report_serial_error('read', error)
110
+ os._exit(2)
111
+ return
112
+ if data:
113
+ try:
44
114
  offset = 0
45
115
  while offset < len(data):
46
116
  offset += os.write(1, data[offset:])
47
- except (OSError, serial.SerialException):
48
- if not stopped.is_set():
49
- os._exit(2)
117
+ except OSError as error:
118
+ if not stopped.is_set():
119
+ _report_serial_error('stdout', error)
120
+ os._exit(2)
121
+ return
50
122
 
51
123
  signal.signal(signal.SIGTERM, terminate)
52
124
  signal.signal(signal.SIGINT, terminate)
@@ -55,13 +127,50 @@ def main():
55
127
  thread = threading.Thread(target=receive, daemon=True)
56
128
  thread.start()
57
129
  print('READY', file=sys.stderr, flush=True)
58
- while data := sys.stdin.buffer.read1(8192):
59
- offset = 0
60
- while offset < len(data):
61
- written = port.write(data[offset:])
62
- if not written:
63
- raise OSError('Zero-byte serial write')
64
- offset += written
130
+ while header := _read_exact(sys.stdin.buffer, 5):
131
+ kind, size = struct.unpack('>BI', header)
132
+ if kind not in (0, 1, 2) or size > 1048576:
133
+ raise OSError('Invalid serial bridge record')
134
+ data = _read_exact(sys.stdin.buffer, size)
135
+ if data is None:
136
+ raise OSError('Truncated serial bridge record')
137
+ if kind == 1:
138
+ request = json.loads(data)
139
+ try:
140
+ baud = request['baudRate']
141
+ if not isinstance(baud, int) or isinstance(baud, bool) or baud <= 0:
142
+ raise ValueError('Invalid baudrate')
143
+ port.flush()
144
+ port.baudrate = baud
145
+ response = {'id': request['id'], 'ok': True, 'baudRate': baud}
146
+ except (KeyError, ValueError, OSError, serial.SerialException):
147
+ response = {'id': request.get('id'), 'ok': False}
148
+ print(json.dumps(response), file=sys.stderr, flush=True)
149
+ continue
150
+ if kind == 2:
151
+ request = json.loads(data)
152
+ try:
153
+ request_id = request['id']
154
+ if not isinstance(request_id, int) or isinstance(request_id, bool) or request_id <= 0:
155
+ raise ValueError('Invalid drain request ID')
156
+ # stdin records are processed in order. All preceding byte
157
+ # records have completed port.write before this drain ACK.
158
+ port.flush()
159
+ response = {'id': request_id, 'ok': True, 'drained': True}
160
+ except (KeyError, ValueError, OSError, serial.SerialException):
161
+ response = {'id': request.get('id'), 'ok': False}
162
+ print(json.dumps(response), file=sys.stderr, flush=True)
163
+ continue
164
+ try:
165
+ offset = 0
166
+ while offset < len(data):
167
+ written = port.write(data[offset:])
168
+ if not written:
169
+ raise OSError('Zero-byte serial write')
170
+ offset += written
171
+ except (OSError, serial.SerialException) as error:
172
+ _report_serial_error('write', error)
173
+ raise SystemExit(2) from None
65
174
  finally:
66
175
  stopped.set()
67
176
  port.close()
@@ -39,62 +39,177 @@ export function tcpTransport(options) {
39
39
 
40
40
  export function serialTransport(options) {
41
41
  const events = subscribers()
42
- let child
42
+ let child, connection, lastError = null, controlId = 0, baudRate = 115200
43
+ const pending = new Map()
44
+ const rejectControls = error => {
45
+ for (const request of pending.values()) request.reject(error)
46
+ pending.clear()
47
+ }
48
+ const send = (kind, bytes) => new Promise((resolveWrite, reject) => {
49
+ if (!child?.stdin.writable) return reject(fail('SERIAL_CLOSED', 'Serial adapter closed'))
50
+ const record = Buffer.allocUnsafe(5 + bytes.byteLength)
51
+ record.writeUInt8(kind, 0)
52
+ record.writeUInt32BE(bytes.byteLength, 1)
53
+ record.set(bytes, 5)
54
+ child.stdin.write(record, error => error ? reject(fail('SERIAL_WRITE', 'Serial write failed')) : resolveWrite())
55
+ })
43
56
  return {
44
57
  subscribe: events.subscribe,
45
58
  connect({ signal }) {
46
59
  return new Promise((resolveConnect, reject) => {
47
60
  const adapter = fileURLToPath(new URL('./node_serial.py', import.meta.url))
48
- child = spawn(options.python, ['-u', adapter, options.serial], { stdio: ['pipe', 'pipe', 'pipe'] })
49
- let ready = false, settled = false, runtimeErrorReported = false, stderr = ''
61
+ const bridge = child = spawn(options.python, ['-u', adapter, options.serial], { stdio: ['pipe', 'pipe', 'pipe'] })
62
+ const current = connection = { intentional: false }
63
+ lastError = null
64
+ let adapterCause = null, processCause = null
65
+ const safeCause = value => {
66
+ if (value?.kind !== 'serial.error' || !['read', 'write', 'stdout'].includes(value.stage)
67
+ || typeof value.type !== 'string' || typeof value.message !== 'string') return null
68
+ const integer = n => Number.isSafeInteger(n) && n >= -0x80000000 && n <= 0xffffffff ? n : null
69
+ return { stage: value.stage, type: value.type.slice(0, 64), errno: integer(value.errno),
70
+ winerror: integer(value.winerror), message: value.message.replace(/[\r\n\x00-\x1f\x7f]/g, ' ').slice(0, 160) }
71
+ }
72
+ const errorDetails = (exitCode = null, signal = null) => ({
73
+ ...(adapterCause ?? processCause ?? { stage: 'process', type: 'ProcessExit', errno: null, winerror: null,
74
+ message: 'Serial adapter exited unexpectedly' }),
75
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
76
+ signal: typeof signal === 'string' ? signal.slice(0, 32) : null,
77
+ })
78
+ let ready = false, settled = false, runtimeErrorReported = false, stderr = '', lines = ''
50
79
  const abort = () => {
51
- child.kill('SIGTERM')
80
+ current.intentional = true
81
+ bridge.kill('SIGTERM')
52
82
  if (!settled) {
53
83
  settled = true
54
84
  reject(fail('CANCELLED', 'Serial connection cancelled'))
55
85
  }
56
86
  }
57
87
  const cleanup = () => signal.removeEventListener('abort', abort)
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')
88
+ const serialError = details => {
89
+ const code = ready ? 'SERIAL_ERROR' : ['SERIAL_ACCESS_DENIED', 'SERIAL_BUSY'].find(code => stderr.includes(code)) ?? 'SERIAL_ERROR'
90
+ const message = ready && details?.stage !== 'process'
91
+ ? `Serial adapter ${details.stage} failed: ${details.message}`
92
+ : 'Serial adapter failed; ensure Python, pyserial and port access are available'
93
+ const error = fail(code, message)
94
+ error.details = details ?? errorDetails()
95
+ error.cause = Object.assign(new Error(error.details.message), { ...error.details, name: error.details.type })
96
+ return error
61
97
  }
62
- const failBeforeReady = () => {
98
+ const failBeforeReady = details => {
63
99
  if (settled) return
64
100
  settled = true
65
101
  cleanup()
66
- const error = serialError()
102
+ const error = serialError(details)
67
103
  reject(error)
68
104
  events.emit('error', error)
69
105
  }
70
- const reportRuntimeError = () => {
106
+ const reportRuntimeError = details => {
71
107
  if (runtimeErrorReported) return
72
108
  runtimeErrorReported = true
73
- events.emit('error', serialError())
109
+ const error = serialError(details)
110
+ lastError = { ...error.details }
111
+ events.emit('error', error)
74
112
  }
75
- child.on('error', () => { if (ready) reportRuntimeError() })
76
- child.stdin.on('error', () => { if (ready) reportRuntimeError() })
113
+ const recordProcessError = (stage, error) => {
114
+ processCause = { stage, type: 'ProcessError', errno: null, winerror: null,
115
+ message: typeof error?.code === 'string' && /^[A-Z0-9_]{1,32}$/.test(error.code)
116
+ ? `Serial adapter ${stage} failed (${error.code})` : `Serial adapter ${stage} failed` }
117
+ }
118
+ child.on('error', error => recordProcessError('process', error))
119
+ child.stdin.on('error', error => recordProcessError('stdin', error))
77
120
  child.stdout.on('data', bytes => events.emit('data', bytes))
78
121
  child.stderr.on('data', bytes => {
79
- if (ready) return
80
122
  // Do not print Python errors: they can contain host paths or device data.
81
123
  stderr = (stderr + bytes.toString()).slice(-128)
82
- if (stderr.split(/\r?\n/).includes('READY')) {
83
- ready = true
84
- settled = true
85
- cleanup()
86
- resolveConnect()
124
+ lines = (lines + bytes.toString()).slice(-8192)
125
+ for (let newline; (newline = lines.indexOf('\n')) !== -1;) {
126
+ const line = lines.slice(0, newline).trim()
127
+ lines = lines.slice(newline + 1)
128
+ if (line === 'READY' && !ready) {
129
+ ready = true
130
+ settled = true
131
+ baudRate = 115200
132
+ cleanup()
133
+ resolveConnect()
134
+ continue
135
+ }
136
+ let response
137
+ try { response = JSON.parse(line) } catch { continue }
138
+ const diagnostic = safeCause(response)
139
+ if (diagnostic) { adapterCause = diagnostic; continue }
140
+ const request = pending.get(response.id)
141
+ if (!request) continue
142
+ pending.delete(response.id)
143
+ if (request.kind === 'drain') {
144
+ if (response.ok === true && response.drained === true) request.resolve()
145
+ else request.reject(fail('SERIAL_DRAIN_FAILED', 'Serial adapter could not drain pending output'))
146
+ } else if (response.ok && response.baudRate === request.baudRate) {
147
+ baudRate = response.baudRate
148
+ request.resolve()
149
+ } else request.reject(fail('SERIAL_BAUD_UNSUPPORTED', 'Serial adapter rejected the requested baudrate'))
87
150
  }
88
151
  })
89
152
  // Wait for close rather than exit: stdout/stderr may still have buffered
90
153
  // data after exit, including the adapter's stable SERIAL_BUSY marker.
91
- child.on('close', () => { cleanup(); if (!ready) failBeforeReady(); events.emit('close') })
154
+ child.on('close', (code, signal) => {
155
+ cleanup()
156
+ if (connection !== current) return
157
+ const details = errorDetails(code, signal)
158
+ if (current.intentional) {
159
+ const error = fail('SERIAL_CLOSED', 'Serial adapter closed')
160
+ rejectControls(error)
161
+ if (!settled) { settled = true; reject(error) }
162
+ } else {
163
+ lastError = { ...details }
164
+ const error = serialError(details)
165
+ rejectControls(error)
166
+ if (!ready) failBeforeReady(details)
167
+ else reportRuntimeError(details)
168
+ }
169
+ events.emit('close')
170
+ })
92
171
  signal.addEventListener('abort', abort, { once: true })
93
172
  if (signal.aborted) abort()
94
173
  })
95
174
  },
96
- writeBytes(bytes) { return new Promise((resolveWrite, reject) => child?.stdin.writable ? child.stdin.write(bytes, error => error ? reject(fail('SERIAL_WRITE', 'Serial write failed')) : resolveWrite()) : reject(fail('SERIAL_CLOSED', 'Serial adapter closed'))) },
175
+ writeBytes(bytes) { return send(0, bytes) },
176
+ drain({ signal } = {}) {
177
+ if (signal?.aborted) return Promise.reject(signal.reason ?? fail('CANCELLED', 'Serial drain cancelled'))
178
+ const id = ++controlId
179
+ return new Promise((resolve, reject) => {
180
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); pending.delete(id) }
181
+ const abort = () => { cleanup(); reject(signal.reason ?? fail('CANCELLED', 'Serial drain cancelled')) }
182
+ const timer = setTimeout(() => { cleanup(); reject(fail('SERIAL_DRAIN_TIMEOUT', 'Serial drain timed out')) }, 2000)
183
+ pending.set(id, {
184
+ kind: 'drain',
185
+ resolve() { cleanup(); resolve() },
186
+ reject(error) { cleanup(); reject(error) },
187
+ })
188
+ signal?.addEventListener('abort', abort, { once: true })
189
+ send(2, Buffer.from(JSON.stringify({ id }))).catch(error => pending.get(id)?.reject(error))
190
+ })
191
+ },
192
+ setBaudRate(speed, { signal } = {}) {
193
+ if (!Number.isSafeInteger(speed) || speed <= 0) return Promise.reject(new RangeError('baudRate must be a positive integer'))
194
+ if (signal?.aborted) return Promise.reject(signal.reason ?? fail('CANCELLED', 'Serial speed change cancelled'))
195
+ const id = ++controlId
196
+ return new Promise((resolve, reject) => {
197
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); pending.delete(id) }
198
+ const abort = () => { cleanup(); reject(signal.reason ?? fail('CANCELLED', 'Serial speed change cancelled')) }
199
+ const timer = setTimeout(() => { cleanup(); reject(fail('SERIAL_BAUD_TIMEOUT', 'Serial speed change timed out')) }, 2000)
200
+ pending.set(id, {
201
+ baudRate: speed,
202
+ resolve() { cleanup(); resolve() },
203
+ reject(error) { cleanup(); reject(error) },
204
+ })
205
+ signal?.addEventListener('abort', abort, { once: true })
206
+ send(1, Buffer.from(JSON.stringify({ id, baudRate: speed }))).catch(error => pending.get(id)?.reject(error))
207
+ })
208
+ },
209
+ getDiagnostics() { return { baudRate, ...(lastError ? { lastError: { ...lastError } } : {}) } },
97
210
  async disconnect() {
211
+ if (connection) connection.intentional = true
212
+ rejectControls(fail('SERIAL_CLOSED', 'Serial adapter closed'))
98
213
  if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return
99
214
  await new Promise(resolveExit => {
100
215
  const timer = setTimeout(() => { child.kill('SIGKILL') }, 2000)
package/src/rom_md5.js ADDED
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // MD5 verifies the flasher stub's transport digest; approved firmware still uses
4
+ // full SHA-256. Web Crypto intentionally does not offer the legacy MD5 algorithm.
5
+ export function romMd5(bytes) {
6
+ const input = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64)
7
+ input.set(bytes); input[bytes.length] = 0x80
8
+ const view = new DataView(input.buffer)
9
+ view.setUint32(input.length - 8, (bytes.length * 8) >>> 0, true)
10
+ view.setUint32(input.length - 4, Math.floor(bytes.length / 0x20000000), true)
11
+ const rotations = [[7, 12, 17, 22], [5, 9, 14, 20], [4, 11, 16, 23], [6, 10, 15, 21]]
12
+ const state = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]
13
+ for (let offset = 0; offset < input.length; offset += 64) {
14
+ let [a, b, c, d] = state
15
+ for (let i = 0; i < 64; i++) {
16
+ const round = i >>> 4
17
+ const f = round === 0 ? (b & c) | (~b & d) : round === 1 ? (d & b) | (~d & c) : round === 2 ? b ^ c ^ d : c ^ (b | ~d)
18
+ const index = round === 0 ? i : round === 1 ? (5 * i + 1) % 16 : round === 2 ? (3 * i + 5) % 16 : (7 * i) % 16
19
+ const value = (a + f + Math.floor(Math.abs(Math.sin(i + 1)) * 0x100000000) + view.getUint32(offset + index * 4, true)) | 0
20
+ const shift = rotations[round][i % 4]
21
+ const next = (b + ((value << shift) | (value >>> (32 - shift)))) | 0
22
+ a = d; d = c; c = b; b = next
23
+ }
24
+ state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0
25
+ state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0
26
+ }
27
+ const output = new Uint8Array(16), result = new DataView(output.buffer)
28
+ state.forEach((value, index) => result.setUint32(index * 4, value, true))
29
+ return output
30
+ }