@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
@@ -0,0 +1,180 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import test from 'node:test'
5
+ import { createDevice } from '../src/index.js'
6
+ import { DeviceSession, UART_BAUD_CANDIDATES, EV_MUX_DEFAULT_LIMITS, EV_MUX_REQUIRED_CHANNELS, EV_MUX_REQUIRED_FEATURES } from '../src/device_session.js'
7
+ import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
8
+ import { EvRepl } from '../src/evrepl.js'
9
+
10
+ function peer({ owner = 'uart', support = true, rejectSpeeds = [], corruptSpeeds = [], corruptFrames = [], silentSpeeds = [], dropCommit = false, lostPrepare = false, lostCommitAndRelease = false, resetOnChange = false } = {}) {
11
+ let listener, session, hostBaud = 115200, deviceBaud = 115200, active = false, token, commitDrops = 0
12
+ const id = 'a'.repeat(32), requests = [], changed = [], diagnostics = [], sleeps = []
13
+ const caps = { ok: true, protocol: { evMuxVersion: 4 }, features: EV_MUX_REQUIRED_FEATURES,
14
+ channels: EV_MUX_REQUIRED_CHANNELS, limits: { ...EV_MUX_DEFAULT_LIMITS, txLargePayloadMax: 262144 },
15
+ session: { policy: 'firstCome', leaseMs: 15000, keepaliveMs: 5000 },
16
+ ...(support ? { uartBaud: { baseline: 115200, current: 115200, candidates: UART_BAUD_CANDIDATES, probeBytes: 512, timeoutMs: 5000, switchDelayMs: 100 } } : {}),
17
+ }
18
+ const reset = () => { active = false; deviceBaud = 115200; token = null }
19
+ const reader = new EvMuxParser({ filterFrame(metadata, bytes) {
20
+ if (hostBaud !== deviceBaud) return false
21
+ const payload = JSON.parse(new TextDecoder().decode(bytes))
22
+ requests.push({ method: metadata.method, payload, baudRate: hostBaud, state: session?.state })
23
+ let result = { ok: true }, acquired = false
24
+ if (metadata.method === 'hello') {
25
+ if (payload.acquire) { active = true; acquired = true }
26
+ result = { ok: true, evMuxVersion: 4, deviceId: 'test', hostname: 'test', vmReady: false,
27
+ session: active ? { id, owner, vmEpoch: 1, leaseMs: 15000, keepaliveMs: 5000 } : null }
28
+ } else if (metadata.method === 'capabilities') result = caps
29
+ else if (metadata.method === 'transport.uart.prepare') {
30
+ token = payload.token
31
+ if (lostPrepare && payload.baudRate === 2000000) { deviceBaud = payload.baudRate; return false }
32
+ result = { ok: true, ...payload, timeoutMs: 5000, switchDelayMs: 100 }
33
+ } else if (metadata.method === 'transport.uart.probe') {
34
+ if (silentSpeeds.includes(hostBaud)) return false
35
+ result = { ok: true, token, data: corruptSpeeds.includes(hostBaud) ? payload.data + 'x' : payload.data }
36
+ } else if (metadata.method === 'transport.uart.commit') {
37
+ if (lostCommitAndRelease && hostBaud === 2000000) return false
38
+ if (dropCommit && commitDrops++ === 0) return false
39
+ result = { ok: true, token, baudRate: deviceBaud }
40
+ }
41
+ if (metadata.method === 'session.release' && lostCommitAndRelease && hostBaud === 2000000) return false
42
+ const response = makeEvMuxFrame({ channel: metadata.channel, type: 'rsp', method: metadata.method, seq: metadata.seq,
43
+ ...((acquired || !['hello', 'capabilities'].includes(metadata.method)) ? { sessionId: id, vmEpoch: 1 } : {}), payload: JSON.stringify(result) })
44
+ if (metadata.method === 'transport.uart.probe' && corruptFrames.includes(hostBaud)) response[response.length - 2] ^= 1
45
+ listener.data(response)
46
+ if (metadata.method === 'transport.uart.prepare') deviceBaud = payload.baudRate
47
+ if (metadata.method === 'session.release') reset()
48
+ return false
49
+ } })
50
+ const transport = {
51
+ subscribe(value) { listener = value; return () => { listener = null } },
52
+ async connect() { hostBaud = 115200 },
53
+ async disconnect() {},
54
+ async writeBytes(bytes) { reader.feedBytes(bytes) },
55
+ async setBaudRate(speed) {
56
+ changed.push(speed)
57
+ if (rejectSpeeds.includes(speed)) throw Object.assign(new Error('unsupported speed'), { code: 'SERIAL_BAUD_UNSUPPORTED' })
58
+ hostBaud = speed
59
+ if (resetOnChange && speed !== 115200) reset()
60
+ },
61
+ }
62
+ const sleep = async ms => { sleeps.push(ms); if (ms >= 1000) reset() }
63
+ session = new DeviceSession({ transport, EvMuxParser, makeEvMuxFrame, EvRepl, sleep, handlers: { diagnostic: event => diagnostics.push(event) } })
64
+ listener = { data: bytes => session.feedBytes(bytes) }
65
+ return { transport, session, requests, changed, diagnostics, sleeps }
66
+ }
67
+
68
+ test('runtime tests 2 Mbps first with varied bidirectional probes and stops after success', async () => {
69
+ const p = peer()
70
+ try {
71
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
72
+ assert.deepEqual(p.changed, [2000000])
73
+ assert.deepEqual(p.requests.filter(x => x.method === 'transport.uart.probe').map(x => x.payload.data.length), [32, 32, 128, 128, 512, 512])
74
+ assert.equal(p.requests.filter(x => x.method === 'transport.uart.commit').length, 1)
75
+ assert.equal(p.session.capabilities.uartBaud.current, 2000000)
76
+ assert.ok(p.requests.every(x => x.state !== 'ready'), 'no request or event can observe ready before verification')
77
+ assert.equal(p.session.state, 'ready')
78
+ } finally { p.session.close() }
79
+ })
80
+
81
+ test('corrupt echoes and adapter rejection recover at baseline and select first passing lower speed', async () => {
82
+ const p = peer({ corruptSpeeds: [2000000], rejectSpeeds: [1500000] })
83
+ try {
84
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
85
+ assert.deepEqual(p.changed, [2000000, 115200, 1500000, 115200, 921600])
86
+ assert.deepEqual(p.requests.filter(x => x.method === 'transport.uart.prepare').map(x => x.payload.baudRate), [2000000, 1500000, 921600])
87
+ assert.equal(p.requests.filter(x => x.method === 'hello' && x.payload.acquire).length, 3)
88
+ assert.equal(p.session.capabilities.uartBaud.current, 921600)
89
+ } finally { p.session.close() }
90
+ })
91
+
92
+ test('all high speeds can fail while retaining a fully verified 115200 connection', async () => {
93
+ const p = peer({ corruptSpeeds: UART_BAUD_CANDIDATES.slice(0, -1) })
94
+ try {
95
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
96
+ assert.deepEqual(p.requests.filter(x => x.method === 'transport.uart.prepare').map(x => x.payload.baudRate), UART_BAUD_CANDIDATES)
97
+ assert.equal(p.session.capabilities.uartBaud.current, 115200)
98
+ assert.equal(p.session.state, 'ready')
99
+ } finally { p.session.close() }
100
+ })
101
+
102
+ test('a timed out new-rate probe and a reopen-triggered reset both recover before the next candidate', async () => {
103
+ for (const options of [{ silentSpeeds: [2000000] }, { corruptFrames: [2000000] }, { resetOnChange: true }]) {
104
+ const p = peer(options)
105
+ try {
106
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
107
+ assert.equal(p.session.capabilities.uartBaud.current, options.resetOnChange ? 115200 : 1500000)
108
+ assert.ok(p.sleeps.some(ms => ms >= (options.resetOnChange ? 1000 : 150)), 'wait for release completion or independent rollback before reacquiring')
109
+ } finally { p.session.close() }
110
+ }
111
+ })
112
+
113
+ test('a lost commit acknowledgement retries the idempotent token without downgrading', async () => {
114
+ const p = peer({ dropCommit: true })
115
+ try {
116
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
117
+ assert.deepEqual(p.changed, [2000000])
118
+ const commits = p.requests.filter(x => x.method === 'transport.uart.commit')
119
+ assert.equal(commits.length, 2)
120
+ assert.equal(commits[0].payload.token, commits[1].payload.token)
121
+ } finally { p.session.close() }
122
+ })
123
+
124
+ test('lost prepare and committed-session ACKs wait full rollback bounds after the last failed release', async () => {
125
+ for (const options of [{ lostPrepare: true }, { lostCommitAndRelease: true }]) {
126
+ const p = peer(options)
127
+ try {
128
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
129
+ assert.ok(p.sleeps.includes(options.lostPrepare ? 5250 : 15250), 'prepare delay and lease renewal must not shorten the recovery window')
130
+ assert.equal(p.session.capabilities.uartBaud.current, 1500000)
131
+ } finally { p.session.close() }
132
+ }
133
+ })
134
+
135
+ test('a timed out adapter retune receives cancellation before baseline recovery', async () => {
136
+ const p = peer(), original = p.transport.setBaudRate
137
+ let aborted = false
138
+ p.transport.setBaudRate = (speed, options) => speed !== 2000000 ? original(speed, options) : new Promise((_, reject) => {
139
+ options.signal.addEventListener('abort', () => { aborted = true; reject(options.signal.reason) }, { once: true })
140
+ })
141
+ try {
142
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
143
+ assert.equal(aborted, true)
144
+ assert.equal(p.session.capabilities.uartBaud.current, 1500000)
145
+ } finally { p.session.close() }
146
+ })
147
+
148
+ test('USB owners, old firmware and transports lacking a retune method keep baseline', async () => {
149
+ for (const options of [{ owner: 'usb' }, { owner: 'usj' }, { owner: 'tcp' }, { support: false }, { noAdapter: true }]) {
150
+ const p = peer(options)
151
+ if (options.noAdapter) delete p.transport.setBaudRate
152
+ try {
153
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
154
+ assert.deepEqual(p.changed, [])
155
+ assert.ok(!p.requests.some(x => x.method.startsWith('transport.uart.')))
156
+ } finally { p.session.close() }
157
+ }
158
+ })
159
+
160
+ test('public connect publishes ready only after runtime speed selection', async () => {
161
+ const p = peer(), events = []
162
+ const device = createDevice({ transport: p.transport })
163
+ device.on('state', event => events.push(event.state))
164
+ device.on('diagnostic', event => { if (event.event === 'uart.baud.selected') events.push('selected') })
165
+ try {
166
+ await device.connect({ probeRepl: false })
167
+ assert.ok(events.indexOf('selected') >= 0)
168
+ assert.ok(events.indexOf('ready') > events.indexOf('selected'))
169
+ } finally { await device.disconnect() }
170
+ })
171
+
172
+ test('public passive identity probes can explicitly disable retuning', async () => {
173
+ const p = peer(), device = createDevice({ transport: p.transport })
174
+ for (const autoBaud of [null, 'false', 0]) await assert.rejects(device.connect({ autoBaud }), { name: 'TypeError' })
175
+ try {
176
+ await device.connect({ probeRepl: false, autoBaud: false })
177
+ assert.deepEqual(p.changed, [])
178
+ assert.ok(!p.requests.some(x => x.method.startsWith('transport.uart.')))
179
+ } finally { await device.disconnect() }
180
+ })
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import { test } from 'node:test'
5
+ import { cliBundlesCore, declaredCoreSupports, highestStableVersion, inMinorRange, minorRange, validateVersionStep } from '../scripts/version-policy.mjs'
6
+
7
+ test('minor dependency ranges accept patch updates and reject adjacent minor or major versions', () => {
8
+ for (const version of ['0.5.0', '0.5.1', '0.5.99']) assert.equal(inMinorRange('~0.5.0', version), true)
9
+ for (const version of ['0.4.9', '0.6.0', '1.5.0']) assert.equal(inMinorRange('~0.5.0', version), false)
10
+ for (const value of ['^0.5.0', '0.5.1', '~0.5.1', '*', '~0.05.0']) assert.throws(() => minorRange(value))
11
+ assert.equal(declaredCoreSupports('0.5.1', '0.5.1'), true)
12
+ assert.equal(declaredCoreSupports('0.5.1', '0.5.2'), false)
13
+ assert.equal(declaredCoreSupports('~0.5.0', '0.5.2'), true)
14
+ })
15
+
16
+ test('only the next patch, minor or major version matches its declared release type', () => {
17
+ for (const [kind, next] of [['patch', '0.5.2'], ['minor', '0.6.0'], ['major', '1.0.0']]) {
18
+ assert.doesNotThrow(() => validateVersionStep('0.5.1', next, kind))
19
+ }
20
+ for (const [kind, next] of [['patch', '0.6.0'], ['patch', '0.5.3'], ['minor', '0.5.2'], ['minor', '0.7.0'], ['major', '0.6.0'], ['major', '1.1.0'], ['feature', '0.6.0']]) {
21
+ assert.throws(() => validateVersionStep('0.5.1', next, kind))
22
+ }
23
+ assert.throws(() => validateVersionStep('0.5.1', '0.5.2-rc.1', 'patch'))
24
+ })
25
+
26
+ test('registry baseline is the numerically highest stable published version', () => {
27
+ assert.equal(highestStableVersion(['0.9.9', '0.10.1', '0.10.0', '0.11.0-beta.1', '0.0.0-stage', '1.0.0']), '1.0.0')
28
+ assert.equal(highestStableVersion(['0.0.0-stage']), undefined)
29
+ })
30
+
31
+ test('a bundled CLI identifies the actual Core patch even when its range allows others', () => {
32
+ const cli = { devDependencies: { '@esp-vision/device-core': '~0.5.0' }, espVision: { resolved: { deviceCore: '0.5.1' } } }
33
+ assert.equal(cliBundlesCore(cli, '0.5.1'), true)
34
+ assert.equal(cliBundlesCore(cli, '0.5.2'), false)
35
+ assert.equal(cliBundlesCore({ devDependencies: { '@esp-vision/device-core': '0.5.1' } }, '0.5.1'), true)
36
+ assert.equal(cliBundlesCore({ devDependencies: { '@esp-vision/device-core': '~0.5.0' } }, '0.5.1'), false)
37
+ })