@esp-vision/device-core 0.5.1 → 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 (46) hide show
  1. package/CHANGELOG.md +25 -0
  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-functional-checks.mjs +12 -2
  36. package/tests/test-device-operations-api.mjs +4 -3
  37. package/tests/test-device-reset.mjs +292 -0
  38. package/tests/test-firmware-flash.mjs +1 -1
  39. package/tests/test-jpeg-validation.mjs +233 -0
  40. package/tests/test-node-rom-port.mjs +305 -0
  41. package/tests/test-node-rom.mjs +160 -0
  42. package/tests/test-node-serial.mjs +206 -0
  43. package/tests/test-npm-release-policy.mjs +62 -0
  44. package/tests/test-observed-capture.mjs +131 -0
  45. package/tests/test-uart-baud.mjs +180 -0
  46. package/tests/test-version-policy.mjs +37 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Added
6
+
7
+ - Negotiate runtime UART speed through the shared session, trying 2,000,000 → 1,500,000 → 921,600 → 460,800 → 115,200 bps until the first verified result; preserve legacy firmware and native USB behavior.
8
+ - Expose a shared browser serial transport with safe internal reopen and preserve subscriptions; support live Node serial retuning through a binary-safe adapter control channel.
9
+ - Require the firmware's versioned `debug.capture_frame.observed.v1` capability for camera observation. Camera capture observes the last successfully published independent camera frame; older unsafe implementations reject locally with `UNSUPPORTED_OPERATION` and a firmware-upgrade message, without issuing a capture RPC. Screen capture remains independent.
10
+
11
+ ### Changed
12
+
13
+ - Declare this release as a minor version after 0.5.1. Device Core, CLI and Plugin retain independent publication lifecycles; a new Core release does not update existing CLI/Plugin bundles or authorize changing their registry locks.
14
+
15
+ ### Fixed
16
+
17
+ - Rebind an explicitly selected native USB runtime to its matching ROM only after EV-MUX MAC/interface verification and one bootloader request. Wait for path readiness, preserve uncertain control/cleanup outcomes, and verify the actual ROM MAC before accepting writes; never replay initialization on the same loader.
18
+ - Serialize bootloader handoff with session cleanup, drain Node serial output before closing its bridge, and discard obsolete leases without claiming an unobserved ROM acknowledgement. Preserve bounded ROM method/error details in CLI JSON diagnostics.
19
+ - Identify existing ROM/stub connections without reset before a bounded reset fallback on descriptor-qualified UART bridges or USB Serial/JTAG; suppress reset fallback on native/unknown USB and close failed handles.
20
+
21
+ - Validate binary capture and preview JPEG structure, table references, dimensions, scans and final EOI before delivery, after existing CRC and response correlation checks. Reject malformed or JPEG-less capture success with `INVALID_IMAGE`; drop invalid previews with diagnostics and counters, while preserving unrelated requests and recovery on the next good frame. This is bounded structural validation, without entropy decoding.
22
+ - Return each `frame.capture` action's correlated image so concurrent, out-of-order responses cannot exchange images through shared state. Use self-created, independently decoded JPEG fixtures in source and installed-package regressions.
23
+ - Replace the browser flashing backend with pinned official esptool-js, compressed writes, qualified pre-write speed fallback, direct USB-OTG ROM synchronization and bounded readback with validated trailing MD5 plus full SHA-256 verification.
24
+ - Normalize signed SPI register responses to 24-bit JEDEC flash IDs and preserve chip/security, installation bounds, partition-layout and reset checks.
25
+
26
+ - Invalidate the old lease after acknowledged or uncertain hard reset, block concurrent renegotiation, and serialize cleanup with reset so stale release failures cannot mask its result. Retain live leases after confirmed reset denials and discard explicit ownership rejections.
27
+
3
28
  ## 0.5.1
4
29
 
5
30
  ### Fixed
package/NOTICE CHANGED
@@ -30,3 +30,11 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30
30
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31
31
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
32
32
  SOFTWARE.
33
+
34
+ Browser ROM flashing uses the separately installed esptool-js 0.7.1 package,
35
+ Copyright (c) 2023 Espressif Systems (Shanghai) Co. Ltd., Apache License 2.0.
36
+ The package's esp-flasher-stub v1.2.2 set-2 binaries retain their upstream
37
+ dual Apache-2.0/MIT terms. Its pako (MIT/Zlib) and atob-lite (MIT) dependencies
38
+ retain their own notices. No upstream source or stub binary is vendored here.
39
+ The S31 native-USB watchdog reset adapter uses the register sequence also
40
+ implemented by Apache-2.0 esptool-js P4 watchdog support.
package/README.md CHANGED
@@ -19,12 +19,15 @@
19
19
  | Visibility | Public; installation needs no npm login |
20
20
  | Source location | `host/device-core` in the ESP-VISION firmware repository |
21
21
 
22
- The **0.5.x** package line is SDK-only: the management CLI lives in `@esp-vision/device-cli`, and Python execution/file reads have no aggregate deadline unless configured. Pin the exact package version and verify it exists in the public registry before installing. For unpublished source changes, use a tarball packed from an exact reviewed source commit. CI authenticates with the protected `NPM_PUBLISH_TOKEN` and verifies that `npm whoami` returns `esp-vision` before publishing. Tokens are not part of this package or its documentation.
22
+ This source targets **0.6.0**, a minor release after 0.5.1. The **0.6.x** package line is SDK-only: the management CLI lives in `@esp-vision/device-cli`, and Python execution/file reads have no aggregate deadline unless configured. Declare a minor-locked dependency and use a lockfile to record the resolved patch and integrity; verify the chosen version exists in the public registry before installing. For unpublished source changes, use a tarball packed from an exact reviewed source commit. CI authenticates with the protected `NPM_PUBLISH_TOKEN` and verifies that `npm whoami` returns `esp-vision` before publishing. Tokens are not part of this package or its documentation.
23
23
 
24
24
  ```sh
25
- npm install --save-exact @esp-vision/device-core@0.5.1 --registry=https://registry.npmjs.org/
25
+ npm view @esp-vision/device-core@0.6.0 version dist.integrity
26
+ npm install '@esp-vision/device-core@~0.6.0' --registry=https://registry.npmjs.org/
26
27
  ```
27
28
 
29
+ A source version, dry run or open merge request does not publish a package. CLI and Plugin bundles keep their own versions and embedded Core bytes; publishing Core alone does not update those installed tools.
30
+
28
31
  EV-MUX v4 firmware is required. A v3 hello is rejected with `VERSION_MISMATCH`; applications should ask the user to update firmware. Core has no runtime dependency on the IDE, Agent, DOM or Node serial libraries. The host supplies the physical byte transport and discovery adapter; Core owns protocol parsing, session negotiation, REPL coordination and device operations.
29
32
 
30
33
  ## Documentation
@@ -112,6 +115,10 @@ try {
112
115
 
113
116
  `connect()` sends Ctrl-C and a short REPL liveness probe by default for compatibility. Pass `probeRepl: false` when a detached application may be running: Core still acquires the EV-MUX session and reads identity/capabilities, but leaves the REPL untouched. In this mode a reported `vmReady: false` stays false; Python operations that require a ready VM fail with a readiness error.
114
117
 
118
+ UART connections start at 115200 bps. With compatible firmware and a transport exposing `setBaudRate(baudRate, { signal })`, Core tests 2000000, 1500000, 921600, 460800 and 115200 bps in order and stops at the first verified rate. `connect({ autoBaud: false })` skips this optional negotiation for bounded identity-only probes; `autoBaud` defaults to `true`. Native USB and legacy firmware skip UART negotiation. The connection deadline defaults to 60000 ms to allow bounded fallback. Runtime speeds and ROM/stub flashing speeds are negotiated independently.
119
+
120
+ The browser subpath exports a passive `serialTransport({ port, signals?, bufferSize? })` adapter. Request and authorize the selected `SerialPort` in the UI click handler, then pass it to this adapter. Core manages opening, internal retuning and closing; internal reopen preserves subscriptions and permission. The default stream buffer is 256 KiB. An unexpected board reset still requires protocol recovery; reopening a port alone does not establish a session.
121
+
115
122
  ### Immutable release snapshots and a single firmware BIN
116
123
 
117
124
  The `/firmware` entry exports `loadOfficialFirmware({ board?, version?, fetch?, signal? })` and `loadReleaseManifest({ url, sha256, version?, sourceCommit?, boards?, fetch?, signal? })`. The official reader prioritizes `[publication]` in `config.toml`, selects exactly one version reference and verifies its raw manifest SHA-256 before returning `{ schemaVersion: 1, version, sourceCommit, pipelineId, layout, releases, website, mcp?, publication }`. History references include exact board sets; unsupported board/version combinations and malformed explicit publication data reject without falling back to guessed URLs or old metadata. Configs without `[publication]` keep the legacy feed; an explicit version limits old metadata requests too.
@@ -134,7 +141,13 @@ To validate before npm publication, check out an exact reviewed commit, run `npm
134
141
 
135
142
  ### Browser ROM flashing
136
143
 
137
- `@esp-vision/device-core/browser` exports `openBrowserRom({ port, logger?, signal? })`. The constructor is passive, and `initialize()` lazily loads the pinned `tasmota-webserial-esptool` browser backend. Use a browser bundler such as webpack, Vite or esbuild; direct native ESM loading of the upstream backend is not supported. No Python installation or consumer-side tasmota patch is needed. The root and `/node` entry points do not load browser modules.
144
+ `@esp-vision/device-core/browser` exports `openBrowserRom({ port, logger?, signal?, interface?, baudRate?, onStatus? })`. Construction is passive; `initialize()` lazily loads the official `esptool-js@0.7.1` backend and synchronizes at 115200. Use a browser bundler such as webpack, Vite or esbuild to include the chip stubs. The root and `/node` entry points do not load browser modules. No Python installation or consumer-side dependency patch is needed.
145
+
146
+ `interface` accepts `auto` (default), `uart`, `usb-otg` and `usb-jtag`. Auto detection distinguishes known USB-UART bridge descriptors from Espressif OTG and Serial/JTAG; ambiguous custom descriptors stay conservative and can be identified explicitly. A ready OTG ROM is synchronized without toggling DTR/RTS. A detached or re-enumerated device requires selecting its current download port again; the SDK never treats a stale port object as a recovered session.
147
+
148
+ For UART, `baudRate: 'auto'` (default) tests **2000000 → 1500000 → 921600 → 460800 → 115200** after validating ROM security and starting the stub, then stops at the first speed that passes register and digest-checked read-only traffic. A numeric value from this list tests only that speed. A failed prewrite candidate resets, synchronizes at 115200, rechecks chip identity/security and reloads the stub before the next candidate. Native USB skips UART speed negotiation; the adapter's `baudRate` is `null` there. `onStatus(event)` reports ROM connection, speed testing/failure/selection, detected flash ID and compressed writing. Firmware writes are compressed and retain the complete SHA-256 readback and protected update bounds. Interrupted writes invalidate the session and require a fresh operation; the SDK never blindly resumes a compressed stream.
149
+
150
+ The bounded readback adapter for upstream issue [esptool-js #273](https://github.com/espressif/esptool-js/issues/273) uses 1 KiB packets, one outstanding packet and a checked trailing MD5 digest. This addresses protocol completeness without claiming a proven cause for the investigated read-after-write timing failures. Remove the adapter only after a pinned upstream replacement passes the same pre-read → complete write → complete readback tests. SHA-256 of the full approved image remains the final verification requirement.
138
151
 
139
152
  ```js
140
153
  import { createDevice } from '@esp-vision/device-core';
@@ -271,7 +284,9 @@ Paths are absolute device paths. `files.list({ path: '/', recursive: false })` r
271
284
 
272
285
  **Binary uploads (SDK 0.3.0):** text, model and image uploads share reliable raw binary `file.write` frames with 16 KiB blocks, continuous native VFS writes and SHA-256 commit. Matching firmware must advertise `file.write.binary`; missing support rejects with a firmware-upgrade message, without a slow upload fallback. File progress is not a durable commit. FAT replacement preserves an old-file backup until ordinary publication succeeds, with rollback on ordinary rename failure; it is not a power-loss-atomic journal. The filesystem must be mounted and allow writing, with room for the temporary upload and any existing destination file. File reads and directory operations currently use the REPL.
273
286
 
274
- `capture({ source: 'screen', format: 'jpeg' })` uses the dedicated `debug.capture_screen` RPC to encode the LCD's last submitted RGB565 framebuffer, including the display's scaling and letterboxing. It does not read the panel electronics or capture the camera. Camera capture uses `debug.capture_frame`. Both return binary JPEG responses correlated to the request; optional `quality` is an integer from 1 to 100. Missing firmware capabilities reject with `UNSUPPORTED_OPERATION`; an uninitialized display returns `DISPLAY_NOT_READY`, and a board without an LCD returns `NOT_SUPPORTED`. Preview is a separate real-time stream and may drop frames under congestion.
287
+ `capture({ source: 'screen', format: 'jpeg' })` uses the dedicated `debug.capture_screen` RPC to encode the LCD's last submitted RGB565 framebuffer, including the display's scaling and letterboxing. It does not read the panel electronics or capture the camera. Camera capture uses `debug.capture_frame` only when firmware also advertises `debug.capture_frame.observed.v1`. It observes the independent frame published after the application's last successful camera snapshot; it never initializes the sensor, acquires another physical frame or overwrites an application-held `Image`. Later application processing is not reflected in that independent observation. Missing safety support rejects locally with `UNSUPPORTED_OPERATION` and a firmware-upgrade message, without issuing the older capture RPC. No valid published frame, camera reconfiguration, shutdown or VM reset returns `CAMERA_FRAME_NOT_READY`; an observer allocation failure returns `NO_MEM` while the application snapshot remains successful. Use `connect({ probeRepl: false })` to observe a running application without the default REPL interruption. Both return binary JPEG responses correlated to the request; optional `quality` is an integer from 1 to 100. Missing firmware capabilities reject with `UNSUPPORTED_OPERATION`; an uninitialized display returns `DISPLAY_NOT_READY`, and a board without an LCD returns `NOT_SUPPORTED`. Preview is a separate real-time stream and may drop frames under congestion.
288
+
289
+ Capture and preview delivery require a binary, self-contained 8-bit Huffman JPEG (SOF0/1/2). After EV-MUX length/CRC and session correlation checks, Core checks marker and segment bounds, quantization and Huffman table structure/references, SOF dimensions against metadata, scan component coverage, nonempty scan/restart segments and a final EOI with no trailing bytes. This is structural validation without entropy decoding; it does not establish that every compressed coefficient can be decoded. Invalid capture responses reject immediately with `INVALID_IMAGE`; explicit firmware errors retain their original codes. Invalid preview frames are discarded before any image callback. Both emit a diagnostic `{ event: 'image.invalid', channel, method, seq, code: 'INVALID_IMAGE', reason }`; the parser's `invalidImageFrames` and `droppedFrames` counters increase. A stale response is filtered before validation and cannot reject another request.
275
290
 
276
291
  ## Discovery and transport adapters
277
292
 
@@ -331,7 +346,9 @@ IDE and Agent adapters that construct sessions or call `session.repl` must migra
331
346
 
332
347
  ## Firmware flashing
333
348
 
334
- ROM flashing remains a separate lifecycle under `@esp-vision/device-core/firmware`. Hosts inject the ROM loader, approved image manifest and reset policy. The adapter validates image integrity, chip/revision, security and partitions, verifies complete readback, and clears the P4 download latch before reset. It does not distribute firmware, open a browser picker or provide a flashing UI. EV-MUX `control({ action: 'bootloader' })` only requests entry into the downloader; it does not flash an image.
349
+ ROM flashing remains a separate lifecycle under `@esp-vision/device-core/firmware`. Hosts inject the ROM loader, approved image manifest and reset policy. The adapter validates image integrity, chip/revision, security and partitions, verifies complete readback, and clears the P4 download latch before reset. It does not distribute firmware, open a browser picker or provide a flashing UI. EV-MUX `control({ action: 'bootloader' })` only requests entry into the downloader; it does not flash an image. Its framed REPL path has no device acknowledgement: `{ acknowledged: false, outcome: 'unknown' }` reports submission, and the next ROM connection must identify the chip before any write. Core invalidates the old EV-MUX lease after submitting a reset command or when that write has an uncertain outcome; disconnect does not send a stale `session.release`. A failure before the reset command is attempted retains the live lease for ordinary cleanup. The Node serial bridge drains its queued output before returning from a successful reset submission.
350
+
351
+ The Node loader resolves only the explicitly selected serial path and waits briefly for that path to become available. A selected ESP-VISION native USB runtime (`303a:4003`, or UVC composite `303a:4023`) must establish the active EV-MUX USB owner and match its device ID (or a legacy runtime's short `machine.unique_id()` probe) to its USB MAC serial before Core requests bootloader once. Core then accepts only one ROM interface with the same USB MAC and physical location, and verifies the ROM-reported factory MAC before allowing writes. Existing ROM, UART and USB Serial/JTAG ports are not probed as EV-MUX runtimes. If the first read-only ROM initialization fails before any handoff or write, Core may recover once only after freshly observing that same device back in its runtime and verifying the runtime again. It does not retry writes or repeat an already submitted bootloader request. `loader.usbTransition` records command acknowledgement, observed ROM enumeration and cleanup separately; `loader.port` is the qualified current OS path. Failed or cancelled initialization closes that loader and must not be replayed on the same instance. A missing old by-id path never authorizes guessing a different device.
335
352
 
336
353
  Agent workflows, MCP tool mappings, model providers and plugin packaging belong to their own integration packages and are not included here.
337
354
 
@@ -352,7 +369,7 @@ Device Core is maintained alongside the firmware at `host/device-core` in [espre
352
369
 
353
370
  Run `npm run release:check` from this directory for unit tests, schema validation, an isolated package install and a publication dry run. SDK-only changes run these Node.js checks without compiling firmware. `ESP_VISION_DEVICE_CORE_ONLY=1` selects SDK checks in an API/web pipeline; `ESP_VISION_BUILD_FIRMWARE=1` requests the final firmware matrix when needed. SDK versions are tracked in `package.json` and npm; do not create SDK Git tags in this firmware repository, because firmware/documentation version discovery uses Git tags. CI defensively excludes accidental `device-core-v*` tags from firmware jobs, but creating these tags is not part of the SDK release workflow.
354
371
 
355
- Only a protected default-branch push (`master` in this firmware repository) with matching SDK changes can publish, using the masked/protected `NPM_PUBLISH_TOKEN`. The account must be `esp-vision` with scope write access. This variable must be configured in the firmware project before its first publication; moving source does not transfer CI credentials. MR, feature-branch, local and SDK-tag runs never publish. The release job verifies registry metadata and integrity: identical content is skipped; different content under an existing version requires a version bump.
372
+ A protected default-branch push (`master` in this firmware repository) with SDK changes automatically publishes a version not yet present in npm, using the masked/protected `NPM_PUBLISH_TOKEN`. The account must be `esp-vision` with scope write access. MR, feature-branch, local and SDK-tag runs never publish. An existing version is skipped even if source has changed; bump `package.json` before releasing those changes. Publication verifies newly published registry integrity.
356
373
 
357
374
  Before enabling this publisher, disable the old IDE-project package release job so there is only one authoritative publisher. Migration is staged: qualify this in-repository SDK and its publication pipeline, publish and verify its registry integrity, then update the IDE/Agent adapters to the fixed package version and remove the old IDE source copy. Until that consumer migration is complete the old worktree is preserved as a backup, not an additional source of future SDK changes. No npm publication is performed by local migration tests.
358
375
 
@@ -381,7 +398,7 @@ npm run test:hardware -- --serial /dev/serial/by-id/YOUR_DEVICE --role usj --che
381
398
  npm run test:hardware -- --tcp 192.168.1.100 --checks display --display-hold 2 --screen-output screen.jpg
382
399
  ```
383
400
 
384
- `--write` explicitly opts into creating a randomly named 1 KiB binary file, verifying every byte by readback, and deleting that file and its temporary upload sibling. Existing matching paths are refused; cleanup failures are reported with the test path and make the command fail. `--capture` opts into a camera JPEG check; `--output` optionally saves that JPEG. SIGINT/SIGTERM cancel the operation; cleanup has a separate bounded deadline. Output excludes network credentials, arbitrary device logs and Python tracebacks. A failing check exits nonzero.
401
+ `--write` explicitly opts into creating a randomly named 1 KiB binary file, verifying every byte by readback, and deleting that file and its temporary upload sibling. Existing matching paths are refused; cleanup failures are reported with the test path and make the command fail. `--capture` explicitly prepares an application snapshot before checking passive camera observation; `--output` optionally saves that JPEG. SIGINT/SIGTERM cancel the operation; cleanup has a separate bounded deadline. Output excludes network credentials, arbitrary device logs and Python tracebacks. A failing check exits nonzero.
385
402
 
386
403
  `--checks all` runs the shared firmware/SDK hardware suite: `identity`, `memory`, `camera`, `framesizes`, `pixformats`, `orientation`, `ipa_stats`, `snapshot_rate`, `preview`, `image_ops`, `display`, and `display_stream`. Select a comma-separated subset with `--checks memory,camera,preview`. Timing controls are `--frames` (default 30), `--frame-timeout` (seconds, default 25), and `--display-hold` (seconds per backlight state, default zero). Sensor defaults are `--framesize QVGA --pixformat RGB565`; `--quality` defaults to 60. Functional checks default to a 600000 ms overall deadline; an explicit `--timeout` overrides it. Each result is a JSON line with PASS/FAIL/SKIP, measurements and duration, followed by a summary. Unsupported hardware is skipped; IPA absence remains a failure on boards whose camera requires IPA.
387
404
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esp-vision/device-core",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Shared EV-MUX, framed REPL, device session, and action contracts for ESP Vision clients.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -73,9 +73,10 @@
73
73
  },
74
74
  "dependencies": {
75
75
  "smol-toml": "^1.7.0",
76
- "tasmota-webserial-esptool": "9.4.2"
76
+ "esptool-js": "0.7.1"
77
77
  },
78
78
  "devDependencies": {
79
79
  "esbuild": "0.25.12"
80
- }
80
+ },
81
+ "espVisionReleaseType": "minor"
81
82
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "ESP Vision Device Actions",
4
- "deviceCoreVersion": "0.5.1",
4
+ "deviceCoreVersion": "0.6.0",
5
5
  "schemaVersion": 1,
6
6
  "actions": [
7
7
  {
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import assert from 'node:assert/strict'
4
+ import { highestStableVersion, validateVersionStep } from './version-policy.mjs'
4
5
  import { verifyPublishedIntegrity } from './verify-registry.mjs'
5
6
  import { spawnSync } from 'node:child_process'
6
7
  import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
@@ -18,6 +19,7 @@ assert.match(manifest.version, /^\d+\.\d+\.\d+$/)
18
19
  assert.equal(manifest.publishConfig?.registry, registry)
19
20
  assert.equal(manifest.publishConfig?.access, 'public')
20
21
  assert.equal(manifest.license, 'Apache-2.0')
22
+ assert.ok(['patch', 'minor', 'major'].includes(manifest.espVisionReleaseType), 'Release type must be patch, minor or major')
21
23
  if (!dryRun) {
22
24
  assert.equal(process.env.CI_PIPELINE_SOURCE, 'push', 'Publish only from the merged default-branch pipeline')
23
25
  assert.ok(process.env.CI_DEFAULT_BRANCH && process.env.CI_COMMIT_BRANCH === process.env.CI_DEFAULT_BRANCH,
@@ -25,6 +27,20 @@ if (!dryRun) {
25
27
  assert.equal(process.env.CI_COMMIT_REF_PROTECTED, 'true', 'Publishing requires a protected branch')
26
28
  assert.ok(process.env.NPM_PUBLISH_TOKEN, 'Protected NPM_PUBLISH_TOKEN is missing')
27
29
  }
30
+ if (!dryRun) {
31
+ const url = new URL(`${encodeURIComponent(manifest.name)}/${encodeURIComponent(manifest.version)}`, registry)
32
+ const response = await fetch(url, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' }, signal: AbortSignal.timeout(15000) })
33
+ if (response.ok) {
34
+ console.log(`${manifest.name}@${manifest.version} already exists; no new version to publish.`)
35
+ process.exit(0)
36
+ }
37
+ assert.equal(response.status, 404, 'Registry lookup failed; do not attempt publication')
38
+ const allResponse = await fetch(new URL(encodeURIComponent(manifest.name), registry), { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' }, signal: AbortSignal.timeout(15000) })
39
+ assert.equal(allResponse.status, 200, 'Cannot validate prior releases; do not attempt publication')
40
+ const previous = highestStableVersion(Object.keys((await allResponse.json()).versions ?? {}))
41
+ assert.ok(previous, 'A prior stable release is required to validate version progression')
42
+ validateVersionStep(previous, manifest.version, manifest.espVisionReleaseType)
43
+ }
28
44
  const temporary = await mkdtemp(join(tmpdir(), 'esp-vision-core-release-'))
29
45
  try {
30
46
  // Store an environment reference, never the token value, outside the package.
@@ -0,0 +1,71 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+
5
+ const stablePattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
6
+ const minorPattern = /^~(0|[1-9]\d*)\.(0|[1-9]\d*)\.0$/
7
+
8
+ export function stableVersion(value) {
9
+ const match = stablePattern.exec(value ?? '')
10
+ assert.ok(match, `Expected an exact stable major.minor.patch version: ${value}`)
11
+ const numbers = match.slice(1).map(Number)
12
+ assert.ok(numbers.every(Number.isSafeInteger), 'Version components must be safe integers')
13
+ return numbers
14
+ }
15
+
16
+ export function minorRange(value) {
17
+ const match = minorPattern.exec(value ?? '')
18
+ assert.ok(match, `Expected a minor-locked range such as ~0.5.0: ${value}`)
19
+ const numbers = match.slice(1).map(Number)
20
+ assert.ok(numbers.every(Number.isSafeInteger), 'Version components must be safe integers')
21
+ return numbers
22
+ }
23
+
24
+ export function inMinorRange(range, version) {
25
+ const [major, minor] = minorRange(range)
26
+ const [candidateMajor, candidateMinor] = stableVersion(version)
27
+ return candidateMajor === major && candidateMinor === minor
28
+ }
29
+
30
+ // Published older CLI releases may declare an exact Core version; newer
31
+ // releases must declare a minor range, enforced at the CLI publisher.
32
+ export function declaredCoreSupports(declaration, version) {
33
+ if (typeof declaration !== 'string') return false
34
+ if (stablePattern.test(declaration)) return declaration === version
35
+ return inMinorRange(declaration, version)
36
+ }
37
+
38
+ // The CLI bundles Core. A compatible range describes allowed inputs; the
39
+ // resolved version identifies the actual bytes in a published CLI archive.
40
+ export function cliBundlesCore(manifest, version) {
41
+ const declaration = manifest.devDependencies?.['@esp-vision/device-core']
42
+ if (!declaredCoreSupports(declaration, version)) return false
43
+ const resolved = manifest.espVision?.resolved?.deviceCore
44
+ if (resolved !== undefined) return resolved === version
45
+ // Older CLI packages used an exact build dependency as their only record.
46
+ return stablePattern.test(declaration ?? '') && declaration === version
47
+ }
48
+
49
+ export function highestStableVersion(versions) {
50
+ let best
51
+ for (const candidate of versions) {
52
+ if (!stablePattern.test(candidate)) continue
53
+ const parts = stableVersion(candidate)
54
+ if (!best || parts.some((value, index) => value !== best.parts[index] &&
55
+ parts.slice(0, index).every((prior, i) => prior === best.parts[i]) && value > best.parts[index])) {
56
+ best = { version: candidate, parts }
57
+ }
58
+ }
59
+ return best?.version
60
+ }
61
+
62
+ export function validateVersionStep(previous, next, change) {
63
+ const [major, minor, patch] = stableVersion(previous)
64
+ const expected = {
65
+ patch: `${major}.${minor}.${patch + 1}`,
66
+ minor: `${major}.${minor + 1}.0`,
67
+ major: `${major + 1}.0.0`,
68
+ }
69
+ assert.ok(Object.hasOwn(expected, change), 'Release type must be patch, minor or major')
70
+ assert.equal(next, expected[change], `A ${change} release after ${previous} must be ${expected[change]}`)
71
+ }
package/src/browser.js CHANGED
@@ -1,8 +1,10 @@
1
1
  // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ export { serialTransport } from './browser_serial.js'
5
+
4
6
  /** Passive browser ROM adapter. Request the SerialPort in the UI's click handler. */
5
- export function openBrowserRom({ port, logger = {}, signal } = {}) {
7
+ export function openBrowserRom({ port, logger = {}, signal, interface: romInterface = 'auto', baudRate = 'auto', onStatus = () => {} } = {}) {
6
8
  if (!port || typeof port.open !== 'function' || typeof port.close !== 'function') throw new TypeError('A browser SerialPort is required')
7
9
  if (signal !== undefined && (!signal || typeof signal.addEventListener !== 'function' || typeof signal.aborted !== 'boolean')) throw new TypeError('signal must be an AbortSignal')
8
10
  let raw, initialized, closing = false, cleanup
@@ -23,6 +25,8 @@ export function openBrowserRom({ port, logger = {}, signal } = {}) {
23
25
  get chipName() { return raw?.chipName ?? null },
24
26
  get chipFamily() { return raw?.chipFamily ?? null },
25
27
  get chipRevision() { return raw?.chipRevision ?? null },
28
+ get interface() { return raw?.interface ?? romInterface },
29
+ get baudRate() { return raw?.baudRate ?? null },
26
30
  initialize() {
27
31
  if (closing) return Promise.reject(closed())
28
32
  if (initialized) return initialized
@@ -39,7 +43,7 @@ export function openBrowserRom({ port, logger = {}, signal } = {}) {
39
43
  log: (...args) => logger.log?.(...args),
40
44
  error: (...args) => logger.error?.(...args),
41
45
  debug: (...args) => logger.debug?.(...args),
42
- })
46
+ }, { interface: romInterface, baudRate, onStatus })
43
47
  if (closing || signal?.aborted) { await disconnect(); check() }
44
48
  await raw.initialize()
45
49
  check()
@@ -1,9 +1,213 @@
1
1
  // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  // Loaded only by openBrowserRom.initialize(), never by the root/Node entry point.
4
- import { connectWithPort } from 'tasmota-webserial-esptool'
5
- import { browserRomCompatibility } from './browser_rom_compat.js'
4
+ import { ESPLoader, Transport } from 'esptool-js'
5
+ import { browserRomCompatibility, browserTransportCompatibility, readBrowserFlash, romError, romWords } from './browser_rom_compat.js'
6
6
 
7
- export async function createBrowserLoader(port, logger) {
8
- return browserRomCompatibility(await connectWithPort(port, logger))
7
+ export const ROM_UART_BAUD_RATES = Object.freeze([2000000, 1500000, 921600, 460800, 115200])
8
+ const families = { 'ESP32-S3': 0x3253, 'ESP32-P4': 0x3280, 'ESP32-S31': 0x3231 }
9
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
10
+ const securityAllowed = security => Number.isSafeInteger(security?.flags) && Number.isSafeInteger(security?.flashCryptCnt)
11
+ && (security.flags & 5) === 0 && security.flashCryptCnt.toString(2).replace(/0/g, '').length % 2 === 0
12
+
13
+ export function browserRomInterface(port, requested = 'auto') {
14
+ if (!['auto', 'uart', 'usb-otg', 'usb-jtag'].includes(requested)) throw new TypeError('ROM interface must be auto, uart, usb-otg or usb-jtag')
15
+ if (requested !== 'auto') return requested
16
+ const { usbVendorId: vid, usbProductId: pid } = port.getInfo?.() ?? {}
17
+ if (vid === 0x303a && pid === 0x1001) return 'usb-jtag'
18
+ if (vid === 0x303a && [2, 9, 18, 32].includes(pid)) return 'usb-otg'
19
+ if ([0x10c4, 0x1a86, 0x0403, 0x067b].includes(vid)) return 'uart'
20
+ // Custom descriptors are ambiguous. Probe without reset, then use the chip's
21
+ // connection registers; callers may explicitly identify an external bridge.
22
+ return 'unknown'
23
+ }
24
+
25
+ export function createBrowserLoader(port, logger = {}, options = {}) {
26
+ return createOfficialBrowserLoader({ ESPLoader, Transport }, port, logger, options)
27
+ }
28
+
29
+ // Dependency injection is internal to the package and permits lifecycle/error
30
+ // testing against fake official-loader dependencies without browser globals.
31
+ export function createOfficialBrowserLoader({ ESPLoader: Loader, Transport: SerialTransport }, port, logger = {}, {
32
+ interface: requestedInterface = 'auto', baudRate = 'auto', onStatus = () => {},
33
+ } = {}) {
34
+ let kind = browserRomInterface(port, requestedInterface)
35
+ if (baudRate !== 'auto' && !ROM_UART_BAUD_RATES.includes(baudRate)) throw new RangeError('Unsupported ROM UART baudRate')
36
+ let loader, transport, identity, initialized = false, active = false, fault, writing = false, disconnected = false
37
+ const status = event => { logger.log?.(`[ROM] ${JSON.stringify(event)}`); onStatus(event) }
38
+ const assertReady = () => {
39
+ if (disconnected) throw romError('ROM_CLOSED', 'Browser ROM adapter is closed')
40
+ if (fault) throw fault
41
+ if (!initialized) throw romError('ROM_NOT_INITIALIZED', 'ROM synchronization and chip qualification are required')
42
+ }
43
+ async function closeTransport() {
44
+ if (transport && (transport.device?.readable || transport.device?.writable)) await transport.disconnect()
45
+ }
46
+ async function detect(mode) {
47
+ transport = browserTransportCompatibility(new SerialTransport(port, false))
48
+ loader = browserRomCompatibility(new Loader({ transport, baudrate: 115200, romBaudrate: 115200, serialOptions: { bufferSize: 256 * 1024 },
49
+ terminal: { clean() {}, write: value => logger.log?.(value), writeLine: value => logger.log?.(value) },
50
+ }), { nativeUsb: kind === 'usb-otg' || kind === 'usb-jtag' })
51
+ // The stub replies once to SYNC; the ROM replies eight times. In 0.7.1 the
52
+ // unconditional eight-reply wait prevents reconnecting to a running stub.
53
+ loader.sync = async function () {
54
+ const request = new Uint8Array(36).fill(0x55); request.set([7, 7, 18, 32])
55
+ let response = await this.command(8, request, 0, true, 500)
56
+ this.syncStubDetected = response[0] === 0
57
+ if (!this.syncStubDetected) for (let i = 0; i < 7; i++) response = await this.readPacket(8, 500)
58
+ return response
59
+ }
60
+ try {
61
+ await loader.detectChip(mode, 3)
62
+ if (disconnected) throw romError('ROM_CLOSED', 'Browser ROM adapter is closed')
63
+ if (!loader.chip || !families[loader.chip.CHIP_NAME]) throw romError('ROM_CHIP_UNSUPPORTED', 'Browser flashing supports ESP32-S3, ESP32-P4 and ESP32-S31')
64
+ const current = { name: loader.chip.CHIP_NAME, family: families[loader.chip.CHIP_NAME], revision: await loader.chip.getChipRevision(loader) }
65
+ if (identity && (identity.family !== current.family || identity.revision !== current.revision)) throw romError('ROM_IDENTITY_CHANGED', 'Chip identity changed while recovering the download session')
66
+ identity = current
67
+ if (kind === 'unknown') {
68
+ if (await loader.usesUsbOtg()) kind = 'usb-otg'
69
+ // Without descriptor/register evidence, retain the conservative mode.
70
+ }
71
+ initialized = true
72
+ status({ phase: 'rom-connected', interface: kind, baudRate: transport.baudrate })
73
+ } catch (error) {
74
+ initialized = false
75
+ await closeTransport().catch(() => {})
76
+ const lost = /device.*lost|disconnected|setSignals|networkerror/i.test(`${error.name} ${error.message}`)
77
+ if (lost) throw romError('ROM_PORT_LOST', 'Download port detached or re-enumerated; select its current ROM port again', error)
78
+ if (kind === 'usb-otg') throw romError('ROM_MANUAL_DOWNLOAD', 'USB-OTG ROM is not ready. Hold BOOT, press RESET, release BOOT, then select the ROM port again', error)
79
+ throw error
80
+ }
81
+ }
82
+ async function qualifyAndStartStub() {
83
+ const security = await loader.getSecurityInfo(false)
84
+ if (!securityAllowed(security)) throw romError('ROM_SECURITY', 'Secure boot, secure download or encrypted devices require their signed flashing workflow')
85
+ if (typeof loader.chip.postConnect === 'function') await loader.chip.postConnect(loader)
86
+ await loader.runStub()
87
+ if (!loader.IS_STUB) throw romError('ROM_STUB_REQUIRED', 'This device has no supported browser flash stub')
88
+ active = true
89
+ }
90
+ async function recover() {
91
+ initialized = active = false
92
+ await closeTransport()
93
+ // A reopen alone is never recovery. Reset/sync at 115200, identify the chip,
94
+ // recheck security and start a validated stub before another candidate.
95
+ await detect('default_reset')
96
+ await qualifyAndStartStub()
97
+ }
98
+ async function preflight() {
99
+ const register = loader.chip.EFUSE_BLOCK1_ADDR ?? loader.CHIP_DETECT_MAGIC_REG_ADDR
100
+ const expected = await loader.readReg(register, 1000)
101
+ for (let count = 0; count < 3; count++) if (await loader.readReg(register, 1000) !== expected) throw romError('ROM_BAUD_PROBE', 'Register response changed during UART speed verification')
102
+ // Read-only bulk traffic checks SLIP, acknowledgements and a device digest.
103
+ await readBrowserFlash(loader, 0, 4096)
104
+ }
105
+ async function chooseBaud() {
106
+ if (kind !== 'uart') {
107
+ status({ phase: 'baud-ready', interface: kind, baudRate: null })
108
+ return
109
+ }
110
+ const candidates = baudRate === 'auto' ? ROM_UART_BAUD_RATES : [baudRate]
111
+ for (let index = 0; index < candidates.length; index++) {
112
+ const candidate = candidates[index]
113
+ try {
114
+ if (index) await recover()
115
+ status({ phase: 'baud-testing', interface: kind, baudRate: candidate })
116
+ if (candidate !== 115200) {
117
+ // Use the official checked command and transport retune primitives.
118
+ // The upstream changeBaud silently falls back to 115200; Core needs
119
+ // each candidate's actual result to obey its ordered selection policy.
120
+ await loader.checkCommand('change baud rate', loader.ESP_CHANGE_BAUDRATE, romWords(candidate, transport.baudrate), 0, 0, 1000)
121
+ await sleep(50)
122
+ await transport.changeBaudrate(candidate, loader.serialOptions)
123
+ loader.baudrate = candidate
124
+ transport.flushInput()
125
+ }
126
+ await preflight()
127
+ status({ phase: 'baud-ready', interface: kind, baudRate: candidate })
128
+ return
129
+ } catch (error) {
130
+ status({ phase: 'baud-failed', interface: kind, baudRate: candidate, code: error.code ?? 'ROM_BAUD_FAILED', message: error.message })
131
+ if (error.code === 'ROM_IDENTITY_CHANGED' || error.code === 'ROM_SECURITY' || error.code === 'ROM_PORT_LOST') throw error
132
+ if (index === candidates.length - 1) throw romError('ROM_BAUD_FAILED', 'No requested UART download speed passed verification', error)
133
+ }
134
+ }
135
+ }
136
+ async function operation(callback) {
137
+ assertReady()
138
+ if (!active || !loader.IS_STUB) throw romError('ROM_STUB_REQUIRED', 'A validated flasher stub is required')
139
+ try { return await callback() }
140
+ catch (error) {
141
+ // Neither failed reads nor compressed writes can be resumed on a merely
142
+ // reopened port. Invalidate the session; the caller must start afresh.
143
+ fault = error
144
+ initialized = active = false
145
+ await closeTransport().catch(() => {})
146
+ throw error
147
+ }
148
+ }
149
+ const stub = {
150
+ get IS_STUB() { return active && !!loader?.IS_STUB },
151
+ flashId: () => operation(async () => {
152
+ // RDID requests 24 bits, while readFlashId exposes the entire signed
153
+ // SPI_W0 register. Upper unused bits may be set on real S31 hardware.
154
+ const raw = await loader.readFlashId()
155
+ if (!Number.isInteger(raw) || raw < -0x80000000 || raw > 0xffffffff) throw romError('ROM_FLASH_ID', 'Invalid SPI flash ID register response')
156
+ const id = raw & 0xffffff
157
+ status({ phase: 'flash-id', id })
158
+ return id
159
+ }),
160
+ readRegister: address => operation(() => loader.readReg(address)),
161
+ writeRegister: (address, value, mask = 0xffffffff) => operation(() => loader.writeReg(address, value, mask)),
162
+ readFlash: (address, size, progress, options) => operation(() => readBrowserFlash(loader, address, size, progress, options)),
163
+ flashData(data, progress, address, _compress) {
164
+ return operation(async () => {
165
+ if (writing) throw romError('ROM_WRITE_REUSE', 'Start a fresh download session before another firmware write')
166
+ writing = true
167
+ status({ phase: 'writing', interface: kind, baudRate: kind === 'uart' ? transport.baudrate : null, compress: true })
168
+ await loader.writeFlash({ fileArray: [{ data: new Uint8Array(data), address }], flashMode: 'keep', flashFreq: 'keep', flashSize: 'keep', eraseAll: false, compress: true,
169
+ reportProgress: (_file, written, total) => progress?.(written, total),
170
+ })
171
+ })
172
+ },
173
+ }
174
+ const adapter = {
175
+ get chipName() { return identity?.name ?? null },
176
+ get chipFamily() { return identity?.family ?? null },
177
+ get chipRevision() { return identity?.revision ?? null },
178
+ get interface() { return kind },
179
+ get baudRate() { return kind === 'uart' ? transport?.baudrate ?? 115200 : null },
180
+ async initialize() { if (disconnected) throw romError('ROM_CLOSED', 'Browser ROM adapter is closed'); await detect(kind === 'uart' || kind === 'usb-jtag' ? 'default_reset' : 'no_reset') },
181
+ async getSecurityInfo() { assertReady(); return loader.getSecurityInfo() },
182
+ async runStub() {
183
+ assertReady()
184
+ try { await qualifyAndStartStub(); await chooseBaud(); return stub }
185
+ catch (error) { fault = error; initialized = active = false; await closeTransport().catch(() => {}); throw error }
186
+ },
187
+ async hardReset() {
188
+ assertReady()
189
+ if (kind === 'usb-otg' && identity.family === 0x3231) {
190
+ // S31's LP_WDT register layout (ESP-IDF soc/rtc_wdt_reg.h). The generic
191
+ // watchdog sequence also appears in Apache-2.0 esptool-js P4 support;
192
+ // 0.7.1's S31 inherits an unimplemented C5 watchdog method.
193
+ const base = 0x20801000
194
+ await loader.writeReg(base + 0x18, 0x50d83aa1)
195
+ await loader.writeReg(base + 4, 2000)
196
+ await loader.writeReg(base, 0xd0000102)
197
+ await loader.writeReg(base + 0x18, 0)
198
+ await sleep(500)
199
+ return
200
+ }
201
+ if (kind !== 'uart' && typeof loader.chip.watchdogReset === 'function' && identity.family === 0x3280) return loader.chip.watchdogReset(loader)
202
+ if (kind === 'usb-otg' || kind === 'unknown') throw romError('ROM_RESET_UNSUPPORTED', 'Firmware verified; press RESET to start the application on this interface')
203
+ // esptool-js 0.7.1 HardReset only deasserts RTS. Pulse it explicitly so
204
+ // bridge-connected devices actually restart after verified flashing.
205
+ await transport.setDTR(false)
206
+ await transport.setRTS(true)
207
+ await sleep(100)
208
+ await transport.setRTS(false)
209
+ },
210
+ async disconnect() { disconnected = true; initialized = active = false; await closeTransport() },
211
+ }
212
+ return adapter
9
213
  }