From f8959efb5acdfaa337d3e259e109d6fd7fbd9d59 Mon Sep 17 00:00:00 2001 From: Mark Nolan Date: Mon, 21 Sep 2026 14:28:36 +0100 Subject: [PATCH] Point ShimmerCapture at its own repository, and leave a redirect Shimmer Capture is now ShimmerResearch/shimmer-capture-web, live at shimmerresearch.github.io/shimmer-capture-web/. It was extracted with git filter-repo, so its history went with it rather than being restarted. What is left here is a redirect stub at ShimmerCapture/index.html. Pages serves static files and cannot issue a 301, so it redirects three ways -- meta refresh for no-JavaScript, location.replace() so Back does not bounce the visitor forward again, and a visible link if both are blocked -- and carries the query string across, because ?mock=1 links are in circulation. rel="canonical" is the part that matters beyond today: it is what moves the search result rather than leaving two competing. Keep the stub. Every link published while the page lived at /webBLEDemos/ShimmerCapture/ points at it. common/ went with the page. It reads as shared code and is named like it, but nothing else here imported it -- no other demo loads so much as common/theme.css. Kept here it would have been twenty modules maintained for one consumer in another repository; copied, it would have drifted. verify.yml went too. It was titled "Verify ShimmerCapture" and ran nothing else. html-format.yml stays and still covers every page here. vendor/ stays -- eighteen pages and the Chrome extension still import it, and the extension still needs its own copy. The new repository vendors the same build from the same source, so sync-all-vendors.ps1 now has a third consumer to keep in step; that is the thing most likely to be forgotten, and it is written down in vendor/README.md and AGENTS.md. Co-Authored-By: Claude Opus 5 --- .github/workflows/verify.yml | 139 - .prettierignore | 6 +- AGENTS.md | 26 +- README.md | 53 +- ShimmerCapture/README.md | 648 +-- ShimmerCapture/index.html | 5678 +----------------------- common/README.md | 218 - common/brand-editor.js | 1198 ----- common/calibration-editor.js | 2101 --------- common/config-form.js | 1356 ------ common/connect-ui.js | 499 --- common/csv-recorder.js | 447 -- common/dev/mock-shimmer3r.js | 2827 ------------ common/dev/verify-known-failures.json | 4 - common/dev/verify.mjs | 5797 ------------------------- common/device-clock.js | 154 - common/factory-test-panel.js | 834 ---- common/kinematic-block-editor.js | 553 --- common/plot.js | 873 ---- common/rtc-drift-panel.js | 1077 ----- common/sd-browser.js | 1143 ----- common/shimmer3-config-schema.js | 553 --- common/stream-stats.js | 252 -- common/theme.css | 1399 ------ common/theme.js | 178 - common/transport-tap.js | 289 -- common/ui-chrome.js | 648 --- common/vendor/README.md | 43 - common/vendor/chart.umd.min.js | 14 - sync-local-sdk.ps1 | 2 +- vendor/README.md | 10 +- 31 files changed, 176 insertions(+), 28843 deletions(-) delete mode 100644 .github/workflows/verify.yml delete mode 100644 common/README.md delete mode 100644 common/brand-editor.js delete mode 100644 common/calibration-editor.js delete mode 100644 common/config-form.js delete mode 100644 common/connect-ui.js delete mode 100644 common/csv-recorder.js delete mode 100644 common/dev/mock-shimmer3r.js delete mode 100644 common/dev/verify-known-failures.json delete mode 100644 common/dev/verify.mjs delete mode 100644 common/device-clock.js delete mode 100644 common/factory-test-panel.js delete mode 100644 common/kinematic-block-editor.js delete mode 100644 common/plot.js delete mode 100644 common/rtc-drift-panel.js delete mode 100644 common/sd-browser.js delete mode 100644 common/shimmer3-config-schema.js delete mode 100644 common/stream-stats.js delete mode 100644 common/theme.css delete mode 100644 common/theme.js delete mode 100644 common/transport-tap.js delete mode 100644 common/ui-chrome.js delete mode 100644 common/vendor/README.md delete mode 100644 common/vendor/chart.umd.min.js diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml deleted file mode 100644 index f96182f..0000000 --- a/.github/workflows/verify.yml +++ /dev/null @@ -1,139 +0,0 @@ -# The ShimmerCapture verification pass, on every pull request and on main. -# -# `common/dev/verify.mjs` drives the real page in a real browser over CDP -# against the ?mock=1 transport: ~290 checks over the connect handshake, the -# configuration form, the sensor rules, the plot, the CSV recorder, the SD -# browser, the self-test and the clock. It has existed for a while and nothing -# ran it, so its failures were invisible unless somebody ran it by hand and -# read the output — which is how a block of eleven broken checks survived, and -# how a regression could have hidden among them. -# -# It gates on CHANGE, not on the total: `common/dev/verify-known-failures.json` -# lists what is known broken and why, a failure outside that list fails the -# build, and a listed check that starts passing ALSO fails the build so the -# list shrinks rather than rots. See the tail of verify.mjs. -name: Verify ShimmerCapture - -# `pull_request` covers the work; `push` is scoped to main and is not -# redundant with it. A pull_request run tests the PREDICTED merge of the branch -# into main, not main as it ends up: two pull requests each green against an -# older main, merged in sequence, can produce a main that no run ever tested. -# There is no branch protection here forcing a branch to be up to date first, -# so the run on main after a merge is the only thing that says main works. -# -# Together they are also exactly one run per push, where `push:` unscoped -# alongside `pull_request` gave two of everything - four, once the CRC matrix -# went in. -# -# A branch with no pull request open gets no run, which is why -# workflow_dispatch is here: it can be started by hand on any ref. -on: - pull_request: - push: - branches: - - main - workflow_dispatch: - -# One run per ref. A second push a minute later cancels the first, because only -# the newest commit's result is interesting. Not on main, where each run is the -# record of a commit that is already merged and cancelling one would leave a -# gap in exactly the history this job exists to keep. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} - -jobs: - verify: - # Both link-CRC settings, because the difference between them is diagnostic - # and nothing else in the pass reports it. A CRC changes more than a - # trailer: `_reframing` turns on, so every inbound chunk goes through the - # SDK's framer even over BLE, and a whole class of defect only exists on - # that path: eighteen checks were failing on it at once, from three - # separate causes, and the CRC-off run coming back clean is what said they - # were defects of that path rather than flaky tests. A failure in one - # variant and not the other says where to look. - # - # The CRC-off run does not block: the shape a user is actually on is the - # default, two bytes, and a red mark on the variant would stop a merge for - # something the product does not do. It is here to be read. - name: verify (${{ matrix.label }}) - runs-on: ubuntu-latest - timeout-minutes: 25 - continue-on-error: ${{ matrix.crc == '0' }} - strategy: - # Independent runs, so one failing must not cancel the other -- the - # comparison between them is the whole point. - fail-fast: false - matrix: - include: - - crc: "2" - label: link CRC on - - crc: "0" - label: link CRC off - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "24" - - # Pinned rather than relying on whatever Chrome the runner image carries, - # so a runner-image change cannot move the browser under the pass. - - name: Setup Chrome - id: chrome - uses: browser-actions/setup-chrome@v1 - with: - chrome-version: stable - - # One step for the server, the browser and the pass. Backgrounded - # processes are not guaranteed to outlive the step that started them, and - # a pass that cannot say WHY the browser did not come up is a pass - # nobody can fix from the log — so both get a log file, and both logs are - # printed when something goes wrong. - - name: Run the verification pass - env: - # Chrome's sandbox does not come up on the runners, and this is a - # throwaway container loading only localhost. - CHROME_FLAGS: >- - --headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage - --no-first-run --no-default-browser-check - run: | - set -uo pipefail - CHROME='${{ steps.chrome.outputs.chrome-path }}' - echo "chrome: $CHROME" - "$CHROME" --version - - # Serves the REPO ROOT, not the demo folder: the cross-demo checks - # walk up from the demo to the root to police which files may import - # the extension's private SDK copy. - npx --yes http-server . -p 8129 -c-1 --silent > "$RUNNER_TEMP/http.log" 2>&1 & - "$CHROME" $CHROME_FLAGS --remote-debugging-port=9333 \ - --user-data-dir="$RUNNER_TEMP/chrome-verify" \ - about:blank > "$RUNNER_TEMP/chrome.log" 2>&1 & - - up() { curl -fsS -o /dev/null "$1"; } - for i in $(seq 1 60); do - if up http://localhost:8129/ShimmerCapture/ && up http://127.0.0.1:9333/json/version; then - ready=1; break - fi - sleep 1 - done - if [ "${ready:-0}" != "1" ]; then - echo "::error::the static server or Chrome never came up" - echo "--- http-server ---"; cat "$RUNNER_TEMP/http.log" || true - echo "--- chrome ---"; cat "$RUNNER_TEMP/chrome.log" || true - exit 1 - fi - - # Strict here and only here: CI is the environment the baseline - # describes, so a listed check that starts passing has to fail the - # build. Elsewhere it is a note, or nobody could run the pass on a - # laptop without editing the baseline first. - VERIFY_CRC='${{ matrix.crc }}' VERIFY_STRICT_BASELINE=1 node common/dev/verify.mjs - status=$? - if [ "$status" != "0" ]; then - echo "--- chrome ---"; tail -40 "$RUNNER_TEMP/chrome.log" || true - fi - exit $status diff --git a/.prettierignore b/.prettierignore index 1a28ce8..8761bd8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,12 +1,10 @@ # Vendored third-party bundles. They are checked in byte-identical to the -# upstream build (see common/vendor/README.md), and prettier un-minifies a -# minified file -- chart.umd.min.js grows from 204 KB to 342 KB, and the diff -# is the whole library. Nothing here is ours to format. +# upstream build, and prettier un-minifies a minified file -- the diff is the +# whole library. Nothing here is ours to format. # # The HTML formatting workflow (.github/workflows/html-format.yml) only ever # touches **/*.html, so this file changes nothing for it; it exists to protect # these bundles from anyone running prettier over the repo by hand. -common/vendor/chart.umd.min.js shimmer-extension/chart.min.js shimmer-extension/jszip.min.js shimmer-extension/vendor/ diff --git a/AGENTS.md b/AGENTS.md index 648ac1f..c2dcfee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,26 +5,38 @@ self-contained directory of static files. Published to GitHub Pages at `shimmerresearch.github.io/webBLEDemos/`. ## The vendor path that matters -**`vendor/` at the repo root is the one almost everything imports** — 18 of the 19 pages plus -`common/`, ShimmerCapture included. There is no `Verisense/vendor`; a sync script once targeted that -path, threw, and silently left the real `vendor/` un-updated. If a demo is running stale SDK -behaviour, check which vendor directory was actually written. + +**`vendor/` at the repo root is the one almost every page imports.** There is no +`Verisense/vendor`; a sync script once targeted that path, threw, and silently left the real +`vendor/` un-updated. If a demo is running stale SDK behaviour, check which vendor directory was +actually written. + +The same bundle is vendored again in `verisense-device-console` and in `shimmer-capture-web`. +`C:\dev\web\sync-all-vendors.ps1` writes all three; prefer it over this repo's script alone. Update with the scripts, never by hand — they stamp `sdk-source.json`: + ``` ./sync-local-sdk.ps1 # sync only ./update-local-sdk.ps1 # build the SDK first, then sync ``` ## Layout + Shimmer3R demos: `break-gyro/`, `break-emg/`, `punch-highG/`, `brick/`, `rythmgame-emggyro/`, -`video-ppg/`, `spell-gyro/`, `ShimmerCapture/`, `consensys-export/`. -Verisense demo: `Verisense/`. Shared code: `common/`. +`video-ppg/`, `spell-gyro/`, `consensys-export/`. +Verisense demo: `Verisense/`. `shimmer-extension/` is a Chrome extension source tree — loaded unpacked, not deployed with the pages. +`ShimmerCapture/` is a redirect stub only — the page moved to +[shimmer-capture-web](https://github.com/ShimmerResearch/shimmer-capture-web) and took `common/`, +the shared UI library nothing else here imported, with it. Leave the stub in place; published +links point at it. ## Demo requirements + Shimmer3R demos need firmware ≥ v1.0.22 and Chrome/Edge — Web Bluetooth for BLE, Web Serial for Classic Bluetooth and USB. Keep that stated on any new demo page. ## CI -`verify.yml` and `html-format.yml`. + +`html-format.yml` only. `verify.yml` went to `shimmer-capture-web` with the page it tested. diff --git a/README.md b/README.md index 67997d6..4069872 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ brick/ │ rythmgame-emggyro/ │ Shimmer3R demos video-ppg/ │ spell-gyro/ │ -ShimmerCapture/ │ consensys-export/ ┘ Verisense/ ← Verisense demo +ShimmerCapture/ ← redirect stub only; the page moved to shimmer-capture-web shimmer-extension/ ← Shimmer3R/Verisense Chrome extension (source; load unpacked in Chrome) sdk-source.json ← Single source-of-truth for SDK source mode/version update-local-sdk.ps1 ← Build + sync local SDK artifacts @@ -28,9 +28,14 @@ sync-local-sdk.ps1 ← Sync-only local SDK artifacts update-local-sdk.cmd ← Windows CMD launcher for update script ``` -The full Verisense control console now lives in a dedicated repository: +Two of these grew past being demos and now live in dedicated repositories: - [ShimmerResearch/verisense-device-console](https://github.com/ShimmerResearch/verisense-device-console) + — the full Verisense control console. +- [ShimmerResearch/shimmer-capture-web](https://github.com/ShimmerResearch/shimmer-capture-web) + — Shimmer Capture, which took `common/` (the shared UI library nothing else + here imported) and the `verify.yml` pass with it. `ShimmerCapture/` here is a + redirect stub; leave it in place, published links point at it. --- @@ -40,26 +45,25 @@ The full Verisense control console now lives in a dedicated repository: **Requirements:** Shimmer3R device, firmware ≥ v1.0.22, Chrome/Edge (Web Bluetooth for BLE; Web Serial for Classic Bluetooth/USB) -| Demo | Link | -| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| Gyro breakout game | [break-gyro](https://shimmerresearch.github.io/webBLEDemos/break-gyro/) | -| EMG breakout game | [break-emg](https://shimmerresearch.github.io/webBLEDemos/break-emg/) | -| 200 G accel punch detector | [punch-highG](https://shimmerresearch.github.io/webBLEDemos/punch-highG/) | -| EMG + Gyro rhythm game | [rythmgame-emggyro](https://shimmerresearch.github.io/webBLEDemos/rythmgame-emggyro/) | -| PPG heart-rate visualiser | [video-ppg](https://shimmerresearch.github.io/webBLEDemos/video-ppg/) | -| Two-device gyro brick game | [brick](https://shimmerresearch.github.io/webBLEDemos/brick/) | -| Spell caster (gyro gestures) | [spell-gyro](https://shimmerresearch.github.io/webBLEDemos/spell-gyro/) | -| Configure, stream, plot, record, browse the SD card, set device names, run the self-test and check clock drift | [ShimmerCapture](https://shimmerresearch.github.io/webBLEDemos/ShimmerCapture/) | -| Consensys trial export + Bluetooth RTC set | [consensys-export](https://shimmerresearch.github.io/webBLEDemos/consensys-export/) | +| Demo | Link | +| ------------------------------------------ | ------------------------------------------------------------------------------------- | +| Gyro breakout game | [break-gyro](https://shimmerresearch.github.io/webBLEDemos/break-gyro/) | +| EMG breakout game | [break-emg](https://shimmerresearch.github.io/webBLEDemos/break-emg/) | +| 200 G accel punch detector | [punch-highG](https://shimmerresearch.github.io/webBLEDemos/punch-highG/) | +| EMG + Gyro rhythm game | [rythmgame-emggyro](https://shimmerresearch.github.io/webBLEDemos/rythmgame-emggyro/) | +| PPG heart-rate visualiser | [video-ppg](https://shimmerresearch.github.io/webBLEDemos/video-ppg/) | +| Two-device gyro brick game | [brick](https://shimmerresearch.github.io/webBLEDemos/brick/) | +| Spell caster (gyro gestures) | [spell-gyro](https://shimmerresearch.github.io/webBLEDemos/spell-gyro/) | +| Consensys trial export + Bluetooth RTC set | [consensys-export](https://shimmerresearch.github.io/webBLEDemos/consensys-export/) | **Consensys Export** packages a logged Shimmer3/Shimmer3R trial into the Consensys import folder structure, zips it, and shares it. It also sets the device real-time clock over Bluetooth. Best on a Chromium browser (Chrome/Edge); on iPhone/iPad use the [Bluefy](https://apps.apple.com/app/bluefy-web-ble-browser/id1492822055) app for the Bluetooth RTC feature. -**Shimmer Capture** is a worked example of driving a single Shimmer3R from a browser: connect over **BLE**, **Classic Bluetooth** (a paired COM port, via Web Serial) or **USB-C**, then configure it, stream from it, plot it and record a CSV. The configuration editor is generated from the SDK's description of the InfoMem, so it covers the whole LogAndStream option set — sampling rate, every sensor's range and rate, GSR, expansion power, the SD-logging and trial settings, the sync settings — and it edits the 384-byte image in place, so the bytes no field on the page models survive a read, an edit and a write untouched. The Calibration group in that form shows each 21-byte block as the offset, sensitivity and alignment grids the Calibration tab uses, with the range the image is configured for beside them. There is a hex view of that image with save and load, a calibration-dump reader and writer (read on connect, so the tab is populated before anybody opens it), the decoded device status flags and a real-world-clock set. A **General** tab leads the strip, as in the Verisense device console: the clock, the red LED and the one-shot device commands. Note that **the Shimmer3R's USB-C port speaks the dock protocol, not the Bluetooth one**, so over USB the page configures the sensor but cannot stream from it; it says so rather than offering a button that cannot work. Append `?mock=1` to the URL to drive the whole page against a scripted sensor with no hardware on the desk. Two further tabs cover what used to be separate pages: **SD card** browses the sensor's card and pulls logged sessions off it, in the Consensys import layout — `//data/…`, the MAC being what the Consensys importer looks for — or as the card is laid out, with a live throughput readout, an abort, resume after an abort, and an option to delete a file only once its download has been verified; **Device naming** reads and writes the EEPROM record holding the Classic-Bluetooth, BLE and USB names, so a sensor advertises a customer's branding instead of the Shimmer defaults, with a restore-to-factory control and the restart a new name needs (armed over Bluetooth, walked through by hand over the dock); and **Test** runs the sensor's own factory self-test and shows the report as it prints, with the verdict words coloured, a parsed pass/fail summary and text or CSV export, measures how far the sensor's real-world clock drifts from this host's with a least-squares fit in ppm, host-clock-step detection, a plot and CSV export, and switches the sensor's red LED so you can tell which one on the bench you are talking to. The standalone `sd-download`, `eeprom-branding` and `rtc-drift-test` pages have been removed in favour of these tabs. It is an example for one device, not a replacement for **Consensys**, Shimmer's -desktop application for configuring, streaming, recording and analysing across -devices — the page says so on its Sensor link card. Every channel is plotted and -recorded in engineering units, the time axis reads the local clock, and the -Configure tab applies Consensys's own sensor-conflict and expansion-power -rules. See the [demo README](./ShimmerCapture/README.md). +**Shimmer Capture** — configure, stream, plot, record, browse the SD card, +set device names, run the factory self-test and measure clock drift on a single +Shimmer3R, over BLE, Classic Bluetooth or USB-C. It moved to its own repository: +[shimmer-capture-web](https://github.com/ShimmerResearch/shimmer-capture-web), +live at +[shimmerresearch.github.io/shimmer-capture-web](https://shimmerresearch.github.io/shimmer-capture-web/). ### Verisense @@ -103,12 +107,11 @@ powershell -ExecutionPolicy Bypass -File .\update-local-sdk.ps1 -SdkRepoPath "C: ### SDK source selection (single location) -Every page and every module under `common/` imports the SDK from the shared -`vendor/` directory at the repository root, e.g. -`../vendor/shimmer-web-sdk.esm.js`. The Chrome extension keeps its own copy at -`shimmer-extension/vendor/` because only that folder is packed for the store — -see `vendor/README.md`. The file that controls where vendor artifacts come from -is `sdk-source.json`: +Every page imports the SDK from the shared `vendor/` directory at the +repository root, e.g. `../vendor/shimmer-web-sdk.esm.js`. The Chrome extension +keeps its own copy at `shimmer-extension/vendor/` because only that folder is +packed for the store — see `vendor/README.md`. The file that controls where +vendor artifacts come from is `sdk-source.json`: ```json { diff --git a/ShimmerCapture/README.md b/ShimmerCapture/README.md index ccb7cc3..8e6d3de 100644 --- a/ShimmerCapture/README.md +++ b/ShimmerCapture/README.md @@ -1,635 +1,27 @@ -# Shimmer Capture +# Shimmer Capture has moved -An example of configuring, streaming, plotting and recording **one** Shimmer3R -from a web browser, with no installer and no driver. +Shimmer Capture now has its own repository: -It is a worked example, not a replacement for **Consensys**, Shimmer's desktop -application — which is where configuring, streaming, recording and analysing -data across your devices belongs. This page says so on its Sensor link card, -because somebody who arrived from a search should not have to work it out. There -is no device list, no trial management, no multi-sensor synchronisation and no -analysis: one sensor, one connection, one recording at a time. What it does -show is that everything a Shimmer3R can be told over its Bluetooth or USB link -is reachable from a page you can read in an afternoon. +- **Source:** [ShimmerResearch/shimmer-capture-web](https://github.com/ShimmerResearch/shimmer-capture-web) +- **Live:** [shimmerresearch.github.io/shimmer-capture-web](https://shimmerresearch.github.io/shimmer-capture-web/) -- Live demo: [https://shimmerresearch.github.io/webBLEDemos/ShimmerCapture/](https://shimmerresearch.github.io/webBLEDemos/ShimmerCapture/) +`index.html` beside this file is a redirect stub, not the page. It is here so +that every link published while Shimmer Capture lived at +`…/webBLEDemos/ShimmerCapture/` keeps working, and so search engines learn the +new URL from its `rel="canonical"`. Leave it in place. -## The three ways to connect +## What went with it -| Link | Configure | Read / write the configuration image | Set the clock | Status flags | Calibration dump | Stream to the host | Log to the SD card | Browse / download the card | Set device names | Throughput test | Factory self-test | Clock drift | Red LED | -| --------------------- | :-------: | :----------------------------------: | :-----------: | :----------: | :--------------: | :----------------: | :----------------: | :------------------------: | :--------------: | :-------------: | :---------------: | :---------: | :-----: | -| **BLE** | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | -| **Classic Bluetooth** | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | -| **USB-C** | yes | yes | yes | battery only | no | **no**\* | **no**\* | **no**\* | yes | **no**\*\*\* | yes\*\* | yes | **no** | +The page, its README, and `common/` — the shared UI library it was built on. +`common/` moved rather than being copied because by the time of the split +nothing else in this repository imported it: the other demos each carry their +own UI, and only Shimmer Capture ever loaded `common/theme.css`. The +`verify.yml` workflow went too; it only ever ran the Shimmer Capture pass. -\* **The Shimmer3R's USB-C port speaks the dock protocol, not the Bluetooth -one** — the firmware routes the bytes arriving on the USB serial port to the -same parser a docked sensor talks to, which is a configuration and -file-transfer channel with no sample stream on it at all. So over USB the page -configures the sensor, sets its clock and reads and writes its configuration -image, and it closes the Stream tab off with a note saying streaming needs a -Bluetooth link. That is a property of the firmware, not a limitation of the -page. +What stayed: every other demo, the Chrome extension, and the shared +`vendor/` copy of the SDK that eighteen pages here still import. The new +repository vendors its own copy from the same build, so +`C:\dev\web\sync-all-vendors.ps1` now has a third consumer to keep in step. -The dock protocol is a different command set, not a subset, which is why the -last few columns differ: it has no `STATUS_RESPONSE` (it reports the battery -instead of the status bits) and no calibration-dump command, so the page greys -those controls out rather than guessing. It does have a clock write, so -setting the clock works over all three links, and it has a test command, so the -self-test runs over all three too. - -\*\* Over the dock the ExG chip test reports FAIL, because that connection and -the chip share pins and the firmware says so in the report itself. That is the -docked test, not the board — run the self-test over Bluetooth to judge an ExG -expansion board. - -\*\*\* **The throughput test is Bluetooth-only**, and for the same command-set -reason: the dock protocol has no data-rate test in it, so there is nothing over USB-C for the -page to call. Which Bluetooth link it runs over is the sensor's business — a -Shimmer3R offers both, while the older RN42 Shimmer3 fleet has no BLE radio at -all — which is why the page's own wording asks for Bluetooth rather than -naming BLE. - -The two Bluetooth links reach the same command set by different routes. **BLE** -uses Web Bluetooth and its own device picker. **Classic Bluetooth** uses Web -Serial against a COM port the operating system created when the sensor was -paired, so the sensor must already be paired with this host and you pick the -port rather than the device. A Shimmer3R pairs as two separate entries — one -classic, one BLE — and only the classic one answers on this path; the page says -so if you pick the wrong one. - -## General - -The first tab, as in the Verisense device console, and for the same reason: the -one-shot commands somebody reaches for straight after connecting come before -the tabs that are a task in themselves. - -The **clock** is here rather than with the configuration because it is a device -command and not a stored setting — the real-world clock is not part of the -configuration image. Set it from this host, or read it back on its own; reading -it alone is deliberately not the Device panel's **Refresh**, which also reads -the battery and the status bytes, because three round trips to answer one -question is three chances for one of the others to put an error on screen about -something nobody asked about. - -The **LEDs** are here rather than on the Test tab because nothing about them is -a test. They drive the firmware's red-LED override, which holds the lower LED -solid red on top of the sensor's own indications: a "which sensor is this one" -aid, not a health check. The firmware never clears the flag, so it stays lit -across a disconnect until it is toggled again or the sensor is power-cycled, -and the pill shows what the sensor's own status byte says rather than what this -page last asked for. Bluetooth only — the dock protocol has no LED command. The -sequence that exercises every LED _colour_ is part of the factory self-test, -which is on **Test**. - -**Device commands** is the general place for the rest. **Re-inquire channel -list** asks the firmware what it is set to send and at what rate — the page -does this on connect and after an Apply, so this is for the case where -something else reconfigured the sensor meanwhile. **Reboot on next disconnect** -arms the firmware's one-shot soft reboot: it cannot reboot while the link is -up, and it skips the reboot entirely while the sensor is recording so that it -can never truncate a trial. Reach for it after writing advertising names, which -the Bluetooth module only re-reads at boot. - -## Configuring - -The configuration editor is generated from the SDK's description of the -Shimmer3/Shimmer3R InfoMem, so it covers the **whole LogAndStream option set** -rather than a hand-picked subset: sampling rate, every sensor's range, rate, -low-power and high-resolution mode, GSR range, expansion-board power, the -pressure sensor's oversampling, the Bluetooth baud rate, the SD-logging -start-up and duration settings, the trial and experiment identifiers, the -multi-sensor sync settings and the stored calibration blocks. Every control -carries the byte and bit it lives in, on hover. - -The **Calibration** group in that form is the six 21-byte kinematic blocks the -image holds, and they are laid out the way the Calibration tab lays out the -dump: an offset vector, a sensitivity vector and a 3x3 alignment matrix, each -labelled with its unit, and a chip naming the range the image is configured for -— which is what makes the numbers mean anything, since the image holds one -block per sensor rather than one per range. Forty-two hex characters in a text -box was what the bytes are and unreadable with it. - -They are editable here, and read-only on the Calibration tab when that tab -falls back to showing the image: the configuration image belongs to this form, -and two panels writing one image would silently drop whichever change lost. A -value the 21-byte format cannot hold is refused with the box named, never -clamped, and a block holding no calibration at all — every byte 0x00 or 0xFF — -shows the factory defaults the firmware would fall back to, greyed, so nobody -reads them as measurements. - -Two things the page owns rather than the schema: - -- **The sensor checkboxes.** The enable bitmaps are per-channel maps rather - than scalar settings, so they get a checkbox grid instead of a spinner. -- **The ExG mode.** Nobody configures an ADS1292R front end by typing register - values, so a single control picks one of the known-good banks — EMG, ECG or - test signal, 16-bit — and takes over the ExG sensor bits so two controls can - never fight over them. -- **The sensor rules.** A Shimmer3R has more sensors than it has ADC inputs, so - some combinations are impossible; see below. - -### Which sensors can be enabled together - -The page behaves as Consensys does: **the newest choice wins.** Tick a sensor -that conflicts with one already on and the other is unticked, with a line in the -banner and the log saying which and why. Nothing is ever refused — a box you -click ticks, and being told what else moved is friendlier than being told no. - -Every message distinguishes two kinds of rule, because they mean different -things to you: - -- **The firmware enforces it.** GSR, the bridge amplifier and ExG each share an - ADC input with an internal ADC channel, and the firmware clears the loser - itself at its next configuration write whatever the page sends. Reporting - these is predicting the device, not overruling it. -- **Only Consensys enforces it.** GSR, the bridge amplifier and ExG are three - different expansion boards and only one board can be fitted, so the firmware - will accept the combination, stream it, and give you nothing usable. These - come from the desktop driver's own conflict tables. - -**Expansion-board power is derived, not left to you.** GSR, the bridge amplifier -and ExG run off the internal expansion rail, and the firmware never switches it -on for them: the bit defaults to off, is read once when sensing starts, and -appears nowhere in the firmware's own configuration correction. So a sensor can -accept your configuration, hand it straight back unchanged, and stream a -perfectly well-formed packet from an unpowered front end. The page turns the -rail on whenever something needs it and off when nothing does — except when an -internal ADC channel is enabled, where it leaves the bit alone, because that -line may be wired to something the page cannot know about. (On a Shimmer3R the -bit does not in fact power the ExG board, which comes up through its own reset -line; Consensys sets it for ExG on every platform, a Shimmer3 genuinely needs -it, and matching Consensys keeps the two tools' images identical.) - -**A sensor this hardware does not have is greyed out**, with the board it needs -in its tooltip: GSR wants a GSR+ board, ExG an ECG/EMG board, and the bridge -amplifier an SR8 or SR49 **on a Shimmer3** — the Shimmer3R firmware has no -bridge-amplifier channel at all. The internal ADC lines only warn rather than -refuse, because the desktop driver's board lists for them are -revision-specific and name boards this page's table does not carry. A board that -will not say what it is gates nothing. And a box that is already ticked is never -disabled, whatever the board says: a configuration read off a device can contain -anything, and you have to be able to untick it. - -**An image that arrives already broken** — read from the device, or loaded from -a `.bin` — cannot be corrected by "newest wins", because nothing was newest. It -gets the same banner with a **Fix** button, which edits the form the way -Consensys would and leaves the writing to you. - -**On required channels: there are none**, in the enable-bitmap sense, and it is -worth saying so rather than leaving a gap. The desktop driver declares a -required-sensor list on every sensor and populates it nowhere, so the code that -reads it never does anything. The real dependencies are the expansion rail -above; the firmware's own rule that forces an internal ADC channel on for the -skin-temperature and resistance-amplifier probes, which are derived channels -this page does not model; and the algorithm layer, which this page does not -have. Pressure and temperature are one enable bit and one checkbox, which is -the closest thing to a required pair here. - -**The working document is the 384-byte image.** Every control reads and writes -those bytes directly, so the reserved bits and the regions no field on the page -models survive a read, an edit and a write untouched — which is what makes it -safe to edit one setting on a sensor configured by something else. A hex view -shows the whole image with the changed bytes highlighted, and it can be saved -to a `.bin` and loaded back, so a configuration can be captured from one sensor -and applied to another. - -**Apply** shows you what it is about to write, and then the steps it will take. -The order is the firmware's, not a preference: the stored image first, then the -SD-card configuration file, then the settings that also have an -immediate-effect command (sensors, then sampling rate, then ranges, then -expansion power) and the ExG bank last, because its oversampling ratio is -derived from the sampling rate that was just set. It finishes by asking the -sensor what it now says rather than trusting what was written. **The firmware -refuses configuration commands while the sensor is sensing**, so Apply is -greyed out with that reason while a stream or a recording is running. - -## Device identity, on every tab - -The panel beside the Sensor link card carries what is true of the sensor -whatever tab you are on: its name, MAC, hardware, firmware, Bluetooth module, -the battery voltage, charge and charger state, which link it is on, and — over -a Bluetooth link — the decoded device status flags: docked, sensing, streaming, -logging, SD card present, SD file error, clock set, USB plugged in. - -Three of those rows say more than their labels suggest. - -**Name** is the sensor's own configured name, out of its configuration image, -which is the name it answers to everywhere else — in its recordings' file -names, in Consensys, on the naming tab. When that has not been read yet, or -was never set, the panel falls back to the name the link reported and says so: -`(advertising name)`. The two can differ, because a Bluetooth module only -re-reads its name at boot, so a rename that has not been followed by a -power-cycle still advertises the old one. A serial link — classic Bluetooth or -USB-C — reports no device name at all, so there the configured name is the only -one there is. - -**Hardware** is the platform, the board and its SR code: `Shimmer3R GSR+ -(SR48-3-0)`. The SR code comes from the board's own id page, and each part is -dropped if it is not known — a board newer than this page's name table still -shows as `Shimmer3R (SR52-1-0)`, and a sensor whose id page was never written -as `Shimmer3R`. - -**Bluetooth** is what the radio module says about _itself_, which is not the -same thing as the sensor's firmware version on the row above. A Shimmer3 -reports its RN module's own banner (`RN4678 v1.23`); a Shimmer3R reports the -CYW20820 in the Vela module (`CYW20820 v1.4.18.18`). It reads `not reported` -when the module has not answered the firmware's version query — which is a -real state on a sensor asked early enough, not an error. - -It sits there rather than on the Configure tab because two of those facts gate -work everywhere else. Whether the sensor is sensing decides both an SD download -and a name write, and the battery is the thing to check before starting a long -download. The flags are also the only way to learn that the sensor was started -from its own button, or that its firmware could not open its SD file; the page -says so in the log when it sees them. - -## The clock - -On the **General** tab. The sensor keeps its clock in **UTC**, which is what -desktop Consensys and the dock software write, so setting it from this host -writes a plain epoch and both the sensor's clock and the host's are shown in -this host's local time — they should read the same. - -It reads and writes over **every** link, USB-C included. There is no dock -equivalent of the Bluetooth GET_RWC command, but the dock protocol has a -read-only `CURR_LOCAL_TIME` property that answers with the same eight bytes in -the same unit, so the two paths are interchangeable — `common/device-clock.js` -picks whichever the current link has. (An earlier version of this page said -the clock could be set but not read back over USB. That was true of the page, -not of the link: it tested for the Bluetooth method and gave up, while the -capability it gated the button on already counted the dock property. So the -button was enabled and always refused.) - -(An earlier version of this page wrote and displayed the clock as local civil -time, a convention belonging to the Verisense console. It made a sensor set by -Consensys read a whole time-zone offset adrift, and a sensor set here read -adrift in Consensys.) The **Test** tab measures how far the sensor's clock -drifts from this host's over time. - -## Calibration - -Its own tab, because calibration is per-sensor and per-range rather than a -device setting, and because reading nine numbers off a hex dump is not a way to -check whether a sensor is calibrated. - -Each sensor gets a card: an offset per axis, a sensitivity per axis in that -sensor's own units, and a 3x3 alignment matrix, with the range the values apply -to and the date they were written. Sensitivity is three numbers rather than a -matrix because that is what the 21-byte block holds — a 3x3 grid would offer -six cells that cannot be saved. Alignment entries are signed bytes scaled by a -hundredth, so they are bounded, and a value the format cannot hold is refused -rather than quietly clamped on the way out. - -A sensor the hardware does not have is not offered: a Shimmer3 has neither the -high-g accelerometer nor the second magnetometer a Shimmer3R carries. Where the -sensor never said what hardware it is, the panel takes the hardware identity -from the dump's own version header rather than from the page's default, so it -cannot invite edits to sensors that may not exist. - -Three states are worth telling apart, and the panel does: values written for -this particular device, values that are still the factory seed, and no record -at all. The last is not zero — an unwritten block reads back as all ones or all -zeros, and showing that as a calibration of zero would be a lie about a sensor -that has never been calibrated. A per-sensor restore puts the factory seed back -for the selected range. - -The calibration is read **as part of the connect handshake**, along with the -configuration image, so the tab is populated before anybody opens it — a page -that showed a sensor's settings without showing what it is calibrated to had -told half the story, and the tab otherwise sat empty until somebody thought to -press Read. It goes last in the handshake, because it needs the generation and -the configured ranges the earlier reads establish, and it is tolerated rather -than required: an older firmware NACKs the command, and a tab that could not -be filled is not a connection that failed. - -Every card carries its date line whenever anything has been read, saying "no -date" where there is none. A line that appeared on three sensors and not on the -others read as those three being the only ones with a calibration date, when -what it meant was that the rest have no calibration at all — a different fact, -and one the pill beside each title already makes. - -The dump read on connect is also handed to the streaming conversion, so a -stream started afterwards reports this sensor's own numbers rather than its -part's factory seed — see **Streaming**. - -Reads, writes and the raw dump's save and load all work over a Bluetooth link. -The dock protocol has no calibration-dump command, so the controls are greyed -out over USB. One ordering the firmware imposes and the tab says out loud: -writing a configuration image regenerates the dump from the configuration -bytes, so a calibration must be written **after** a configuration, never -before. - -## Streaming, plotting and recording - -Start a stream on its own, or a stream and an SD recording together. The plot -draws one panel per sensor group from the channels the sensor is actually -sending, in raw or calibrated units, over a 5, 10 or 30 second window, and can -be paused or cleared without interrupting the stream. **Clear plots** drops -what is on screen and keeps going, which is how you get a clean view of what -happens next; it leaves the recording alone, and it leaves the time axis -counting from the stream's first sample, so the plot and the CSV never -disagree about when something happened. Alongside it a statistics strip -reads the achieved rate, the configured rate, packet loss — measured against -gaps in the _device_ clock, not host arrival times, so host Bluetooth buffering -cannot invent losses — throughput, frame count and elapsed time. - -### Every channel, in its own units - -**Calibrated means calibrated, for all of them.** Battery, the external and -internal ADC lines, PPG and the bridge amplifier read in millivolts; GSR in -microsiemens, with its resistance in kΩ and the resistor it used alongside; -pressure in kilopascals and temperature in degrees Celsius; ExG in millivolts; -and the six inertial groups in m/(s^2), deg/s and local flux. Until now -nineteen of the thirty-eight channels a sensor can send reached the plot as bare -ADC counts while the selector said "Calibrated", because only the inertial -groups and GSR had a conversion. - -Only the inertial sensors carry calibration **on the device**; everything else -is a fixed conversion the host has to know, which is why this was worth doing -once and properly. Pressure sits between the two: nothing per-device is stored, -but the part's own factory trim has to be fetched, so the page reads it on -connect over Bluetooth. Firmware that does not serve that command leaves -pressure and temperature raw-only, and the log says so rather than inventing a -number. - -The inertial values use **this sensor's own calibration**, from the dump read on -connect, falling back to its part's factory seed where the dump has nothing for -the configured range. Which of the two is in force appears in the log at stream -start, because the difference is a percent or so and invisible otherwise. - -Each quantity gets its own plot panel and its own axis label — battery, -pressure, temperature, the ADC lines and the bridge amplifier used to share one -axis with everything unrecognised, which stops working the moment they are -millivolts, kilopascals and degrees Celsius at the same time. - -### The time axis - -The x axis reads the **local time of day**. Two things can anchor it: - -- **The sensor's own real-world clock**, which is exact on a Shimmer3R: its - packet timestamp is the low 24 bits of the very counter its clock is kept in, - so one reading pins every later sample to the tick. On a Shimmer3 the counter - cannot be set and the clock is that counter plus an offset the firmware keeps - to itself, so the anchor is estimated from the request's round trip and the - log says by how much. -- **This host's clock**, if the sensor's is unset or its firmware has no clock - command. That is what Consensys does for every device, and it means the times - carry your computer's clock error rather than the sensor's. - -Switch the **Time axis** control to _Elapsed_ for seconds since the stream -started. Either way the counter is unwrapped first, so the axis no longer jumps -back to zero every 512 seconds — and a duplicated packet no longer adds 512 -seconds permanently, which the desktop driver's own rule does. - -Setting the sensor's clock mid-stream steps the counter the samples are timed -by, so the page re-reads it rather than carrying a stale anchor. - -### The recording - -Recording writes a CSV named `Shimmer3R__.csv`. -Its columns are derived from the first frame that arrives, so they are the -channels the sensor is sending rather than the ones the page expected, and rows -stream straight to the file you pick instead of being held in memory — a long -session is not lost if the tab closes. If the link drops mid-recording the file -is closed properly and what was captured is kept. - -Every channel appears twice, `_RAW` and `_CAL`, with the unit in the second row -— `mV`, `uS`, `kOhms`, `kPa`, `Degrees Celsius`, `m/(s^2)`, `deg/s`, -`local_flux`, and `no_units` for a raw column, which are the words Consensys -writes so one script can read either tool's files. Alongside them: -`HostTime_ms` when each frame reached this computer, `TIMESTAMP` as the raw -counter in ticks, `TIMESTAMP_CAL` as the device's own clock in milliseconds -(unwrapped), and `Timestamp_Unix_CAL` as wall-clock milliseconds whenever the -axis has an anchor. - -## The event log - -A drawer docked to the bottom of the viewport carries every command, reply and -status message, filterable by text and severity, and downloadable — which is -the first thing to attach to a support request. - -Collapsed it is a single bar showing the newest line, with a badge counting the -errors and warnings you have not seen — anything that arrived while the drawer -was closed, or while it was open but scrolled back through history. Opening it, -or scrolling back to the newest line, clears the badge. -Expanded it keeps the full page width, and whether it is open is remembered per -browser. The page reserves the space it occupies in either state, so it never -covers what is underneath it. - -**Copy** puts the whole log on the clipboard — every line, not just the ones -the filter is showing, which is the same text **Download** saves. It reports -what happened either way: the clipboard API is refused outside a secure context -and on an unfocused document, and a log is exactly what somebody wants to copy -when something has gone wrong, so a copy that silently did nothing would be the -worst possible failure. - -**Log raw TX/RX bytes** adds the bytes themselves, in both directions, on any -of the three links — the diagnostic to reach for when a sensor answers -something unexpected, or answers nothing. It is off until asked, and it leaves -out the streaming data packets unless you tick the second box, because a sensor -at 1024 Hz sends one every millisecond. Either way it is capped at 100 lines a -second, with one line saying what was held back. The severity filter's -**TX / RX** option shows exactly these lines; if the two controls are set so -that nothing can appear, the drawer says which one to change. - -## The SD card - -Browses the sensor's card and pulls logged sessions off it. Sizes and free -space come from the card itself; pick whole sessions or individual files. - -Files can be written either as the card lays them out or into the folder -structure Consensys imports, which is the default — the layout matters, because -Consensys will not find a session filed the other way. That structure is - - ///data/// - -and the second level is the sensor's **MAC address**, twelve lowercase hex -digits, not its name: a name folder produces a tree the Consensys importer -walks straight past, so the download looks complete and cannot be imported. -The panel shows the path it is about to write before it writes anything, with -the real MAC in it, and says so plainly if the address could not be read. - -The destination folder is remembered between visits, so a long download does -not start with a file dialog every time. A browser can never preselect an -absolute path, so the first download of a session asks once. - -A transfer shows its throughput and an estimate of the time left, and can be -aborted. Aborting keeps what has already been written and the folder it went -into, so pressing Download again resumes rather than starting a second copy -alongside the first. A file can optionally be deleted from the card once its -download has been verified — only verified files, and it says how many before -it does it. - -This needs a Bluetooth link and **firmware v1.01.011 or later**. Earlier -firmware either has no SD file-transfer commands at all or, on v1.01.009 and -v1.01.010, has them and corrupts every 512-byte block in transit, so the tab -refuses to start rather than hand back a file that looks fine and is not. - -## Device names - -Reads and writes the record in the sensor's EEPROM that decides the names it -advertises over Classic Bluetooth and BLE, and presents over USB, so a sensor -can carry a customer's branding instead of the Shimmer defaults. - -Type one Classic-Bluetooth name and the BLE and USB product names follow it -unless you set them yourself; the USB manufacturer string is used verbatim by -the descriptor and is never derived. The name lengths a sensor can carry differ -by hardware and by field, and the editor holds you to them rather than letting -the firmware truncate a name on air — where a sensor will not say what hardware -it is, it applies the shorter limit rather than assuming the roomier one. - -A write is CRC-protected, read back and compared, and shows what is changing -before it goes. **A new name only takes effect after the sensor restarts.** Over -a Bluetooth link the tab can arm the restart and trigger it by disconnecting; -over the dock it walks you through a power-cycle, because the dock protocol has -no restart command. "Restore stock defaults" returns the sensor to its factory -names. - -Works over all three links: the record lives in the same place and is reached -the same way whether the sensor is on a radio or in a dock. - -## Test - -Three things that take the sensor's link exclusively for a while. (The red LED -used to be here too; it is on **General** now, because nothing about it is a -test.) - -**Throughput** free-runs the firmware's data-rate test for five seconds and -counts what arrives, which is the only honest way to know a link's throughput: -BLE negotiates its connection interval with the host's own Bluetooth stack, so -two hosts and the same sensor can differ severalfold. It is named for what it -measures rather than for a link, but on this sensor only the Bluetooth ones can -run it — the dock command set has no data-rate test at all. It is refused -while the sensor is sensing or a transfer is running, because it saturates the -link on purpose. The figure lands in the -SD card tab's stats and drives its download estimates, so measuring once after -connecting makes those estimates worth reading. - -It used to sit beside the connect buttons. It is here now because it is a test -that holds the link, which is what everything else on this tab does, and it -shares their gating. - -**The factory self-test** is the same suite the firmware runs on the -production line, and it prints the same report: pick one of its four suites -(everything, LEDs only, chips only, or the LED operating states), press Run, -and the report appears line by line as the sensor prints it, with PASS, FAIL -and WARNING picked out. When it finishes, the parsed verdict appears above it -— including the failing test names decoded from the report's own fail mask — -and the report can be copied, saved as text, or saved as a CSV row. - -Two things are worth knowing before pressing Run. The sensor stops everything -else while it runs, and answers no other command until the report ends, so the -rest of the page is refused with that reason meanwhile — up to about a minute -for the LED-state walk-through. And **the firmware has no way to be -interrupted**: Cancel stops this page listening, but the sensor keeps printing -to its own end, so the page stays busy until then and says so. Disconnecting -is the only way out early. The LED suites are meant to be watched — each line -names the LED that should be lit at that moment. - -Over the USB-C/dock link the test runs through the dock protocol's own test -command. One line comes out differently there: the ExG chip test reports FAIL -from the dock, because that connection and the chip share pins. That is the -docked test, not the board. - -**The clock-drift monitor** samples the sensor's real-world clock against this -host's on an interval and least-squares fits the slope in ppm, with a plot, a -seconds-per-day readout, and CSV export carrying the fit and its metadata. The -sensor's clock is driven by its 32 kHz crystal, so the crystal's error shows up -here directly — the absolute figure the self-test's own crystal check cannot -give, because that one measures the 32 kHz crystal against the 16 MHz one and -reports only the difference. A wired link is the better one for this: its round -trips jitter less than Bluetooth. Expect a usable figure within an hour or two. -If this host's clock is stepped (by NTP, or by a daylight-saving change) the -fit rebaselines itself rather than fitting across the discontinuity, and a -sensor whose clock was set on a whole-quarter-hour offset — by a tool using a -different convention — is recognised as such and reported, rather than shown -as an hour of error. A clock that is genuinely wrong still shows as wrong. - -First measurements on hardware, docked: a stock Shimmer3R read about −9 ppm, -and a unit reworked to 22 pF crystal load capacitors about −98 ppm. Docked -sensors read a few ppm low from charge self-heating, so a battery run at room -temperature gives the comparable figure. - -## Requirements - -- A **Shimmer3R**. Firmware v1.0.22 or later for BLE streaming; the - configuration and calibration paths need firmware that serves the InfoMem - commands. Calibrated pressure and temperature need firmware that serves - `GET_PRESSURE_CALIBRATION_COEFFICIENTS`; without it those two channels stream - raw-only and the log says so. -- A **Chromium browser** — Chrome or Edge. BLE needs Web Bluetooth; classic - Bluetooth and USB-C need Web Serial. Neither is available in iOS browsers, - and Android has Web Serial for paired Bluetooth ports only. -- A **secure origin**: `https://` or `localhost`. Opening the file directly - from disk will not do — the browser refuses both APIs on a `file://` page. -- Streaming a CSV straight to disk uses the File System Access API. Without it - the page buffers the recording in memory and downloads it when you stop. - -## `?mock=1` — developing without a sensor - -Append `?mock=1` to the URL and a **Connect (mock)** button appears, which -connects the page to a scripted Shimmer3R that answers on a loopback link. It -is a development aid, not a firmware simulator: it answers the commands this -page sends with plausible values and correct framing, and emits synthetic sine -data at the configured rate. It models a small synthetic card, but not timing, power or -error paths, and it deliberately does not implement every command — a refused -one is a useful thing to be able to see. - -| Parameter | Effect | -| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `?mock=1` | Framed replies, one per notification — how BLE behaves. | -| `&framed=0` | Replies dribbled three bytes at a time — how a Classic-Bluetooth or USB byte stream behaves, and what re-framing is for. | -| `&rate=` | Sampling rate, default 51.2. | -| `&sdKBps=` | Throttle the synthetic card's transfer rate, so progress and abort have something to act on. | -| `&fw=` | Report a different firmware version, to see the SD tab refuse an unsupported one. | -| `&hw=none` | Refuse to say what hardware it is, to see the conservative name limits apply. | -| `&testMs=` | Shorten the self-test's per-LED dwell (2000 ms on real hardware, so a full LED test really is 18 seconds). | -| `&testFail=1` | A failing self-test: a FAIL line, a line long enough for the firmware to truncate, and the fail mask that goes with it. | -| `&ppm=` | Run the mock sensor's clock at this error, so the drift monitor has a slope to find. | -| `&clockBase=local` | Start the sensor's clock on this host's civil time rather than UTC — what a sensor set by a tool using the other convention looks like. | -| `&srBoard=` | The board's SR identity, as `id-rev-special` (default `48-3-0`, a GSR+). `none` fills the id page with 0xFF, an erased chip; `0-0-0` leaves it all zeroes, a page never written. Both read as no board. | -| `&btVersion=` | What the Bluetooth module replied. Defaults to a CYW20820 line, or an RN4678 banner with `&hw=3`. Empty models a module that never answered. | -| `&sensors=` | Preload the enabled-sensor bitmap, `0x0422E6` or decimal — how a calibrated multi-channel stream, or an image that already breaks a sensor rule, is reachable from a URL. | -| `&pressure=` | Which pressure part the mock claims: `390`, `581`, `180`, or `nack` / `silent` for the two ways real firmware fails to serve the coefficients. | -| `&rtcWrapIn=` | Put the sensor's clock this many seconds short of the point where the 24-bit stream counter rolls over, so a short stream crosses a wrap. It moves the clock, not the counter, because on a Shimmer3R the two are the same number. | -| `&debug=1` | Log every command and reply to the browser console. | - -While the mock is connected, `mockTransport.writes` in the console is every -command the page has sent, and `mockTransport.emitDisconnect()` simulates a -dropped link. `mockTransport.factoryTest` reports how many self-tests have run, -whether one is still printing and exactly what text it printed; -`mockTransport.rtc` exposes the sensor's own running clock. The panels -themselves are `factoryTestPanel` and `rtcDriftPanel`, beside `sdBrowser`, -`brandEditor` and `calibrationEditor`. - -It is opt-in from the URL only, and deliberately so: a page that reached for -the mock on its own would quietly show fake data to somebody debugging real -hardware. - -## Status - -This is an early example. It has been exercised end to end against the mock -link; the paths that only a real sensor can prove — that a configuration write -is accepted and applied, that a calibration dump round-trips, that a long -recording holds up at high rates, that the self-test report arrives whole over -a real link, that the red LED really lights — want confirming on hardware -before anyone relies on them for real work. Check a recording before it -matters. - -The calibrated values are the newest part and the least proven. Worth checking -against Consensys reading the same sensor, in this order: - -- **Pressure and temperature.** The whole conversion is host-side and none of it - has met a real BMP390 or BMP581. A Shimmer3 with a BMP280 is worth its own - look: its 20-bit registers are reassembled from a 16-bit temperature and a - 24-bit pressure, and getting that wrong is not subtle. -- **GSR in µS and kΩ**, and the battery in mV — both against Consensys, and the - battery against a meter. -- **ExG in mV** with the test-signal preset, whose amplitude is known. -- **The time axis.** That a Shimmer3R's packet timestamp really is the low bits - of its real-world clock is read out of the firmware, not measured; if it is - wrong the axis will be out by a whole multiple of 512 seconds, which is - obvious the moment you compare it with the Device panel's clock. -- **The sensor rules**, by writing an image that breaks one and reading it back: - the device should correct exactly what the banner predicted. +A change to `common/` no longer reaches anything in this repository, and a +change here no longer reaches Shimmer Capture. diff --git a/ShimmerCapture/index.html b/ShimmerCapture/index.html index 4515075..3a8d5ce 100644 --- a/ShimmerCapture/index.html +++ b/ShimmerCapture/index.html @@ -1,5612 +1,128 @@ - - - - + + Shimmer Capture has moved + + + + + - - - Shimmer Capture (Shimmer3R) - - - - - - -
-
-
-

Shimmer Capture

-
- Example: configure, stream, plot, record, rename and download the SD - card of a single Shimmer3R from the browser -
-
- - SDK … +

Shimmer Capture has moved

+

+ It now lives at + shimmerresearch.github.io/shimmer-capture-web, built from + ShimmerResearch/shimmer-capture-web. +

+

You should be redirected automatically. If not:

+

+ Go to Shimmer Capture - - -

- - - - - - -
- - - - - - - -
- - - -
- - -
-
Clock
-
- - -
-
-
Real-world clock
-
–
-
Host clock
-
–
-
-
- The sensor keeps its real-world clock in UTC, as desktop - Consensys and the dock software set it, so both lines above are - shown in this host's local time and should read the same. The - Test tab measures how far the sensor's clock drifts - from this host's over time. -
-
- -
-
LEDs
-
- - - - not read -
-
-
- The firmware's toggle-LED command holds the lower LED solid red on - top of the sensor's own indications, and leaves it that way until it - is toggled again or the sensor is power-cycled — a disconnect does - not clear it. It is a "which sensor is this one" aid rather than a - health check; the sequence that exercises every LED colour is on the - Test tab, as part of the factory self-test. -
-
- -
-
Device commands
-
- - -
-
-
- The channel inquiry is what tells this page the shape of a stream - frame, so it runs on connect and after an Apply; it is here for the - case where the sensor was reconfigured by something else meanwhile. - The reboot is armed rather than immediate — the firmware cannot - reboot while the link is up, and skips it altogether while the - sensor is recording so that it can never truncate a trial. It is - one-shot: it never carries into a later disconnect. Reach for it - after writing advertising names, which the Bluetooth module only - re-reads at boot. -
-
-
- - -
-
-
Configuration
- - - - - -
-
- -
-
Configuration image (InfoMem, 384 bytes)
-
- - - - - - not read -
- -
-
- Highlighted bytes differ from the image last read from the sensor. - Every byte no field on this page models — the reserved bits, the MPL - regions — is carried through a read, edit and write untouched. -
-
- -
- - - - - - -
-
- - -
- - -
-
-
- Calibration turns the sensor's raw counts into physical units: - C = R−1·K−1·(U - − B), with B the offset, K the diagonal - sensitivity and R the alignment. It is per sensor and per - range, so a sensor calibrated at ± 2 g has nothing stored for ± 16 g - and falls back to the factory defaults there — which is normal, not - a fault. Nothing on this tab changes what the sensor - measures; it changes how those measurements are read. -
-
-
- - -
- - -
-
Stream
-
- - - - -
-
- - - - - - - - - - -
-
-
- -
-
Record to CSV
-
- - - not recording -
-
-
File
-
–
-
Rows
-
0
-
Written
-
0 B
-
-
- Recording starts once the first frame has arrived, so the columns - are the ones the sensor is actually sending. Rows stream straight to - the file you pick, so a long session is not held in memory. -
-
- -
-
- - -
- - -
-
-
- Files are pulled off the card over the same Bluetooth link that - carries the configuration, so a download and a live stream cannot - run at once. Each file is verified by size on this host before it is - deleted from the card, and an interrupted download resumes from what - is already on disk — re-running it is always safe. -
-
-
- - -
- - -
-
-
- These names live in a record in the sensor's expansion-board EEPROM, - not in its configuration image, so they survive a configuration - write and are not part of a saved - .bin. The firmware seeds the record with the factory - names at first boot and treats it as the only source of truth - afterwards, so what is shown above is what the sensor really - announces. Every link reaches the record — BLE, Classic Bluetooth - and the USB-C/dock connection alike. -
-
-
- - -
- - - -
-
Throughput test
-
-
- - not measured -
-
- Streams data for five seconds and counts what arrives, which is the - only honest way to know a link's throughput: on BLE the connection - interval is negotiated with the host's own Bluetooth stack, and on - Classic Bluetooth the rate follows that host's own serial buffering, - so two hosts and the same sensor can differ severalfold. It needs - one of the Bluetooth links either way — the USB-C dock command set - has no data-rate test at all. The figure lands in the SD card tab's - stats and drives its download estimates, so measuring once after - connecting makes those estimates worth reading. -
-
- -
-
Factory self-test
-
- -
-
- This is the same self-test the firmware runs on the production line. - The sensor prints its report over this link as it goes, and stops - everything else for the duration — up to about a minute for the - LED-state walk-through. Nothing can interrupt it: Cancel stops this - page listening, and the sensor keeps printing until its TEST END - line. The LED tests are meant to be watched, because each line names - the LED that should be lit as it is lit. -
-
- -
-
Real-time clock drift
-
- -
-
- The sensor's real-world clock is driven by its 32 kHz crystal, so - the crystal's error shows up here directly — the absolute figure the - self-test's own crystal check cannot give, because that one measures - the 32 kHz crystal against the 16 MHz one and reports the - difference. A wired link is the better one for this: its round trips - jitter less than Bluetooth. Expect a usable figure within an hour or - two, and leave it longer for a tighter one; keep this tab open and - stop the host from sleeping. First measurements on hardware, docked: - a stock Shimmer3R read about −9 ppm, and a unit reworked to 22 pF - crystal load capacitors about −98 ppm. Docked sensors read a few ppm - low from charge self-heating, so a battery run at room temperature - gives the comparable figure. The fit rebaselines itself if this - host's own clock is stepped. -
-
-
-
- - -
- -
-
- - - - - - - - - - -
- -
-
-
- - +

+ diff --git a/common/README.md b/common/README.md deleted file mode 100644 index 6f7f6e5..0000000 --- a/common/README.md +++ /dev/null @@ -1,218 +0,0 @@ -# `common/` — shared UI library - -Framework-free ES modules and one stylesheet, shared by the demo pages in -this repo. This site is served straight off GitHub Pages: there is no build -step, no bundler and no `npm install`. Everything here is loaded by the -browser exactly as it is checked in. - -Three rules the whole library follows, so a page can rely on them: - -1. **Import convention.** Every module is reachable from any demo folder as - `../common/.js`. Modules import each other by bare relative path - (`./ui-chrome.js`), and reach the SDK at - `../vendor/shimmer-web-sdk.esm.js`. -2. **No DOM at import time.** No module touches `document` or `window` while - it is being imported — every one of them does its DOM work inside an - exported function. A page can therefore import in ``, in any order, - without a load-order knot. -3. **Say "host", not "PC".** User-facing strings talk about "this host", - because these pages run on phones and tablets too. - -## Modules - -| File | What it does | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `theme.css` | Design tokens (Pantone Orange 021C accents) and base components: page, cards, rows, grids, buttons, inputs, pills, banners, collapsible groups, config fields, tabs, toasts, the event log, a hex byte grid, a stats strip and plot panels. Light, explicit-dark and OS-dark. | -| `theme.js` | `THEME_BOOTSTRAP_SNIPPET` (the pre-paint `` script, as a string), `initThemeToggle`, `getTheme`, `setTheme`, `onThemeChange`. | -| `ui-chrome.js` | `$`, `el`, `createLog`, `showToast`, `createGate`, `initTabs`, `downloadBlob`, `fmtBytes`, `fmtHz`, `fmtDuration`, `classifyLogLine`, `formatLogTime`. `createLog` wires the filter, severity, clear, download and copy controls itself; the copy has two paths and reports which one worked, because the clipboard API is refused outside a secure context and on an unfocused document. | -| `connect-ui.js` | `createConnectController` — capability gating for the BLE / Classic-Bluetooth / wired buttons, connect-failure hints, port identity logging, platform advice, SDK version probe and disconnect detection. | -| `transport-tap.js` | `createTransportTap` — wraps any `ShimmerTransport` and logs the bytes crossing it as `TX …` / `RX …` hex lines, which is what feeds `createLog`'s TX / RX filter. Off until asked, streaming data packets excluded by default, capped per second. Also `formatHexLine`. | -| `config-form.js` | `createConfigForm` — renders a configuration editor from a declarative field schema and edits the device's configuration image in place, so bytes no field models survive untouched. Tracks which fields are dirty, validates before committing, and relocates fields a given hardware generation does not have. Its `editorFor` hook lets a page replace one field's control with an editor of its own, sharing the same commit, validation and dirty path — the module itself has no SDK dependency, so anything needing a codec is injected. | -| `kinematic-block-editor.js` | `createKinematicBlockEditorFactory` — the `editorFor` hook for a 21-byte calibration block: three labelled grids matching the calibration editor's, the configured range beside them, and the factory defaults greyed in when the block holds nothing. Also `calibrationFamilyFor`. | -| `shimmer3-config-schema.js` | The Shimmer3/Shimmer3R glue for that form: `describeShimmer3Caps` (feature detection for the gating keys), `LIVE_OVERLAYS` (fields that also have an immediate-effect Bluetooth setter), `buildApplyPlan` (the order the firmware requires), `EXG_MODES`, `SENSOR_GROUPS`. | -| `calibration-editor.js` | `createCalibrationEditor` — one card per sensor showing the offset, the per-axis sensitivity and the alignment matrix for a chosen range, with the factory seed, the never-calibrated state and a device-specific record told apart, and per-sensor restore. | -| `plot.js` | `createStreamPlot` — one Chart.js panel per sensor group, fed from ring buffers. Also `groupForField`, `padRange`, `PLOT_GROUPS`, `SHIMMER_TRACE_PALETTE`. | -| `stream-stats.js` | `createStreamStats` — rate, expected rate, loss, throughput, frames and duration, over the SDK's `StreamStatsTracker`. | -| `device-clock.js` | `readDeviceRwc`, `canReadRwc` — reading the real-world clock on whichever link is up. Over Bluetooth that is `getRtcTime()`; over the USB-C/dock link there is no such command and the dock's read-only `CURR_LOCAL_TIME` property answers with the same eight 32768 Hz ticks. One definition, because a page that gated a control on one rule and acted on another had a button that was enabled and always refused. | -| `csv-recorder.js` | `createCsvRecorder` — streams rows to a file the user picks, or buffers and downloads. | -| `sd-browser.js` | `createSdBrowser` — the on-card tree, a destination folder remembered across sessions, the Consensys-Backup layout (`//…`, with the real MAC in the path preview and a warning when it could not be read), progress with a rolling rate and an ETA, delete-after-verified and abort. Its `measureLinkSpeed` runs the firmware data-rate test, but the button for it belongs to the mounting page, since it measures the link and not the card. Also `fmtEta`. | -| `brand-editor.js` | `createBrandEditor` — reads and writes the expansion-board EEPROM record holding the Classic-Bluetooth, BLE and USB names, with per-field limits that follow the hardware, stock-versus-custom detection, write-and-verify, restore-to-factory and the restart a new name needs. | -| `factory-test-panel.js` | `createFactoryTestPanel` — runs the firmware's own factory self-test and shows the report as it prints, with the verdict words coloured, a parsed summary, and text/CSV export. Owns the cancel-and-drain behaviour the firmware's missing abort command forces on a host. | -| `rtc-drift-panel.js` | `createRtcDriftPanel` — samples the sensor's real-world clock against this host's, least-squares fits the drift in ppm, plots it, holds a screen wake lock, detects a stepped host clock and a sensor set on a different time convention, and exports CSV with its metadata. | -| `vendor/chart.umd.min.js` | Chart.js 4.5.1, pinned. See `vendor/README.md`. | -| `dev/mock-shimmer3r.js` | `createMockShimmer3RTransport`, `mockEnabledFromUrl` — a scripted Shimmer3R for developing without hardware. | -| `dev/verify.mjs` | The browser verification pass — see below. Runs in CI. | - -## Using it from a page - -```html - - - - - - - - -``` - -```js -import { initThemeToggle } from "../common/theme.js"; -import { $, createLog, showToast } from "../common/ui-chrome.js"; -import { createConnectController } from "../common/connect-ui.js"; -import * as sdkNs from "../vendor/shimmer-web-sdk.esm.js"; -``` - -Pass the SDK to `createConnectController` as a **namespace object**, not as -destructured names: a vendored bundle that predates one of the exports then -degrades to a warning instead of breaking the page's import. - -## Chart.js is pinned - -`common/vendor/chart.umd.min.js` is Chart.js **4.5.1**, checked in and -pinned. `plot.js` reads the global `Chart` rather than importing it, and -depends on version-specific behaviour (`parsing: false` with pre-sorted -point arrays, and the min-max `decimation` plugin). Do not swap in a CDN -copy or bump the version without re-checking a high-rate stream on hardware. -Details in `vendor/README.md`. - -## `?mock=1` — running without hardware - -Append `?mock=1` to a page's URL and `mockEnabledFromUrl()` returns true; -the page then hands `createMockShimmer3RTransport()` to the client instead -of opening a real link: - -```js -import { - createMockShimmer3RTransport, - mockEnabledFromUrl, -} from "../common/dev/mock-shimmer3r.js"; - -const transport = mockEnabledFromUrl() - ? createMockShimmer3RTransport() - : undefined; // undefined → the client opens a real BLE/serial link -const client = new Shimmer3RClient({ transport }); -``` - -The mock answers ACK/NACK, INQUIRY, firmware and device version, status, -battery, InfoMem read/write, the RTC, the sensor/rate/range setters and -start/stop for both streaming and SD-plus-Bluetooth logging, and emits -synthetic sine data at the configured rate. It also serves a small -synthetic SD card — one trial folder holding two session folders, sizes -that are not round, one file large enough to span three read windows — -over the real transfer protocol: `SD_LIST_DIR` with paging, -`SD_FILE_STAT`, `SD_FILE_READ` as CRC'd block frames, `SD_TRANSFER_ABORT`, -`SD_FREE_SPACE` and `SD_DELETE`, plus `SET_DATA_RATE_TEST` for a -link-speed readout. Options worth knowing: - -- `framed: false` makes it behave like an RFCOMM byte stream, delivering - every reply in 3-byte dribbles — that is how to exercise the SDK's - control-plane re-framing without a paired sensor. -- `firmware: {major, minor, patch}` sets what `GET_FW_VERSION` reports. - The default v1.01.012 is above the SD-transfer gate; pass v1.01.010 to - exercise a page's refusal path. -- `sdKBps` paces the streamed file blocks, so a download takes long - enough to have a progress bar, a throughput readout and an ETA worth - looking at, and long enough to abort mid-flight. -- `srBoard: '48-3-0'` sets the board's SR identity. The two blank patterns - are separate cases: `'none'` fills the page with 0xFF, an **erased** chip, - and `'0-0-0'` leaves it all zeroes, a page that was **never written**. The - SDK reads both as "no board", so both are worth exercising. Note this is - the daughter-card ID page, the first sixteen EEPROM bytes — a different - page from the card MEMORY the brand record lives in, which is why the mock - keeps two stores. -- `btVersion` is what the Bluetooth module replied when the firmware asked - it: a CYW20820 line by default, an RN4678 banner when the hardware id is - 3, and an empty string for a module that never answered — a real state, - since the firmware's buffer starts zeroed. -- `debug: true` logs every command and reply to the console. - -`transport.emitDisconnect()` simulates a dropped link, -`transport.writes` is every command the page sent, -`transport.identity` is the SR board and module version as the mock now -holds them, and `transport.sdCard.bytes(path)` is exactly what a download of -that card file should produce — which is what a test compares against. - -It is a development aid, not a firmware simulator: it does not model -power or most error paths, and its timing is plausible rather than real. -It is opt-in from the URL only, deliberately — a page that reached for -the mock on its own would quietly show fake data to someone debugging -real hardware. - -## Verifying without hardware - -`dev/verify.mjs` drives the whole of Shimmer Capture against the mock over the -Chrome DevTools Protocol and checks what came back. It has no dependencies — -Node's own WebSocket and `fetch` are all it uses — and it writes nothing into -the repository. - -```bash -npx http-server . -p 8129 -c-1 -chrome --headless=new --remote-debugging-port=9333 --user-data-dir=/tmp/verify-chrome -node common/dev/verify.mjs 9333 -``` - -Some of what it asserts is not observable in a browser at all: it reads the -page's own source to check that every panel's "why can't I do this" sentence -can name every OTHER panel's busy flag. All the panels compete for one link, so -each new panel silently ages the refusal text of every panel written before it -— a control greys out with nothing on screen to say why. Adding a panel means -adding its flag to that matrix. - -### It runs in CI, and gates on change rather than on the total - -`.github/workflows/verify.yml` runs the pass on every push and pull request. -For a long time nothing did, and that cost more than it saved: a block of -eleven genuinely broken checks sat failing for months, invisible because -reading the result meant running the pass by hand — and a regression could have -hidden among them without anyone noticing which failures were new. - -So the gate is about **change**. `dev/verify-known-failures.json` lists what is -known broken, one entry per check, each with the reason it is still there. It -is **empty**, and worth keeping that way: - -- a failing check that is **not** in the list is a regression, and fails the - build -- a check in the list that now **passes** is stale, and also fails the build, - so the list shrinks as things get fixed rather than growing quietly -- a failing check that is in the list is printed with its reason and tolerated - -Regenerate the list from a run with `node common/dev/verify.mjs ---update-baseline`, then read the diff before committing it. Adding a name is a -decision to ship a known-broken check, and it needs a reason written next to -it — the file is the record of that decision, not a mute allow-list. - -`VERIFY_CRC=0` runs the whole pass with the link CRC off. That switch is what -emptied the list: eleven SD-transfer checks failed with a CRC on and passed -with it off, which said they shared a cause rather than being flaky, and the -cause turned out to be the mock applying the link CRC to transfer frames the -firmware sends raw. CI runs that variant too, non-blocking. - -**Do not run Prettier across the whole repository from here.** This checkout -has CRLF line endings, so `--list-different "**/*.html"` flags every HTML file -on line endings alone; CI checks out LF and sees none of it. Format the file -you actually touched. - -## Formatting - -CI (`.github/workflows/html-format.yml`) runs prettier 3.3.3 over -`**/*.html` only, and auto-commits the result onto the pushed branch. It -does **not** touch the `.js`, `.css` or `.md` files in this folder, so -format them yourself before pushing: - -```bash -npx --yes prettier@3.3.3 --write "common/**/*.{js,css,md}" -``` - -Use that exact version. A different prettier reflows the whole folder and -buries the real change in a diff nobody can read. - -That glob does match `vendor/chart.umd.min.js`, and prettier un-minifies a -minified file — it grows from 204 KB to 342 KB and the diff is the whole -library. The repo's `.prettierignore` lists the vendored bundles for exactly -that reason, so the command above is safe as written; if you run prettier -with `--ignore-path` pointed somewhere else, exclude `common/vendor/` -yourself. diff --git a/common/brand-editor.js b/common/brand-editor.js deleted file mode 100644 index 7eda13d..0000000 --- a/common/brand-editor.js +++ /dev/null @@ -1,1198 +0,0 @@ -/** - * Device naming: read and write the EEPROM brand record that decides the name - * a sensor advertises over Classic Bluetooth, the name it advertises over BLE, - * and the product and manufacturer strings it presents over USB. - * - * Extracted from the retired `eeprom-branding` demo: the current-record readout and - * its stock/custom/invalid pill (L305-344, L705-756), the four provisioning - * fields with their per-field validation and live preview (L346-426, - * L592-685), the record I/O with its read-back byte compare (L691-702, - * L977-1012), the erase-to-factory path (L1014-1038) and the restart banner - * with its two routes (L266-303, L556-581, L940-962). The ~350 lines of - * connect, platform-advice and event-log plumbing that demo also carried are - * deliberately NOT here — `common/connect-ui.js` and `common/ui-chrome.js` - * own those now. - * - * The panel builds its own markup inside the host element and owns the - * `disabled` state of every control in it, so a page mounts it with one - * `
` and one call. It holds no page-specific ids and reads no page - * globals: the only things it knows about the outside world are the client it - * is handed, the log it writes to, and the callbacks below. That is what lets - * a combined Verisense + Shimmer3 application mount it unchanged. - * - * Read and write are the same two calls on every link — - * `readDaughterCardMem(BRAND_RECORD_HOST_OFFSET, BRAND_RECORD_SIZE)` and - * `writeDaughterCardMem(BRAND_RECORD_HOST_OFFSET, bytes)` — which - * `Shimmer3RClient`, `Shimmer3Client` and `WiredShimmerClient` all provide, - * so this panel needs no per-transport branch at all. The only thing the link - * decides is whether a soft restart can be requested; see - * {@link createBrandEditor} and `opts.mode`. - * - * Nothing here touches `document` at import time. - * - * import { createBrandEditor } from "../common/brand-editor.js"; - */ - -import { el } from "./ui-chrome.js"; -/* The whole namespace rather than destructured names: a vendored bundle that - predates one of the brand-record exports then degrades to a message from - `createBrandEditor()` instead of breaking the importing page. Destructuring - would throw at import time and take the whole page with it. */ -import * as sdk from "../vendor/shimmer-web-sdk.esm.js"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** Hardware ids, as GET_DEVICE_VERSION reports them. */ -const HW_ID = Object.freeze({ SHIMMER3: 3, SHIMMER3R: 10 }); - -/** - * Factory USB manufacturer string, matching BRAND_DEFAULT_USB_MANUFACTURER in - * log-and-stream-common `EEPROM/shimmer_eeprom.h`. Pre-filled into the form so - * rebranding only the advertising name needs no retyping, and used to tell a - * stock record from a custom one. - */ -const STOCK_MANUFACTURER = "Shimmer Research Ltd."; - -/** - * Factory defaults per platform, mirroring BRAND_DEFAULT_* in - * log-and-stream-common `EEPROM/shimmer_eeprom.h`. - * - * Deliberately keyed by hardware id and deliberately incomplete: there is no - * entry for "unknown", so {@link createBrandEditor}'s `isStockRecord` returns - * null rather than guessing when the sensor never said what it is. - */ -const STOCK_DEFAULTS = Object.freeze({ - [HW_ID.SHIMMER3]: Object.freeze({ - btClassic: "Shimmer3", - ble: "S3BLE", - usbProduct: "Shimmer", - }), - [HW_ID.SHIMMER3R]: Object.freeze({ - btClassic: "Shimmer3R", - ble: "Shimmer3R", - usbProduct: "Shimmer", - }), -}); - -/** - * Link names that carry the Bluetooth command set, and therefore SET_FEATURE. - * - * A soft restart is a Bluetooth command, so it is available over BLE and over - * Classic Bluetooth but NOT over the dock UART, whose protocol has no - * equivalent property. Today the method check alone would be enough — the - * wired client does not carry `setRebootOnDisconnect` at all — but naming the - * link keeps the reason visible instead of leaving it an accident of feature - * detection, exactly as `describeShimmer3Caps` does for streaming. - */ -const BLUETOOTH_LINKS = new Set(["ble", "rfcomm", "btclassic", "bt", "spp"]); - -/** The four names in the record, in the order the form shows them. */ -const FIELD_DEFS = Object.freeze([ - Object.freeze({ - key: "btClassic", - label: "Classic Bluetooth prefix", - placeholder: "e.g. YourBrand", - blankProblem: "enter a name", - hint: "The one name everything else falls back to.", - }), - Object.freeze({ - key: "ble", - label: "BLE prefix", - placeholder: "derived from the Classic Bluetooth prefix", - blankProblem: "enter a name", - hint: "Leave blank to reuse the Classic Bluetooth prefix.", - }), - Object.freeze({ - key: "usbProduct", - label: "USB product prefix", - placeholder: "derived from the Classic Bluetooth prefix", - blankProblem: "enter a name", - hint: "Leave blank to reuse the Classic Bluetooth prefix.", - }), - Object.freeze({ - key: "usbManufacturer", - label: "USB manufacturer", - placeholder: "e.g. YourBrand Ltd.", - blankProblem: "enter a manufacturer name", - /* Never derived: the USB device descriptor uses this string verbatim, so - there is nothing sensible to derive it FROM. A blank one is reported as - an error rather than silently filled, because it is pre-filled — an - empty box means the user cleared it on purpose. */ - hint: "Used verbatim; never derived from the other names.", - }), -]); - -// --------------------------------------------------------------------------- -// The panel -// --------------------------------------------------------------------------- - -/** - * Mount the device-naming editor inside `host`. - * - * @param {HTMLElement} host an empty container; its contents are replaced - * @param {object} opts - * @param {object|(() => object|null)} opts.client the connected client, or a - * getter for it. Pass the GETTER form from a page whose client comes and - * goes with the link — the panel is mounted once and then reads whatever is - * current, so it can never hold a stale client. - * @param {number|null|(() => number|null)} [opts.identifiedHardwareVersion] - * the hardware id the sensor ACTUALLY REPORTED, or `null`/`undefined` when - * identification failed. - * - * READ THIS BEFORE WIRING IT UP. It must be the raw identification result, - * never a defaulted one. Pages routinely default an unknown hardware - * version to a Shimmer3R when they build an InfoMem context, because the - * field schema needs *some* layout to render; passing that default here - * defeats the conservative BLE cap in precisely the case it exists for — - * an unidentified Shimmer3 would be offered a 10-character BLE prefix that - * its own firmware truncates to 8 on air. When in doubt pass nothing: the - * panel then assumes the shorter cap, which is safe on both platforms. - * @param {string|null|(() => string|null)} [opts.macSuffix] last four MAC - * characters, for the name preview; `null` when it could not be read - * @param {string|null|(() => string|null)} [opts.mode] which link the client - * is on — see {@link BLUETOOTH_LINKS}. Only decides whether the soft - * restart is offered; reading and writing work on every link. - * @param {{log: Function, warn: Function, error: Function}} [opts.log] - * @param {(busy: boolean) => void} [opts.onBusyChange] called when a record - * read, write or erase starts and finishes. A host page folds this into its - * own busy state, so the controls that share the link (Apply, an SD - * transfer) are refused while a name write is in flight. - * @param {(message: string, kind?: string) => void} [opts.toast] optional - * transient notification hook, e.g. `showToast` from `ui-chrome.js` - * @param {(text: string) => boolean} [opts.confirm] confirmation prompt; - * defaults to `window.confirm` - * @param {() => Promise|void} [opts.disconnect] how the host page drops - * the link after a soft restart is armed. Defaults to `client.disconnect()`, - * which is right for a single-purpose page and wrong for one that also has a - * recording to close, so pass the page's own teardown. - * @returns {{ - * read: () => Promise, - * write: () => Promise, - * restoreDefaults: () => Promise, - * armRestart: () => Promise, - * record: () => object|null, - * fields: () => {btClassic: string, ble: string, usbProduct: string, usbManufacturer: string}, - * setFields: (patch: object) => void, - * bleCap: () => number, - * stockDefaults: () => object|null, - * isStockRecord: (record?: object|null) => boolean|null, - * canSoftRestart: () => boolean, - * dismissRestartBanner: () => void, - * setEnabled: (enabled: boolean) => void, - * destroy: () => void, - * }} - */ -export function createBrandEditor(host, opts = {}) { - const getClient = - typeof opts.client === "function" ? opts.client : () => opts.client ?? null; - const log = opts.log ?? { log() {}, warn() {}, error() {} }; - const toast = opts.toast ?? (() => {}); - const ask = opts.confirm ?? ((text) => window.confirm(text)); - const getHardware = asGetter(opts.identifiedHardwareVersion); - const getMacSuffix = asGetter(opts.macSuffix); - const getMode = asGetter(opts.mode); - - /** The record last read from (or verified on) the sensor, or null. */ - let record = null; - /** A record read, write or erase is in flight. */ - let busy = false; - /** The floor the host page sets: can this link reach the record at all? */ - let enabled = false; - /** - * Sticky once the firmware has NACKed the soft-restart feature id, so the - * banner keeps offering the manual power-cycle rather than a button that has - * already been refused once. - */ - let softRestartRefused = false; - let destroyed = false; - - /* A vendored bundle from before the brand record shipped: say so once, - here, rather than throwing from the first button press. The constants - matter as much as the functions — an undefined offset or length would - reach `readDaughterCardMem` and fail there instead, where the message - would be about a bad argument rather than about a stale bundle. */ - const missing = [ - ...[ - "parseBrandRecord", - "buildBrandRecord", - "buildBlankBrandRecord", - "brandNameProblem", - ].filter((name) => typeof sdk[name] !== "function"), - ...[ - "BRAND_RECORD_HOST_OFFSET", - "BRAND_RECORD_SIZE", - "BRAND_BT_CLASSIC_MAX_CHARS", - "BRAND_BLE_MAX_CHARS", - "BRAND_BLE_MAX_CHARS_SHIMMER3", - "BRAND_USB_PRODUCT_MAX_CHARS", - "BRAND_USB_MANUFACTURER_MAX_CHARS", - ].filter((name) => typeof sdk[name] !== "number"), - ]; - if (missing.length) { - host.replaceChildren( - el( - "div", - { class: "banner err" }, - `This page is running an SDK bundle with no brand-record support (missing ${missing.join(", ")}). Re-vendor the SDK to read or change the names this sensor advertises.`, - ), - ); - log.error( - `device naming unavailable: the vendored SDK has no ${missing.join(", ")}`, - ); - return inertPanel(); - } - - // ------------------------------------------------------------------------- - // Markup - // ------------------------------------------------------------------------- - - const stat = (key, label) => - el( - "div", - {}, - el("span", { class: "stat-label" }, label), - el("span", { class: "stat-value", dataset: { brandStat: key } }, "–"), - ); - - const statsStrip = el( - "div", - { class: "stats" }, - stat("btClassic", "Classic Bluetooth"), - stat("ble", "BLE"), - stat("usbProduct", "USB product"), - stat("usbManufacturer", "USB manufacturer"), - stat("device", "Sensor"), - stat("mac", "MAC suffix"), - ); - - const setStat = (key, text) => { - const node = statsStrip.querySelector(`[data-brand-stat="${key}"]`); - if (node) node.textContent = text ?? "–"; - }; - - /* `data-brand-role` on every control the panel owns. Not decoration: it is - how a mounting application (or a test) addresses one of these without the - panel having to plant ids that would collide if it were mounted twice on - one page. */ - const recordPill = el( - "span", - { class: "pill", dataset: { brandRole: "recordPill" } }, - "not read", - ); - const btnRead = el( - "button", - { type: "button", dataset: { brandRole: "read" } }, - "Read names from sensor", - ); - const recordNote = el("div", { - class: "field-hint", - dataset: { brandRole: "recordNote" }, - }); - - /** key -> {input, preview, error, wrap} */ - const rows = new Map(); - for (const def of FIELD_DEFS) { - const input = el("input", { - type: "text", - placeholder: def.placeholder, - autocomplete: "off", - spellcheck: "false", - dataset: { brandInput: def.key }, - oninput: sync, - onchange: sync, - }); - const preview = el("div", { - class: "brand-preview", - dataset: { brandPreview: def.key }, - }); - const error = el("div", { - class: "brand-error", - dataset: { brandError: def.key }, - }); - const wrap = el( - "div", - { class: "field", dataset: { brandField: def.key } }, - /* The input sits INSIDE its label rather than being tied to it by id: a - panel a page can mount twice must not plant a fixed id. */ - el("label", {}, def.label, input), - el("div", { class: "field-hint" }, def.hint), - preview, - error, - ); - rows.set(def.key, { def, input, preview, error, wrap }); - } - - const btnWrite = el( - "button", - { type: "button", class: "primary", dataset: { brandRole: "write" } }, - "Write names to sensor", - ); - const btnRestore = el( - "button", - { - type: "button", - class: "danger", - dataset: { brandRole: "restore" }, - title: - "Erases the brand record. The firmware re-seeds the factory names at " + - "the next restart.", - }, - "Restore factory names", - ); - - // The restart banner, hidden until a write or an erase has landed. - const restartAuto = el( - "div", - { dataset: { brandRole: "restartAuto" } }, - "Restart now arms a one-shot restart and drops the link — the sensor " + - "restarts by itself as the link goes, then advertises the new names.", - ); - const restartManual = el( - "div", - { dataset: { brandRole: "restartManual" } }, - "This link cannot ask for a restart — the dock/USB serial protocol has no " + - "such command — so power-cycle the sensor by hand:", - el( - "ol", - { class: "brand-steps" }, - el("li", {}, "Disconnect this page from the sensor, or unplug it."), - el( - "li", - {}, - "Shimmer3: undock and re-dock it, or briefly press the reset pin " + - "through the hole in the enclosure. Shimmer3R: hold the button to " + - "power it off, then on again, or undock and re-dock it.", - ), - el( - "li", - {}, - "Reconnect and read the names again to confirm, or look for the new " + - "name in a Bluetooth scan.", - ), - ), - ); - const btnRestart = el( - "button", - { type: "button", class: "primary", dataset: { brandRole: "restart" } }, - "Restart now", - ); - const btnDismiss = el( - "button", - { type: "button", class: "secondary", dataset: { brandRole: "dismiss" } }, - "Dismiss", - ); - const restartCard = el( - "div", - { class: "card", dataset: { brandRole: "restart-banner" }, hidden: true }, - el( - "div", - { class: "banner warn" }, - el( - "strong", - {}, - "Restart needed — the new names are stored but not in use yet", - ), - el( - "div", - {}, - "The Bluetooth module only picks up its name when Bluetooth starts, " + - "and the BLE advertising name cannot change while advertising is " + - "running, so the sensor has to restart before it announces itself " + - "differently.", - ), - restartAuto, - restartManual, - el("div", { class: "row" }, btnRestart, btnDismiss), - ), - ); - - host.replaceChildren( - restartCard, - el( - "div", - { class: "card" }, - el("div", { class: "card-title" }, "Names on this sensor ", recordPill), - statsStrip, - el("div", { class: "row" }, btnRead), - recordNote, - ), - el( - "div", - { class: "card" }, - el("div", { class: "card-title" }, "New names"), - el( - "div", - { class: "grid" }, - FIELD_DEFS.map((def) => rows.get(def.key).wrap), - ), - el("div", { class: "row" }, btnWrite, btnRestore), - el( - "div", - { class: "field-hint" }, - "Printable ASCII, no commas. The firmware appends the MAC suffix to " + - "the Classic Bluetooth, BLE and USB product names; the USB " + - "manufacturer string is used exactly as typed. The record is never " + - "write-protected — it stays rewritable and erasable from here — and " + - "every write is CRC-protected, so a garbled record simply reverts " + - "to the factory names at the next restart.", - ), - ), - ); - - // ------------------------------------------------------------------------- - // Hardware-dependent rules - // ------------------------------------------------------------------------- - - /** - * The BLE prefix length this sensor can actually advertise. - * - * The full field width is offered ONLY when the sensor is positively - * identified as a Shimmer3R. Anything else — a Shimmer3, or a sensor whose - * identification failed — gets the shorter Shimmer3 cap, because Shimmer3 - * firmware truncates the prefix to 8 characters so "-XXXX" fits the - * RN4678's 31-byte advertisement. Guessing the wrong way here writes a name - * the device silently shortens on air, which is invisible from this page. - */ - function bleCap() { - return getHardware() === HW_ID.SHIMMER3R - ? sdk.BRAND_BLE_MAX_CHARS - : sdk.BRAND_BLE_MAX_CHARS_SHIMMER3; - } - - function capFor(key) { - if (key === "btClassic") return sdk.BRAND_BT_CLASSIC_MAX_CHARS; - if (key === "ble") return bleCap(); - if (key === "usbProduct") return sdk.BRAND_USB_PRODUCT_MAX_CHARS; - return sdk.BRAND_USB_MANUFACTURER_MAX_CHARS; - } - - function deviceLabel() { - const hw = getHardware(); - if (hw === HW_ID.SHIMMER3) return "Shimmer3"; - if (hw === HW_ID.SHIMMER3R) return "Shimmer3R"; - return "not identified"; - } - - /** The factory names for this platform, or null when it is unknown. */ - function stockDefaults() { - const d = STOCK_DEFAULTS[getHardware()]; - return d ? { ...d, usbManufacturer: STOCK_MANUFACTURER } : null; - } - - /** - * Is this record the factory one? - * - * Returns **null** when the hardware version is unknown: there is no - * platform to compare against, and reporting "custom" for a record that may - * be perfectly stock would send somebody looking for a brand that is not - * there. Declining to judge is the honest third answer. - * - * Derived by comparing the names rather than read from a flag, because - * firmware no longer stores one — the stock manufacturer string lives in the - * record itself, so the record is applied unconditionally. - * - * @param {object|null} [rec] defaults to the record last read - * @returns {boolean|null} - */ - function isStockRecord(rec = record) { - const d = stockDefaults(); - if (!rec || !d) return null; - return ( - rec.btClassic === d.btClassic && - rec.ble === d.ble && - rec.usbProduct === d.usbProduct && - rec.usbManufacturer === d.usbManufacturer - ); - } - - /** - * Can this link ask the sensor to restart itself? - * - * SET_FEATURE / FEATURE_REBOOT_ON_DISCONNECT is a Bluetooth command, so a - * BLE or Classic-Bluetooth link can arm it; the dock UART cannot. - */ - function canSoftRestart() { - if (softRestartRefused) return false; - const client = getClient(); - return ( - BLUETOOTH_LINKS.has(String(getMode() ?? "")) && - typeof client?.setRebootOnDisconnect === "function" - ); - } - - // ------------------------------------------------------------------------- - // The form - // ------------------------------------------------------------------------- - - /** - * What will actually be written, after the derivation rules. - * - * The BLE and USB *product* prefixes fall back to the Classic Bluetooth - * prefix when left blank, so a customer need only type one name. The USB - * *manufacturer* never derives: the device descriptor uses it verbatim. - * - * A TYPED name is taken exactly as typed, so one that is too long is - * refused by the validation below rather than quietly shortened; only a - * DERIVED name is truncated to fit, because nobody typed it and the - * alternative is refusing a classic prefix for being too long for a field - * the user never filled in. The original demo truncated both, and its BLE - * box kept a fixed maxlength of 10 while the cap could be 8 — so on a - * Shimmer3 a name typed in full was silently cut by two characters with - * nothing on screen to say so. - * - * @returns {{btClassic: string, ble: string, usbProduct: string, usbManufacturer: string}} - */ - function effectiveFields() { - const btClassic = rows.get("btClassic").input.value.trim(); - const rawBle = rows.get("ble").input.value.trim(); - const rawProduct = rows.get("usbProduct").input.value.trim(); - return { - btClassic, - ble: rawBle || btClassic.slice(0, bleCap()), - usbProduct: - rawProduct || btClassic.slice(0, sdk.BRAND_USB_PRODUCT_MAX_CHARS), - usbManufacturer: rows.get("usbManufacturer").input.value.trim(), - }; - } - - /** Per-field problem, or null. Keyed the same as {@link effectiveFields}. */ - function fieldProblems(eff = effectiveFields()) { - const out = {}; - for (const def of FIELD_DEFS) { - const value = eff[def.key]; - out[def.key] = value - ? sdk.brandNameProblem(value, capFor(def.key)) - : def.blankProblem; - } - return out; - } - - /** The advertised name a prefix produces, per platform. */ - function previewFor(key, eff) { - const hw = getHardware(); - const sfx = getMacSuffix() || "XXXX"; - const value = eff[key]; - if (!value) return ""; - if (key === "usbManufacturer") { - if (hw === HW_ID.SHIMMER3) { - /* A Shimmer3's USB is the dock's own bridge chip, so this string never - reaches a USB descriptor; the RN4678 does carry a 7-character - manufacturer field, which is where it lands instead. */ - return `→ BLE module manufacturer "${value.slice(0, 7)}" (the field fits 7 characters)`; - } - return `→ USB manufacturer "${value}"`; - } - if (key === "usbProduct") { - if (hw === HW_ID.SHIMMER3) { - return "→ unused on a Shimmer3 — its USB is the dock's own bridge chip"; - } - if (hw === HW_ID.SHIMMER3R) return `→ USB product "${value} ${sfx}"`; - return `→ USB product "${value} ${sfx}" on a Shimmer3R; unused on a Shimmer3`; - } - const tail = key === "ble" ? "-BLE" : "-BT"; - if (hw === HW_ID.SHIMMER3) return `→ ${value}-${sfx}`; - if (hw === HW_ID.SHIMMER3R) return `→ ${value}-${sfx}${tail}`; - /* Hardware not identified. The BLE cap has already been narrowed to the - Shimmer3 one; the SHAPE of the advertised name still differs between the - platforms, and asserting either would be a claim this panel cannot - back. */ - return `→ ${value}-${sfx} on a Shimmer3, ${value}-${sfx}${tail} on a Shimmer3R`; - } - - /** - * One writer for every `disabled` in this panel, plus the validation and the - * preview, so there is a single place that decides what the form looks like. - * - * `enabled` is the host page's floor — can this link reach the record at all - * — and nothing here lifts it. - */ - function sync() { - if (destroyed) return; - const client = getClient(); - const usable = enabled && !!client && !busy; - const eff = effectiveFields(); - const problems = fieldProblems(eff); - const anyProblem = FIELD_DEFS.some((def) => problems[def.key]); - - for (const def of FIELD_DEFS) { - const row = rows.get(def.key); - const problem = problems[def.key]; - row.input.disabled = busy; - row.input.maxLength = capFor(def.key); - row.preview.textContent = previewFor(def.key, eff); - /* Quiet while there is nothing to write to: an empty form under a - disconnected page is not a mistake anyone has made yet. */ - row.error.textContent = usable ? (problem ?? "") : ""; - row.input.classList.toggle("bad", !!(usable && problem)); - } - - /* Derivation truncates rather than refusing — a 16-character classic - prefix cannot fit the BLE field — so say so where it happens, because - the value that gets written is otherwise indistinguishable from one - that was typed. */ - for (const key of ["ble", "usbProduct"]) { - const row = rows.get(key); - if (!row.input.value.trim() && eff[key] && eff[key] !== eff.btClassic) { - row.preview.textContent += - " (truncated from the Classic Bluetooth prefix)"; - } - } - - btnRead.disabled = !usable; - btnWrite.disabled = !usable || anyProblem; - btnRestore.disabled = !usable; - btnRestart.disabled = !usable || !canSoftRestart(); - paintRestartBanner(); - } - - /** - * The guard every device operation starts with. - * - * `enabled` is checked here and not only on the buttons: it is the host - * page's statement that this link can be used for this right now — a stream - * is not running, an SD transfer is not holding the link — and a - * programmatic caller must be held to it too, or a name write could - * interleave with a file transfer's block stream on the one link they share. - * - * @param {string} what - * @returns {object|null} the client to use, or null when it must not proceed - */ - function clientFor(what) { - const client = getClient(); - if (!client) { - log.warn(`Connect a sensor before ${what}.`); - return null; - } - if (!enabled) { - log.warn( - `Not ${what}: the sensor's names cannot be reached over this link right now.`, - ); - return null; - } - if (busy) { - log.warn("A device-naming operation is already running."); - return null; - } - return client; - } - - function setBusy(next) { - if (busy === next) return; - busy = next; - sync(); - try { - opts.onBusyChange?.(next); - } catch (err) { - log.warn(`device-naming busy handler failed: ${err?.message ?? err}`); - } - } - - /** Fill the form. Values are taken as typed, not as effective. */ - function setFields(patch = {}) { - for (const def of FIELD_DEFS) { - if (patch[def.key] === undefined) continue; - rows.get(def.key).input.value = String(patch[def.key] ?? ""); - } - sync(); - } - - // ------------------------------------------------------------------------- - // The readout - // ------------------------------------------------------------------------- - - function showRecord(rec) { - record = rec; - setStat("btClassic", rec.btClassic || "–"); - setStat("ble", rec.ble || "–"); - setStat("usbProduct", rec.usbProduct || "–"); - setStat("usbManufacturer", rec.usbManufacturer || "–"); - setStat("device", deviceLabel()); - setStat("mac", getMacSuffix() || "unknown"); - - if (!rec.valid) { - recordPill.textContent = `invalid: ${rec.invalidReason}`; - recordPill.className = "pill err"; - recordNote.textContent = - `The record does not check out (${rec.invalidReason}), so the sensor ` + - "is using its factory names. This is not a fault: the firmware " + - "re-seeds the factory record at the next restart."; - sync(); - return; - } - - const stock = isStockRecord(rec); - const platform = - Object.entries(sdk.BRAND_PLATFORM ?? {}).find( - ([, v]) => v === rec.seededPlatform, - )?.[0] ?? "an unknown platform"; - recordPill.textContent = - stock === null - ? "valid" - : stock - ? "valid · factory names" - : "valid · custom names"; - recordPill.className = "pill on"; - recordNote.textContent = - stock === null - ? "This sensor did not report which hardware it is, so this page " + - "cannot say whether these are the factory names or a custom set — " + - "and it assumes the shorter Shimmer3 limit for the BLE prefix." - : stock - ? `These are the factory names, seeded by ${platform}.` - : "These are custom names, not the factory set."; - sync(); - } - - /** Forget the sensor: no record, no readouts. */ - function clearRecord() { - record = null; - for (const key of [ - "btClassic", - "ble", - "usbProduct", - "usbManufacturer", - "device", - "mac", - ]) { - setStat(key, "–"); - } - recordPill.textContent = "not read"; - recordPill.className = "pill"; - recordNote.textContent = ""; - softRestartRefused = false; - } - - // ------------------------------------------------------------------------- - // The restart banner - // ------------------------------------------------------------------------- - - /** - * Show whichever restart route this link actually has. - * - * Called from `sync`, so the route follows the link rather than being - * decided once: a page that reconnects over the dock after arming a restart - * over BLE gets the manual walkthrough without the panel being rebuilt. - */ - function paintRestartBanner() { - const auto = canSoftRestart(); - restartAuto.hidden = !auto; - restartManual.hidden = auto; - btnRestart.hidden = !auto; - } - - function showRestartBanner() { - paintRestartBanner(); - restartCard.hidden = false; - restartCard.scrollIntoView({ behavior: "smooth", block: "nearest" }); - } - - function dismissRestartBanner() { - restartCard.hidden = true; - } - - // ------------------------------------------------------------------------- - // Record I/O - // ------------------------------------------------------------------------- - - /** - * Rephrase a read failure as something the reader can act on. - * - * A truncated response and a stale SDK bundle look identical from the - * outside — both report fewer bytes than were asked for — and the fix for - * one is nothing like the fix for the other. - */ - function readFailureHint(err) { - const raw = String(err?.message ?? err ?? "unknown error"); - if (/response truncated/i.test(raw)) { - return `${raw} — the link dropped mid-response; try again, and check the sensor is in range`; - } - if (/returned \d+ of \d+ bytes/.test(raw)) { - return `${raw} — this page is running a stale SDK bundle (it needs 0.1.12 or later); hard-refresh, or re-vendor the SDK`; - } - if (/NACK/i.test(raw)) { - return `${raw} — the sensor refused the read; its firmware may predate the brand record`; - } - return `${raw} — check the firmware supports the brand record`; - } - - function firstDifference(a, b) { - if (a.length !== b.length) return Math.min(a.length, b.length); - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return i; - return -1; - } - - function readBytes(client) { - return client.readDaughterCardMem( - sdk.BRAND_RECORD_HOST_OFFSET, - sdk.BRAND_RECORD_SIZE, - ); - } - - /** - * Read the record and show it. - * - * @returns {Promise} the parsed record, or null on failure - */ - async function read() { - const client = clientFor("reading the names it advertises"); - if (!client) return null; - setBusy(true); - try { - log.log( - `reading the brand record (${sdk.BRAND_RECORD_SIZE} B at expansion-board offset ${sdk.BRAND_RECORD_HOST_OFFSET})…`, - ); - const bytes = await readBytes(client); - const rec = sdk.parseBrandRecord(bytes); - showRecord(rec); - /* The manufacturer box follows the DEVICE, not the factory string. - Pre-filling it with "Shimmer Research Ltd." and never updating it - would silently reset a customer's own manufacturer the next time - somebody changed only the Bluetooth name. */ - setFields({ - usbManufacturer: - rec.valid && rec.usbManufacturer - ? rec.usbManufacturer - : STOCK_MANUFACTURER, - }); - log.log( - rec.valid - ? `names on the sensor: Classic Bluetooth "${rec.btClassic}" · BLE "${rec.ble}" · USB product "${rec.usbProduct}" · USB manufacturer "${rec.usbManufacturer}"` - : `the brand record is invalid (${rec.invalidReason}) — the sensor is using its factory names`, - ); - return rec; - } catch (err) { - const message = `Reading the names failed: ${readFailureHint(err)}`; - log.error(message); - toast(message, "err"); - return null; - } finally { - setBusy(false); - } - } - - /** ` Label old -> new` lines for a confirmation prompt. */ - function changeLines(next) { - const pad = Math.max(...FIELD_DEFS.map((d) => d.label.length)); - return FIELD_DEFS.map((def) => { - const was = record?.valid ? record[def.key] : null; - const label = def.label.padEnd(pad); - if (next[def.key] === undefined) return ` ${label} ${was ?? "?"}`; - if (was === null) return ` ${label} → ${next[def.key]}`; - if (was === next[def.key]) { - return ` ${label} ${was} (unchanged)`; - } - return ` ${label} ${was} → ${next[def.key]}`; - }).join("\n"); - } - - /** - * Write the form to the sensor, then read it back and byte-compare. - * - * @returns {Promise} true when the write verified - */ - async function write() { - const client = clientFor("changing the names it advertises"); - if (!client) return false; - const eff = effectiveFields(); - const problems = fieldProblems(eff); - const bad = FIELD_DEFS.filter((def) => problems[def.key]); - if (bad.length) { - /* Should be unreachable from the button, which is disabled — but this is - also the programmatic entry point, and a write that shipped a name the - firmware rejects would leave the record CRC-valid and wrong. */ - log.warn( - `not writing: ${bad.map((def) => `${def.label} — ${problems[def.key]}`).join("; ")}`, - ); - sync(); - return false; - } - - let bytes; - try { - /* Always the SDK's builder, never a hand-rolled record: it owns the - magic, the layout version, the length bytes and the CRC, and a record - whose CRC is wrong is one the firmware throws away at the next boot. */ - bytes = sdk.buildBrandRecord(eff); - } catch (err) { - log.error(`the brand record could not be built: ${err?.message ?? err}`); - return false; - } - - if ( - !ask( - "Change the names this sensor advertises?\n\n" + - `${changeLines(eff)}\n\n` + - (record?.valid - ? "" - : "The record currently on the sensor is blank or invalid, so " + - "there is nothing to compare against.\n\n") + - "The record is written, read back and compared byte for byte. The " + - "new names are only advertised after the sensor restarts.", - ) - ) { - log.log("name change cancelled"); - return false; - } - - setBusy(true); - try { - log.log( - `writing names: Classic Bluetooth "${eff.btClassic}" · BLE "${eff.ble}" · USB product "${eff.usbProduct}" · USB manufacturer "${eff.usbManufacturer}"`, - ); - await client.writeDaughterCardMem(sdk.BRAND_RECORD_HOST_OFFSET, bytes); - const verify = await readBytes(client); - const at = firstDifference(bytes, verify); - if (at >= 0) { - const message = - `The names were written but the read-back differs at byte ${at}, ` + - "so the record is NOT provisioned correctly — try the write again."; - log.error(message); - toast(message, "err"); - showRecord(sdk.parseBrandRecord(verify)); - return false; - } - showRecord(sdk.parseBrandRecord(verify)); - log.log( - "names written and verified — the read-back matches byte for byte", - ); - toast( - "Names written and verified. The sensor has to restart before it advertises them.", - "ok", - ); - showRestartBanner(); - return true; - } catch (err) { - const message = `Writing the names failed: ${err?.message ?? err}`; - log.error(message); - toast(message, "err"); - return false; - } finally { - setBusy(false); - } - } - - /** - * Erase the record so the firmware re-seeds the factory names. - * - * Erasing rather than writing this page's own idea of the factory names is - * deliberate: the firmware is the authority on what "factory" means for the - * platform it is running on, {@link STOCK_DEFAULTS} is only a transcription - * of a C header, and an erase is the one restore that also works on a sensor - * that never said what hardware it is. - * - * @returns {Promise} true when the erase verified - */ - async function restoreDefaults() { - const client = clientFor("restoring its factory names"); - if (!client) return false; - const d = stockDefaults(); - if ( - !ask( - "Erase the brand record and go back to the factory names?\n\n" + - `${changeLines(d ?? {})}\n\n` + - (d - ? "" - : "This sensor did not report which hardware it is, so the exact " + - "factory names cannot be shown here — the firmware picks the " + - "right ones for its own platform.\n\n") + - "The record is erased. The firmware re-seeds the factory names at " + - "the next restart.", - ) - ) { - log.log("factory-name restore cancelled"); - return false; - } - - setBusy(true); - try { - const blank = sdk.buildBlankBrandRecord(); - log.log("erasing the brand record…"); - await client.writeDaughterCardMem(sdk.BRAND_RECORD_HOST_OFFSET, blank); - const verify = await readBytes(client); - const at = firstDifference(blank, verify); - const rec = sdk.parseBrandRecord(verify); - showRecord(rec); - if (at >= 0 || rec.valid) { - const message = - at >= 0 - ? `The erase did not take: byte ${at} still differs from an erased record.` - : "The erase did not take: the record still reads as valid."; - log.error(message); - toast(message, "err"); - return false; - } - setFields({ - btClassic: "", - ble: "", - usbProduct: "", - usbManufacturer: STOCK_MANUFACTURER, - }); - log.log( - "erase verified — the firmware re-seeds the factory names at the next restart", - ); - toast( - "Brand record erased. The factory names come back when the sensor restarts.", - "ok", - ); - showRestartBanner(); - return true; - } catch (err) { - const message = `Erasing the brand record failed: ${err?.message ?? err}`; - log.error(message); - toast(message, "err"); - return false; - } finally { - setBusy(false); - } - } - - /** - * Arm the one-shot soft restart, then drop the link so it fires. - * - * The restart cannot happen while still connected — the Bluetooth module has - * to come up again to re-read its name — so arming and disconnecting are one - * action, not two. - * - * @returns {Promise} true when the restart was armed and the link - * dropped - */ - async function armRestart() { - const client = clientFor("asking the sensor to restart"); - if (!client) return false; - if (!canSoftRestart()) { - log.warn( - "This link cannot ask the sensor to restart — power-cycle it by hand.", - ); - paintRestartBanner(); - return false; - } - setBusy(true); - try { - log.log("arming the restart that fires when this host disconnects…"); - await client.setRebootOnDisconnect(true); - } catch (err) { - /* Firmware without FEATURE_REBOOT_ON_DISCONNECT NACKs the unknown - feature id. Fall back to the manual instructions rather than leaving - the reader believing a restart has been arranged. */ - softRestartRefused = true; - const message = `The sensor would not arm a restart (${err?.message ?? err}) — power-cycle it by hand instead.`; - log.error(message); - toast(message, "warn"); - paintRestartBanner(); - return false; - } finally { - setBusy(false); - } - log.log("restart armed — disconnecting so the sensor restarts"); - try { - await (opts.disconnect ? opts.disconnect() : client.disconnect()); - } catch (err) { - log.warn(`disconnect reported an error: ${err?.message ?? err}`); - } - dismissRestartBanner(); - log.log( - "the sensor should be restarting; reconnect in a few seconds and read the names to confirm", - ); - return true; - } - - // ------------------------------------------------------------------------- - // Wiring - // ------------------------------------------------------------------------- - - btnRead.addEventListener("click", () => { - read().catch(() => {}); - }); - btnWrite.addEventListener("click", () => { - write().catch(() => {}); - }); - btnRestore.addEventListener("click", () => { - restoreDefaults().catch(() => {}); - }); - btnRestart.addEventListener("click", () => { - armRestart().catch(() => {}); - }); - btnDismiss.addEventListener("click", dismissRestartBanner); - - setFields({ usbManufacturer: STOCK_MANUFACTURER }); - clearRecord(); - sync(); - - return { - read, - write, - restoreDefaults, - armRestart, - record: () => record, - fields: effectiveFields, - setFields, - bleCap, - stockDefaults, - isStockRecord, - canSoftRestart, - dismissRestartBanner, - setEnabled(next) { - const was = enabled; - enabled = !!next; - /* On the falling edge only. A link that can no longer reach the record - has no record to show, and keeping a stale one would let the next - sensor to connect be judged stock or custom on the last one's names — - but this runs on every re-gate, so it must not fire while already - disabled or it would wipe the panel continuously. - NOTE for the host page: do NOT fold this panel's own busy state back - into what you pass here, or the read that follows a write clears the - record the write just verified. The panel already refuses a second - operation itself. - The restart banner deliberately SURVIVES this: on a link that cannot - soft-restart, its instructions are what the reader needs *after* - disconnecting. */ - if (was && !enabled) clearRecord(); - sync(); - }, - destroy() { - destroyed = true; - host.replaceChildren(); - }, - }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Accept a value or a getter for it, and always return a getter. */ -function asGetter(value) { - return typeof value === "function" ? value : () => value ?? null; -} - -/** - * The API shape, with every operation refusing, for an SDK bundle that has no - * brand-record support. A host page then gates and mounts exactly as it - * always does and gets a message on screen instead of a thrown import. - */ -function inertPanel() { - const no = async () => false; - return { - read: async () => null, - write: no, - restoreDefaults: no, - armRestart: no, - record: () => null, - fields: () => ({ - btClassic: "", - ble: "", - usbProduct: "", - usbManufacturer: "", - }), - setFields() {}, - bleCap: () => 0, - stockDefaults: () => null, - isStockRecord: () => null, - canSoftRestart: () => false, - dismissRestartBanner() {}, - setEnabled() {}, - destroy() {}, - }; -} diff --git a/common/calibration-editor.js b/common/calibration-editor.js deleted file mode 100644 index 8c42ae9..0000000 --- a/common/calibration-editor.js +++ /dev/null @@ -1,2101 +0,0 @@ -/** - * Calibration: read, show and edit the per-sensor kinematic calibration a - * Shimmer3 or Shimmer3R holds — offset, sensitivity and alignment, per sensor - * and per range — instead of the hex blob the page used to print. - * - * Modelled on the Verisense console's calibration tab - * (`verisense-device-console/console.js:6537-6610`, `renderCalibTab`): one - * card per sensor, each with the sensor name, a range selector, an "as of" - * timestamp and labelled grids. Two things are deliberately NOT copied from - * it: - * - * - the console renders sensitivity as a 3x3 grid, which suits the ASM - * calibration blob it edits. A Shimmer3 21-byte kinematic block stores - * sensitivity as a DIAGONAL of three i16s, so nine cells here would offer - * six edits the format cannot store. Offset is 3x1, sensitivity is 3x1, - * alignment is 3x3. - * - the console's sensor list is fixed. Here availability is three-state — - * shown, shown-but-disabled with a reason, or hidden — because a Shimmer3 - * has no ADXL371 alt-accel and no LIS3MDL alt-mag, and offering them would - * say the sensor could be calibrated when it does not exist. - * - * WHICH STORE THIS EDITS - * ---------------------- - * Two different things on the device hold calibration: - * - * the CALIBRATION DUMP — a TLV of records keyed by sensor id AND range, each - * with its own timestamp, read with GET_CALIB_DUMP (0x9A) and written with - * SET_CALIB_DUMP (0x98) + UPD_CALIB_DUMP (0x9B). It is the only store that - * can answer "which range is this calibration for, and when was it taken", - * so it is the one this panel EDITS wherever the link offers it. - * - * the INFOMEM BLOCKS — six 21-byte blocks in the configuration image, one - * per sensor, for whichever range is configured right now. No range, no - * date. The dock/USB link and the classic `Shimmer3Client` have no dump - * commands at all, so on those links this panel falls back to SHOWING - * those blocks, read-only, and says so on screen. It does not write them: - * the configuration image belongs to the configuration form, which holds - * its own unsaved edits and its own write-and-verify, and a second writer - * racing it would silently drop whichever change lost. - * - * Whichever store is in play is named in the header card, in the write - * confirmation and in the log line, so a Write is never ambiguous. - * - * AND THE ORDER THAT MATTERS. The firmware regenerates its whole dump FROM the - * configuration bytes whenever InfoMem page D (page C on a Shimmer3R) is - * written — `ShimCalib_configBytes0To127ToCalibDumpBytes`, - * `Calibration/shimmer_calibration.c:932-953`, called from - * `Comms/shimmer_bt_uart.c` on a config write. So a configuration write AFTER - * a calibration write throws the calibration away. The panel says that in the - * header rather than only in a comment. - * - * The panel builds its own markup inside the host element and owns the - * `disabled` state of every control in it, so a page mounts it with one - * `
` and one call. It holds no page-specific ids and reads no page - * globals — which is what lets a combined Verisense + Shimmer3 application - * mount it unchanged. - * - * Nothing here touches `document` at import time. - * - * import { createCalibrationEditor } from "../common/calibration-editor.js"; - */ - -import { el, downloadBlob } from "./ui-chrome.js"; -/* The whole namespace rather than destructured names: a vendored bundle that - predates one of the calibration exports then degrades to a message from - `mount()` instead of breaking the importing page. */ -import * as sdk from "../vendor/shimmer-web-sdk.esm.js"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** Hardware ids, as GET_DEVICE_VERSION reports them. */ -const HW_ID = Object.freeze({ SHIMMER3: 3, SHIMMER3R: 10 }); - -/** Bytes in one kinematic calibration block (SC_DATA_LEN_STD_IMU_CALIB). */ -const BLOCK_BYTES = 21; - -/** RTC tick rate. The dump timestamp is a 64-bit tick count, not seconds. */ -const RTC_TICKS_PER_SECOND = 32768; - -/** - * Plausibility window for a decoded calibration date, as Unix milliseconds. - * - * The timestamp is whatever the device's real-world clock read when the - * calibration was stored (`RTC_getRwcTime()`, - * `Calibration/shimmer_calibration.c:1054`). A sensor whose clock has never - * been set stamps a tick count measured from boot, which decodes to 1970 — - * a date that is not wrong so much as meaningless, and printing it as - * "calibrated 1970-01-01" reads as a fact. Anything outside this window is - * reported as an unset clock instead. - */ -const STAMP_MIN_MS = Date.UTC(2010, 0, 1); -const STAMP_MAX_MS = Date.UTC(2100, 0, 1); - -/** - * Calibration-domain sensor ids, from the SDK — which takes them from the - * firmware the dump comes from (`SC_SENSOR_*` in log-and-stream-common - * `Calibration/shimmer_calibration.h`). - * - * This table used to be copied here. It is the SDK's now because the streaming - * calibration needs the same mapping to decide which dump record belongs to - * which channel group, and two copies of a table like this drift. - * - * NOT the SDK's `CalibSensorId`, which is the Verisense/ASM domain and - * disagrees on two values — there 40 is an LSM6DS3 accel and 41 an LSM6DS3 - * gyro, whereas Shimmer3R firmware uses 40 for the ADXL371 high-g accel and 41 - * for the LIS3MDL alt-mag. Reading a Shimmer3R dump through that table - * mislabels two of its six sensors. - */ -const SC_SENSOR = - sdk.SC_SENSOR ?? - Object.freeze({ - ANALOG_ACCEL: 2, - MPU9X50_GYRO: 30, - LSM303_ACCEL: 31, - LSM303_MAG: 32, - MPU9X50_ACCEL: 33, - MPU9X50_MAG: 34, - BMP180_PRESSURE: 36, - LSM6DSV_ACCEL: 37, - LSM6DSV_GYRO: 38, - LIS2DW12_ACCEL: 39, - ADXL371_ACCEL: 40, - LIS3MDL_MAG: 41, - LIS2MDL_MAG: 42, - BMP390_PRESSURE: 43, - }); - -/** - * Every sensor the panel can name, in the order the cards appear. - * - * `group` is the SDK's `InertialGroup`, which is what keys both - * `getGroupDefaults` and the InfoMem calibration blocks. A row with no - * `group` (the pressure sensors) is a sensor whose calibration is a - * chip-specific coefficient block rather than a kinematic one: it is shown, - * disabled, with the reason — not hidden, because the device really does hold - * a calibration for it and a panel that omitted it would look like the dump - * had fewer records than it has. - * - * `rangesKey` names the SDK option table with the human range labels. Absent - * where the SDK has no table (the Kionix low-noise accel has exactly one - * range), and the range value is then shown as itself. - */ -const SENSOR_ROWS = Object.freeze({ - "shimmer3-old": Object.freeze([ - { - group: "lnAccel", - id: SC_SENSOR.ANALOG_ACCEL, - label: "Low-noise accelerometer", - chip: "KXRB5-2042", - }, - { - group: "gyro", - id: SC_SENSOR.MPU9X50_GYRO, - label: "Gyroscope", - chip: "MPU9x50 / ICM20948", - rangesKey: "SHIMMER3_MPU9X50_GYRO_RANGE_OPTIONS", - }, - { - group: "wrAccel", - id: SC_SENSOR.LSM303_ACCEL, - label: "Wide-range accelerometer", - chip: "LSM303DLHC", - rangesKey: "SHIMMER3_LSM303DLHC_ACCEL_RANGE_OPTIONS", - }, - { - group: "mag", - id: SC_SENSOR.LSM303_MAG, - label: "Magnetometer", - chip: "LSM303DLHC", - rangesKey: "SHIMMER3_LSM303DLHC_MAG_RANGE_OPTIONS", - }, - { - id: SC_SENSOR.BMP180_PRESSURE, - label: "Pressure / temperature", - chip: "BMP180", - unmodelled: true, - }, - ]), - "shimmer3-new": Object.freeze([ - { - group: "lnAccel", - id: SC_SENSOR.ANALOG_ACCEL, - label: "Low-noise accelerometer", - chip: "KXRB5-2042", - }, - { - group: "gyro", - id: SC_SENSOR.MPU9X50_GYRO, - label: "Gyroscope", - chip: "MPU9x50 / ICM20948", - rangesKey: "SHIMMER3_MPU9X50_GYRO_RANGE_OPTIONS", - }, - { - group: "wrAccel", - id: SC_SENSOR.LSM303_ACCEL, - label: "Wide-range accelerometer", - chip: "LSM303AHTR", - rangesKey: "SHIMMER3_LSM303AH_ACCEL_RANGE_OPTIONS", - }, - { - group: "mag", - id: SC_SENSOR.LSM303_MAG, - label: "Magnetometer", - chip: "LSM303AHTR", - rangesKey: "SHIMMER3_LSM303AH_MAG_RANGE_OPTIONS", - }, - { - id: SC_SENSOR.BMP180_PRESSURE, - label: "Pressure / temperature", - chip: "BMP180 / BMP280", - unmodelled: true, - }, - ]), - shimmer3r: Object.freeze([ - { - group: "lnAccel", - id: SC_SENSOR.LSM6DSV_ACCEL, - label: "Low-noise accelerometer", - chip: "LSM6DSV", - rangesKey: "SHIMMER3_LSM6DSV_ACCEL_RANGE_OPTIONS", - }, - { - group: "gyro", - id: SC_SENSOR.LSM6DSV_GYRO, - label: "Gyroscope", - chip: "LSM6DSV", - rangesKey: "SHIMMER3_LSM6DSV_GYRO_RANGE_OPTIONS", - }, - { - group: "wrAccel", - id: SC_SENSOR.LIS2DW12_ACCEL, - label: "Wide-range accelerometer", - chip: "LIS2DW12", - rangesKey: "SHIMMER3_LIS2DW12_ACCEL_RANGE_OPTIONS", - }, - { - group: "mag", - id: SC_SENSOR.LIS2MDL_MAG, - label: "Magnetometer", - chip: "LIS2MDL", - rangesKey: "SHIMMER3_LIS2MDL_MAG_RANGE_OPTIONS", - }, - { - group: "altAccel", - id: SC_SENSOR.ADXL371_ACCEL, - label: "High-g accelerometer", - chip: "ADXL371", - rangesKey: "SHIMMER3_ADXL371_ACCEL_RANGE_OPTIONS", - }, - { - group: "altMag", - id: SC_SENSOR.LIS3MDL_MAG, - label: "Alternate magnetometer", - chip: "LIS3MDL", - rangesKey: "SHIMMER3_LIS3MDL_ALT_MAG_RANGE_OPTIONS", - }, - { - id: SC_SENSOR.BMP390_PRESSURE, - label: "Pressure / temperature", - chip: "BMP390 / BMP581", - unmodelled: true, - }, - ]), -}); - -/** Axis labels down the side of every grid. */ -const AXES = Object.freeze(["x", "y", "z"]); - -/** - * What the three parts of a block can hold, straight out of the byte layout - * that `generateKinematicCalibBlock` writes: - * - * offset 3 x i16 big-endian, stored verbatim (Java truncates a - * fraction with an `(int)` cast, so a fractional offset is - * refused here rather than silently losing its fraction) - * sensitivity 3 x i16 big-endian, stored as round(value x scale). The - * scale is 100 for gyro and 1 for everything else, so the - * bound depends on the sensor. - * alignment 9 x i8, stored as round(value x 100) - */ -const I16_MIN = -32768; -const I16_MAX = 32767; -const I8_MIN = -128; -const I8_MAX = 127; - -// --------------------------------------------------------------------------- -// The panel -// --------------------------------------------------------------------------- - -/** - * Mount the calibration editor inside `host`. - * - * @param {HTMLElement} host an empty container; its contents are replaced - * @param {object} opts - * @param {object|(() => object|null)} opts.client the connected client, or a - * getter for it. Pass the GETTER form from a page whose client comes and - * goes with the link — the panel is mounted once and then reads whatever is - * current, so it can never hold a stale client. - * @param {string|null|(() => string|null)} [opts.generation] the value - * `inferShimmer3Generation` returned: `"shimmer3r"`, `"shimmer3-new-imu"` or - * `"shimmer3-old-imu"`. Decides which sensors exist and which default - * calibration tables apply. Pass nothing (or null) when the sensor could not - * be identified: the panel then takes the hardware id out of the dump's own - * version header, and only if that fails too does it disable every card with - * the reason. - * @param {number|null|(() => number|null)} [opts.hardwareVersion] the hardware - * id the sensor actually reported, used only as a fallback for - * `opts.generation`. Never pass a defaulted one. - * @param {object|null|(() => object|null)} [opts.activeRanges] the ranges the - * sensor is configured for, as `{lnAccel, wrAccel, gyro, mag, altAccel, - * altMag}`. Only used to mark one option "configured" and to open each card - * on the range that is actually in use; entirely optional. - * @param {object|null|(() => object|null)} [opts.infoMemBlocks] the six - * 21-byte InfoMem calibration blocks (`parseInfoMem(...).calibration`), for - * the read-only fallback on a link with no calibration-dump commands. A - * getter, so the panel always reads the image the page holds NOW. - * @param {() => Promise} [opts.readInfoMem] how the host page - * re-reads its configuration image, for the fallback's Read button. - * @param {{log: Function, warn: Function, error: Function}} [opts.log] - * @param {(busy: boolean) => void} [opts.onBusyChange] called when a - * calibration read or write starts and finishes. A host page folds this into - * its own busy state, so everything that shares the link is refused while a - * calibration write is in flight. - * @param {(message: string, kind?: string) => void} [opts.toast] - * @param {(text: string) => boolean} [opts.confirm] defaults to - * `window.confirm` - * @param {string|(() => string|null)} [opts.fileNamePrefix="shimmer"] leading - * part of the saved dump's file name; a getter, so a page can name the file - * after a sensor that was not connected when the panel was mounted - * @returns {{ - * read: (opts?: {reuseInfoMem?: boolean}) => Promise, - * write: () => Promise, - * save: () => boolean, - * load: (bytes: Uint8Array) => boolean, - * restoreDefaults: (group: string) => boolean, - * dump: () => object|null, - * bytes: () => Uint8Array|null, - * store: () => "dump"|"infomem"|"none", - * family: () => string|null, - * changes: () => {group: string, sensorId: number, range: number, - * label: string, was: Uint8Array|null, now: Uint8Array}[], - * problems: () => string[], - * setEnabled: (enabled: boolean) => void, - * destroy: () => void, - * }} - */ -export function createCalibrationEditor(host, opts = {}) { - const getClient = - typeof opts.client === "function" ? opts.client : () => opts.client ?? null; - const log = opts.log ?? { log() {}, warn() {}, error() {} }; - const toast = opts.toast ?? (() => {}); - const ask = opts.confirm ?? ((text) => window.confirm(text)); - const getGeneration = asGetter(opts.generation); - const getHardware = asGetter(opts.hardwareVersion); - const getActiveRanges = asGetter(opts.activeRanges); - const getInfoMemBlocks = asGetter(opts.infoMemBlocks); - /* A getter as well as a value: a page names the file after the sensor, and - the sensor is not connected when the panel is mounted. */ - const getFilePrefix = asGetter(opts.fileNamePrefix); - - /* A vendored bundle from before the calibration codec shipped: say so once, - here, rather than throwing from the first button press. */ - const missing = [ - "parseCalibDump", - "generateCalibDump", - "parseKinematicCalibBlock", - "generateKinematicCalibBlock", - "getGroupDefaults", - "getDefaultCalibration", - ].filter((name) => typeof sdk[name] !== "function"); - if (missing.length) { - host.replaceChildren( - el( - "div", - { class: "banner err" }, - `This page is running an SDK bundle with no calibration support (missing ${missing.join(", ")}). Re-vendor the SDK to read or change what this sensor is calibrated to.`, - ), - ); - log.error( - `calibration unavailable: the vendored SDK has no ${missing.join(", ")}`, - ); - return inertPanel(); - } - - // ------------------------------------------------------------------------- - // State - // ------------------------------------------------------------------------- - - /** The dump exactly as the device last handed it over, or as loaded. */ - let dumpBytes = null; - /** Its parsed form. */ - let dumpParsed = null; - /** `${sensorId}:${range}` -> CalibDumpRecord, from `dumpParsed`. */ - let records = new Map(); - /** True when `dumpBytes` came from a file rather than from the sensor. */ - let fromFile = false; - /** The InfoMem blocks last shown by the read-only fallback, or null. */ - let infoMemShown = null; - /** - * Edited values, keyed `${group}:${range}`, each `{offset[3], sens[3], - * align[9]}` of the strings that are in the boxes. Kept per range so - * switching ranges and back does not lose an edit in progress. - */ - const edited = new Map(); - /** The range each card is currently showing. */ - const shownRange = new Map(); - /** A calibration read or write is in flight. */ - let busy = false; - /** The floor the host page sets: can this link do calibration at all? */ - let enabled = false; - let destroyed = false; - /** Set once the cards have been built, so a rebuild can be detected. */ - let builtFamily = null; - - // ------------------------------------------------------------------------- - // Markup — the header card - // ------------------------------------------------------------------------- - - const storePill = el( - "span", - { class: "pill", dataset: { calRole: "storePill" } }, - "not read", - ); - const btnRead = el( - "button", - { type: "button", dataset: { calRole: "read" } }, - "Read from sensor", - ); - const btnWrite = el( - "button", - { type: "button", class: "warn", dataset: { calRole: "write" } }, - "Write to sensor", - ); - const btnSave = el( - "button", - { type: "button", dataset: { calRole: "save" } }, - "Save dump", - ); - const btnLoad = el( - "button", - { type: "button", dataset: { calRole: "load" } }, - "Load dump", - ); - const fileInput = el("input", { - type: "file", - accept: ".bin,application/octet-stream", - hidden: true, - dataset: { calRole: "file" }, - onchange: onFilePicked, - }); - const storeBanner = el("div", { - class: "banner", - dataset: { calRole: "storeBanner" }, - }); - const storeNote = el("div", { - class: "field-hint", - dataset: { calRole: "storeNote" }, - }); - const changeNote = el("div", { - class: "field-hint", - dataset: { calRole: "changeNote" }, - }); - - const headerCard = el( - "div", - { class: "card" }, - el("div", { class: "card-title" }, "Calibration store ", storePill), - storeBanner, - el("div", { class: "row" }, btnRead, btnWrite, btnSave, btnLoad, fileInput), - changeNote, - storeNote, - el( - "div", - { class: "field-hint" }, - "The firmware rebuilds its whole calibration dump from the configuration " + - "bytes every time a configuration image is written, so a configuration " + - "write AFTER this one throws these values away. Apply a configuration " + - "first, then write calibration.", - ), - ); - - /** Where the per-sensor cards go. Rebuilt when the hardware family changes. */ - const sensorHost = el("div", { dataset: { calRole: "sensors" } }); - - host.replaceChildren(headerCard, sensorHost); - - // ------------------------------------------------------------------------- - // Hardware family - // ------------------------------------------------------------------------- - - /** - * The SDK `ImuFamily` this sensor belongs to, or null when nothing has said. - * - * Three sources, in order of how much they actually know: what the page - * identified, the hardware id the page read, and — last — the hardware id - * written into the dump's own version header, which is the device's own - * statement about what produced these records. That last one is why a - * sensor that refuses GET_DEVICE_VERSION still gets a usable tab. - */ - function family() { - const gen = getGeneration(); - if (gen === "shimmer3r") return "shimmer3r"; - if (gen === "shimmer3-new-imu") return "shimmer3-new"; - if (gen === "shimmer3-old-imu") return "shimmer3-old"; - const hw = getHardware() ?? dumpParsed?.version?.hardwareId ?? null; - if (hw === HW_ID.SHIMMER3R) return "shimmer3r"; - /* A Shimmer3 with no expansion-board read is old-IMU by the same rule - `inferShimmer3Generation` uses when it is handed no board. */ - if (hw === HW_ID.SHIMMER3) return "shimmer3-old"; - return null; - } - - /** - * Which store is in play: the calibration dump, the InfoMem blocks, or - * neither. - * - * Decided by the CLIENT first, because that is what decides where a Write - * would land — a link with `readCalibDump`/`writeCalibDump` edits the dump, - * any other link can only be shown the InfoMem blocks. With nothing - * connected it follows whatever is on screen, so a dump loaded from a file - * can still be read, edited and saved with no sensor on the desk. - */ - function store() { - const client = getClient(); - if (client) { - return typeof client.readCalibDump === "function" && - typeof client.writeCalibDump === "function" - ? "dump" - : "infomem"; - } - if (dumpBytes) return "dump"; - return infoMemShown ? "infomem" : "none"; - } - - const STORE_LABEL = Object.freeze({ - dump: "calibration dump", - infomem: "configuration image (InfoMem)", - none: "no calibration store", - }); - - // ------------------------------------------------------------------------- - // Number formatting and validation - // ------------------------------------------------------------------------- - - /** A number as short as it can be written without changing it. */ - function num(v) { - if (!Number.isFinite(v)) return ""; - /* Six decimals is past anything the byte layout can hold (a sensitivity - resolves to 1/100 and an alignment to 1/100), so this only ever trims - floating-point dust. */ - return String(Number(v.toFixed(6))); - } - - /** - * What one box may hold, as the byte layout sees it. - * - * Returns `{min, max, step, describe}` in the units shown on screen, so the - * refusal names the number the user typed rather than the integer it would - * have been scaled to. - */ - function limitsFor(part, sensitivityScale) { - if (part === "offset") { - return { - scale: 1, - min: I16_MIN, - max: I16_MAX, - describe: `a whole number from ${I16_MIN} to ${I16_MAX}`, - }; - } - if (part === "sens") { - const scale = sensitivityScale || 1; - return { - scale, - min: I16_MIN / scale, - max: I16_MAX / scale, - describe: - scale === 1 - ? `a whole number from ${I16_MIN} to ${I16_MAX}` - : `${num(I16_MIN / scale)} to ${num(I16_MAX / scale)} in steps of ${num(1 / scale)}`, - }; - } - return { - scale: 100, - min: I8_MIN / 100, - max: I8_MAX / 100, - describe: `${num(I8_MIN / 100)} to ${num(I8_MAX / 100)} in steps of 0.01`, - }; - } - - /** - * Validate one typed value against what the format can hold. - * - * Deliberately refuses rather than clamping. A silently clamped offset is a - * calibration nobody asked for, written under the name of one somebody did. - * - * @returns {{value: number}|{problem: string}} - */ - function checkValue(text, part, sensitivityScale) { - const raw = String(text ?? "").trim(); - if (!raw) return { problem: "needs a value" }; - if (!/^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(raw)) { - return { problem: "not a number" }; - } - const v = Number(raw); - if (!Number.isFinite(v)) return { problem: "not a number" }; - const lim = limitsFor(part, sensitivityScale); - if (v < lim.min || v > lim.max) { - return { problem: `out of range — ${lim.describe}` }; - } - /* The encoder rounds sensitivity and alignment and TRUNCATES offset. A - value that would not survive that round trip is refused here, so the - number in the box is always the number on the device. */ - const settled = Math.round(v * lim.scale) / lim.scale; - if (Math.abs(settled - v) > 1e-9) { - return { - problem: - lim.scale === 1 - ? "must be a whole number" - : `must be a multiple of ${num(1 / lim.scale)}`, - }; - } - return { value: v }; - } - - // ------------------------------------------------------------------------- - // Cards - // ------------------------------------------------------------------------- - - /** group (or `id:` for an unmodelled sensor) -> the card's parts. */ - const cards = new Map(); - - /** Range options for one row: `[value, label]` pairs, always non-empty. */ - function rangesFor(row, fam) { - const table = row.rangesKey ? sdk[row.rangesKey] : null; - const known = row.group ? sdk.getGroupDefaults(fam, row.group) : null; - const fromDefaults = known ? Object.keys(known.byRange).map(Number) : []; - if (Array.isArray(table) && table.length) { - const labels = new Map(table.map(([v, l]) => [Number(v), String(l)])); - /* The union, so a range the defaults table knows but the option table - does not (and the other way round) still appears rather than becoming - a range the user cannot select a stored record for. */ - const values = [...new Set([...labels.keys(), ...fromDefaults])].sort( - (a, b) => a - b, - ); - return values.map((v) => [v, labels.get(v) ?? `range ${v}`]); - } - if (fromDefaults.length) { - return fromDefaults.sort((a, b) => a - b).map((v) => [v, `range ${v}`]); - } - return [[0, "range 0"]]; - } - - function makeInput(group, part, index, onEdit) { - return el("input", { - type: "text", - inputmode: "decimal", - autocomplete: "off", - spellcheck: "false", - class: "cal-cell", - "aria-label": `${part} ${AXES[index % 3]}`, - dataset: { calInput: `${group}:${part}:${index}` }, - oninput: onEdit, - onchange: onEdit, - }); - } - - /** One labelled grid: a title, an optional unit, and n boxes. */ - function makeGrid(group, part, title, unitNode, count, onEdit) { - const cells = []; - const body = el("div", { - class: count === 9 ? "cal-matrix cols-3" : "cal-matrix cols-1", - }); - for (let r = 0; r < 3; r++) { - body.appendChild(el("div", { class: "cal-axis" }, AXES[r])); - for (let c = 0; c < count / 3; c++) { - const i = r * (count / 3) + c; - const input = makeInput(group, part, i, onEdit); - cells.push(input); - body.appendChild(input); - } - } - return { - cells, - node: el( - "div", - { class: "cal-block" }, - el("div", { class: "cal-block-title" }, title, unitNode), - body, - ), - }; - } - - function buildCards() { - const fam = family(); - builtFamily = fam; - cards.clear(); - edited.clear(); - shownRange.clear(); - - if (!fam) { - /* Nothing has said what this sensor is — not the page, not the dump. - The three-state rule applies to the whole list rather than to one - card: showing a Shimmer3R's six sensors on what might be a Shimmer3 - would offer two that do not exist. */ - const connected = !!getClient(); - sensorHost.replaceChildren( - el( - "div", - { class: "card", dataset: { calRole: "unknownHardware" } }, - el("div", { class: "card-title" }, "Sensors"), - el( - "div", - { class: connected ? "banner warn" : "banner" }, - connected - ? "This sensor has not said what hardware it is, and no " + - "calibration has been read from it, so which sensors it " + - "carries is unknown. Read from sensor to find out — the " + - "dump names the hardware that wrote it." - : "Which sensors appear here depends on the hardware. Connect a " + - "sensor, or load a saved dump — a dump names the hardware " + - "that wrote it.", - ), - ), - ); - return; - } - - const nodes = []; - for (const row of SENSOR_ROWS[fam]) { - const key = row.group ?? `id:${row.id}`; - const ranges = rangesFor(row, fam); - const groupDefaults = row.group - ? sdk.getGroupDefaults(fam, row.group) - : null; - const sensitivityScale = groupDefaults?.sensitivityScale ?? 1; - - const statePill = el( - "span", - { class: "pill", dataset: { calState: key } }, - "not read", - ); - const rangeSel = el("select", { - dataset: { calRange: key }, - "aria-label": `${row.label} range`, - onchange: () => onRangeChanged(key), - }); - for (const [value, label] of ranges) { - rangeSel.appendChild( - el("option", { value: String(value) }, `${label} (${value})`), - ); - } - const asOf = el( - "span", - { class: "muted", dataset: { calAsOf: key } }, - "–", - ); - const btnDefaults = el( - "button", - { - type: "button", - class: "secondary", - dataset: { calDefaults: key }, - title: - "Fill the boxes with the factory default calibration for this " + - "sensor and range. Nothing is written until Write to sensor.", - }, - "Restore defaults", - ); - const errorNode = el("div", { - class: "cal-error", - dataset: { calError: key }, - hidden: true, - }); - const noteNode = el("div", { - class: "field-hint", - dataset: { calNote: key }, - }); - - const onEdit = () => onCellEdited(key); - const unit = groupDefaults?.unit ?? ""; - const offsetGrid = row.group - ? makeGrid( - key, - "offset", - "Offset", - el("span", { class: "cal-unit" }, "raw counts"), - 3, - onEdit, - ) - : null; - const sensGrid = row.group - ? makeGrid( - key, - "sens", - "Sensitivity", - el( - "span", - { class: "cal-unit" }, - unit ? `counts per ${unit}` : "counts per unit", - ), - 3, - onEdit, - ) - : null; - const alignGrid = row.group - ? makeGrid( - key, - "align", - "Alignment", - el("span", { class: "cal-unit" }, "unitless, −1.28…1.27"), - 9, - onEdit, - ) - : null; - - const card = el( - "div", - { - class: "card", - dataset: { - calSensor: key, - calSensorId: String(row.id), - calAvailability: row.unmodelled ? "disabled" : "editable", - }, - }, - el( - "div", - { class: "card-title" }, - row.label, - el("span", { class: "cal-chip" }, row.chip), - statePill, - ), - row.unmodelled - ? el( - "div", - { class: "banner", dataset: { calReason: key } }, - `The ${row.chip} calibration is a chip-specific coefficient ` + - "block, not the offset / sensitivity / alignment set the " + - "sensors above use, so this page shows that a calibration " + - "exists but cannot edit it.", - ) - : null, - row.unmodelled - ? null - : el( - "div", - { class: "row" }, - el("label", { class: "cal-range" }, "Range", rangeSel), - asOf, - /* Pushed to the far end of the row rather than sitting next to - the range: it acts on the whole card, and a destructive-ish - button beside the selector reads as if it acted on the - selector. */ - el("span", { class: "cal-push" }), - btnDefaults, - ), - row.unmodelled - ? null - : el( - "div", - { class: "cal-grids" }, - offsetGrid.node, - sensGrid.node, - alignGrid.node, - ), - errorNode, - noteNode, - ); - - cards.set(key, { - row, - key, - ranges, - sensitivityScale, - card, - statePill, - rangeSel, - asOf, - btnDefaults, - errorNode, - noteNode, - cells: row.unmodelled - ? null - : { - offset: offsetGrid.cells, - sens: sensGrid.cells, - align: alignGrid.cells, - }, - }); - if (!row.unmodelled) shownRange.set(key, ranges[0][0]); - btnDefaults.addEventListener("click", () => restoreDefaults(key)); - nodes.push(card); - } - sensorHost.replaceChildren(...nodes); - } - - /** Rebuild the cards if what the page knows about the hardware has moved. */ - function ensureCards() { - if (builtFamily !== family() || !cards.size) buildCards(); - } - - // ------------------------------------------------------------------------- - // Reading values into the cards - // ------------------------------------------------------------------------- - - const recordKey = (sensorId, range) => `${sensorId}:${range}`; - const editKey = (key, range) => `${key}:${range}`; - - /** - * The 21-byte block stored for this sensor at this range, or null. - * - * A block that is all-0xFF or all-zero counts as NOT stored, because that - * is exactly what those two patterns mean (`UtilShimmer.isAllFF` / - * `isAllZeros`, which is why `parseKinematicCalibBlock` answers null for - * them). A record can exist and say nothing — a Shimmer3R ships with one - * like that for its alternate magnetometer — and the difference between - * "never calibrated" and "calibrated to 65535" is the whole point of - * showing calibration at all. - */ - function storedBlock(entry, range) { - const usable = (block) => - block && - block.length >= BLOCK_BYTES && - sdk.parseKinematicCalibBlock(block, { - sensitivityScale: entry.sensitivityScale, - }) - ? block - : null; - if (store() === "infomem") { - /* One block per sensor, for whatever range is configured. Showing it - under another range would claim the device holds something it does - not. */ - if (!infoMemShown) return null; - if (range !== configuredRange(entry)) return null; - return usable(infoMemShown[entry.row.group]); - } - return usable(records.get(recordKey(entry.row.id, range))?.calibBytes); - } - - /** The dump record for this sensor at this range, or null. */ - function storedRecord(entry, range) { - if (store() === "infomem") return null; - return records.get(recordKey(entry.row.id, range)) ?? null; - } - - /** The range the device is configured for, when the page said. */ - function configuredRange(entry) { - const active = getActiveRanges(); - const v = active?.[entry.row.group]; - return Number.isFinite(v) ? Number(v) : null; - } - - /** The SDK default calibration for this sensor at this range, or null. */ - function defaultsFor(entry, range) { - if (!entry.row.group) return null; - const fam = family(); - if (!fam) return null; - return sdk.getDefaultCalibration(fam, entry.row.group, range); - } - - /** The 21 bytes the SDK defaults encode to for this sensor and range. */ - function defaultBlock(entry, range) { - const d = defaultsFor(entry, range); - if (!d) return null; - return sdk.generateKinematicCalibBlock( - d.calibration.offset, - d.calibration.sensitivity, - d.calibration.alignment, - { sensitivityScale: d.sensitivityScale }, - ); - } - - /** Strings for the boxes, from a parsed calibration. */ - function valuesFromCalibration(cal) { - return { - offset: cal.offset.map(num), - sens: cal.sensitivity.map(num), - align: cal.alignment.map(num), - }; - } - - /** Strings for the boxes, from a stored 21-byte block, or null. */ - function valuesFromBlock(entry, block) { - if (!block) return null; - const cal = sdk.parseKinematicCalibBlock(block, { - sensitivityScale: entry.sensitivityScale, - }); - /* An all-0xFF or all-zero block parses to null: the SDK's way of saying - "nothing stored". That is a state of its own — see `paintCard`. */ - return cal ? valuesFromCalibration(cal) : null; - } - - /** What is in the boxes right now, as strings. */ - function readCells(entry) { - return { - offset: entry.cells.offset.map((i) => i.value), - sens: entry.cells.sens.map((i) => i.value), - align: entry.cells.align.map((i) => i.value), - }; - } - - /** Put strings into the boxes; `null` empties them. */ - function writeCells(entry, values) { - for (const part of ["offset", "sens", "align"]) { - entry.cells[part].forEach((input, i) => { - input.value = values ? (values[part][i] ?? "") : ""; - }); - } - } - - /** Placeholders show the defaults when nothing is stored and nothing typed. */ - function setPlaceholders(entry, range) { - const d = defaultsFor(entry, range); - const values = d ? valuesFromCalibration(d.calibration) : null; - for (const part of ["offset", "sens", "align"]) { - entry.cells[part].forEach((input, i) => { - input.placeholder = values ? (values[part][i] ?? "") : ""; - }); - } - } - - // ------------------------------------------------------------------------- - // Painting - // ------------------------------------------------------------------------- - - function bytesEqual(a, b) { - if (!a || !b || a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; - return true; - } - - /** - * What the record's 8-byte stamp says: a `Date`, `"none"` or `"unset"`. - * - * The three are genuinely different and none of them is the others: - * - * a Date the sensor's real-world clock when the calibration was stored - * "none" an all-zero stamp — the firmware's mark for a calibration it - * seeded itself rather than one anybody measured - * "unset" a stamp that decodes to a date nobody could have calibrated - * on, which happens when the sensor's clock had never been set: - * the tick count is then measured from boot and lands in 1970 - * - * Note the unit. This is a 64-bit count of 32768 Hz ticks - * (`RTC_getRwcTime()`, `Calibration/shimmer_calibration.c:1054`), NOT the - * Unix seconds the SDK's `calibTsBytesToUnixSeconds` decodes for the - * Verisense blob — reading it as seconds puts every calibration about 1.8 - * million years into the future. - */ - function readStamp(ticks) { - if (!ticks || ticks.length < 8) return "none"; - let v = 0; - for (let i = 7; i >= 0; i--) v = v * 256 + (ticks[i] & 0xff); - if (v === 0) return "none"; - const ms = (v / RTC_TICKS_PER_SECOND) * 1000; - if (!Number.isFinite(ms) || ms < STAMP_MIN_MS || ms > STAMP_MAX_MS) { - return "unset"; - } - return new Date(ms); - } - - /** - * A calibration stamp, shown in this host's local time. - * - * The stamp is a plain Unix epoch, because the sensor's real-world clock is - * one — desktop Consensys and the dock software both write `Date.now()` - * ticks. So it is read out with the LOCAL accessors, like every other date - * on the page, and a stamp the firmware wrote agrees with the device clock - * shown beside it. - * - * (These were the UTC accessors while this page believed the sensor kept - * local civil time. That cancelled out for a stamp this page had written and - * was wrong for every stamp the firmware had.) - */ - function formatStamp(date) { - const p2 = (n) => String(n).padStart(2, "0"); - return ( - `${date.getFullYear()}-${p2(date.getMonth() + 1)}-${p2(date.getDate())} ` + - `${p2(date.getHours())}:${p2(date.getMinutes())}` - ); - } - - /** - * The 21 bytes the boxes currently describe, or a list of problems. - * - * @returns {{bytes: Uint8Array}|{problems: {part: string, index: number, - * problem: string}[]}|{empty: true}} - */ - function blockFromCells(entry) { - return blockFromValues(entry, readCells(entry)); - } - - /** - * The 21 bytes one set of typed strings describes. - * - * A card with every box empty is `{empty: true}` rather than fifteen "needs - * a value" problems: nothing typed is not the same as something wrong, and - * it is the state a never-calibrated sensor starts in. - */ - function blockFromValues(entry, values) { - const parts = ["offset", "sens", "align"]; - if (parts.every((p) => values[p].every((v) => String(v).trim() === ""))) { - return { empty: true }; - } - const problems = []; - const nums = { offset: [], sens: [], align: [] }; - for (const part of parts) { - values[part].forEach((raw, index) => { - const r = checkValue(raw, part, entry.sensitivityScale); - if (r.problem) problems.push({ part, index, problem: r.problem }); - else nums[part].push(r.value); - }); - } - if (problems.length) return { problems }; - return { - bytes: sdk.generateKinematicCalibBlock( - nums.offset, - nums.sens, - nums.align, - { sensitivityScale: entry.sensitivityScale }, - ), - }; - } - - /** Repaint one card from the store plus whatever is typed into it. */ - function paintCard(entry) { - if (entry.row.unmodelled) { - const rec = - records.get(recordKey(entry.row.id, 0)) ?? firstRecordFor(entry.row.id); - if (rec) { - const stamp = readStamp(rec.timestampTicks); - entry.statePill.textContent = - stamp instanceof Date - ? `stored ${formatStamp(stamp)}` - : "stored, no date"; - entry.statePill.className = "pill"; - entry.noteNode.textContent = `${rec.calibLen} bytes at range ${rec.range}.`; - entry.card.dataset.calState = "unmodelled"; - } else { - entry.statePill.textContent = "no record"; - entry.statePill.className = "pill"; - entry.noteNode.textContent = ""; - entry.card.dataset.calState = "unmodelled"; - } - return; - } - - const range = shownRange.get(entry.key); - const stored = storedBlock(entry, range); - const def = defaultBlock(entry, range); - const rec = storedRecord(entry, range); - setPlaceholders(entry, range); - - const built = blockFromCells(entry); - const typed = built.bytes ?? null; - /* Empty boxes are never "dirty": there is nothing to write, so saying so - would light up a Write that is refused. Emptying them is how a reader - clears the editor and goes back to what the sensor holds. */ - const dirty = !!typed && !bytesEqual(typed, stored); - - /* ---- the "as of" readout - Present on EVERY card, always, once something has been read: a line - that appears on three sensors and not on the others reads as those - three being the only ones with a calibration date, when in fact the - rest have no calibration at all — a different fact entirely, and one - the pill beside the title is already making. Saying "no date" for a - sensor with nothing stored is the honest version of the blank. It only - stays empty before the first read, where there is genuinely nothing to - report yet. */ - const stamp = rec ? readStamp(rec.timestampTicks) : "none"; - const date = stamp instanceof Date ? stamp : null; - if (store() === "none") { - entry.asOf.textContent = ""; - } else if (store() === "infomem") { - entry.asOf.textContent = stored - ? "no date — the configuration image stores no calibration date" - : "no date — nothing is stored for this sensor at this range"; - } else if (!stored) { - entry.asOf.textContent = - "no date — nothing is stored for this sensor at this range"; - } else if (date) { - entry.asOf.textContent = `as of ${formatStamp(date)}`; - } else if (stamp === "unset") { - entry.asOf.textContent = - "date unreadable — the sensor's clock had not been set when this was stored"; - } else { - entry.asOf.textContent = "no date — the firmware seeded this itself"; - } - - // ---- the state pill: never calibrated / defaults / this device's own - let pill = ""; - let pillClass = "pill"; - let note = ""; - if (!stored) { - pill = "never calibrated"; - pillClass = "pill warn"; - if (store() !== "infomem") { - note = - "Nothing is stored for this sensor at this range, so the sensor " + - "falls back to the factory defaults shown greyed below. That is " + - "not a fault — most sensors leave the factory with defaults for " + - "every range but the one they were calibrated at."; - } else { - const cfg = configuredRange(entry); - note = - "The configuration image holds no calibration for this sensor" + - (cfg !== null && range !== cfg - ? " at this range — it stores one range at a time, and the sensor " + - "is configured for another." - : ".") + - " The values shown greyed are the factory defaults it falls back to."; - } - } else if (def && bytesEqual(stored, def)) { - pill = "factory defaults"; - note = - "What is stored is byte-for-byte the factory default for this range, " + - "so this sensor has no calibration of its own here."; - } else if (date) { - pill = `calibrated ${formatStamp(date)}`; - pillClass = "pill on"; - note = ""; - } else { - pill = "this sensor's own values, no date"; - pillClass = "pill on"; - note = - store() === "infomem" - ? "Values that are not the factory defaults. The configuration " + - "image keeps no calibration date, so when they were measured is " + - "not recorded there." - : "Values that are not the factory defaults, but with no usable " + - "calibration date — either seeded by the firmware or stored " + - "while the sensor's clock had never been set."; - } - if (built.problems?.length) { - pill = "value out of range"; - pillClass = "pill err"; - } else if (dirty) { - pill = "edited, not written"; - pillClass = "pill warn"; - } - entry.statePill.textContent = pill; - entry.statePill.className = pillClass; - - // ---- validation - const bad = new Map(); - for (const p of built.problems ?? []) bad.set(`${p.part}:${p.index}`, p); - for (const part of ["offset", "sens", "align"]) { - entry.cells[part].forEach((input, i) => { - const p = bad.get(`${part}:${i}`); - input.classList.toggle("bad", !!p); - input.setAttribute("aria-invalid", p ? "true" : "false"); - }); - } - if (built.problems?.length) { - const first = built.problems[0]; - const partName = - first.part === "sens" - ? "Sensitivity" - : first.part === "align" - ? "Alignment" - : "Offset"; - const where = - first.part === "align" - ? `row ${AXES[Math.floor(first.index / 3)]}, column ${AXES[first.index % 3]}` - : AXES[first.index]; - entry.errorNode.textContent = - `${partName} ${where}: ${first.problem}.` + - (built.problems.length > 1 - ? built.problems.length === 2 - ? " One other value also needs attention." - : ` ${built.problems.length - 1} other values also need attention.` - : "") + - " Nothing is written while a value is out of range."; - entry.errorNode.hidden = false; - } else { - entry.errorNode.textContent = ""; - entry.errorNode.hidden = true; - } - entry.noteNode.textContent = note; - entry.card.dataset.calDirty = dirty ? "true" : "false"; - entry.card.dataset.calState = built.problems?.length - ? "invalid" - : !stored - ? "never" - : def && bytesEqual(stored, def) - ? "defaults" - : "device"; - } - - /** The first record for a sensor id at any range, for the unmodelled cards. */ - function firstRecordFor(sensorId) { - for (const rec of records.values()) - if (rec.sensorId === sensorId) return rec; - return null; - } - - /** Load the boxes for one card from the store or from an edit in progress. */ - function loadCard(entry) { - if (entry.row.unmodelled) return; - const range = shownRange.get(entry.key); - const key = editKey(entry.key, range); - if (edited.has(key)) writeCells(entry, edited.get(key)); - else writeCells(entry, valuesFromBlock(entry, storedBlock(entry, range))); - } - - function onRangeChanged(key) { - const entry = cards.get(key); - if (!entry) return; - /* Keep whatever is typed for the range being left, so flicking between - ranges to compare them does not throw an edit away. */ - stashEdit(entry); - shownRange.set(key, Number(entry.rangeSel.value)); - loadCard(entry); - paintCard(entry); - paintHeader(); - } - - function onCellEdited(key) { - const entry = cards.get(key); - if (!entry) return; - stashEdit(entry); - paintCard(entry); - paintHeader(); - } - - /** Remember (or forget) what is typed for the range on screen. */ - function stashEdit(entry) { - const range = shownRange.get(entry.key); - const key = editKey(entry.key, range); - const values = readCells(entry); - const stored = valuesFromBlock(entry, storedBlock(entry, range)); - const same = - stored && - ["offset", "sens", "align"].every((part) => - values[part].every((v, i) => String(v) === String(stored[part][i])), - ); - const blank = ["offset", "sens", "align"].every((part) => - values[part].every((v) => String(v).trim() === ""), - ); - if (same || (blank && !stored)) edited.delete(key); - else edited.set(key, values); - } - - // ------------------------------------------------------------------------- - // The header - // ------------------------------------------------------------------------- - - /** - * Every sensor+range whose boxes now differ from what the store holds. - * - * @returns {{group: string, sensorId: number, range: number, label: string, - * was: Uint8Array|null, now: Uint8Array}[]} - */ - function changes() { - const out = []; - for (const entry of cards.values()) { - if (entry.row.unmodelled) continue; - for (const [value] of entry.ranges) { - const key = editKey(entry.key, value); - if (!edited.has(key)) continue; - const saved = edited.get(key); - const isOnScreen = shownRange.get(entry.key) === value; - const built = isOnScreen - ? blockFromCells(entry) - : blockFromValues(entry, saved); - if (!built.bytes) continue; - const was = storedBlock(entry, value); - if (bytesEqual(built.bytes, was)) continue; - out.push({ - group: entry.row.group, - sensorId: entry.row.id, - range: value, - label: `${entry.row.label} (${entry.row.chip}) at ${rangeLabel(entry, value)}`, - was: was ? Uint8Array.from(was) : null, - now: built.bytes, - }); - } - } - return out; - } - - function rangeLabel(entry, value) { - const found = entry.ranges.find(([v]) => v === value); - return found ? found[1] : `range ${value}`; - } - - /** Every validation problem on the page, as plain sentences. */ - function problems() { - const out = []; - for (const entry of cards.values()) { - if (entry.row.unmodelled) continue; - const built = blockFromCells(entry); - for (const p of built.problems ?? []) { - out.push( - `${entry.row.label}: ${p.part} ${AXES[p.index % 3]} ${p.problem}`, - ); - } - for (const [value] of entry.ranges) { - if (value === shownRange.get(entry.key)) continue; - const key = editKey(entry.key, value); - if (!edited.has(key)) continue; - const b = blockFromValues(entry, edited.get(key)); - for (const p of b.problems ?? []) { - out.push( - `${entry.row.label} at ${rangeLabel(entry, value)}: ${p.part} ${AXES[p.index % 3]} ${p.problem}`, - ); - } - } - } - return out; - } - - function paintHeader() { - const which = store(); - const bad = problems(); - const changed = changes(); - - storePill.textContent = - which === "none" - ? "not connected" - : dumpBytes - ? `${dumpBytes.length} bytes ${fromFile ? "loaded" : "read"}` - : infoMemShown - ? "read from the configuration image" - : "not read"; - storePill.className = dumpBytes || infoMemShown ? "pill on" : "pill"; - - if (which === "dump") { - const v = dumpParsed?.version; - storeBanner.className = "banner info"; - storeBanner.textContent = - "This tab reads and writes the sensor's CALIBRATION DUMP — the store " + - "that keeps one record per sensor and range, each with the date it " + - "was calibrated. Write to sensor sends SET_CALIB_DUMP and then asks " + - "the firmware to apply it." + - (v - ? ` The dump on this sensor was written by hardware id ${v.hardwareId}, firmware ${v.firmwareMajor}.${v.firmwareMinor}.${v.firmwareInternal}, and holds ${dumpParsed.records.length} record${dumpParsed.records.length === 1 ? "" : "s"}.` - : ""); - } else if (which === "infomem") { - storeBanner.className = "banner warn"; - storeBanner.textContent = - "This link has no calibration-dump commands, so the values below come " + - "from the CONFIGURATION IMAGE (InfoMem) instead — one block per " + - "sensor, for the range configured right now, with no calibration " + - "date. They are shown read-only: the configuration image belongs to " + - "the Configure tab, which holds its own unsaved edits, and two " + - "writers on one image would silently drop whichever change lost. " + - "Connect over Bluetooth to change calibration."; - } else { - storeBanner.className = "banner"; - storeBanner.textContent = - "Connect a sensor to read the calibration it holds."; - } - - storeNote.textContent = - which === "dump" && fromFile - ? "These values came from a file on this host, not from the sensor. Nothing has been written yet." - : ""; - - if (bad.length) { - changeNote.textContent = - `${bad.length} value${bad.length === 1 ? "" : "s"} the calibration format cannot hold — ` + - `${bad[0]}. Write is refused until every box is in range.`; - } else if (changed.length) { - changeNote.textContent = - `${changed.length} calibration${changed.length === 1 ? "" : "s"} edited and not yet written: ` + - changed.map((c) => c.label).join("; ") + - "."; - } else if (dumpBytes && !fromFile) { - changeNote.textContent = "No changes since the dump was read."; - } else { - changeNote.textContent = ""; - } - - sync(); - } - - // ------------------------------------------------------------------------- - // Enablement - // ------------------------------------------------------------------------- - - function sync() { - if (destroyed) return; - const which = store(); - const live = enabled && !busy && !!getClient(); - const editable = which === "dump"; - btnRead.disabled = !live; - btnSave.disabled = !dumpBytes; - /* Loading a dump works with nothing connected — inspecting and editing a - saved dump is half of what saving one is for. It is refused only on a - link whose store is the configuration image, where a loaded dump would - be values with nowhere to go. */ - btnLoad.disabled = busy || which === "infomem"; - btnWrite.disabled = - !live || - !editable || - !dumpBytes || - problems().length > 0 || - changes().length === 0; - - /* A sensor that is connected but gated — sensing, or another panel - holding the link — locks the boxes: an edit made now could not be - written, and a box that accepts a value it cannot send is a lie. With - NOTHING connected they stay open, because editing a dump loaded from a - file and saving it back is half of what saving one is for. */ - const linkFree = !getClient() || enabled; - for (const entry of cards.values()) { - if (entry.row.unmodelled) continue; - entry.rangeSel.disabled = busy || which === "none"; - entry.btnDefaults.disabled = busy || !editable || !linkFree; - for (const part of ["offset", "sens", "align"]) { - for (const input of entry.cells[part]) { - input.disabled = busy || !editable || !linkFree; - input.readOnly = !editable; - } - } - entry.card.dataset.calAvailability = editable ? "editable" : "readonly"; - } - } - - function setBusy(next) { - if (busy === next) return; - busy = next; - sync(); - try { - opts.onBusyChange?.(next); - } catch (err) { - log.warn(`calibration busy handler failed: ${err?.message ?? err}`); - } - } - - /** - * The connected client, or null with the reason logged. - * - * Same shape as the device-naming panel's: a refusal that names what was - * being attempted reads far better than a button that does nothing. - */ - function clientFor(what) { - const client = getClient(); - if (!client) { - log.warn(`Connect a sensor before ${what}.`); - return null; - } - if (!enabled) { - log.warn( - `Not ${what}: calibration cannot be reached over this link right now.`, - ); - return null; - } - if (busy) { - log.warn("A calibration operation is already running."); - return null; - } - return client; - } - - // ------------------------------------------------------------------------- - // Read - // ------------------------------------------------------------------------- - - /** Index the parsed dump by sensor and range. */ - function indexRecords() { - records = new Map(); - for (const rec of dumpParsed?.records ?? []) { - records.set(recordKey(rec.sensorId, rec.range), rec); - } - } - - /** - * Open each card on the range worth looking at first: the one the sensor is - * configured for when it holds a record, else any range that holds one, else - * the configured range, else the group's own fallback. - */ - function chooseRanges() { - for (const entry of cards.values()) { - if (entry.row.unmodelled) continue; - const configured = configuredRange(entry); - const withRecord = entry.ranges - .map(([v]) => v) - .filter((v) => !!storedBlock(entry, v)); - let pick; - if (configured !== null && withRecord.includes(configured)) - pick = configured; - else if (withRecord.length) pick = withRecord[0]; - else if ( - configured !== null && - entry.ranges.some(([v]) => v === configured) - ) - pick = configured; - else { - const g = entry.row.group - ? sdk.getGroupDefaults(family(), entry.row.group) - : null; - pick = - g && entry.ranges.some(([v]) => v === g.fallbackRange) - ? g.fallbackRange - : entry.ranges[0][0]; - } - shownRange.set(entry.key, pick); - entry.rangeSel.value = String(pick); - } - } - - function repaintAll() { - ensureCards(); - chooseRanges(); - for (const entry of cards.values()) { - loadCard(entry); - paintCard(entry); - } - paintHeader(); - } - - /** - * Read the calibration the sensor holds and show it. - * - * @param {{reuseInfoMem?: boolean}} [opts] `reuseInfoMem: true` skips the - * configuration-image re-read the InfoMem fallback normally starts with, - * and shows the image the page already holds. For a caller that has just - * read it — a page reading calibration as part of its connect handshake — - * where the re-read would be a second full 384-byte transfer for bytes - * arriving a moment earlier. The dump path is unaffected: there is - * nothing to reuse there, and a read is a read. - * @returns {Promise} the parsed dump (or the InfoMem blocks) or - * null on failure - */ - /** - * Hand a dump the DEVICE just produced to the streaming calibration, so the - * next stream's calibrated inertial values use this sensor's own numbers - * rather than its part's factory seed. - * - * Only for a dump that came off the link. A dump loaded from a file - * (`adopt(..., true)`) is a candidate for WRITING — a statement about what - * the sensor should hold, not about what it does — and adopting one would - * calibrate this sensor's data with another's numbers. - * - * `?.`-guarded: a vendored SDK older than `applyCalibDump` should leave the - * stream calibrated against defaults, not break the calibration tab. - */ - function pushDumpToClient(client, dump) { - if (typeof client?.applyCalibDump !== "function" || !dump) return; - try { - const groups = client.applyCalibDump(dump); - if (groups?.length) { - log.log( - `streaming calibration now follows this sensor's own dump for: ${groups.join(", ")}`, - ); - } - } catch (err) { - log.warn( - `the SDK did not accept the dump for streaming calibration: ${err?.message ?? err}`, - ); - } - } - - async function read(opts = {}) { - const which = store(); - if (which === "infomem") return readInfoMemFallback(opts); - const client = clientFor("reading its calibration"); - if (!client) return null; - setBusy(true); - try { - log.log("reading the calibration dump (GET_CALIB_DUMP, 128-byte pages)…"); - const { bytes, dump } = await client.readCalibDump(); - adopt(bytes, dump, false); - pushDumpToClient(client, dump); - log.log( - `calibration dump: ${bytes.length} bytes, ${dump.records.length} record(s) — ` + - (dump.records.length - ? dump.records - .map((r) => `${sensorName(r.sensorId)} range ${r.range}`) - .join(", ") - : "none"), - ); - return dump; - } catch (err) { - const message = `Reading the calibration failed: ${err?.message ?? err}`; - log.error(message); - toast(message, "err"); - return null; - } finally { - setBusy(false); - } - } - - /** The read-only path: the six InfoMem blocks the page already holds. */ - async function readInfoMemFallback({ reuseInfoMem = false } = {}) { - const client = clientFor("reading its calibration"); - if (!client) return null; - setBusy(true); - try { - if (!reuseInfoMem && typeof opts.readInfoMem === "function") { - log.log( - "this link has no calibration-dump commands — reading the calibration blocks out of the configuration image instead…", - ); - await opts.readInfoMem(); - } - infoMemShown = getInfoMemBlocks(); - if (!infoMemShown) { - const message = - "The configuration image could not be read, so there is no calibration to show."; - log.error(message); - toast(message, "err"); - return null; - } - dumpBytes = null; - dumpParsed = null; - records = new Map(); - fromFile = false; - repaintAll(); - log.log("calibration read from the configuration image (read-only here)"); - return infoMemShown; - } catch (err) { - const message = `Reading the calibration failed: ${err?.message ?? err}`; - log.error(message); - toast(message, "err"); - return null; - } finally { - setBusy(false); - } - } - - function adopt(bytes, dump, loaded) { - dumpBytes = Uint8Array.from(bytes); - dumpParsed = dump; - fromFile = !!loaded; - infoMemShown = null; - indexRecords(); - edited.clear(); - repaintAll(); - } - - function sensorName(id) { - for (const rows of Object.values(SENSOR_ROWS)) { - const row = rows.find((r) => r.id === id); - if (row) return `${row.label} (${row.chip})`; - } - return `sensor id ${id}`; - } - - // ------------------------------------------------------------------------- - // Write - // ------------------------------------------------------------------------- - - /** The dump bytes the edits describe, or null when there is nothing to send. */ - function buildDump() { - if (!dumpParsed) return null; - const changed = changes(); - if (!changed.length) return null; - /* Every record the device sent, in the order it sent them, with the - edited ones replaced and any brand-new sensor+range appended. Rewriting - the whole dump is not a choice: SET_CALIB_DUMP takes the total length - from the first chunk and counts forward, so a partial write is a - different dump, not a patch. */ - const out = dumpParsed.records.map((r) => ({ - sensorId: r.sensorId, - range: r.range, - calibLen: r.calibLen, - timestampTicks: Uint8Array.from(r.timestampTicks), - calibBytes: Uint8Array.from(r.calibBytes), - isDefault: r.isDefault, - })); - const stamp = hostStamp(); - for (const c of changed) { - const found = out.find( - (r) => r.sensorId === c.sensorId && r.range === c.range, - ); - if (found) { - found.calibBytes = c.now; - found.calibLen = c.now.length; - found.timestampTicks = stamp; - found.isDefault = false; - } else { - out.push({ - sensorId: c.sensorId, - range: c.range, - calibLen: c.now.length, - timestampTicks: stamp, - calibBytes: c.now, - isDefault: false, - }); - } - } - return sdk.generateCalibDump(dumpParsed.version, out); - } - - /** - * Now, as the 8-byte tick stamp the firmware writes. - * - * A plain Unix epoch, matching the sensor's own real-world clock: desktop - * Consensys and the dock software both write `Date.now()` ticks, so a stamp - * written here reads back against the same scale as every other date the - * sensor produces. - */ - function hostStamp() { - const nowMs = Date.now(); - let ticks = BigInt(Math.round((nowMs / 1000) * RTC_TICKS_PER_SECOND)); - const out = new Uint8Array(8); - for (let i = 0; i < 8; i++) { - out[i] = Number(ticks & 0xffn); - ticks >>= 8n; - } - return out; - } - - /** - * Write the edits to the sensor, then read the dump back and byte-compare. - * - * @returns {Promise} true when the write verified - */ - async function write() { - if (store() !== "dump") { - log.warn( - "Not writing: this link has no calibration-dump commands, and this tab does not write the configuration image.", - ); - return false; - } - const client = clientFor("writing its calibration"); - if (!client) return false; - - const bad = problems(); - if (bad.length) { - const message = `Not writing: ${bad.length} value${bad.length === 1 ? "" : "s"} the calibration format cannot hold (${bad[0]}).`; - log.error(message); - toast(message, "err"); - return false; - } - const changed = changes(); - if (!changed.length) { - log.warn("Nothing to write — no calibration value has been changed."); - return false; - } - const bytes = buildDump(); - if (!bytes) return false; - - if (!ask(buildConfirmation(changed, bytes))) { - log.log("calibration write cancelled"); - return false; - } - - setBusy(true); - try { - log.log( - `writing the calibration dump (${bytes.length} bytes, ${changed.length} changed record(s)) with SET_CALIB_DUMP, then UPD_CALIB_DUMP…`, - ); - await client.writeCalibDump(bytes); - /* Read back and compare, like every other write on these pages. The - dump is small and the read is paged at 128 bytes, so this costs two - round trips and is the only thing that proves the sensor took it. */ - const back = await client.readCalibDump(); - const same = bytesEqual(bytes, back.bytes); - adopt(back.bytes, back.dump, false); - // The device holds different numbers now, so the stream must too. - pushDumpToClient(client, back.dump); - if (same) { - log.log( - "calibration written and verified — the dump read back byte-identical", - ); - toast("Calibration written and verified", "ok"); - return true; - } - const at = firstDifference(bytes, back.bytes); - const message = - `The calibration read back differently from what was written` + - (at >= 0 - ? ` (first difference at byte ${at})` - : ` (${bytes.length} bytes out, ${back.bytes.length} back)`) + - ". The sensor may have rebuilt its dump from the configuration bytes."; - log.error(message); - toast(message, "err"); - return false; - } catch (err) { - const message = `Writing the calibration failed: ${err?.message ?? err}`; - log.error(message); - toast(message, "err"); - return false; - } finally { - setBusy(false); - } - } - - function firstDifference(a, b) { - const n = Math.min(a.length, b.length); - for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i; - return a.length === b.length ? -1 : n; - } - - /** What the confirmation shows: the store, and every value that moves. */ - function buildConfirmation(changed, bytes) { - const lines = changed.map((c) => { - const entry = cards.get(c.group); - const before = c.was - ? describeBlock(entry, c.was) - : "nothing stored (the sensor was using the factory defaults)"; - return ` ${c.label}\n was ${before}\n now ${describeBlock(entry, c.now)}`; - }); - return ( - `Write calibration to the sensor's CALIBRATION DUMP?\n\n` + - `${changed.length} record${changed.length === 1 ? "" : "s"} change; the whole ${bytes.length}-byte dump is rewritten ` + - `because the firmware takes the dump's length from the first chunk.\n\n` + - `${lines.join("\n")}\n\n` + - `The changed records are stamped with this host's clock.\n\n` + - `NOTE: writing a configuration image afterwards makes the firmware rebuild ` + - `the dump from the configuration bytes, which discards this. Apply a ` + - `configuration first, then write calibration.` - ); - } - - function describeBlock(entry, block) { - const cal = sdk.parseKinematicCalibBlock(block, { - sensitivityScale: entry?.sensitivityScale ?? 1, - }); - if (!cal) return "an empty block"; - return ( - `offset [${cal.offset.map(num).join(", ")}] ` + - `sensitivity [${cal.sensitivity.map(num).join(", ")}] ` + - `alignment [${cal.alignment.map(num).join(", ")}]` - ); - } - - // ------------------------------------------------------------------------- - // Defaults, save and load - // ------------------------------------------------------------------------- - - /** - * Fill one sensor's boxes with the SDK's factory defaults for the range on - * screen. Nothing is sent; the user still has to Write. - * - * @returns {boolean} true when defaults for that sensor and range exist - */ - function restoreDefaults(group) { - const entry = cards.get(group); - if (!entry || entry.row.unmodelled) return false; - const range = shownRange.get(entry.key); - const d = defaultsFor(entry, range); - if (!d) { - log.warn( - `No factory default calibration is known for ${entry.row.label} at ${rangeLabel(entry, range)}.`, - ); - return false; - } - writeCells(entry, valuesFromCalibration(d.calibration)); - stashEdit(entry); - paintCard(entry); - paintHeader(); - log.log( - `${entry.row.label}: boxes filled with the factory defaults for ${rangeLabel(entry, range)} — not written yet`, - ); - return true; - } - - /** - * Save the dump to a file — the edits included. - * - * Deliberately what is ON SCREEN rather than what was read: a file that - * silently dropped the edits above it would be the one thing on this page - * that did not mean what it showed. - */ - function save() { - if (!dumpBytes) return false; - const edits = problems().length ? null : buildDump(); - const bytes = edits ?? dumpBytes; - const stamp = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 19); - downloadBlob( - `${getFilePrefix() ?? "shimmer"}-calibration-${stamp}.bin`, - new Blob([bytes], { type: "application/octet-stream" }), - ); - log.log( - `calibration dump saved (${bytes.length} bytes${edits ? ", including the edits on screen" : ""})`, - ); - return true; - } - - /** Adopt a dump from a file. */ - function load(bytes) { - try { - const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); - const dump = sdk.parseCalibDump(u8); - if (!dump.records.length) { - toast( - "That file parsed as a calibration dump but holds no records.", - "warn", - ); - } - adopt(u8, dump, true); - log.log( - `calibration dump loaded from a file: ${u8.length} bytes, ${dump.records.length} record(s)`, - ); - return true; - } catch (err) { - const message = `That file is not a calibration dump: ${err?.message ?? err}`; - log.error(message); - toast(message, "err"); - return false; - } - } - - async function onFilePicked(e) { - const file = e.target.files?.[0]; - e.target.value = ""; - if (!file) return; - load(new Uint8Array(await file.arrayBuffer())); - } - - // ------------------------------------------------------------------------- - // Wiring - // ------------------------------------------------------------------------- - - btnRead.addEventListener("click", () => { - read().catch(() => {}); - }); - btnWrite.addEventListener("click", () => { - write().catch(() => {}); - }); - btnSave.addEventListener("click", () => save()); - btnLoad.addEventListener("click", () => fileInput.click()); - - buildCards(); - paintHeader(); - - return { - read, - write, - save, - load, - restoreDefaults, - dump: () => dumpParsed, - bytes: () => (dumpBytes ? Uint8Array.from(dumpBytes) : null), - store, - family, - changes, - problems, - setEnabled(next) { - const was = enabled; - enabled = !!next; - /* Cleared when the LINK has gone, not merely when the panel has been - gated: keeping the last sensor's values would let the next one be - judged calibrated on numbers that were never its own, but throwing - away a read because somebody started a stream would be losing work - for nothing. (The device-naming panel clears on every falling edge; - it can afford to, because a name is two round trips to read again. - A dump is a paged read and a screen of numbers.) - This runs on every re-gate, so it must not fire while already - disabled or it would wipe the panel continuously. - NOTE for the host page: do NOT fold this panel's own busy state back - into what you pass here, or the read-back that follows a write clears - the values the write just verified. */ - if (was && !enabled && !getClient()) { - dumpBytes = null; - dumpParsed = null; - records = new Map(); - infoMemShown = null; - fromFile = false; - edited.clear(); - repaintAll(); - return; - } - /* A new link can mean new hardware, and the cards are built per family. */ - ensureCards(); - paintHeader(); - }, - destroy() { - destroyed = true; - host.replaceChildren(); - }, - }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Accept a value or a getter for it, and always return a getter. */ -function asGetter(value) { - return typeof value === "function" ? value : () => value ?? null; -} - -/** - * The API shape, with every operation refusing, for an SDK bundle that has no - * calibration support. A host page then gates and mounts exactly as it always - * does and gets a message on screen instead of a thrown import. - */ -function inertPanel() { - return { - read: async () => null, - write: async () => false, - save: () => false, - load: () => false, - restoreDefaults: () => false, - dump: () => null, - bytes: () => null, - store: () => "none", - family: () => null, - changes: () => [], - problems: () => [], - setEnabled() {}, - destroy() {}, - }; -} diff --git a/common/config-form.js b/common/config-form.js deleted file mode 100644 index e24e75c..0000000 --- a/common/config-form.js +++ /dev/null @@ -1,1356 +0,0 @@ -/** - * Generic, schema-driven configuration form. - * - * Renders one editable control per declarative field definition, grouped into - * collapsible panels, and edits the device's configuration image IN PLACE. - * - * THE WORKING DOCUMENT IS THE BYTE IMAGE. There is no parallel JavaScript - * object model of the configuration: every control reads and writes the - * 384-byte InfoMem image through the injected codec, so any byte no field - * models (the MPL regions, the reserved bits, the sensor bitmaps) survives a - * read/edit/write round-trip untouched. That is the whole reason this module - * takes a `codec` instead of a parsed config object. - * - * The schema and the codec are INJECTED, never imported. This module has no - * dependency on the SDK bundle, so it renders a Shimmer3/3R InfoMem today and - * a Verisense operational config (or anything else with the same field shape) - * tomorrow, and it can be developed before the SDK that describes the fields - * has even been vendored. - * - * Follows the pattern of the Verisense device console's op-config editor - * (verisense-device-console/console.js: `renderAllOpFieldsEditor` L571-762, - * the unsupported-group relocation L764-821 and the sync/apply/dirty passes - * L823-927), with three deliberate departures: - * - * 1. Dirty state is computed from the BYTES, not from the control values. - * The console compares a control's string value against a captured - * string; here a field is dirty when its decoded value differs from the - * decode of the baseline image. Loading a preset, writing a byte by hand - * or reverting one field therefore all mark exactly the fields that - * really changed. - * 2. Invalid input never reaches the image. The console writes whatever the - * control holds; here a value is validated first, and a rejected value - * leaves the bytes alone and flags the control. - * 3. Support (the "not on this hardware" relocation) is per FIELD, not per - * group. A group relocates when it has no supported members left. - * - * Nothing here touches `document` at import time. - * - * import { createConfigForm } from "../common/config-form.js"; - */ - -import { el } from "./ui-chrome.js"; - -/** Shimmer3/3R InfoMem image length. Also the default working-image size. */ -export const CONFIG_IMAGE_SIZE_DEFAULT = 384; - -/** Title of the collapsed panel unsupported groups are moved into. */ -const UNSUPPORTED_TITLE = "Not available on this hardware"; - -/** ASCII bytes an `ascii12` field may hold — printable, no DEL. */ -const ASCII_MIN = 0x20; -const ASCII_MAX = 0x7e; - -/** - * Kinds that decode to a plain number, and so render as a number input. - * `bit` is handled separately (it can be a checkbox or a select). - */ -const NUMERIC_KINDS = new Set(["bit", "u8", "u16le", "u16be", "u32be"]); - -/** One MAC is 6 bytes, i.e. 12 hex characters. */ -const MAC_HEX_LENGTH = 12; - -/** Default cap on a `mac6[]` list when the field declares no `max`. */ -const MAC_LIST_MAX_DEFAULT = 21; - -/** - * Length of a raw byte-block kind, or null when the kind is not one. - * - * The schema names these by their length (`bytes21` for a kinematic - * calibration block, `bytes10` for an ADS1292R register bank), so the length - * is parsed out of the kind rather than tabulated. A `bytes6` added tomorrow - * therefore renders correctly with no change here — which is the point: the - * ExG banks arrived as `u8` and became `bytes10` mid-development, and a - * hard-coded table would have silently rendered the new kind as a number - * input and written ten zeroes over the register bank on the first commit. - * - * @param {string} kind - * @returns {number|null} - */ -function byteBlockLength(kind) { - const m = /^bytes(\d+)$/.exec(String(kind)); - if (!m) return null; - const n = Number(m[1]); - return Number.isInteger(n) && n > 0 ? n : null; -} - -/** Instance counter, so two forms on one page cannot collide on element ids. */ -let instanceSeq = 0; - -// --------------------------------------------------------------------------- -// Encoding tooltips -// --------------------------------------------------------------------------- - -function hex2(n) { - return (n & 0xff).toString(16).toUpperCase().padStart(2, "0"); -} - -/** - * `byte 130, bits 6-7 (mask 0xC0)` for one bitfield slice. - * - * The mask is the IN-BYTE mask, i.e. already shifted into place, because the - * reader hovering this is holding a register map or a hex dump and wants the - * bits as they sit in that byte. An unshifted 0x03 for bits 6-7 is a mask of - * the field's VALUE, which is not a thing anyone can look up. - * - * Each slice of a composite field is shifted by its OWN shift, so the two - * halves of the LSM6DSV gyro range read `mask 0x03` and `mask 0x04` — the - * bytes they live in, not one mask applied twice. - */ -function bitSpanText(index, shift, width) { - const bits = - width === 1 ? `bit ${shift}` : `bits ${shift}-${shift + width - 1}`; - const mask = ((1 << width) - 1) << shift; - return `byte ${index}, ${bits} (mask 0x${hex2(mask)})`; -} - -function byteSpanText(index, length) { - return length === 1 - ? `byte ${index}` - : `bytes ${index}-${index + length - 1}`; -} - -/** - * Human description of where a field physically lives, for the control's - * `title`. Kept out of the visible helper text (the console does the same): - * the byte/bit citation is what a firmware engineer wants on hover and what - * everyone else wants out of the way. - * - * A COMPOSITE field — one declaring `msbLayoutKey`, whose value is split - * across two non-adjacent bytes — reads as both slices joined by `+`. - * - * @param {object} field - * @param {number} index resolved byte offset of the field (or its low part) - * @param {number|undefined} msbIndex resolved offset of the high part - * @returns {string} - */ -export function encodingTooltip(field, index, msbIndex) { - switch (field.kind) { - case "bit": { - const low = bitSpanText(index, field.shift ?? 0, field.width ?? 1); - if (msbIndex === undefined) return low; - const high = bitSpanText( - msbIndex, - field.msbShift ?? 0, - field.msbWidth ?? 1, - ); - return `${low} + ${high}`; - } - case "u8": - return byteSpanText(index, 1); - case "u16le": - return `${byteSpanText(index, 2)} (little-endian)`; - case "u16be": - return `${byteSpanText(index, 2)} (big-endian)`; - case "u32be": - return `${byteSpanText(index, 4)} (big-endian)`; - case "ascii12": - return `${byteSpanText(index, 12)} (ASCII, 0xFF-padded)`; - case "mac6[]": { - const slots = field.max ?? MAC_LIST_MAX_DEFAULT; - return `${byteSpanText(index, slots * 6)} (${slots} slots x 6 bytes)`; - } - default: { - const block = byteBlockLength(field.kind); - if (block !== null) return `${byteSpanText(index, block)} (raw)`; - return `${byteSpanText(index, 1)} (unrecognised encoding '${field.kind}')`; - } - } -} - -/** - * Largest value a field can hold, from its declared `max` or, failing that, - * from the width its encoding gives it. - * - * For a `bit` field the derived maximum includes the composite high part, so - * the LSM6DSV gyro range (2 low bits + 1 high bit) accepts 0-7 rather than - * silently clamping to 0-3. - * - * @param {object} field - * @returns {number|undefined} - */ -export function derivedMax(field) { - if (typeof field.max === "number" && field.kind !== "ascii12") { - return field.max; - } - switch (field.kind) { - case "bit": { - const width = - (field.width ?? 1) + (field.msbLayoutKey ? (field.msbWidth ?? 1) : 0); - return (1 << width) - 1; - } - case "u8": - return 0xff; - case "u16le": - case "u16be": - return 0xffff; - case "u32be": - return 0xffffffff; - default: - return undefined; - } -} - -// --------------------------------------------------------------------------- -// Value helpers -// --------------------------------------------------------------------------- - -function bytesToHex(bytes) { - let s = ""; - for (const b of bytes) s += hex2(b); - return s; -} - -/** True when two decoded field values are the same value. */ -function sameValue(a, b) { - if (a instanceof Uint8Array && b instanceof Uint8Array) { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; - return true; - } - if (Array.isArray(a) && Array.isArray(b)) { - return a.length === b.length && a.every((v, i) => v === b[i]); - } - return a === b; -} - -/** - * A decoded value as a person would read it — the option label where the - * schema has one, so a confirm dialog says "± 4 g", not "1". - * - * @param {object} field - * @param {number|string|Uint8Array|string[]} value - * @returns {string} - */ -export function displayValue(field, value) { - if (value instanceof Uint8Array) return bytesToHex(value); - if (Array.isArray(value)) return value.length ? value.join(", ") : "(none)"; - if (typeof value === "string") return value === "" ? "(empty)" : value; - if (field.options) { - const hit = field.options.find(([v]) => v === value); - if (hit) return hit[1]; - return `${value} (not in this part's table)`; - } - return String(value); -} - -// --------------------------------------------------------------------------- -// Per-kind validation -// --------------------------------------------------------------------------- - -/** - * Turn a control's raw string into a value the codec can write, or explain - * why it cannot be written. - * - * @returns {{ok: true, value: unknown} | {ok: false, error: string}} - */ -function parseControlValue(field, raw) { - switch (field.kind) { - case "ascii12": { - const s = String(raw); - if (s.length > 12) return { ok: false, error: "at most 12 characters" }; - for (let i = 0; i < s.length; i++) { - const c = s.charCodeAt(i); - if (c < ASCII_MIN || c > ASCII_MAX) { - return { - ok: false, - error: `character ${i + 1} is not printable ASCII`, - }; - } - } - return { ok: true, value: s }; - } - case "mac6[]": { - const limit = field.max ?? MAC_LIST_MAX_DEFAULT; - const macs = []; - const lines = String(raw).split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - // Blank lines are ignored, so a trailing newline is not an error and - // a list can be edited without fighting the whitespace. - const mac = lines[i].replace(/[\s:-]/g, "").toUpperCase(); - if (!mac) continue; - if (!/^[0-9A-F]{12}$/.test(mac)) { - return { - ok: false, - error: `line ${i + 1} is not ${MAC_HEX_LENGTH} hex digits`, - }; - } - macs.push(mac); - } - if (macs.length > limit) { - return { ok: false, error: `at most ${limit} addresses` }; - } - return { ok: true, value: macs }; - } - default: { - const block = byteBlockLength(field.kind); - if (block !== null) { - const hex = String(raw) - .replace(/[\s:-]/g, "") - .toUpperCase(); - if (!/^[0-9A-F]*$/.test(hex)) { - return { ok: false, error: "hex digits only" }; - } - if (hex.length !== block * 2) { - return { - ok: false, - error: `${block * 2} hex characters needed, ${hex.length} given`, - }; - } - const out = new Uint8Array(block); - for (let i = 0; i < block; i++) { - out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); - } - return { ok: true, value: out }; - } - // Numeric kinds are named, not assumed: an encoding this module does - // not know must be refused, never parsed as a number and written over - // the field's bytes. (`buildControl` renders such a field read-only, so - // this is the second of two guards.) - if (!NUMERIC_KINDS.has(field.kind)) { - return { - ok: false, - error: `unrecognised field encoding '${field.kind}'`, - }; - } - const s = String(raw).trim(); - if (s === "") return { ok: false, error: "a number is needed" }; - const n = Number(s); - if (!Number.isFinite(n) || !Number.isInteger(n)) { - return { ok: false, error: "a whole number is needed" }; - } - const min = typeof field.min === "number" ? field.min : 0; - const max = derivedMax(field); - if (n < min) return { ok: false, error: `at least ${min}` }; - if (max !== undefined && n > max) { - return { ok: false, error: `at most ${max}` }; - } - return { ok: true, value: n }; - } - } -} - -/** The string a control should show for a decoded value. */ -/** - * Render Unix seconds as a local date/time for DISPLAY only. - * - * The field keeps its numeric control and its stored bytes; this is shown - * beside the value, so what the device holds and what the screen says can - * never disagree. A raw second count is unreadable, and the one thing a user - * wants from a configuration timestamp — is this configuration recent? — is - * unanswerable without it. - * - * @param {unknown} value seconds since the Unix epoch - * @returns {string} a formatted local date/time, or "" when not a usable stamp - */ -export function unixSecondsText(value) { - const secs = Number(value); - if (!Number.isFinite(secs) || secs <= 0) return ""; - const d = new Date(secs * 1000); - if (Number.isNaN(d.getTime())) return ""; - // Local time, deliberately: this answers a wall-clock question ("was this - // configured today?"), and the stored value is timezone-independent. - return d.toLocaleString(); -} - -function formatForControl(field, value) { - if (value instanceof Uint8Array) return bytesToHex(value); - if (Array.isArray(value)) return value.join("\n"); - return String(value); -} - -// --------------------------------------------------------------------------- -// The form -// --------------------------------------------------------------------------- - -/** - * Build a configuration form inside `host`. - * - * @param {HTMLElement} host container the form is rendered into (emptied) - * @param {object} cfg - * @param {readonly object[]} cfg.fields field definitions, ALREADY filtered - * for the device generation (`infoMemFieldsFor(generation)`), in the order - * they should appear within their group - * @param {readonly object[]} cfg.groups group definitions, in display order; - * `{id, title, openByDefault?, subgroups?: [{id, title}]}` - * @param {object} cfg.layout the resolved layout the codec resolves indexes - * against - * @param {{ - * resolveFieldIndex: (field: object, layout: object) => number, - * readInfoMemFieldValue: (bytes: Uint8Array, field: object, layout: object) - * => number|string|Uint8Array|string[], - * writeInfoMemFieldValue: (bytes: Uint8Array, field: object, layout: object, - * value: unknown) => void, - * }} cfg.codec the SDK's three field accessors, injected - * @param {(field: object, api: { - * id: string, - * onEdit: () => void, - * describe: (key: string) => {value: unknown, label: string}|null, - * }) => { - * node: HTMLElement, - * get: () => {ok: true, value: unknown}|{ok: false, error: string}, - * set: (value: unknown) => void, - * setDisabled?: (disabled: boolean, reason: string) => void, - * setInvalid?: (message: string) => void, - * focus?: () => void, - * refresh?: () => void, - * }|null} [cfg.editorFor] a custom editor for a field, in place of the plain - * control this module would build. - * - * `refresh()`, if provided, is called on every OTHER editor after any field - * commits — it is how an editor hears that a sibling field it derives - * something from has changed, since a commit otherwise repaints only the - * field that was committed. It must NOT overwrite what the user has typed - * into the editor itself; that is what `set()` is for. - * - * Returning null (for every field, or for one) keeps the default control, so - * a page opts in per field and this module keeps working with no `editorFor` - * at all. The editor is handed the same commit path as a plain control: it - * calls `api.onEdit()`, this module asks it for a value through `get()`, and - * a valid one is written into the image exactly as any other field's is — - * which is the point of the hook rather than a second writer. Dirty state, - * Discard, Apply and the hex view therefore need no knowledge of it. - * - * `api.describe(key)` reads ANOTHER field's current value and its option - * label out of the same image, for an editor that has to show something - * about a sibling field — the configured range beside a calibration block, - * say. It returns null for a field this form does not hold. - * - * The hook exists because this module deliberately has no SDK dependency - * (see the header): an editor that has to decode a field's bytes into - * something structured needs the SDK's codec for that encoding, so it is - * built by the page and passed in. - * @param {readonly string[]} [cfg.dateTimeFields] field keys whose value is - * Unix seconds and should additionally be shown as a local date/time. The - * control and the stored bytes are unchanged — the formatting is display - * only, so it cannot disagree with what gets written to the device. - * @param {boolean} [cfg.search=true] render a search box above the groups that - * live-filters fields, opens the groups holding matches and hides the rest. - * @param {number} [cfg.imageSize=384] working-image length - * @param {Uint8Array} [cfg.image] initial working image; defaults to a - * zero-filled buffer, so the form renders before anything has been read - * @param {(key: string, value: unknown, dirtyKeys: string[]) => void} - * [cfg.onChange] fired only on a VALID commit - * @param {(dirtyCount: number) => void} [cfg.onDirtyChange] - * @returns {{ - * setImage: (bytes: Uint8Array) => void, - * getImage: () => Uint8Array, - * captureBaseline: () => void, - * dirtyKeys: () => string[], - * dirtyFields: () => {key: string, label: string, from: string, to: string}[], - * revert: () => void, - * refresh: () => void, - * setEnabled: (enabled: boolean) => void, - * setFieldSupport: (key: string, supported: boolean, reason?: string) => void, - * setPlaceholder: (key: string, text: string) => void, - * focusField: (key: string) => void, - * destroy: () => void, - * }} - */ -export function createConfigForm(host, cfg) { - const { - fields = [], - groups = [], - layout, - codec, - editorFor, - onChange, - onDirtyChange, - } = cfg ?? {}; - - if (!host) throw new TypeError("createConfigForm needs a host element"); - if (!codec?.readInfoMemFieldValue || !codec?.writeInfoMemFieldValue) { - throw new TypeError( - "createConfigForm needs codec.readInfoMemFieldValue / .writeInfoMemFieldValue", - ); - } - - const imageSize = cfg.imageSize ?? CONFIG_IMAGE_SIZE_DEFAULT; - const idPrefix = `cf${++instanceSeq}`; - /** Field keys shown with a local date/time beside the raw seconds. */ - const dateTimeKeys = new Set(cfg.dateTimeFields ?? []); - /** - * Group id -> the reason its fields are locked. Read once here rather than - * per field, so a group-level lock needs no flag on each of its fields. - */ - const lockedGroups = new Map( - groups - .filter((g) => g.readOnly) - .map((g) => [g.id, g.readOnlyReason || "Not editable here."]), - ); - - /** The working document. Every control reads and writes THIS array. */ - let image = adoptImage(cfg.image, imageSize); - /** The last-known-clean copy of the image; dirty state is measured off it. */ - let baseline = new Uint8Array(image); - - let enabled = true; - - /** - * Open/closed state of every group from before a search began, so clearing - * the box restores what the user had rather than leaving everything open. - * Null when no search is active. - * - * Declared up here, with the other mutable state, because `render()` below - * reaches it through `refreshGroupPlacement` — a `let` further down the - * function is in its temporal dead zone at that point and throws. - * @type {Map|null} - */ - let searchOpenState = null; - - /** @type {Map} field key -> entry */ - const entries = new Map(); - /** @type {Map} group id -> its
*/ - const groupNodes = new Map(); - /** @type {Map} group id -> its [data-group-body] slot */ - const groupBodies = new Map(); - /** @type {Map} group/subgroup id -> the .grid it fills */ - const grids = new Map(); - - // Two hosts, as in the console: the groups this hardware supports, and a - // collapsed panel for the rest. Relocation moves the same
node - // between them, so it is non-destructive — open state, control values and - // event listeners all survive, and moving a group back is just as cheap. - const supportedHost = el("div", { class: "config-groups" }); - const unsupportedBody = el("div", { class: "config-groups" }); - const unsupportedSection = el( - "details", - { class: "group", hidden: true }, - el("summary", {}, UNSUPPORTED_TITLE), - el( - "div", - { class: "group-body" }, - el("div", { - class: "field-hint", - text: - "These settings are still part of the configuration and are written " + - "to the sensor unchanged, but this hardware has no matching part.", - }), - unsupportedBody, - ), - ); - - /* Field search, as in the Verisense console: live-filters the field cards, - opens the groups that hold matches and hides the rest. There are enough - settings here that finding one by scrolling is the slow path. */ - const searchInput = el("input", { - type: "search", - id: `${idPrefix}-search`, - placeholder: "Search settings…", - autocomplete: "off", - spellcheck: "false", - }); - const searchCount = el("div", { class: "field-hint muted" }); - const searchBar = el( - "div", - { class: "config-search" }, - el("label", { for: `${idPrefix}-search` }, "Search"), - searchInput, - searchCount, - ); - const searchEnabled = cfg.search !== false; - - host.replaceChildren( - ...(searchEnabled ? [searchBar] : []), - supportedHost, - unsupportedSection, - ); - if (searchEnabled) searchInput.addEventListener("input", applySearch); - - render(); - repopulate(); - - // ---- Rendering --------------------------------------------------------- - - function render() { - for (const g of groups) { - const details = el("details", { - class: "group", - dataset: { groupId: g.id }, - }); - if (g.openByDefault) details.open = true; - details.appendChild(el("summary", {}, g.title)); - - const body = el("div", { - class: "group-body", - dataset: { groupBody: g.id }, - }); - groupBodies.set(g.id, body); - // Said once at the top of a locked group rather than on each of its - // fields: the reason is a property of the group, and repeating it on - // every card buries the settings it is explaining. - if (lockedGroups.has(g.id)) { - body.appendChild( - el("div", { class: "field-hint muted" }, lockedGroups.get(g.id)), - ); - } - // Ungrouped fields fill this grid; labelled subpanels follow it. - const grid = el("div", { class: "grid" }); - body.appendChild(grid); - grids.set(g.id, grid); - - for (const sg of g.subgroups ?? []) { - const subGrid = el("div", { class: "grid" }); - const sub = el( - "details", - { - class: "group", - open: true, - dataset: { subgroupId: sg.id }, - }, - el("summary", {}, sg.title), - el("div", { class: "group-body" }, subGrid), - ); - body.appendChild(sub); - grids.set(sg.id, subGrid); - } - - details.appendChild(body); - supportedHost.appendChild(details); - groupNodes.set(g.id, details); - } - - for (const field of fields) { - const entry = buildField(field); - if (!entry) continue; - entries.set(field.key, entry); - /* Applied at build time, not left to the first setEnabled(): a lock is - a property of the setting itself, so a host that never calls - setEnabled would otherwise render a locked field as editable. */ - if (entry.lockReason) applyEnabled(entry); - // A field may name a subgroup as its group ("sdLogging.startup"); fall - // back to the group's own grid, and then to the first grid there is, so - // a schema/group mismatch shows the field somewhere rather than losing - // it silently. - const grid = - grids.get(field.group) ?? - grids.get(String(field.group).split(".")[0]) ?? - grids.values().next().value; - grid?.appendChild(entry.wrap); - } - - refreshGroupPlacement(); - } - - /** One `.field` card: label, control, helper text, error, support note. */ - function buildField(field) { - let index; - try { - index = codec.resolveFieldIndex - ? codec.resolveFieldIndex(field, layout) - : layout?.[field.layoutKey]; - } catch { - index = undefined; - } - if (typeof index !== "number") { - // An unresolvable layout key means this field does not exist in this - // firmware's layout at all. Rendering a control that writes byte - // `undefined` would corrupt the image, so drop the field instead. - return null; - } - const msbIndex = - field.msbLayoutKey === undefined - ? undefined - : typeof layout?.[field.msbLayoutKey] === "number" - ? layout[field.msbLayoutKey] - : undefined; - - const slug = String(field.key).replace(/[^A-Za-z0-9_-]/g, "-"); - const id = `${idPrefix}-${slug}`; - const hintId = `${id}-hint`; - const errId = `${id}-err`; - const noteId = `${id}-note`; - const tooltip = encodingTooltip(field, index, msbIndex); - - /* A page-supplied editor takes the place of the control entirely. It - is asked for BEFORE buildControl so a field it claims never gets a - default control built for it, and it is asked once per entry, so an - editor holds per-field state safely. - - The box is declared first and filled in at the end of this function - because the editor is constructed before the entry exists but commits - through it: `onEdit` has to reach the finished entry, not the half-built - one, and a closure over a `let` that is assigned later would read - undefined if an editor ever called back during construction. */ - const entryRef = { current: null }; - const editor = - typeof editorFor === "function" - ? (editorFor(field, { - id, - onEdit: () => commit(entryRef.current), - describe: describeField, - }) ?? null) - : null; - - const control = editor ? editor.node : buildControl(field, id); - control.dataset.fieldKey = field.key; - if (!editor) { - control.title = tooltip; - control.setAttribute("aria-describedby", `${hintId} ${errId} ${noteId}`); - } - - /* An editor supplies its own titles for its own boxes; a `label for=` at - the top of a nine-box matrix points at nothing in particular. The field - name still has to be there, so it becomes a plain heading. */ - const label = editor - ? el("div", { class: "field-editor-label", title: tooltip }, field.label) - : el("label", { for: id, title: tooltip }, field.label); - const hint = el("div", { class: "field-hint", id: hintId }, field.desc); - // aria-live so a rejected value is announced when it is rejected, not - // only when the control is next focused. - const error = el("div", { - class: "field-hint", - id: errId, - hidden: true, - style: { color: "var(--danger)" }, - "aria-live": "polite", - }); - const note = el("div", { - class: "field-hint muted", - id: noteId, - hidden: true, - }); - - const wrap = el( - "div", - { class: "field", dataset: { fieldKey: field.key }, title: tooltip }, - label, - control, - hint, - error, - note, - ); - - const entry = { - field, - index, - msbIndex, - tooltip, - wrap, - control, - editor, - error, - note, - supported: true, - reason: "", - /* Locked fields render and round-trip, but are never editable: a - deprecated option whose meaning firmware no longer honours, or one - belonging to a licensed feature. Group-level locks cover every field - in the group, so the field's own flag is only for the exceptions. */ - lockReason: - (field.readOnly ? field.readOnlyReason || "Not editable." : "") || - lockedGroups.get(field.group) || - lockedGroups.get(String(field.group).split(".")[0]) || - "", - /** Shows the local date/time for a Unix-seconds field. */ - dateNote: dateTimeKeys.has(field.key) - ? el("div", { class: "field-hint muted" }) - : null, - }; - if (entry.dateNote) wrap.appendChild(entry.dateNote); - // Only a field's OWN reason is shown on its card — a group-level one is - // already stated once at the top of the group. - if (field.readOnly) { - wrap.appendChild( - el("div", { class: "field-hint muted" }, entry.lockReason), - ); - } - wrap.classList.toggle("readonly", !!entry.lockReason); - entryRef.current = entry; - - // An editor commits through its own `onEdit`, on whatever event it - // decides is a change; wiring `change` here as well would commit twice - // for every edit inside it. - if (editor) return entry; - - // Numeric, text, hex and MAC controls all commit on `change`, never per - // keystroke: "13" on the way to "130" is out of range for plenty of - // fields, and a per-keystroke commit would either reject it or write it. - control.addEventListener("change", () => commit(entry)); - if (byteBlockLength(field.kind) !== null || field.kind === "mac6[]") { - // Hex and MAC input gets live validity styling while typing — the error - // is what tells the user the length is still wrong — but still only - // commits on change. - control.addEventListener("input", () => validateOnly(entry)); - } - - return entry; - } - - function buildControl(field, id) { - const options = field.options; - if (options?.length) { - return el( - "select", - { id }, - options.map(([value, text]) => - el("option", { value: String(value) }, text), - ), - ); - } - switch (field.kind) { - case "bit": - if ((field.width ?? 1) === 1 && field.msbLayoutKey === undefined) { - // A single unlabelled bit is a checkbox. `width: auto` because - // theme.css stretches `.field input` to the full card width, which - // a checkbox must not do. - return el("input", { - id, - type: "checkbox", - style: { width: "auto", alignSelf: "flex-start" }, - }); - } - return numberControl(field, id); - case "ascii12": - return el("input", { - id, - type: "text", - maxlength: "12", - autocomplete: "off", - spellcheck: "false", - }); - case "mac6[]": - return el("textarea", { - id, - rows: "4", - autocomplete: "off", - spellcheck: "false", - placeholder: "one 12-hex-digit address per line", - }); - // Listed explicitly rather than left to fall through to `default`: the - // default is now the byte-block/unknown branch, and a numeric kind that - // reached it would render read-only and silently stop being editable. - case "u8": - case "u16le": - case "u16be": - case "u32be": - return numberControl(field, id); - default: { - const block = byteBlockLength(field.kind); - if (block !== null) { - return el("input", { - id, - type: "text", - class: "mono", - maxlength: String(block * 2), - autocomplete: "off", - spellcheck: "false", - placeholder: `${block * 2} hex characters`, - }); - } - // An encoding this module does not recognise. A number input would be - // the WRONG guess for a block or a string kind, and committing it - // would write a plain number over the field's bytes, so render a - // read-only display of what the codec decoded instead. The bytes stay - // exactly as they were and the page is told, rather than the image - // being quietly corrupted by a control that guessed. - return el("input", { - id, - type: "text", - class: "mono", - readonly: true, - title: `Unrecognised field encoding '${field.kind}' — shown read-only`, - }); - } - } - } - - function numberControl(field, id) { - const max = derivedMax(field); - return el("input", { - id, - type: "number", - step: "1", - min: String(typeof field.min === "number" ? field.min : 0), - max: max === undefined ? null : String(max), - inputmode: "numeric", - }); - } - - // ---- Populate / commit ------------------------------------------------ - - /** Push every field's decoded value from the image into its control. */ - function repopulate() { - for (const entry of entries.values()) { - clearError(entry); - setControlValue(entry, read(entry.field)); - } - refreshDirty(); - } - - function read(field, bytes = image) { - return codec.readInfoMemFieldValue(bytes, field, layout); - } - - /** - * Tell every custom editor that the image changed underneath it. - * - * `refresh()` is the half of the `editorFor` contract that is about OTHER - * fields: an editor is handed its own value through `set()`, but anything it - * derives from a sibling — the range a calibration block belongs to — has no - * other way to hear about an edit, because a commit only repaints the field - * that was committed. - * - * Optional, and a throwing one must not cost the commit that provoked it: - * the bytes are already written by this point, and an editor that fails to - * repaint must not make a successful write look like a failed one. - * - * @param {object|null} [except] the entry that just committed - */ - function notifyEditorsOfChange(except = null) { - for (const entry of entries.values()) { - if (entry === except || !entry.editor?.refresh) continue; - try { - entry.editor.refresh(); - } catch { - /* An editor that cannot repaint is a display problem, not a write - problem. Deliberately silent: this runs once per editor per commit, - so a broken one would otherwise log on every keystroke-commit. */ - } - } - } - - /** - * One field's current value plus the label its option table gives it, for - * an editor that needs to show something about a SIBLING field. - * - * Reads the working image, not the baseline, so an editor showing "the - * configured range" follows an edit to that range immediately rather than - * waiting for a write. - * - * @param {string} key - * @returns {{value: unknown, label: string}|null} null when this form has - * no such field — including a field dropped because its layout key does - * not resolve on this firmware, which is exactly the case a caller must - * not mistake for "the value is zero". - */ - function describeField(key) { - const entry = entries.get(key); - if (!entry) return null; - const value = read(entry.field); - const option = entry.field.options?.find( - ([v]) => String(v) === String(value), - ); - return { value, label: option ? String(option[1]) : String(value) }; - } - - function setControlValue(entry, value) { - const { field, control } = entry; - // Repainted from the SAME value the control is being set to, so the - // formatted date can never drift from the number beside it. - if (entry.dateNote) { - const text = unixSecondsText(value); - entry.dateNote.textContent = text ? `= ${text}` : "not set"; - } - if (entry.editor) { - entry.editor.set(value); - return; - } - if (control.type === "checkbox") { - control.checked = Number(value) !== 0; - return; - } - if (control.tagName === "SELECT") { - // The image can hold a value this part's option table does not list — - // an unprovisioned InfoMem, or a config written by another firmware - // generation. A select with no matching option would read back as "" on - // the next commit and zero the field, so surface the raw value as a - // temporary option instead. It disappears as soon as the user picks a - // real one, and the bytes are never touched by merely displaying it. - for (const opt of Array.from(control.options)) { - if (opt.dataset.synthetic) opt.remove(); - } - const wanted = String(value); - if (!Array.from(control.options).some((o) => o.value === wanted)) { - control.insertBefore( - el( - "option", - { - value: wanted, - dataset: { synthetic: "1" }, - }, - `${wanted} (not in this part's table)`, - ), - control.firstChild, - ); - } - control.value = wanted; - return; - } - control.value = formatForControl(field, value); - } - - /** Validate without writing; used for live styling while typing. */ - function validateOnly(entry) { - const parsed = entry.editor - ? entry.editor.get() - : parseControlValue(entry.field, entry.control.value); - if (parsed.ok) clearError(entry); - else showError(entry, parsed.error); - return parsed; - } - - /** - * Validate the control, and on success write it into the image. - * - * A rejected value leaves the bytes exactly as they were — the point of - * validating before writing rather than after. - */ - function commit(entry) { - if (!entry) return; - const { field, control, editor } = entry; - // A read-only control is a display of bytes this module cannot encode. - // Never write from one, however the change event arrived. - if (!editor && control.readOnly) return; - let parsed; - if (editor) { - parsed = editor.get(); - } else { - const raw = - control.type === "checkbox" ? (control.checked ? 1 : 0) : control.value; - parsed = parseControlValue(field, raw); - } - if (!parsed.ok) { - showError(entry, parsed.error); - refreshDirty(); - return; - } - clearError(entry); - codec.writeInfoMemFieldValue(image, field, layout, parsed.value); - // Re-show the decode rather than the typed text, so "0a" becomes "0A", - // " 51 " becomes "51" and a select drops its synthetic option — the - // control always shows what the bytes now actually say. - setControlValue(entry, read(field)); - refreshDirty(); - /* Give every OTHER editor a chance to repaint anything it derives from a - sibling field. The kinematic calibration editor shows the range its - block belongs to, read out of this same image — edit that range and the - chip beside the matrices was left naming the old one, and the greyed - factory defaults were the old range's too. Editors only, and never the - one that just committed (it has already repainted itself from the - bytes), so this costs nothing on a form with no editors in it. */ - notifyEditorsOfChange(entry); - onChange?.(field.key, parsed.value, dirtyKeys()); - } - - function showError(entry, message) { - /* An editor reports its own refusals, in its own words and against its own - boxes — a nine-box matrix needs to say WHICH box, which this line - cannot. Populating both would print the same refusal twice. */ - if (entry.editor) { - entry.editor.setInvalid?.(message); - return; - } - entry.error.textContent = message; - entry.error.hidden = false; - entry.control.setAttribute("aria-invalid", "true"); - entry.control.style.borderColor = "var(--danger)"; - entry.control.style.background = "var(--danger-bg)"; - } - - function clearError(entry) { - if (entry.editor) { - entry.editor.setInvalid?.(""); - return; - } - entry.error.textContent = ""; - entry.error.hidden = true; - entry.control.removeAttribute("aria-invalid"); - entry.control.style.borderColor = ""; - entry.control.style.background = ""; - } - - // ---- Dirty state ------------------------------------------------------ - - function isDirty(entry) { - return !sameValue(read(entry.field), read(entry.field, baseline)); - } - - function refreshDirty() { - let count = 0; - for (const entry of entries.values()) { - const dirty = isDirty(entry); - if (dirty) count++; - entry.wrap.classList.toggle("dirty", dirty); - } - onDirtyChange?.(count); - return count; - } - - function dirtyKeys() { - const keys = []; - for (const [key, entry] of entries) if (isDirty(entry)) keys.push(key); - return keys; - } - - // ---- Support and relocation ------------------------------------------- - - /** - * Move each group into the supported host or the collapsed panel, in schema - * order, so both hosts always list groups canonically however often support - * changes. - */ - function refreshGroupPlacement() { - let unsupportedGroups = 0; - for (const g of groups) { - const details = groupNodes.get(g.id); - if (!details) continue; - - const members = [...entries.values()].filter( - (e) => - e.field.group === g.id || - String(e.field.group).startsWith(`${g.id}.`), - ); - // An empty group (the sensor-enable bitmaps are not schema fields, so - // "Sensor Enables" arrives with none) is hidden unless the page has put - // its own controls into the group's [data-group-body] slot. Held as a - // direct reference rather than looked up by selector, so a group id - // with a quote or a dot in it cannot break the query. - const injected = groupBodies.get(g.id); - const hasOwnContent = - !!injected && - [...injected.children].some( - (c) => !c.classList.contains("grid") && c.tagName !== "DETAILS", - ); - if (!members.length) { - details.hidden = !hasOwnContent; - supportedHost.appendChild(details); - continue; - } - details.hidden = false; - - const supported = members.some((e) => e.supported); - (supported ? supportedHost : unsupportedBody).appendChild(details); - if (!supported) unsupportedGroups++; - } - unsupportedSection.hidden = unsupportedGroups === 0; - // Placement decides group visibility from support alone, so an active - // search has to be re-applied on top of it or a filtered-out group would - // reappear. Safe from recursion: the clearing branch of applySearch drops - // searchOpenState before it calls back here. - if (searchOpenState) applySearch(); - } - - /** Filter the field cards to those matching the search box. */ - function applySearch() { - const q = String(searchInput.value || "") - .trim() - .toLowerCase(); - const allDetails = [ - ...supportedHost.querySelectorAll("details"), - ...unsupportedBody.querySelectorAll("details"), - ]; - - /* Every `.field` in the host, not just the schema's own: the page injects - controls of its own (the sensor grid, the ExG preset, the sampling-rate - helper) as sibling cards. Leaving those always-visible kept their groups - always-open, so a search for one setting still showed several. */ - const cards = host.querySelectorAll(".field"); - - if (!q) { - for (const card of cards) card.hidden = false; - for (const d of allDetails) { - d.hidden = false; - if (searchOpenState?.has(d)) d.open = searchOpenState.get(d); - } - searchOpenState = null; - searchCount.textContent = ""; - // Group placement decides which host each group belongs to and whether - // the unsupported panel shows at all; a search must not outlive that. - refreshGroupPlacement(); - return; - } - - if (!searchOpenState) { - searchOpenState = new Map(allDetails.map((d) => [d, d.open])); - } - - let matches = 0; - for (const card of cards) { - /* The card's own text covers label, helper text and option labels, and - works for a page-injected card too. Schema cards add their key and - encoding tooltip, because a byte offset is often what someone is - actually searching for. */ - const entry = entries.get(card.dataset.fieldKey); - const hay = entry - ? `${card.textContent} ${entry.field.key} ${entry.tooltip}` - : card.textContent; - const hit = hay.toLowerCase().includes(q); - card.hidden = !hit; - if (hit) matches++; - } - for (const d of allDetails) { - const any = !!d.querySelector(".field:not([hidden])"); - d.hidden = !any; - if (any) d.open = true; - } - // The unsupported panel is a
in neither list, so it needs the - // same treatment: hidden unless something inside it matched. - unsupportedSection.hidden = !unsupportedSection.querySelector( - ".field:not([hidden])", - ); - searchCount.textContent = matches - ? `${matches} match${matches === 1 ? "" : "es"}` - : "no matches"; - } - - function applyEnabled(entry) { - // A lock is unconditional: unlike the enable/support states it never - // lifts, so no later setEnabled(true) can make the field editable. - const locked = !!entry.lockReason; - const disabled = !enabled || !entry.supported || locked; - const reason = entry.supported - ? locked - ? entry.lockReason - : "" - : entry.reason || UNSUPPORTED_TITLE; - if (entry.editor) { - entry.editor.setDisabled?.(disabled, reason); - return; - } - entry.control.disabled = disabled; - entry.control.title = reason - ? `${reason} — ${entry.tooltip}` - : entry.tooltip; - } - - // ---- Public API ------------------------------------------------------- - - return { - setImage(bytes) { - image = adoptImage(bytes, imageSize); - baseline = new Uint8Array(image); - repopulate(); - }, - - getImage() { - return image; - }, - - captureBaseline() { - baseline = new Uint8Array(image); - refreshDirty(); - }, - - dirtyKeys, - - dirtyFields() { - const out = []; - for (const [key, entry] of entries) { - if (!isDirty(entry)) continue; - out.push({ - key, - label: entry.field.label, - from: displayValue(entry.field, read(entry.field, baseline)), - to: displayValue(entry.field, read(entry.field)), - }); - } - return out; - }, - - /** - * Re-read every control from the working image, without touching the - * baseline. - * - * For a caller that writes bytes into `getImage()` itself rather than - * through a control - a derived value, say, where setting one field implies - * another. `setImage` is the wrong tool there: it re-baselines, so the - * derived change would look like it came from the device and the edit would - * vanish from the dirty set. - */ - refresh() { - repopulate(); - }, - - revert() { - // In place, so `getImage()` stays a valid live reference across a - // revert — a page holding it does not have to re-fetch. - image.set(baseline); - repopulate(); - }, - - setEnabled(next) { - enabled = !!next; - for (const entry of entries.values()) applyEnabled(entry); - }, - - setFieldSupport(key, supported, reason = "") { - const entry = entries.get(key); - if (!entry) return; - entry.supported = !!supported; - entry.reason = reason; - entry.note.textContent = reason; - entry.note.hidden = !reason || entry.supported; - entry.wrap.classList.toggle("unsupported", !entry.supported); - applyEnabled(entry); - refreshGroupPlacement(); - }, - - setPlaceholder(key, text) { - const entry = entries.get(key); - if (!entry) return; - // Only a text control has a placeholder; a select or checkbox silently - // ignores the attribute, so guard rather than set it and wonder. - const control = entry.control; - if (!(control instanceof HTMLInputElement) || control.type !== "text") { - if (!(control instanceof HTMLTextAreaElement)) return; - } - if (text) control.placeholder = String(text); - else control.removeAttribute("placeholder"); - }, - - focusField(key) { - const entry = entries.get(key); - if (!entry) return; - // Open every collapsed ancestor first, or focus() lands on a control - // inside a closed
and nothing appears to happen. - for (let n = entry.wrap.parentElement; n; n = n.parentElement) { - if (n.tagName === "DETAILS") n.open = true; - if (n === host) break; - } - if (entry.editor?.focus) entry.editor.focus(); - else entry.control.focus?.({ preventScroll: true }); - entry.wrap.scrollIntoView({ block: "nearest" }); - }, - - destroy() { - // Every listener is on a node inside `host`, so dropping the subtree - // drops the listeners with it. - host.replaceChildren(); - entries.clear(); - groupNodes.clear(); - groupBodies.clear(); - grids.clear(); - }, - }; -} - -/** - * Take the caller's buffer as the working image where possible. - * - * Adopting rather than copying is deliberate: the page reads the InfoMem into - * one array, hands it here, edits it through the form and writes that same - * array back, so there is exactly one copy of the configuration in flight and - * no way for a stale duplicate to be the one that gets written. A buffer of - * the wrong length cannot be adopted, so it is copied into a right-sized one. - */ -function adoptImage(bytes, size) { - if (!bytes) return new Uint8Array(size); - if (!(bytes instanceof Uint8Array)) { - throw new TypeError("config image must be a Uint8Array"); - } - if (bytes.length === size) return bytes; - const out = new Uint8Array(size); - out.set(bytes.subarray(0, Math.min(bytes.length, size)), 0); - return out; -} diff --git a/common/connect-ui.js b/common/connect-ui.js deleted file mode 100644 index c46f827..0000000 --- a/common/connect-ui.js +++ /dev/null @@ -1,499 +0,0 @@ -/** - * Connect / disconnect chrome shared by the webBLEDemos pages: capability - * gating for the three connect buttons, the connect-failure diagnostics that - * were worth more than the rest of the session when a link misbehaved, port - * identity logging, platform advice and disconnect detection. - * - * Ported from the two pages that grew this logic independently: - * - the retired sd-download demo — setConnected (L370-387), connectVia - * (L470-550), connectFailureHints (L559-576), - * logPortIdentity (L600-624), the startup - * advice (L872-885) - * - the retired eeprom-branding demo — the three-button setConnected (L527-544), - * the classic-BT catch (L875-889), the SDK - * version probe (L1053-1063) and the - * transportAdvice loop (L1075-1078) - * - * The controller never constructs a client itself — the page supplies - * factories — so the same code drives Shimmer3RClient, Shimmer3Client, - * WiredShimmerClient and, later, Verisense. - * - * No DOM access at import time. - * - * import { createConnectController } from "../common/connect-ui.js"; - */ - -/** - * Human label per connect mode, used in log lines and hint text. - * `rfcomm` rather than `btclassic` because that is what the link actually is - * (a Bluetooth serial port), and it distinguishes it from BLE at a glance. - */ -const MODE_LABELS = Object.freeze({ - ble: "BLE", - rfcomm: "Classic Bluetooth", - usb: "wired serial", -}); - -/** Which `TransportNeed` each mode rides, for transportAdvice(). */ -const MODE_NEEDS = Object.freeze({ - ble: "ble", - rfcomm: "classicBluetooth", - usb: "wiredSerial", -}); - -/** - * The exact status string the SDK emits when the link drops. Matched as an - * equality test, not a regex: "disconnect" appears in plenty of benign status - * lines ("disconnecting…", "requested disconnect"), and treating one of those - * as a real drop tears down a live session. - */ -export const SDK_DISCONNECT_STATUS = "Device disconnected"; - -/** Oldest vendored SDK whose BLE record reads are not truncated. */ -const MIN_USEFUL_SDK = "0.1.12"; - -/** - * Create the connect controller. - * - * @param {object} cfg - * @param {object} cfg.els buttons and status elements; all optional, so a - * page that offers only BLE just omits `usb`/`bt` - * @param {HTMLButtonElement} [cfg.els.ble] "Connect BLE" - * @param {HTMLButtonElement} [cfg.els.usb] "Connect USB / dock" - * @param {HTMLButtonElement} [cfg.els.bt] "Connect Classic Bluetooth" - * @param {HTMLButtonElement} [cfg.els.disconnect] - * @param {HTMLElement} [cfg.els.pill] connection pill (`.pill`, gets `.on`) - * @param {HTMLElement} [cfg.els.info] free-text status line - * @param {HTMLElement} [cfg.els.banner] `.banner` for platform advice - * @param {{log: Function, warn?: Function, error?: Function}|Function} cfg.log - * a `createLog()` handle, or a bare `log(msg)` function - * @param {object} cfg.sdkNs the whole vendored SDK namespace object — passed - * as a namespace, not destructured, so a bundle missing a newer export - * degrades to a warning instead of breaking the page's import - * @param {object} cfg.makeClients factories keyed by mode. Each returns either - * a client, or `{client, transport}` when the page built the transport - * itself (the classic-BT path needs the transport for logPortIdentity) - * @param {() => unknown} [cfg.makeClients.ble] - * @param {() => unknown} [cfg.makeClients.bt] - * @param {() => unknown} [cfg.makeClients.usb] - * @param {(session: object) => unknown} [cfg.afterConnect] post-connect hook; - * its failure is logged and never tears down the connection it reports on - * @param {(reason?: Error|string) => void} [cfg.onDisconnected] - * @param {boolean} [cfg.announceStartup=true] log the SDK version and the - * per-transport platform advice on creation - * @returns {{ - * connectVia: (mode: "ble"|"rfcomm"|"usb") => Promise, - * disconnect: () => Promise, - * readonly session: object|null, - * setConnected: (on: boolean, label?: string) => void, - * support: object, - * connectFailureHints: (mode: string, err: unknown) => string[], - * logPortIdentity: (transport: unknown) => void, - * }} - */ -export function createConnectController(cfg) { - const els = cfg.els ?? {}; - const sdkNs = cfg.sdkNs ?? {}; - const makeClients = cfg.makeClients ?? {}; - - const logger = - typeof cfg.log === "function" ? { log: cfg.log } : (cfg.log ?? {}); - const log = (msg) => logger.log?.(String(msg)); - const warn = (msg) => (logger.warn ?? logger.log)?.(String(msg)); - const error = (msg) => (logger.error ?? logger.log)?.(String(msg)); - - /* Capability snapshot, hoisted above the first setConnected(false) below: - * setConnected reads `support`, so taking the snapshot afterwards would - * gate the buttons on `undefined` on the very first paint. */ - const support = sdkNs.describePlatformSupport?.() ?? { - webSerial: typeof navigator?.serial?.requestPort === "function", - webBluetooth: typeof navigator?.bluetooth?.requestDevice === "function", - isAndroid: false, - isIOS: false, - serialBluetoothOnly: false, - }; - - /** @type {{client: unknown, mode: string, transport: unknown, label: string}|null} */ - let session = null; - let connecting = false; - /** Guards the disconnect path against firing twice for one drop. */ - let disconnectReported = false; - /** Detach the SDK disconnect subscription, when it returned one. */ - let detachDisconnect = null; - - // ------------------------------------------------------------------------- - // Button state - // ------------------------------------------------------------------------- - - /** - * Apply the connected/disconnected state to the chrome. - * - * Capability is a floor the connected-state cannot lift. Assigning - * `disabled = on` alone re-enabled every button on each disconnect, so on a - * browser with only some of these APIs — desktop Firefox has Web Serial but - * no Web Bluetooth — one connect/disconnect cycle handed the user a button - * that cannot work. - * - * The wired/dock button is the exception that proves the rule: it gates on - * webSerial only, because on Android that is true while a wired port is - * merely "unlikely" — advised about below, deliberately not disabled. - * - * @param {boolean} on - * @param {string} [label] pill text while connected - */ - function setConnected(on, label) { - if (els.ble) els.ble.disabled = on || connecting || !support.webBluetooth; - if (els.bt) els.bt.disabled = on || connecting || !support.webSerial; - if (els.usb) els.usb.disabled = on || connecting || !support.webSerial; - if (els.disconnect) els.disconnect.disabled = !on; - if (els.pill) { - els.pill.textContent = on - ? (label ?? session?.label ?? "connected") - : "disconnected"; - els.pill.classList.toggle("on", !!on); - } - } - - // ------------------------------------------------------------------------- - // Diagnostics - // ------------------------------------------------------------------------- - - /** - * Extra guidance for a failed connect. - * - * The Web Serial picker is drawn by the browser and cannot be filtered by - * device name — the API only filters on USB VID/PID or Bluetooth service - * class, and a port's name is not even readable via getInfo(). So when the - * wrong entry is picked, the best we can do is explain the symptom - * afterwards. - * - * @param {"ble"|"rfcomm"|"usb"|string} mode - * @param {unknown} err - * @returns {string[]} - */ - function connectFailureHints(mode, err) { - if (mode !== "rfcomm") return []; - const message = String(err?.message ?? err ?? ""); - const hints = []; - if (/timeout/i.test(message)) { - /* A Shimmer3R pairs as two separate Bluetooth entries: "…-BT" - * (classic, speaks this protocol) and "…-BLE" (GATT, does not). The - * SPP service-class filter should keep the BLE one out of the picker, - * but if it appears anyway, opening it succeeds and then nothing ever - * answers — which looks exactly like this. */ - hints.push( - 'the port opened but the sensor never answered — if you picked a "…-BLE" entry, choose the "…-BT" one instead (or use Connect BLE)', - ); - } - hints.push( - "also check the sensor is paired with this host, powered, in range, and not held open by another app (e.g. Consensys)", - ); - return hints; - } - - /** - * What the picker really gave us. - * - * getInfo() reports the RFCOMM service class the port was matched on, which - * is the one fact that tells you why a paired sensor did or did not appear: - * if this is not 00001101-0000-1000-8000-00805f9b34fb then the sensor - * exposes a non-standard service class, and SHIMMER3_SPP_SERIAL_OPTIONS is - * filtering on the wrong UUID. Cheap to log every time and useless to guess - * at afterwards. - * - * @param {unknown} transport a WebSerialTransport (anything else is a no-op) - */ - function logPortIdentity(transport) { - const port = transport?.port; - if (!port?.getInfo) return; - try { - const info = port.getInfo(); - log(`port: ${JSON.stringify(info)}`); - if (info.bluetoothServiceClassId) { - const spp = sdkNs.SHIMMER3_SPP_UUID; - const isSpp = - !spp || - String(info.bluetoothServiceClassId).toLowerCase() === - String(spp).toLowerCase(); - const line = - `service class ${info.bluetoothServiceClassId}` + - (isSpp ? " (standard SPP)" : " — NOT standard SPP"); - if (isSpp) log(line); - else error(line); - } else { - error( - "port reports no Bluetooth service class — not an RFCOMM port (a wired serial port may have been picked)", - ); - } - } catch (e) { - error(`port.getInfo() failed: ${e?.message ?? e}`); - } - } - - // ------------------------------------------------------------------------- - // Disconnect detection - // ------------------------------------------------------------------------- - - /** - * Report a link that went away on its own (not a user disconnect). - * Idempotent: one drop can reach us through both the SDK hook and the - * status string, and it should tear the session down once. - */ - function handleDropped(reason) { - if (disconnectReported) return; - disconnectReported = true; - const why = reason?.message ?? reason ?? "link lost"; - error(`disconnected: ${why}`); - teardown(); - cfg.onDisconnected?.(reason); - } - - function teardown() { - try { - detachDisconnect?.(); - } catch { - /* nothing to detach */ - } - detachDisconnect = null; - session = null; - setConnected(false); - } - - /** - * Wire status and disconnect callbacks on a freshly built client. - * - * Two paths, because the SDK grew the client-level hook after these pages - * were written: - * - `client.onDisconnect` where it exists (added in SDK 0.1.22) — either a - * settable callback property or a subscribe method returning an - * unsubscribe, so both shapes are accepted; - * - otherwise watch `onStatus` for the exact SDK_DISCONNECT_STATUS string. - * - * Either way the page's own `onStatus` — which a factory may have assigned - * before returning the client — is wrapped, not replaced, so it still - * fires. - */ - function wireClient(client) { - const pageStatus = - typeof client.onStatus === "function" ? client.onStatus : null; - - let hasHook = false; - /* Two shapes to tell apart, and `typeof === "function"` does not tell them - apart: a callback slot that someone has already filled looks exactly - like a subscribe method. Own-property is the honest test -- the SDK - clients declare `onDisconnect` as an instance field (null until set), - while a subscribe API would live on the prototype. Getting it backwards - calls the existing handler with a callback as its `reason` and wires - nothing, so a dropped link would go unnoticed. */ - const ownCallbackSlot = Object.prototype.hasOwnProperty.call( - client, - "onDisconnect", - ); - if (!ownCallbackSlot && typeof client.onDisconnect === "function") { - // Subscribe-method shape: onDisconnect(cb) -> unsubscribe. - try { - const off = client.onDisconnect((reason) => handleDropped(reason)); - detachDisconnect = typeof off === "function" ? off : null; - hasHook = true; - } catch { - hasHook = false; - } - } else if ("onDisconnect" in client) { - /* Callback-property shape, like onStatus. Anything already in the slot - is kept and called first: it belongs to whoever built the client, and - swallowing it would be a silent regression for them. */ - const existing = - typeof client.onDisconnect === "function" ? client.onDisconnect : null; - client.onDisconnect = (reason) => { - if (existing) { - try { - existing.call(client, reason); - } catch { - /* their handler, their problem -- but not at the cost of ours */ - } - } - handleDropped(reason); - }; - detachDisconnect = () => { - client.onDisconnect = existing; - }; - hasHook = true; - } - - client.onStatus = (msg) => { - log(msg); - pageStatus?.call(client, msg); - if (!hasHook && String(msg) === SDK_DISCONNECT_STATUS) { - handleDropped(new Error(SDK_DISCONNECT_STATUS)); - } - }; - } - - // ------------------------------------------------------------------------- - // Connect / disconnect - // ------------------------------------------------------------------------- - - /** - * Connect over `mode`, then hand the session to `afterConnect`. - * - * @param {"ble"|"rfcomm"|"usb"} mode - * @returns {Promise} true when a session is live - */ - async function connectVia(mode) { - if (session) { - warn("already connected — disconnect first"); - return false; - } - const factory = makeClients[mode === "rfcomm" ? "bt" : mode]; - if (!factory) { - error(`no client factory for ${MODE_LABELS[mode] ?? mode}`); - return false; - } - const linkName = MODE_LABELS[mode] ?? mode; - - /* Declared OUTSIDE the try so the catch can still reach it. If connect() - * rejects after the port opened, WebSerialTransport leaves the COM port - * open, and a port nothing references can never be closed — the next - * attempt then fails with "port already open" until the page is - * reloaded. */ - let client = null; - let transport = null; - connecting = true; - setConnected(false); - try { - const built = factory(); - // A factory may hand back the transport it built (the classic-BT path - // needs it for logPortIdentity), or just the client. - if (built && typeof built === "object" && "client" in built) { - client = built.client; - transport = built.transport ?? null; - } else { - client = built; - } - if (!client) throw new Error("client factory returned nothing"); - - disconnectReported = false; - wireClient(client); - log(`connecting over ${linkName}…`); - await client.connect(); - - connecting = false; - session = { client, mode, transport, label: linkName }; - setConnected(true, linkName); - - if (mode === "rfcomm" && transport) logPortIdentity(transport); - - /* Before anything else can fail: identifying what we connected to is - * worth more than the rest of the session when a link misbehaves. - * Diagnostic only, so a failure inside the hook is logged and never - * allowed to tear down the connection it is reporting on. */ - try { - await cfg.afterConnect?.(session); - } catch (hookErr) { - error( - `post-connect diagnostics failed: ${hookErr?.message ?? hookErr}`, - ); - } - return true; - } catch (e) { - /* Release the link before dropping the reference (see the comment on - * `client` above). */ - try { - await client?.disconnect(); - } catch { - /* already down */ - } - error(`Connect failed over ${linkName}: ${e?.message ?? e}`); - for (const hint of connectFailureHints(mode, e)) warn(hint); - connecting = false; - teardown(); - return false; - } - } - - /** - * Close the live session. A user-initiated disconnect, so it does NOT call - * `onDisconnected` — the page asked for this and already knows. - */ - async function disconnect() { - const live = session; - // Set before awaiting: the SDK emits its disconnect status from inside - // this call, and that must not be reported as a dropped link. - disconnectReported = true; - if (!live) { - setConnected(false); - return; - } - try { - await live.client?.disconnect(); - } catch (e) { - warn(`disconnect reported an error: ${e?.message ?? e}`); - } finally { - teardown(); - log("disconnected"); - } - } - - // ------------------------------------------------------------------------- - // Startup - // ------------------------------------------------------------------------- - - setConnected(false); - - if (cfg.announceStartup !== false) { - /* Log which SDK build this page actually runs — a stale vendored bundle - * is otherwise indistinguishable from a firmware fault. Read off the - * namespace object so an older bundle (no SDK_VERSION export) degrades - * to a warning instead of breaking the import. */ - if (sdkNs.SDK_VERSION) { - log(`vendored shimmer-web-sdk v${sdkNs.SDK_VERSION}`); - } else { - warn( - `WARNING: vendored shimmer-web-sdk predates v${MIN_USEFUL_SDK} — BLE record reads will truncate; re-vendor the SDK bundle`, - ); - } - - /* Capability and guidance both come from the SDK, so every consumer says - * the same accurate thing about each link: it owns the awkward facts - * (Android serves Web Serial for RFCOMM only; iOS cannot reach classic - * Bluetooth at any layer) and returns null when a link simply works. - * - * The dock deliberately gets the middle "unlikely" state as advice rather - * than a disable: on Android `serial` in navigator is true while wired - * ports are still rolling out, and no feature detection separates the - * two, so a hard disable would lock out the phones where it does land. */ - const offered = [ - els.ble && "ble", - els.bt && "rfcomm", - els.usb && "usb", - ].filter(Boolean); - let firstAdvice = null; - for (const mode of offered) { - const advice = sdkNs.transportAdvice?.(support, MODE_NEEDS[mode]); - if (!advice) continue; - const blocked = - mode === "ble" ? !support.webBluetooth : !support.webSerial; - if (blocked) error(`note: ${advice}`); - else warn(`note: ${advice}`); - if (blocked && !firstAdvice) firstAdvice = advice; - } - // A banner only for a link that cannot work at all — advice about a - // merely unlikely link belongs in the log, not above the page. - if (firstAdvice && els.banner) { - els.banner.className = "banner err"; - els.banner.textContent = firstAdvice; - } - } - - return { - connectVia, - disconnect, - get session() { - return session; - }, - setConnected, - support, - connectFailureHints, - logPortIdentity, - }; -} diff --git a/common/csv-recorder.js b/common/csv-recorder.js deleted file mode 100644 index a37d470..0000000 --- a/common/csv-recorder.js +++ /dev/null @@ -1,447 +0,0 @@ -/** - * CSV recording for the webBLEDemos pages: turn a live ObjectCluster stream - * into a file on the host. - * - * Generalised from ShimmerCapture/index.html, which built a header row from - * the chart's datasets (L248-261), pushed one joined row per frame (L389) and - * dumped the whole thing through a Blob at the end (L366). Two changes to - * that: cells go through the SDK's `csvCell` so a unit or a device name - * containing a comma cannot shift every following column, and rows stream to - * disk through the File System Access API instead of being held in memory - * until the user stops — a 512 Hz session with 12 channels is tens of - * megabytes of string, and the in-memory version loses all of it if the tab - * is closed. - * - * That choice decides what happens when a write to the picked file fails - * mid-recording: there is no complete copy to fall back on, so the recording - * ENDS there rather than quietly continuing into a second, partial file. See - * `fail()`. - * - * import { createCsvRecorder } from "../common/csv-recorder.js"; - * - * No DOM access at import time. - */ - -import { csvCell } from "../vendor/shimmer-web-sdk.esm.js"; -import { downloadBlob } from "./ui-chrome.js"; - -/** How often buffered rows are handed to the writable stream. */ -const FLUSH_INTERVAL_MS = 1000; - -const encoder = new TextEncoder(); - -/** `shimmer-capture-2026-09-02_141530.csv` */ -function defaultFileName() { - const d = new Date(); - const p2 = (n) => String(n).padStart(2, "0"); - return ( - `shimmer-capture-${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}` + - `_${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}.csv` - ); -} - -/** - * Create a CSV recorder. - * - * @param {object} [opts] - * @param {() => string} [opts.fileNameFn] names the file; called once per - * `start()`, so a name can carry the device id or a trial name - * @param {boolean} [opts.preferFileSystemAccess=true] stream to a file the - * user picks. Set false, or run in a browser without - * `showSaveFilePicker`, to buffer in memory and download on `stop()` - * @param {boolean} [opts.unitsRow=true] emit a second header row of units - * @param {boolean} [opts.hostTimeColumn=true] emit a leading `HostTime_ms` - * @param {{warn?: Function, error?: Function, log?: Function}|Function} [opts.log] - * @param {(info: {rows: number, rowsDropped: number, bytes: number, fileName: string, complete: false, error: string}) => void} [opts.onError] - * called once, from the recorder's own timeline, when a write to the picked - * file fails and the recording is abandoned. Receives what `stop()` would - * return. `active` is already false by then; the page should repaint and - * say so somewhere the user will see it (the log line this module writes is - * not enough on its own). Not called for a failure discovered inside - * `stop()` — the caller already has the result in hand. - * @returns {{ - * start: (columns: {name: string, kind?: string|null, unit?: string|null, header?: string}[]) => Promise, - * push: (hostMs: number, oc: {fields: {name: string, value: number, kind: string|null}[]}) => boolean, - * stop: () => Promise<{rows: number, rowsDropped: number, bytes: number, fileName: string, complete: boolean, error: string|null}>, - * readonly active: boolean, - * }} - */ -export function createCsvRecorder(opts = {}) { - const fileNameFn = opts.fileNameFn ?? defaultFileName; - const preferFsa = opts.preferFileSystemAccess !== false; - const unitsRow = opts.unitsRow !== false; - const hostTimeColumn = opts.hostTimeColumn !== false; - - const logger = - typeof opts.log === "function" ? { log: opts.log } : (opts.log ?? {}); - const warn = (m) => (logger.warn ?? logger.log)?.(String(m)); - const err = (m) => (logger.error ?? logger.warn ?? logger.log)?.(String(m)); - - let active = false; - let fileName = ""; - /** @type {{name: string, kind: string|null, header: string}[]} */ - let columns = []; - /** `name|kind` → column index. Built once, so `push` is a single pass. */ - let routeByKey = new Map(); - /** Frame width the file was opened for; a change means the schema moved. */ - let expectedFieldCount = null; - let widthWarned = false; - - /** Rows `push()` accepted. */ - let rowsIn = 0; - /** Rows that actually reached the sink — what the file holds. */ - let rowsOut = 0; - let bytes = 0; - /** - * Set the first time a write to the picked file fails, and never cleared - * until the next `start()`. Once set, the recording is over — see `fail()`. - * @type {string|null} - */ - let failure = null; - /** True inside `stop()`, so a failure there is reported by return, not callback. */ - let stopping = false; - /** @type {string[]} */ - let pending = []; - let lastFlushMs = 0; - - /** @type {FileSystemWritableFileStream|null} */ - let writable = null; - /** - * Set while a failure is committing or discarding the picked file's stream in - * the background, so `stop()` can wait for the handle to be released. - * @type {Promise|null} - */ - let dying = null; - /** - * Serialises writes. `push()` is synchronous, so a flush is kicked off and - * chained rather than awaited; `stop()` awaits the tail. - */ - let writeChain = Promise.resolve(); - /** In-memory fallback (and the buffer for the Blob download). */ - let memory = []; - - function row(cells) { - return cells.map(csvCell).join(",") + "\r\n"; - } - - /** Queue text; the caller decides when to flush. */ - function emit(text) { - pending.push(text); - } - - /** What `stop()` reports, and what a failure is described by. */ - function result() { - return { - rows: rowsOut, - rowsDropped: Math.max(0, rowsIn - rowsOut), - bytes, - fileName, - complete: failure === null, - error: failure, - }; - } - - /** - * A write to the picked file failed. End the recording here. - * - * The tempting alternative — keep going and hand the rest to the in-memory - * buffer — is what this replaces, because it produces TWO plausible-looking - * files: a truncated one where the user asked for it, and a downloaded one - * holding only the post-failure tail, with nothing on either saying it is a - * fragment. Making that download complete instead would mean retaining - * every row in memory for the whole session on the off chance of a failure, - * and not doing that is the reason this module streams at all (see the - * header: a 512 Hz 12-channel session is tens of megabytes of string). - * - * So: one file, short, and said out loud — in the log, through `onError`, - * and in what `stop()` returns. - * - * @param {unknown} e - * @param {"write"|"close"} what - */ - function fail(e, what) { - if (failure) return; // the first failure is the interesting one - failure = String(e?.message ?? e); - active = false; - // Hand the stream to the cleanup below before clearing the reference, so - // nothing else can write to it in the meantime. - const orphan = writable; - writable = null; - pending = []; - memory = []; - const r = result(); - // Release the picked file. Dropping the reference alone left the handle - // locked and the file EMPTY: a FileSystemWritableFileStream writes to a - // swap file that only reaches the real file on close(), so with neither a - // close() nor an abort() the user was left 0 bytes while the message below - // promised a short one. So commit what already landed, and fall back to - // abort() when even that fails - or go straight there when close() is - // what failed, since there is nothing left to commit through. - if (orphan) { - const discard = () => Promise.resolve(orphan.abort?.()).catch(() => {}); - dying = - what === "close" - ? discard() - : Promise.resolve(orphan.close()).catch(discard); - } - err( - `CSV ${what} failed: ${failure} — recording stopped. ${fileName} is ` + - `INCOMPLETE: ${r.rows} rows written, ${r.rowsDropped} lost.`, - ); - // Inside stop() the caller is already about to read the result, so a - // callback would only duplicate it. - if (stopping) return; - try { - opts.onError?.(r); - } catch (cbError) { - warn(`CSV onError handler threw: ${cbError?.message ?? cbError}`); - } - } - - /** - * Is this the tick-counter timestamp that gets its own second column? - * - * Kind, not just name: the SDK emits `TIMESTAMP` twice per frame — `raw` in - * ticks and `cal` in unwrapped milliseconds — and only the first is the - * dedicated column. Matching on the name alone dropped the calibrated one - * from the file and, on the row path, let it overwrite the raw cell it was - * mistaken for. `null` counts as raw so a vendored bundle from before the - * kinds were set still writes its timestamp where it always did. - */ - function isRawTimestamp(f) { - return f.name === "TIMESTAMP" && (f.kind ?? "raw") === "raw"; - } - - /** - * Hand everything buffered to the sink. Returns a promise, but callers on - * the hot path deliberately do not await it. - */ - function flush() { - if (failure) { - pending = []; - return writeChain; - } - if (!pending.length) return writeChain; - const chunk = pending.join(""); - pending = []; - const encoded = encoder.encode(chunk); - // Rows the file will hold once THIS chunk lands. Counted on success only, - // so a failure cannot leave `stop()` claiming rows that never arrived. - const rowsAfter = rowsIn; - if (writable) { - const sink = writable; - writeChain = writeChain - .then(() => sink.write(encoded)) - .then(() => { - bytes += encoded.byteLength; - rowsOut = rowsAfter; - }) - .catch((e) => fail(e, "write")); - } else { - memory.push(chunk); - bytes += encoded.byteLength; - rowsOut = rowsAfter; - } - lastFlushMs = performance.now(); - return writeChain; - } - - /** - * Open a file and write the header. - * - * Must be called from a user gesture when `preferFileSystemAccess` is on: - * `showSaveFilePicker` is gesture-gated, so calling it from a stream - * callback throws and silently drops the recording to the in-memory path. - * - * @param {{name: string, kind?: string|null, unit?: string|null, header?: string}[]} cols - * the data columns, in file order. The page derives them from the first - * frame. The RAW `TIMESTAMP` is written separately, as the second column, - * and is dropped from here if present; a `TIMESTAMP` of any other kind is - * kept as an ordinary column, because `TIMESTAMP_CAL` is a different - * number — unwrapped milliseconds, where the raw column is a 24-bit tick - * counter that restarts every 512 seconds. Consensys writes both. - * @returns {Promise} false if the user cancelled the picker - */ - async function start(cols) { - if (active) { - warn("CSV recorder already running"); - return false; - } - columns = (cols ?? []) - .filter((c) => c?.name && !isRawTimestamp(c)) - .map((c) => ({ - name: c.name, - kind: c.kind ?? null, - unit: c.unit ?? "", - header: c.header ?? (c.kind ? `${c.name}_${c.kind}` : c.name), - })); - if (!columns.length) { - warn("CSV recorder: nothing to record (no columns)"); - return false; - } - routeByKey = new Map( - columns.map((c, i) => [`${c.name}|${c.kind ?? ""}`, i]), - ); - - fileName = fileNameFn(); - rowsIn = 0; - rowsOut = 0; - bytes = 0; - failure = null; - stopping = false; - pending = []; - memory = []; - writable = null; - dying = null; - writeChain = Promise.resolve(); - expectedFieldCount = null; - widthWarned = false; - - if (preferFsa && typeof globalThis.showSaveFilePicker === "function") { - try { - const handle = await globalThis.showSaveFilePicker({ - suggestedName: fileName, - types: [{ description: "CSV", accept: { "text/csv": [".csv"] } }], - }); - writable = await handle.createWritable(); - fileName = handle.name ?? fileName; - } catch (e) { - // AbortError is the user closing the picker — that is a "no", not a - // reason to start recording somewhere they did not ask for. - if (e?.name === "AbortError") return false; - warn( - `file picker unavailable (${e?.message ?? e}) — buffering in memory instead`, - ); - writable = null; - } - } - - const head = []; - if (hostTimeColumn) head.push("HostTime_ms"); - head.push("TIMESTAMP"); - for (const c of columns) head.push(c.header); - emit(row(head)); - - if (unitsRow) { - const units = []; - if (hostTimeColumn) units.push("ms"); - units.push("ticks"); - for (const c of columns) units.push(c.unit ?? ""); - emit(row(units)); - } - - active = true; - lastFlushMs = performance.now(); - flush(); - return true; - } - - /** - * Append one frame. - * - * @param {number} hostMs host receive time in milliseconds - * @param {{fields: {name: string, value: number, kind: string|null}[]}} oc - * @returns {boolean} false when the row was refused - */ - function push(hostMs, oc) { - if (!active) return false; - const fields = oc?.fields; - if (!fields) return false; - - /* Rectangularity is the whole value of a CSV. If the device's schema - * changes mid-recording (a reconfigure, or a second stream starting) the - * frame width moves, and appending those rows under the old header - * silently misaligns every column. Refuse them and say so once — a - * thousand identical warnings at 512 Hz would bury the log. */ - if (expectedFieldCount === null) { - expectedFieldCount = fields.length; - } else if (fields.length !== expectedFieldCount) { - if (!widthWarned) { - widthWarned = true; - warn( - `CSV: frame has ${fields.length} fields, file was opened for ${expectedFieldCount} — rows refused until the stream is restarted`, - ); - } - return false; - } - - const cells = new Array(columns.length + 1 + (hostTimeColumn ? 1 : 0)).fill( - "", - ); - let at = 0; - if (hostTimeColumn) cells[at++] = Math.round(hostMs); - const tsAt = at++; - const base = at; - for (const f of fields) { - if (isRawTimestamp(f)) { - cells[tsAt] = f.value; - continue; - } - const idx = routeByKey.get(`${f.name}|${f.kind ?? ""}`); - if (idx !== undefined) cells[base + idx] = f.value; - } - emit(row(cells)); - rowsIn++; - - if (performance.now() - lastFlushMs >= FLUSH_INTERVAL_MS) flush(); - return true; - } - - /** - * Close the file (or download the buffer) and report what actually landed. - * Idempotent: calling it again returns the same numbers without writing. - * - * `complete` is false — and `error` set — when a write failed part way - * through. `rows`/`bytes` then describe the truncated file, and - * `rowsDropped` says how much of the capture never reached it. - * - * @returns {Promise<{rows: number, rowsDropped: number, bytes: number, fileName: string, complete: boolean, error: string|null}>} - */ - async function stop() { - // A failure mid-session already cleared `active`, so this is the path a - // caller reaches after one. Still wait for the stream: the cleanup runs in - // the background from fail(), and the file is not on disk until it lands. - if (!active) { - if (dying) { - await dying; - dying = null; - } - return result(); - } - active = false; - stopping = true; - await flush(); - if (writable) { - try { - await writable.close(); - } catch (e) { - fail(e, "close"); - } - writable = null; - } else if (memory.length) { - downloadBlob( - fileName, - new Blob(memory, { type: "text/csv;charset=utf-8" }), - ); - } - // A failure left the stream being committed or discarded in the background. - // Wait for it, so the result is not reported before the file is written and - // a caller that starts recording again immediately does not meet a lock. - if (dying) { - await dying; - dying = null; - } - memory = []; - stopping = false; - return result(); - } - - return { - start, - push, - stop, - get active() { - return active; - }, - }; -} diff --git a/common/dev/mock-shimmer3r.js b/common/dev/mock-shimmer3r.js deleted file mode 100644 index 8ba4b02..0000000 --- a/common/dev/mock-shimmer3r.js +++ /dev/null @@ -1,2827 +0,0 @@ -/** - * A scripted Shimmer3R "firmware" for developing the pages in this repo - * without hardware on the desk. - * - * Built on the SDK's `LoopbackTransport`, which preserves notification chunk - * boundaries, so the same client code and the same re-framing paths run - * against it as against a real link. Follows the scripting pattern the SDK's - * own tests use (`tests/shimmer3r/unframed-transport.test.ts`, - * `tests/infomem/client.test.ts`): set an `onWrite` handler, inspect the - * outgoing command, answer with `notify()`. - * - * import { createMockShimmer3RTransport, mockEnabledFromUrl } from "../common/dev/mock-shimmer3r.js"; - * - * const transport = mockEnabledFromUrl() ? createMockShimmer3RTransport() : undefined; - * const client = new Shimmer3RClient({ transport }); // undefined → real link - * - * This is a development aid, not a firmware simulator. It answers the - * commands these pages send, with plausible values and correct framing; it - * does not model timing, power or most error paths. What it IS good for: - * exercising the config form, the plot, the CSV recorder, the stats strip, - * the SD-card browser — it serves a small synthetic card, see - * {@link buildSyntheticCard} — and, with `framed: false`, the SDK's - * byte-stream re-framing. - */ - -import { - BRAND_PLATFORM, - BRAND_RECORD_HOST_OFFSET, - BRAND_RECORD_SIZE, - LoopbackTransport, - SD_ATTR_DIR, - SD_STATUS, - SD_TRANSFER_OPCODES, - SD_XFER, - buildBrandRecord, - defaultTrialIdentity, - generateCalibDump, - generateKinematicCalibBlock, - getDefaultCalibration, - parseBrandRecord, - sdCrc16, - appendCrc, -} from "../../vendor/shimmer-web-sdk.esm.js"; - -// --------------------------------------------------------------------------- -// Protocol constants (LiteProtocol). Repeated here rather than imported so -// this file reads as the firmware side of the wire. -// --------------------------------------------------------------------------- - -const ACK = 0xff; -const NACK = 0xfe; - -const CMD = Object.freeze({ - DATA_PACKET: 0x00, - INQUIRY: 0x01, - INQUIRY_RESPONSE: 0x02, - SET_SAMPLING_RATE: 0x05, - TOGGLE_LED: 0x06, - START_STREAMING: 0x07, - SET_SENSORS: 0x08, - SET_WR_ACCEL_RANGE: 0x09, - SET_CONFIG_SETUP_BYTES: 0x0e, - STOP_STREAMING: 0x20, - SET_GSR_RANGE: 0x21, - DEVICE_VERSION_RESPONSE: 0x25, - GET_FW_VERSION: 0x2e, - FW_VERSION_RESPONSE: 0x2f, - GET_DEVICE_VERSION: 0x3f, - SET_GYRO_RANGE: 0x49, - SET_ALT_ACCEL_RANGE: 0x4f, - SET_INTERNAL_EXP_POWER_ENABLE: 0x5e, - SET_EXG_REGS: 0x61, - EXG_REGS_RESPONSE: 0x62, - GET_EXG_REGS: 0x63, - SET_DAUGHTER_CARD_ID: 0x64, - DAUGHTER_CARD_ID_RESPONSE: 0x65, - GET_DAUGHTER_CARD_ID: 0x66, - SET_DAUGHTER_CARD_MEM: 0x67, - DAUGHTER_CARD_MEM_RESPONSE: 0x68, - GET_DAUGHTER_CARD_MEM: 0x69, - START_SDBT: 0x70, - STATUS_RESPONSE: 0x71, - GET_STATUS: 0x72, - SET_DATA_RATE_TEST: 0xa4, - DATA_RATE_TEST_RESPONSE: 0xa5, - GET_BT_VERSION_STR: 0xa1, - BT_VERSION_STR_RESPONSE: 0xa2, - SET_FACTORY_TEST: 0xa8, - INSTREAM_CMD_RESPONSE: 0x8a, - SET_CRC: 0x8b, - SET_INFOMEM: 0x8c, - INFOMEM_RESPONSE: 0x8d, - GET_INFOMEM: 0x8e, - SET_RWC: 0x8f, - RWC_RESPONSE: 0x90, - GET_RWC: 0x91, - VBATT_RESPONSE: 0x94, - GET_VBATT: 0x95, - PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE: 0xa6, - GET_PRESSURE_CALIBRATION_COEFFICIENTS: 0xa7, - STOP_SDBT: 0x97, - SET_CALIB_DUMP: 0x98, - RSP_CALIB_DUMP: 0x99, - GET_CALIB_DUMP: 0x9a, - UPD_CALIB_DUMP: 0x9b, - SET_FEATURE: 0xb7, -}); - -/** - * The firmware's `factory_test_t` (log-and-stream-common - * `Test/shimmer_test.h:21-27`), which is also SET_FACTORY_TEST's argument byte. - */ -const FACTORY_TEST_TYPE = Object.freeze({ - MAIN: 0, - LEDS: 1, - ICS: 2, - LED_STATES: 3, -}); -/** `FACTORY_TEST_COUNT` — the firmware ignores anything at or above it. */ -const FACTORY_TEST_TYPE_COUNT = 4; - -/** Feature ids for SET_FEATURE, mirroring the SDK's `BT_FEATURE`. */ -const FEATURE = Object.freeze({ - RN4678_ERROR_LEDS: 1, - REBOOT_ON_DISCONNECT: 2, -}); - -/** SET_* commands that are accepted, remembered and otherwise inert. */ -const REMEMBERED_SETS = Object.freeze({ - [CMD.SET_WR_ACCEL_RANGE]: "wrAccelRange", - [CMD.SET_GYRO_RANGE]: "gyroRange", - [CMD.SET_ALT_ACCEL_RANGE]: "altAccelRange", - [CMD.SET_GSR_RANGE]: "gsrRange", - [CMD.SET_INTERNAL_EXP_POWER_ENABLE]: "expPowerEnabled", -}); - -/** - * Sensor enable bit → the channel IDs it puts in the stream, in the order the - * firmware reports them. This is `channelIdToSensorBit()` from the SDK read - * backwards; the IDs themselves come from `CHANNEL_FORMATS`. - */ -const SENSOR_CHANNELS = Object.freeze([ - { bit: 0x000080, label: "LN accel", ids: [0x00, 0x01, 0x02] }, - { bit: 0x001000, label: "WR accel", ids: [0x04, 0x05, 0x06] }, - { bit: 0x000020, label: "mag", ids: [0x07, 0x08, 0x09] }, - { bit: 0x000040, label: "gyro", ids: [0x0a, 0x0b, 0x0c] }, - { bit: 0x000100, label: "PPG", ids: [0x12] }, - { bit: 0x400000, label: "HG accel", ids: [0x14, 0x15, 0x16] }, - { bit: 0x000004, label: "GSR", ids: [0x1c] }, - // The ExG status byte (0x1d / 0x20) rides with either width, hence the - // duplicates — they are deduplicated when the channel list is built. - { bit: 0x000010, label: "ExG1 24-bit", ids: [0x1d, 0x1e, 0x1f] }, - { bit: 0x000008, label: "ExG2 24-bit", ids: [0x20, 0x21, 0x22] }, - { bit: 0x100000, label: "ExG1 16-bit", ids: [0x1d, 0x23, 0x24] }, - { bit: 0x080000, label: "ExG2 16-bit", ids: [0x20, 0x25, 0x26] }, - /* The rest of what a real sensor can send. Absent until now, which meant the - mock could not exercise the calibrated ADC, battery, pressure or - bridge-amplifier paths at all. */ - { bit: 0x002000, label: "battery", ids: [0x03] }, - { bit: 0x200000, label: "alt mag", ids: [0x17, 0x18, 0x19] }, - { bit: 0x000002, label: "ext ADC 0", ids: [0x0d] }, - { bit: 0x000001, label: "ext ADC 1", ids: [0x0e] }, - { bit: 0x000800, label: "ext ADC 2", ids: [0x0f] }, - { bit: 0x000400, label: "int ADC 3", ids: [0x10] }, - { bit: 0x000200, label: "int ADC 0", ids: [0x11] }, - { bit: 0x800000, label: "int ADC 2", ids: [0x13] }, - /* Pressure and temperature are one enable bit and two channels, and the - firmware emits them in the OPPOSITE order on the two generations — - pressure first on a Shimmer3R, temperature first on a Shimmer3 — with - different widths too. `sensorChannelsFor` picks the right pair. */ - { bit: 0x040000, label: "pressure/temperature", ids: [0x1b, 0x1a] }, - // Shimmer3 only: there is no Shimmer3R bridge-amplifier channel. - { bit: 0x008000, label: "bridge amp", ids: [0x27, 0x28], shimmer3Only: true }, -]); - -/** - * The channel table for one hardware generation. - * - * Two things differ. A Shimmer3 has no ADS7028 and its bridge amplifier is a - * real expansion board, so `0x27`/`0x28` exist there and nowhere else; and the - * BMP pair is emitted in the opposite order with different widths. - */ -function sensorChannelsFor(hardwareVersion) { - const isShimmer3 = hardwareVersion === 3; - return SENSOR_CHANNELS.filter((g) => isShimmer3 || !g.shimmer3Only).map( - (g) => (g.bit === 0x040000 && isShimmer3 ? { ...g, ids: [0x1a, 0x1b] } : g), - ); -} - -/** - * Channel width and byte order, per channel ID. The same table the SDK's - * parser uses (`CHANNEL_FORMATS`), so a frame this mock encodes cannot - * disagree with the frame the client decodes. - */ -const CHANNEL_WIDTH = Object.freeze({ - 0x00: { bytes: 2, be: false }, - 0x01: { bytes: 2, be: false }, - 0x02: { bytes: 2, be: false }, - 0x04: { bytes: 2, be: false }, - 0x05: { bytes: 2, be: false }, - 0x06: { bytes: 2, be: false }, - 0x07: { bytes: 2, be: false }, - 0x08: { bytes: 2, be: false }, - 0x09: { bytes: 2, be: false }, - 0x0a: { bytes: 2, be: false }, - 0x0b: { bytes: 2, be: false }, - 0x0c: { bytes: 2, be: false }, - 0x12: { bytes: 2, be: false }, - 0x14: { bytes: 2, be: false }, - 0x15: { bytes: 2, be: false }, - 0x16: { bytes: 2, be: false }, - 0x1c: { bytes: 2, be: false, unsigned: true }, - 0x1d: { bytes: 1, be: false, unsigned: true }, - 0x1e: { bytes: 3, be: true }, - 0x1f: { bytes: 3, be: true }, - 0x20: { bytes: 1, be: false, unsigned: true }, - 0x21: { bytes: 3, be: true }, - 0x22: { bytes: 3, be: true }, - 0x23: { bytes: 2, be: true }, - 0x24: { bytes: 2, be: true }, - 0x25: { bytes: 2, be: true }, - 0x26: { bytes: 2, be: true }, - 0x03: { bytes: 2, be: false, unsigned: true }, - 0x0d: { bytes: 2, be: false, unsigned: true }, - 0x0e: { bytes: 2, be: false, unsigned: true }, - 0x0f: { bytes: 2, be: false, unsigned: true }, - 0x10: { bytes: 2, be: false, unsigned: true }, - 0x11: { bytes: 2, be: false, unsigned: true }, - 0x13: { bytes: 2, be: false, unsigned: true }, - 0x17: { bytes: 2, be: false }, - 0x18: { bytes: 2, be: false }, - 0x19: { bytes: 2, be: false }, - 0x27: { bytes: 2, be: false, unsigned: true }, - 0x28: { bytes: 2, be: false, unsigned: true }, -}); - -/** - * The pressure/temperature widths, which are the one place the two generations - * disagree about a channel's LAYOUT rather than its name: 3 little-endian bytes - * each on a Shimmer3R, 2 big-endian temperature bytes plus 3 big-endian - * pressure bytes on a Shimmer3 (`CHANNEL_FORMAT_OVERRIDES`). - */ -const BMP_WIDTH = Object.freeze({ - shimmer3: { - 0x1a: { bytes: 2, be: true, unsigned: true }, - 0x1b: { bytes: 3, be: true, unsigned: true }, - }, - shimmer3r: { - 0x1a: { bytes: 3, be: false, unsigned: true }, - 0x1b: { bytes: 3, be: false, unsigned: true }, - }, -}); - -/** - * Raw values that convert to something a person would believe. - * - * A full-scale sine is the right synthetic signal for an accelerometer axis, - * where the point is to see the trace move. It is the wrong one for a channel - * whose calibrated value is a physical quantity: 4095 counts of battery is - * 6 V, and a BMP390 fed a sine reports the compensation clamp. These centres - * and amplitudes are chosen so the CALIBRATED plot reads plausibly — about - * 3.9 V of battery, room temperature, sea-level pressure, mid-scale ADC. - */ -const PLAUSIBLE_SAMPLES = Object.freeze({ - 0x03: { centre: 2000, amplitude: 12 }, // battery ≈ 3.93 V after the ×2 divider - 0x0d: { centre: 2048, amplitude: 400 }, // ext ADC ≈ 1.5 V ± 0.3 - 0x0e: { centre: 2048, amplitude: 400 }, - 0x0f: { centre: 2048, amplitude: 400 }, - 0x10: { centre: 2048, amplitude: 400 }, // int ADC - 0x11: { centre: 2048, amplitude: 400 }, - 0x13: { centre: 2048, amplitude: 400 }, - 0x12: { centre: 2048, amplitude: 600 }, // PPG - 0x1c: { centre: 2000, amplitude: 300 }, // GSR, inside range 0's window - 0x27: { centre: 2048, amplitude: 300 }, // bridge amp - 0x28: { centre: 2048, amplitude: 300 }, -}); - -/** - * The pressure pair, whose raw registers mean different things on the two - * generations — and whose Shimmer3R values do not even FIT a Shimmer3's - * channel widths. - * - * A Shimmer3 sends temperature as 2 bytes, so the BMP390's `0x7FBA00` is - * truncated to `0xBA00` on the wire: fed through the BMP180 compensation that - * is about 149 °C, and its 24-bit pressure is eight times the BMP180's largest - * possible reading. Keying these by channel id alone made a `&hw=3` mock look - * like a broken conversion. - * - * Shimmer3R values are the BMP390 vector's own registers (about 100.9 kPa and - * 23.2 °C through the fixture below). Shimmer3 values are the BMP180 - * datasheet's worked example, `UT = 27898` and `UP = 23843 << 8` — the same - * numbers the SDK's own compensation tests use, so a mock stream and a unit - * test agree on what the answer should be. - */ -const PRESSURE_SAMPLES = Object.freeze({ - shimmer3: { - 0x1b: { centre: 23843 << 8, amplitude: 0x400 }, - 0x1a: { centre: 27898, amplitude: 40 }, - }, - shimmer3r: { - 0x1b: { centre: 0x640d00, amplitude: 0x400 }, - 0x1a: { centre: 0x7fba00, amplitude: 0x200 }, - }, -}); - -/** - * What the mock answers `GET_PRESSURE_CALIBRATION_COEFFICIENTS` with. - * - * The BMP390 block is the vector in the Java driver's own - * `CalibDetailsBmp390.main()`, so the kPa and °C this mock produces can be - * checked against a number nobody here chose. The BMP180 block is the - * datasheet's worked example (BST-BMP180-DS000 §3.5). A BMP581 sends no - * coefficients at all — that is the part, not a fault. - * - * There is no BMP280 fixture: no public worked vector was to hand, and - * inventing coefficients would produce a confident, unverifiable pressure. - */ -const PRESSURE_FIXTURES = Object.freeze({ - 390: { - id: 2, - coeffs: [ - 0xe7, 0x6b, 0xf0, 0x4a, 0xf9, 0xab, 0x1c, 0x9b, 0x15, 0x06, 0x01, 0xd2, - 0x49, 0x18, 0x5f, 0x03, 0xfa, 0x3a, 0x0f, 0x07, 0xf5, - ], - }, - 581: { id: 3, coeffs: [] }, - 180: { - id: 0, - // AC1..MD, big-endian pairs, from the datasheet's example. - coeffs: [ - 0x01, 0x98, 0xff, 0xb8, 0xc7, 0xd1, 0x7f, 0xe5, 0x7f, 0xf5, 0x5a, 0x71, - 0x18, 0x2e, 0x00, 0x04, 0x80, 0x00, 0xdd, 0xf9, 0x0b, 0x34, - ], - }, -}); - -/** Width for one channel id, on one generation. */ -function channelWidthFor(id, hardwareVersion) { - const bmp = BMP_WIDTH[hardwareVersion === 3 ? "shimmer3" : "shimmer3r"][id]; - return bmp ?? CHANNEL_WIDTH[id] ?? { bytes: 2, be: false }; -} - -/** Sampling clock: rate = 32768 / divisor. */ -const SAMPLING_CLOCK_HZ = 32768; - -/** InfoMem field offsets (flat addressing), from `resolveInfoMemLayout`. */ -const IM = Object.freeze({ - samplingRate: 0, - bufferSize: 2, - sensors0: 3, - sensors1: 4, - sensors2: 5, - configSetupByte0: 6, - configSetupByte3: 9, - /* Shimmer3R puts the later config bytes in the second segment, and byte 6 is - not adjacent to byte 5 (resolveInfoMemLayout: 128, 129, 132). */ - configSetupByte4: 128, - configSetupByte5: 129, - configSetupByte6: 132, - exg1: 10, - exg2: 20, - exgBankLength: 10, - btCommBaudRate: 30, - shimmerName: 187, - expIdName: 199, - configTime0: 211, - macAddress: 224, - nameLength: 12, -}); - -/** - * Backing store for GET_INFOMEM / SET_INFOMEM. 512 bytes, deliberately larger - * than the 384-byte InfoMem image the SDK models (`INFOMEM_SIZE`): the extra - * space makes an over-long read return zeros rather than undefined, which is - * what real flash does. - */ -const INFOMEM_STORE_BYTES = 512; - -/** - * Backing store for GET/SET_DAUGHTER_CARD_MEM: the expansion-board EEPROM as - * the HOST sees it. Firmware maps host offset 0 past the first (hardware - * details) EEPROM page, so host offsets 0..2031 are absolute bytes 16..2047 — - * which is why this is 2032 and not 2048, and why an offset past the end is - * refused rather than wrapped. - */ -const EEPROM_HOST_BYTES = 2032; - -/** - * Firmware's ceiling on the Bluetooth module version string: the reply is - * `strlen()` of `char btVerStrResponse[100]` in - * `Comms/shimmer_bt_uart.c`, so 99 characters plus its terminator is the most - * a real sensor can report. - */ -const BT_VERSION_MAX_BYTES = 99; - -/** Firmware's ceiling on one daughter-card read or write. */ -const EEPROM_MAX_PER_CALL = 128; - -// --------------------------------------------------------------------------- -// Calibration dump -// --------------------------------------------------------------------------- - -/** - * Calibration RAM, `SHIMMER_CALIB_RAM_MAX` in log-and-stream-common - * `Calibration/shimmer_calibration.h:16-20`. The dump lives at the front of - * it and the rest reads as zeros, which is what a read past the end of a real - * dump returns. - */ -const CALIB_RAM_BYTES = 1024; - -/** - * Bytes the firmware will move in one GET_CALIB_DUMP / SET_CALIB_DUMP - * (`Comms/shimmer_bt_uart.c:2241-2249`). Bigger requests are refused rather - * than served short, because the SDK sizes its pages against this. - */ -const CALIB_MAX_PER_CALL = 128; - -/** - * Calibration-domain sensor ids, `SC_SENSOR_*` in log-and-stream-common - * `Calibration/shimmer_calibration.h:96-112`. NOT the SDK's `CalibSensorId`, - * which is the Verisense domain and disagrees on 40 and 41. - */ -const SC_SENSOR = Object.freeze({ - ANALOG_ACCEL: 2, - MPU9X50_GYRO: 30, - LSM303_ACCEL: 31, - LSM303_MAG: 32, - BMP180_PRESSURE: 36, - LSM6DSV_ACCEL: 37, - LSM6DSV_GYRO: 38, - LIS2DW12_ACCEL: 39, - ADXL371_ACCEL: 40, - LIS3MDL_MAG: 41, - LIS2MDL_MAG: 42, - BMP390_PRESSURE: 43, -}); - -/** 32768 Hz ticks, the unit `RTC_getRwcTime()` stamps a calibration with. */ -function calibStamp(unixSeconds) { - const out = new Uint8Array(8); - let ticks = BigInt(Math.round(unixSeconds * 32768)); - for (let i = 0; i < 8; i++) { - out[i] = Number(ticks & 0xffn); - ticks >>= 8n; - } - return out; -} - -/** The all-zero stamp the firmware writes for a seeded (default) calibration. */ -const CALIB_STAMP_NONE = new Uint8Array(8); - -/** - * A 21-byte kinematic block built from the SDK's own defaults for a family, - * group and range, optionally perturbed to stand in for a per-unit - * calibration. - * - * Built through `generateKinematicCalibBlock` rather than typed out as bytes - * so the fixture cannot drift away from the parser that reads it: if the - * codec changes, this changes with it. - */ -function calibBlock(family, group, range, tweak) { - const d = getDefaultCalibration(family, group, range); - if (!d) return null; - const offset = [...d.calibration.offset]; - const sensitivity = [...d.calibration.sensitivity]; - const alignment = [...d.calibration.alignment]; - if (tweak) { - tweak.offset?.forEach((v, i) => (offset[i] = v)); - tweak.sensitivity?.forEach((v, i) => (sensitivity[i] = v)); - tweak.alignment?.forEach((v, i) => (alignment[i] = v)); - } - return generateKinematicCalibBlock(offset, sensitivity, alignment, { - sensitivityScale: d.sensitivityScale, - }); -} - -function calibRecord(sensorId, range, bytes, ts) { - return { - sensorId, - range, - calibLen: bytes.length, - timestampTicks: ts, - calibBytes: bytes, - isDefault: ts.every((b) => b === 0), - }; -} - -/** - * The synthetic calibration dump, mixed on purpose so every state the - * calibration UI has to render is reachable without hardware: - * - * - the low-noise accel and the magnetometer hold this unit's OWN values - * with a real calibration date; - * - the gyro and the wide-range accel hold the factory defaults, stamped - * all-zero exactly as the firmware seeds them; - * - the gyro's record is at ONE range only, so every other gyro range has - * no record at all — the "never calibrated" state, reached by moving the - * range selector rather than by finding a different sensor; - * - on a Shimmer3R the alt-magnetometer's record is an all-0xFF block (the - * other flavour of "nothing stored": a record that exists and says - * nothing) and the high-g accel has NO record at all; - * - the pressure sensor holds a 22-byte coefficient block, which is not a - * kinematic set and must not be offered as one. - * - * @param {number|null} hardwareVersion 3, 10, or null (treated as 10) - * @param {{major: number, minor: number, patch: number}} fw - */ -function buildSyntheticCalibDump(hardwareVersion, fw) { - const hw = hardwareVersion == null ? 10 : hardwareVersion; - const version = { - hardwareId: hw, - firmwareId: 3, // LogAndStream - firmwareMajor: fw.major, - firmwareMinor: fw.minor, - firmwareInternal: fw.patch, - }; - /* Fixed civil dates so a screenshot and a test see the same thing every - run. Midday, so a host time zone either side of UTC still reads the day - the fixture names. */ - const CAL_A = Date.UTC(2026, 5, 11, 12, 0, 0) / 1000; - const CAL_B = Date.UTC(2026, 3, 2, 12, 0, 0) / 1000; - /* Not a kinematic block: the pressure chips' factory coefficients, which - the calibration tab has to show as present-but-not-editable. - - A record no FIRMWARE would write, on purpose. `ShimCalib_findLength` - returns 0 for id 43, so a Shimmer3R never creates one — but - `SET_CALIB_DUMP` writes bytes straight into the blob at a host-chosen - offset, so a host CAN put one there and the device will store and echo it - without ever applying it. Serving one here keeps a host honest about that: - a dump is not a statement of what the device understands. */ - const pressure = Uint8Array.from({ length: 22 }, (_, i) => 0x40 + i); - - if (hw === 3) { - const family = "shimmer3-old"; - return generateCalibDump(version, [ - calibRecord( - SC_SENSOR.ANALOG_ACCEL, - 0, - calibBlock(family, "lnAccel", 0, { - offset: [2051, 2043, 2049], - sensitivity: [84, 83, 82], - }), - calibStamp(CAL_A), - ), - calibRecord( - SC_SENSOR.MPU9X50_GYRO, - 3, - calibBlock(family, "gyro", 3), - CALIB_STAMP_NONE, - ), - calibRecord( - SC_SENSOR.LSM303_ACCEL, - 0, - calibBlock(family, "wrAccel", 0), - CALIB_STAMP_NONE, - ), - calibRecord( - SC_SENSOR.LSM303_MAG, - 1, - calibBlock(family, "mag", 1, { sensitivity: [1104, 1098, 981] }), - calibStamp(CAL_B), - ), - calibRecord(SC_SENSOR.BMP180_PRESSURE, 0, pressure, calibStamp(CAL_B)), - ]); - } - - const family = "shimmer3r"; - return generateCalibDump(version, [ - calibRecord( - SC_SENSOR.LSM6DSV_ACCEL, - 0, - calibBlock(family, "lnAccel", 0, { - offset: [12, -30, 4], - sensitivity: [1674, 1670, 1673], - }), - calibStamp(CAL_A), - ), - calibRecord( - SC_SENSOR.LSM6DSV_GYRO, - 3, - calibBlock(family, "gyro", 3), - CALIB_STAMP_NONE, - ), - calibRecord( - SC_SENSOR.LIS2DW12_ACCEL, - 0, - calibBlock(family, "wrAccel", 0), - CALIB_STAMP_NONE, - ), - calibRecord( - SC_SENSOR.LIS2MDL_MAG, - 0, - calibBlock(family, "mag", 0, { - offset: [-6, 11, 2], - sensitivity: [669, 664, 671], - }), - calibStamp(CAL_B), - ), - /* A record that exists and stores nothing — `parseKinematicCalibBlock` - answers null for an all-0xFF block, and the UI must say "never - calibrated" rather than printing 65535s. */ - calibRecord( - SC_SENSOR.LIS3MDL_MAG, - 0, - new Uint8Array(21).fill(0xff), - CALIB_STAMP_NONE, - ), - calibRecord(SC_SENSOR.BMP390_PRESSURE, 0, pressure, calibStamp(CAL_B)), - /* No ADXL371 record at all: deliberately absent. */ - ]); -} - -/** How long the mock waits before answering, in ms. */ -const REPLY_DELAY_MS = 0; - -/** Streaming is delivered in bursts on this cadence, like a real BLE link. */ -const STREAM_TICK_MS = 20; - -/** Default dribble chunk size on an unframed transport. */ -const DEFAULT_DRIBBLE_BYTES = 3; - -// --------------------------------------------------------------------------- -// The synthetic SD card -// --------------------------------------------------------------------------- - -/** - * Capacity reported by SD_FREE_SPACE, in KB — a nominal 32 GB card after - * formatting. `RESERVED` is everything on the card that is not under `data/` - * (the FAT itself, the firmware's own files), so free space is neither the - * whole card nor exactly capacity-minus-data. - */ -const SD_TOTAL_KB = 31_166_976; -const SD_RESERVED_KB = 12_845; - -/** - * Entries the mock returns per SD_LIST_DIR page. - * - * The firmware caps at {@link SD_LIST_MAX_ENTRIES} (16) AND at the response - * byte budget, so a short page with `hasMore` set is normal behaviour rather - * than an edge case. Two here, deliberately: it makes every directory listing - * exercise the client's paging loop instead of leaving it untested. - */ -const SD_ENTRIES_PER_PAGE = 2; - -/** FatFs result codes the mock returns. Raw FRESULTs, as the firmware does. */ -const FR_NO_FILE = 4; -const FR_NO_PATH = 5; -const FR_DENIED = 7; - -/** Cadence of streamed SD blocks and data-rate-test packets. */ -const SD_TICK_MS = 20; - -/** - * Default streamed throughput, in KB/s. - * - * Faster than a real BLE link (~10 KB/s) so a demo is not a coffee break, - * but slow enough that a 128 KB read window takes about a second — which is - * what a rolling-throughput readout and an ETA need in order to have - * anything to show. - */ -const SD_DEFAULT_KBPS = 120; - -/** Default raw link speed reported by the data-rate test, in KB/s. */ -const LINK_DEFAULT_KBPS = 180; - -/** - * Build the card contents: one trial folder holding two session folders. - * - * Sizes are deliberately not round, and `000` in the first session is large - * enough to span three 128 KB read windows, so a download exercises the - * window loop, the resume arithmetic and the progress/ETA maths rather than - * finishing inside a single window. - * - * @param {string} shimmerName e.g. `Shimmer_8091` - * @returns {{path: string, size: number, seed: number, fdate: number, - * ftime: number}[]} - */ -function buildSyntheticCard(shimmerName) { - const trial = "data/DefaultTrial_5f2c1a90"; - const spec = [ - [`${trial}/${shimmerName}-000/000`, 293_117], - [`${trial}/${shimmerName}-000/001`, 41_983], - [`${trial}/${shimmerName}-001/000`, 17_622], - [`${trial}/${shimmerName}-001/001`, 6_145], - [`${trial}/${shimmerName}-001/002`, 931], - ]; - // A fixed base date, so a listing shows the same timestamps every reload. - const base = new Date(2026, 7, 14, 10, 23, 44); - return spec.map(([path, size], i) => { - const when = new Date(base.getTime() + i * 137_000); - const [fdate, ftime] = fatStamp(when); - return { path, size, seed: (i * 61 + 7) & 0xff, fdate, ftime }; - }); -} - -/** Pack a Date into the FAT date/time pair the card stores. */ -function fatStamp(d) { - const fdate = - ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(); - const ftime = - (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1); - return [fdate & 0xffff, ftime & 0xffff]; -} - -/** - * The byte a synthetic file holds at absolute offset `at`. - * - * Position-dependent on purpose: the v1.01.009/.010 firmware bug this - * transfer path exists to work around shifted every block by three bytes, - * and a file full of a repeating pattern would have hidden it. - * - * @param {number} seed - * @param {number} at - * @returns {number} - */ -function syntheticByte(seed, at) { - return (seed + at * 7 + (at >> 8) * 31 + (at >> 16) * 131) & 0xff; -} - -/** - * The factory brand record for a platform, as firmware seeds it at first - * boot: the names in BRAND_DEFAULT_* in log-and-stream-common - * `EEPROM/shimmer_eeprom.h`. - * - * Serialised by the SDK's own `buildBrandRecord` rather than by a byte table - * here, so the mock cannot drift from the parser it is feeding — the magic, - * the layout version, the length bytes and the CRC all come from one place, - * and a change to the record layout breaks both sides at once instead of - * leaving them agreeing with each other and with nothing else. - * - * @param {number|null} hardwareVersion - * @returns {Uint8Array} BRAND_RECORD_SIZE bytes - */ -function buildStockBrandRecord(hardwareVersion) { - const isShimmer3 = hardwareVersion === 3; - return buildBrandRecord({ - btClassic: isShimmer3 ? "Shimmer3" : "Shimmer3R", - ble: isShimmer3 ? "S3BLE" : "Shimmer3R", - usbProduct: "Shimmer", - usbManufacturer: "Shimmer Research Ltd.", - seededPlatform: isShimmer3 - ? BRAND_PLATFORM.SHIMMER3 - : BRAND_PLATFORM.SHIMMER3R, - }); -} - -/** - * True when the page URL asks for the mock (`?mock=1`). - * - * Deliberately opt-in and query-string-only: a demo page that reached for the - * mock on its own — on a missing API, say — would quietly show fake data to - * someone debugging real hardware, which is worse than an error. - * - * @returns {boolean} - */ -export function mockEnabledFromUrl() { - try { - return new URLSearchParams(location.search).get("mock") === "1"; - } catch { - return false; - } -} - -/** - * Build a mock Shimmer3R on a LoopbackTransport. - * - * @param {object} [opts] - * @param {boolean} [opts.framed=true] `true` behaves like BLE (each reply is - * one notification); `false` behaves like an RFCOMM byte stream, delivering - * every reply in small chunks so the SDK's re-framing is exercised - * @param {number} [opts.rateHz=51.2] initial sampling rate - * @param {number} [opts.sensors=0x00E0] initial sensor bitmap - * (default = LN accel | gyro | mag) - * @param {number} [opts.dribbleBytes=3] chunk size when `framed` is false - * @param {string} [opts.deviceName] advertised name - * @param {string} [opts.mac="000666668091"] MAC, hex, no separators - * @param {{major: number, minor: number, patch: number}} [opts.firmware] - * version reported by GET_FW_VERSION. Defaults to v1.01.012, which is above - * the SD-transfer gate; pass v1.01.010 to exercise a page's refusal path. - * @param {number|null} [opts.hardwareVersion=10] what GET_DEVICE_VERSION - * reports. Pass `null` to NACK it instead, which is how a page's - * "hardware not positively identified" path gets exercised — the one a - * defaulted hardware version silently defeats. - * @param {"390"|"581"|"180"|"nack"|"silent"} [opts.pressure] how the mock - * answers GET_PRESSURE_CALIBRATION_COEFFICIENTS (0xA7). Defaults to the part - * the hardware version implies — BMP390 on a Shimmer3R, BMP180 on a - * Shimmer3. `nack` refuses it, as firmware without the command does; `silent` - * answers nothing at all, as firmware old enough to lack even the refusal - * does. Either way pressure and temperature stream raw-only, which is a path - * worth being able to see. - * @param {number} [opts.sdKBps=120] throughput of streamed SD file blocks - * @param {number} [opts.linkKBps=180] throughput reported by the firmware - * data-rate test (SET_DATA_RATE_TEST) - * @param {boolean} [opts.debug=false] console.log every command - * @returns {LoopbackTransport} pass it to `new Shimmer3RClient({ transport })`. - * `transport.emitDisconnect()` simulates a dropped link; - * `transport.writes` is every command the page sent; `transport.sdCard` - * is the synthetic card, with a `bytes(path)` that returns exactly what a - * download of that file should produce; `transport.eeprom` is the - * expansion-board EEPROM, with the brand record and the restart - * bookkeeping; `transport.calib` is the calibration RAM, with the dump - * as it now stands and a count of the chunks the firmware dropped; and - * `transport.status` docks, undocks or pushes an unsolicited - * STATUS_RESPONSE, which is how the firmware tells a host about a change - * the host did not cause. `transport.factoryTest` is the self-test run - * count, whether a report is still printing and the exact text printed; - * `transport.rtc` exposes the sensor's own running clock. - */ -export function createMockShimmer3RTransport(opts = {}) { - const framed = opts.framed !== false; - const dribbleBytes = Math.max(1, opts.dribbleBytes ?? DEFAULT_DRIBBLE_BYTES); - const debug = !!opts.debug; - const mac = (opts.mac ?? "000666668091").replace(/[^0-9a-fA-F]/g, ""); - const fw = { major: 1, minor: 1, patch: 12, ...(opts.firmware ?? {}) }; - /* `undefined` means "not asked for" and gets the Shimmer3R default; `null` - means "this sensor will not say", which NACKs GET_DEVICE_VERSION. Anything - non-finite is neither -- it used to reach `hardwareVersion & 0xff` and - answer 0, a fourth behaviour nobody asked for -- so it is read as the - default, the same as not passing one. */ - const hardwareVersion = - opts.hardwareVersion === undefined || - (opts.hardwareVersion !== null && !Number.isFinite(opts.hardwareVersion)) - ? 10 - : opts.hardwareVersion; - - /* Which pressure part this mock claims to carry. Defaults to the one the - platform really would: a Shimmer3R has a BMP390 or BMP581, a Shimmer3 a - BMP180 or BMP280. */ - const pressureMode = String( - opts.pressure ?? (hardwareVersion === 3 ? "180" : "390"), - ); - const sdKBps = Math.max(1, opts.sdKBps ?? SD_DEFAULT_KBPS); - const linkKBps = Math.max(1, opts.linkKBps ?? LINK_DEFAULT_KBPS); - - /* A real-world clock that RUNS, and runs at a settable error. A frozen - clock (what a stored tick count gives) reads back correctly but has no - slope, so nothing that measures drift can be exercised against it. - `clockBase: "local"` starts the sensor on this host's civil time instead - of UTC, which is what a sensor set by a tool using the other convention - looks like — the case a host's clock-base detection exists for. */ - const rtcOpts = opts.rtc ?? {}; - const rtcPpm = Number.isFinite(rtcOpts.ppm) ? Number(rtcOpts.ppm) : 0; - const rtcClockBase = rtcOpts.clockBase === "local" ? "local" : "utc"; - /* `wrapInSec` puts the sensor's clock where its low 24 bits are about to - roll over, so a short stream crosses a wrap. It moves the CLOCK, not the - stream counter, because on a Shimmer3R the two are the same number (see - `startStreaming`) and moving one without the other would model a sensor - that does not exist. Up to 512 s of date shift is the price, and that is - itself a legitimate sensor state. */ - /* A bad value is a typo, not a request. `hw=` and `sensors=` both warn on - one; this said nothing and behaved as though the option were absent. */ - const rtcWrapInSec = - rtcOpts.wrapInSec === undefined || rtcOpts.wrapInSec === null - ? null - : Number.isFinite(Number(rtcOpts.wrapInSec)) && - Number(rtcOpts.wrapInSec) >= 0 - ? Number(rtcOpts.wrapInSec) - : (console.warn( - `mock: ignoring rtcWrapIn="${rtcOpts.wrapInSec}" — it must be a non-negative number of seconds.`, - ), - null); - const rtc = { - devMsAtSet: - Date.now() + - (rtcClockBase === "local" ? -new Date().getTimezoneOffset() * 60000 : 0), - setAtHostMs: Date.now(), - }; - if (rtcWrapInSec != null) { - const ticks = Math.round(rtc.devMsAtSet * 32.768); - const target = 0x1000000 - Math.round(rtcWrapInSec * SAMPLING_CLOCK_HZ); - // Forward to the next tick count whose low 24 bits are `target`, so the - // clock never moves backwards past a reading a host may already hold. - const ahead = (target - (ticks % 0x1000000) + 0x1000000) % 0x1000000; - rtc.devMsAtSet = (ticks + ahead) / 32.768; - } - /** The sensor's clock now, in its own epoch, drifting at `rtcPpm`. */ - const deviceNowMs = () => - rtc.devMsAtSet + (Date.now() - rtc.setAtHostMs) * (1 + rtcPpm / 1e6); - - const state = { - rateHz: opts.rateHz ?? 51.2, - sensors: opts.sensors ?? 0x00e0, - streaming: false, - logging: false, - /** Seated in a dock. Toggled through `transport.status`, which pushes. */ - docked: false, - /** A card is in the slot — this mock serves one, see `buildSyntheticCard`. */ - sdInserted: true, - /** The firmware could not open its log file. */ - sdBadFile: false, - /** The clock has been set since the sensor last lost power. */ - rwcSet: false, - /** The red LED, as TOGGLE_LED leaves it. */ - redLedOn: false, - /** The USB rail — the Shimmer3R's second status byte. */ - usbPluggedIn: false, - wrAccelRange: 0, - gyroRange: 3, - altAccelRange: 0, - gsrRange: 4, - expPowerEnabled: 0, - /* Seeded to the firmware's own Shimmer3R defaults rather than zeros. - Byte 1 is the whole LSM6DSV accel/gyro ODR, and zero means POWER-DOWN - - so an all-zero default modelled a device whose IMU never produces a new - sample, which is not a state a real sensor ships in. `shimmer_config.c` - pairs the 51.2 Hz default packet rate with "next highest", 60 Hz - (LSM6DSV_ODR_AT_60Hz = 5), and this follows it so the mock exercises a - coherent configuration by default. */ - configSetupBytes: Uint8Array.of(0x02, 0x05, 0x01, 0x08, 0x00, 0x88, 0x10), - /** 64-bit RTC ticks, LSB first on the wire. */ - rwcTicks: 0n, - /** A soft restart has been armed for the next disconnect. */ - rebootArmed: false, - /** How many times the armed restart has actually fired. */ - reboots: 0, - /** Self-test bookkeeping, read through `transport.factoryTest`. */ - factoryTest: { runs: 0, running: false, lastType: null }, - }; - - const infoMem = new Uint8Array(INFOMEM_STORE_BYTES); - seedInfoMem(); - - /* An erased EEPROM with one record written into it, which is what a - provisioned board actually holds — everything the firmware has not - claimed reads 0xFF. Leaving bytes 0..15 erased also keeps - `parseExpansionBoard` returning null, i.e. "no expansion board", which is - the truth about a bare Shimmer3R. */ - const eeprom = new Uint8Array(EEPROM_HOST_BYTES).fill(0xff); - const stockBrand = buildStockBrandRecord(hardwareVersion); - eeprom.set(stockBrand, BRAND_RECORD_HOST_OFFSET); - - /* The daughter-card ID page: the FIRST sixteen EEPROM bytes, which the - card-memory store above deliberately does not cover. Firmware answers - GET_DAUGHTER_CARD_ID from a copy it caches at boot rather than from the - chip, which is why it is a separate array here too. - - Default `[48, 3, 0]` — a GSR+ board, SR48-3-0 — so the page has something - to name. The two blank patterns are distinct and both worth modelling: - `&srBoard=none` fills the page with 0xFF, an ERASED chip, and - `&srBoard=0-0-0` leaves it all zeroes, a page that was NEVER WRITTEN. The - SDK reads both as "no board". */ - const srBoardPage = new Uint8Array(16).fill(0xff); - if (opts.srBoard !== "none") { - const parts = String(opts.srBoard ?? "48-3-0") - .split("-") - .map((n) => Number.parseInt(n, 10)); - const triple = [ - Number.isFinite(parts[0]) ? parts[0] & 0xff : 48, - Number.isFinite(parts[1]) ? parts[1] & 0xff : 3, - Number.isFinite(parts[2]) ? parts[2] & 0xff : 0, - ]; - /* An all-zero SR code means the page was never written, so the WHOLE page - is zero - not three zeroes in front of the 0xFF fill above, which is - neither pattern and would misrepresent the state to anything that - looked past the first three bytes. A real board's remaining bytes hold - other hardware details, so those stay 0xFF. */ - if (triple.every((v) => v === 0)) srBoardPage.fill(0x00); - else srBoardPage.set(triple, 0); - } - - /* What the Bluetooth module replied when the firmware asked it, verbatim. - The Shimmer3R default is the line `BT_generateCyw20820FirmwareVersionStr` - composes (`CYW20820.c:1893-1903`); a Shimmer3 forwards the RN module's - own banner instead, so `hardwareVersion === 3` gets one of those. - - `&btVersion=` (empty) models the real zero-length case: the firmware's - buffer starts zeroed and is filled only once the module has answered its - own query, so a sensor asked early enough reports nothing. */ - const btVersionString = - opts.btVersion !== undefined - ? String(opts.btVersion) - : hardwareVersion === 3 - ? "RN4678 V1.23 06/30/2021 (c)Microchip Technology Inc" - : "CYW20820 app=v01.04.18.18, stack=0x00000000, protocol=0x0000, hardware=0x00"; - - /* Calibration RAM with the synthetic dump at the front of it. Everything - past the dump reads as zeros, which is what a read past a real dump - returns — and what makes the SDK's "take the total from the first - chunk's header" paging worth exercising. */ - const calibRam = new Uint8Array(CALIB_RAM_BYTES); - calibRam.set(buildSyntheticCalibDump(hardwareVersion, fw), 0); - /** - * A SET_CALIB_DUMP in progress. The firmware takes the dump's total length - * from the FIRST chunk's own header and counts the rest in, refusing a - * write that does not start at the beginning - * (`ShimCalib_ramWrite`, `Calibration/shimmer_calibration.c:330-370`). - * `null` between writes. - */ - let calibStaging = null; - /** Bookkeeping the harness reads: applies, and writes the firmware dropped. */ - const calibStats = { updates: 0, discarded: 0 }; - - const transport = new LoopbackTransport({ - capabilities: { framed }, - deviceName: - opts.deviceName ?? - `Shimmer3R-${mac.slice(-4).toUpperCase()}${framed ? "-BLE" : "-BT"}`, - }); - - let streamTimer = null; - let streamTicks = 0; - let streamStartMs = 0; - let samplesEmitted = 0; - - // ------------------------------------------------------------------------- - // InfoMem - // ------------------------------------------------------------------------- - - function writeName(offset, text) { - const bytes = new TextEncoder().encode(text); - for (let i = 0; i < IM.nameLength; i++) { - infoMem[offset + i] = i < bytes.length ? bytes[i] : 0x00; - } - } - - function seedInfoMem() { - // Sampling divisor, LSB first. 32768/640 = 51.2 Hz. - const divisor = Math.max( - 1, - Math.round(SAMPLING_CLOCK_HZ / (opts.rateHz ?? 51.2)), - ); - infoMem[IM.samplingRate] = divisor & 0xff; - infoMem[IM.samplingRate + 1] = (divisor >> 8) & 0xff; - infoMem[IM.bufferSize] = 1; - - // The sensor bitmap is LSB-first BOTH on the wire (SET_SENSORS payload) - // and in InfoMem (idxSensors0 holds bits 0-7). Getting these two out of - // step is the trap here: a config form reading InfoMem would then show a - // different sensor set than the inquiry reports. - const sensors = opts.sensors ?? 0x00e0; - infoMem[IM.sensors0] = sensors & 0xff; - infoMem[IM.sensors1] = (sensors >> 8) & 0xff; - infoMem[IM.sensors2] = (sensors >> 16) & 0xff; - - /* The seven config setup bytes, in step with what the inquiry reports for - the same reason the sensor bitmap is: a config form reads them from - InfoMem while the stream schema comes from the inquiry, so two sources - that disagree would have the page showing one configuration and decoding - another. Byte 1 is the accel/gyro ODR, which is exactly the pair a host - has to keep coherent with the sampling rate above. */ - infoMem.set(state.configSetupBytes.subarray(0, 4), IM.configSetupByte0); - /* Bytes 4-6 are NOT contiguous with 0-3, and byte 6 is not adjacent to 5 - either: the Shimmer3R layout puts them at 128, 129 and 132. Writing them - as a run is the mistake to avoid - it lands byte 6 on 130, which is a - different field. */ - infoMem[IM.configSetupByte4] = state.configSetupBytes[4]; - infoMem[IM.configSetupByte5] = state.configSetupBytes[5]; - infoMem[IM.configSetupByte6] = state.configSetupBytes[6]; - - infoMem[IM.btCommBaudRate] = 9; // 1 Mbaud, the Shimmer3R default - /* From the SDK rather than hand-rolled, so the mock cannot drift from what - the firmware's own ShimConfig_setDefaultShimmerName / - ShimConfig_setDefaultTrialId produce. The previous inline - `mac.slice(-4)` also assumed a separator-free MAC, which this mock - happens to use but a caller passing a colon-separated one would break. */ - const identity = defaultTrialIdentity(mac); - if (identity.deviceName !== null) { - writeName(IM.shimmerName, identity.deviceName); - } - writeName(IM.expIdName, identity.trialName); - - // Config time, big-endian over 4 bytes — a plausible "last configured" - // stamp rather than 0, so a page rendering it shows a real date. - const configTime = Math.floor(Date.now() / 1000); - for (let i = 0; i < 4; i++) { - infoMem[IM.configTime0 + i] = (configTime >>> ((3 - i) * 8)) & 0xff; - } - - for (let i = 0; i < 6; i++) { - infoMem[IM.macAddress + i] = - parseInt(mac.slice(i * 2, i * 2 + 2), 16) || 0; - } - } - - /** - * Map a wire address to a store offset. Older firmware addresses the three - * InfoMem pages at 0x1800/0x1880/0x1900 while newer firmware and every - * Shimmer3R uses flat 0/128/256, and a page may send either. - */ - function pageOffset(addr) { - return addr >= 0x1800 ? addr - 0x1800 : addr; - } - - // ------------------------------------------------------------------------- - // Expansion-board EEPROM and the soft restart - // ------------------------------------------------------------------------- - - /** - * Fire an armed soft restart, if one is armed. - * - * Firmware skips the restart while sensing, so an armed request can never - * truncate an active SD recording, and clears the request either way — it - * is strictly one-shot and never carries into a later disconnect. - * - * The restart is where an erased brand record becomes the factory one - * again: firmware validates the record at boot and re-seeds the platform - * defaults when it does not check out. Modelling that here is what makes a - * page's "restore factory names" path provable end to end, rather than only - * up to the erase. - */ - function applyPendingReboot() { - if (!state.rebootArmed) return; - state.rebootArmed = false; - if (state.streaming) { - if (debug) console.warn("[mock] restart skipped: still sensing"); - return; - } - state.reboots++; - const record = eeprom.subarray( - BRAND_RECORD_HOST_OFFSET, - BRAND_RECORD_HOST_OFFSET + BRAND_RECORD_SIZE, - ); - /* Judged by the SDK's own parser, for the same anti-drift reason the - record is built with the SDK's builder: the firmware and this mock then - agree on what "does not check out" means. */ - if (!parseBrandRecord(record).valid) { - eeprom.set(stockBrand, BRAND_RECORD_HOST_OFFSET); - if (debug) console.log("[mock] brand record re-seeded at boot"); - } - } - - /** - * The EEPROM, exposed for development and for tests: `brandBytes()` is what - * a page's write actually left behind, `stockBrandBytes()` is what the - * factory record should look like, and `reboots` counts the armed restarts - * that fired. - */ - transport.eeprom = { - read: (offset, length) => eeprom.slice(offset, offset + length), - brandBytes: () => - eeprom.slice( - BRAND_RECORD_HOST_OFFSET, - BRAND_RECORD_HOST_OFFSET + BRAND_RECORD_SIZE, - ), - stockBrandBytes: () => stockBrand.slice(), - get rebootArmed() { - return state.rebootArmed; - }, - get reboots() { - return state.reboots; - }, - }; - - /** - * The calibration RAM, exposed for development and for tests. `bytes()` is - * the dump as it stands now — after any SET_CALIB_DUMP the firmware - * accepted — `updates` counts UPD_CALIB_DUMP, and `discarded` counts the - * chunks the firmware dropped while still ACKing them, which is how a test - * proves an out-of-order write really did go nowhere. - */ - transport.calib = { - bytes: () => { - const total = (calibRam[0] | (calibRam[1] << 8)) + 2; - return calibRam.slice(0, Math.min(Math.max(total, 2), calibRam.length)); - }, - ram: () => calibRam.slice(), - get updates() { - return calibStats.updates; - }, - get discarded() { - return calibStats.discarded; - }, - }; - - /** - * The sensor's own news: dock it, undock it, or push the status as it - * stands. - * - * On real firmware a dock and an undock each send an unsolicited - * STATUS_RESPONSE to whatever host is connected - * (`LogAndStream_setupDock` / `LogAndStream_setupUndock`, - * log-and-stream-common `log_and_stream_common.c`), and so does a sensing - * change the firmware made itself — the user button, a trial ending, a low - * battery. That is the only way a host hears about any of them, and it is - * what `Shimmer3RClient.onDeviceStatus` exists to deliver. - * - * Deliberately NOT sent when this mock's own state changes because of a - * command: the firmware suppresses that one - * (`ShimBt_instreamStatusRespSendIfNotBtCmd`) rather than echo back what - * the host just asked for, so a page that wants to show its own start or - * stop has to reflect it itself. - */ - transport.status = { - get docked() { - return state.docked; - }, - /** Dock or undock, pushing the status the change produced. */ - setDocked: (docked) => { - state.docked = !!docked; - reply(statusResponse()); - }, - /** Push the status as it stands, without changing anything. */ - push: () => reply(statusResponse()), - /** The frame a push (or a GET_STATUS answer) carries, for comparison. */ - bytes: () => new Uint8Array(statusResponse()), - }; - - transport.factoryTest = { - /** How many self-tests this sensor has been asked to run. */ - get runs() { - return state.factoryTest.runs; - }, - /** True while the report is still printing — a cancel cannot stop it. */ - get running() { - return state.factoryTest.running; - }, - get lastType() { - return state.factoryTest.lastType; - }, - /** Exactly the text put on the wire, for a byte-for-byte comparison. */ - text: () => testText, - }; - - transport.identity = { - /** The id page's first three bytes as written, or null when erased. */ - get srBoard() { - const [boardId, boardRev, specialRev] = srBoardPage; - if (boardId === 0xff && boardRev === 0xff && specialRev === 0xff) - return null; - return { boardId, boardRev, specialRev }; - }, - /** What the Bluetooth module replied, exactly as it goes on the wire. */ - get btVersion() { - return btVersionString; - }, - }; - - transport.rtc = { - get ppm() { - return rtcPpm; - }, - get clockBase() { - return rtcClockBase; - }, - /** The sensor's own clock, in its own epoch. */ - deviceNowMs, - }; - - /* A normal disconnect does NOT fire LoopbackTransport's onDisconnect - callbacks — only `emitDisconnect` does — so the restart is hooked on both - paths. `applyPendingReboot` is one-shot, so being reached twice is - harmless. */ - const transportDisconnect = transport.disconnect.bind(transport); - transport.disconnect = async () => { - applyPendingReboot(); - await transportDisconnect(); - }; - - // ------------------------------------------------------------------------- - // Reply plumbing - // ------------------------------------------------------------------------- - - /** - * Deliver one reply. - * - * Framed: a single notification, as a BLE characteristic notify would. - * Unframed: `dribbleBytes` at a time on successive macrotasks — the - * worst case a serial port can present, and the one the SDK's control-plane - * re-framing exists for. - */ - /** - * CRC bytes appended to everything the device sends, per SET_CRC_COMMAND. - * Zero until a host asks, which is the state after every power cycle. - */ - let crcMode = 0; - - function reply(bytes) { - const u8 = appendCrc( - bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), - crcMode, - ); - if (debug) console.log("[mock] ->", hex(u8)); - if (framed) { - setTimeout(() => transport.notify(u8), REPLY_DELAY_MS); - return; - } - let tick = REPLY_DELAY_MS; - for (let off = 0; off < u8.length; off += dribbleBytes) { - const chunk = u8.slice(off, off + dribbleBytes); - setTimeout(() => transport.notify(chunk), tick++); - } - } - - /** - * Bulk traffic: one buffer per burst, chunked but never spread over time. - * - * `crc` says whether the LINK CRC belongs on it, because that is a property - * of which firmware path sent the bytes rather than of how they are - * delivered. `btCrcMode` is honoured in exactly three places — the command - * response path, the instream status push and the stream data packet - * (`Sensing/shimmer_sensing.c:680`) — so a data packet carries it and an SD - * transfer frame does not: those are written straight to the TX buffer with - * their own CRC-16 per frame (`Comms/shimmer_sd_file_transfer.c:340,634`). - * The data-rate test bypasses the ring and the CRC both (`:2987`). - */ - function sendBulk(frame, { crc }) { - const u8 = crc ? appendCrc(frame, crcMode) : frame; - if (framed || u8.length <= dribbleBytes) { - transport.notify(u8); - return; - } - for (let off = 0; off < u8.length; off += dribbleBytes) { - transport.notify(u8.slice(off, off + dribbleBytes)); - } - } - - /** Stream data packets, which the firmware DOES CRC. */ - const replyStream = (frame) => sendBulk(frame, { crc: true }); - - /** - * SD-transfer frames and the data-rate test, which it does not. - * - * Applying the link CRC to these was a mock defect that read as an SDK one: - * the client correctly treats them as exempt, so the two extra bytes per - * frame desynchronised every download on a link with a CRC — which is the - * default the demo page connects with. Eleven checks in the verification - * pass failed on it, and passed with `VERIFY_CRC=0`, which is what - * eventually pointed at the mock rather than at the SDK. - */ - const replyStreamNoCrc = (frame) => sendBulk(frame, { crc: false }); - - function hex(u8) { - return Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join(" "); - } - - // ------------------------------------------------------------------------- - // Device status - // ------------------------------------------------------------------------- - - /** - * `[0x8A][0x71][status0][status1]` — the in-stream status message. - * - * ONE builder for both the answer to GET_STATUS and the unsolicited push, so - * the two cannot drift apart: on the wire they are the same message, and a - * mock whose push disagreed with its own reply would let a page's status - * handling look right while being wrong. - * - * Bit order is `ShimBt_assembleStatusBytes` (log-and-stream-common - * `Comms/shimmer_bt_uart.c`): docked, sensing, RTC set, SD logging, - * streaming, card inserted, bad file, red LED. `sensing` is set by either - * kind of recording, which is how the firmware reports it — it is not a - * fourth thing the host can start. - * - * Always two bytes, the Shimmer3R length: a Shimmer3 sends one, but this - * mock answers as a Shimmer3R whatever hardware id it is told to report, - * and the SDK reads only as many bytes as the platform it identified has. - */ - function statusResponse() { - const sensing = state.streaming || state.logging; - const status0 = - (state.docked ? 0x01 : 0) | - (sensing ? 0x02 : 0) | - (state.rwcSet ? 0x04 : 0) | - (state.logging ? 0x08 : 0) | - (state.streaming ? 0x10 : 0) | - (state.sdInserted ? 0x20 : 0) | - (state.sdBadFile ? 0x40 : 0) | - (state.redLedOn ? 0x80 : 0); - return [ - CMD.INSTREAM_CMD_RESPONSE, - CMD.STATUS_RESPONSE, - status0, - state.usbPluggedIn ? 1 : 0, - ]; - } - - // ------------------------------------------------------------------------- - // Stream schema and synthetic data - // ------------------------------------------------------------------------- - - /** Enabled channel IDs, deduplicated, in firmware report order. */ - function channelIds() { - const ids = []; - for (const group of sensorChannelsFor(hardwareVersion)) { - if (!(state.sensors & group.bit)) continue; - for (const id of group.ids) if (!ids.includes(id)) ids.push(id); - } - return ids; - } - - /** - * INQUIRY_RESPONSE, Shimmer3R layout: - * [0x02][divisor u16 LE][7 config bytes][nCh][bufSize][channel IDs…] - * The 7-byte config word is what distinguishes this from a Shimmer3's - * 4-byte one, and is why the two clients cannot share an inquiry parser. - */ - function inquiryResponse() { - const ids = channelIds(); - const divisor = Math.max(1, Math.round(SAMPLING_CLOCK_HZ / state.rateHz)); - const out = new Uint8Array(12 + ids.length); - out[0] = CMD.INQUIRY_RESPONSE; - out[1] = divisor & 0xff; - out[2] = (divisor >> 8) & 0xff; - out.set(state.configSetupBytes.subarray(0, 7), 3); - out[10] = ids.length; - out[11] = 1; // buffer size: one sample per packet - out.set(ids, 12); - return out; - } - - /** - * One synthetic sample for `id` at sample index `n`. - * - * Sine waves at a few hertz, one per axis with a phase offset, scaled to a - * fraction of full range. That is enough to tell a working plot from a - * broken one at a glance, and to make an axis mix-up obvious. - */ - function sampleFor(id, n) { - const width = channelWidthFor(id, hardwareVersion); - const t = n / state.rateHz; - const phase = ((id * 37) % 360) * (Math.PI / 180); - const freq = 0.7 + (id % 5) * 0.4; - const swing = Math.sin(2 * Math.PI * freq * t + phase); - - /* The channels whose calibrated value is a physical quantity get a - plausible one, because a full-scale sine through the ADC formula reads as - 3000 mV of battery or 125 kPa of air and makes a calibrated plot useless - for telling right from wrong. The rest keep the old full-scale sine, - which is what makes an axis mix-up obvious. */ - const plausible = - (hardwareVersion === 3 - ? PRESSURE_SAMPLES.shimmer3 - : PRESSURE_SAMPLES.shimmer3r)[id] ?? PLAUSIBLE_SAMPLES[id]; - if (plausible) { - return Math.max( - 0, - Math.round(plausible.centre + plausible.amplitude * swing), - ); - } - - const full = width.unsigned - ? (1 << (width.bytes * 8)) - 1 - : (1 << (width.bytes * 8 - 1)) - 1; - if (width.unsigned) return Math.round(full * (0.5 + 0.3 * swing)); - return Math.round(full * 0.45 * swing); - } - - /** `[0x00][ts u24 LE][channel values…]` for one sample. */ - function dataFrame(ids, ticks, n) { - let size = 1 + 3; - for (const id of ids) size += channelWidthFor(id, hardwareVersion).bytes; - const out = new Uint8Array(size); - out[0] = CMD.DATA_PACKET; - out[1] = ticks & 0xff; - out[2] = (ticks >> 8) & 0xff; - out[3] = (ticks >> 16) & 0xff; - let at = 4; - for (const id of ids) { - const width = channelWidthFor(id, hardwareVersion); - let v = sampleFor(id, n); - if (!width.unsigned && v < 0) v += 1 << (width.bytes * 8); - for (let i = 0; i < width.bytes; i++) { - const shift = width.be ? (width.bytes - 1 - i) * 8 : i * 8; - out[at + i] = (v >>> shift) & 0xff; - } - at += width.bytes; - } - return out; - } - - /** - * Where the stream's tick counter starts, which is a per-generation fact. - * - * On a **Shimmer3R** the packet timestamp is `RTC_get32()` — the low 24 bits - * of the very counter `GET_RWC` reports as `RTC_get64()` - * (`RTC/shimmer_rtc.h:25-28`; `Core/Src/rtc.c` gives the two identical - * bodies). That identity is the whole reason a host can pin a Shimmer3R - * stream to a wall clock exactly, so a mock that started the counter at zero - * would let an aligned anchor look like it worked while placing every sample - * up to 256 s from the truth — the error is bounded by the wrap, so it never - * looks absurd enough to notice. - * - * On a **Shimmer3** it is a free-running counter since boot - * (`Shimmer_Driver/5xx_HAL/hal_RTC.c`), and the real-world clock is that - * counter plus a stored offset which never leaves the device. Zero is right - * there: a host has to estimate the offset from the request round trip, and - * a mock whose counter happened to agree with its clock would hide that. - */ - function streamStartTicks() { - if (hardwareVersion === 3) return 0; - return Number(BigInt(Math.round(deviceNowMs() * 32.768)) & 0xffffffn); - } - - function startStreaming() { - if (streamTimer) return; - const ids = channelIds(); - streamTicks = streamStartTicks(); - samplesEmitted = 0; - /* The DEVICE's clock, not this host's. A real sensor times its samples - from the same crystal its real-world clock runs on, so under `&ppm=` the - two must drift together; pacing the stream off `performance.now()` let - the counter and the clock separate linearly over a long run, which on a - Shimmer3R would break the very identity `streamStartTicks` exists to - model. */ - streamStartMs = deviceNowMs(); - const ticksPerSample = SAMPLING_CLOCK_HZ / state.rateHz; - streamTimer = setInterval(() => { - // Emit whatever is due since the last tick rather than one frame per - // timer callback: browsers clamp timers, so a fixed one-frame tick - // would silently cap the rate at ~250 Hz. - const elapsed = (deviceNowMs() - streamStartMs) / 1000; - const due = Math.floor(elapsed * state.rateHz) - samplesEmitted; - for (let i = 0; i < due; i++) { - replyStream( - dataFrame(ids, Math.round(streamTicks) & 0xffffff, samplesEmitted), - ); - streamTicks = (streamTicks + ticksPerSample) % 0x1000000; - samplesEmitted++; - } - }, STREAM_TICK_MS); - } - - function stopStreaming() { - if (!streamTimer) return; - clearInterval(streamTimer); - streamTimer = null; - } - - // ------------------------------------------------------------------------- - // SD card: the file model - // ------------------------------------------------------------------------- - - /* Mutable, because SD_DELETE removes from it. Files are the source of - truth; the directory list is derived, so an emptied session folder still - lists (and still needs deleting) exactly as it does on a real card. */ - let sdFiles = buildSyntheticCard(`Shimmer_${mac.slice(-4).toUpperCase()}`); - let sdDirs = derivedDirs(sdFiles); - - /** Every directory implied by the file paths, parents before children. */ - function derivedDirs(files) { - const seen = new Set(["data"]); - for (const f of files) { - const parts = f.path.split("/"); - for (let n = 1; n < parts.length; n++) - seen.add(parts.slice(0, n).join("/")); - } - return Array.from(seen).sort( - (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b), - ); - } - - const parentOf = (path) => { - const at = path.lastIndexOf("/"); - return at < 0 ? "" : path.slice(0, at); - }; - const nameOf = (path) => path.slice(path.lastIndexOf("/") + 1); - const sdFileAt = (path) => sdFiles.find((f) => f.path === path) ?? null; - - /** The bytes a download of `path` should produce, for a test to compare. */ - function sdFileBytes(path) { - const file = sdFileAt(path); - if (!file) return null; - return sdFileSlice(file, 0, file.size); - } - - function sdFileSlice(file, at, len) { - const out = new Uint8Array(len); - for (let i = 0; i < len; i++) out[i] = syntheticByte(file.seed, at + i); - return out; - } - - /** - * The synthetic card, exposed for development and for tests: `bytes(path)` - * is the ground truth a downloaded file must match, and `files` shrinks as - * SD_DELETE removes entries. - */ - transport.sdCard = { - get files() { - return sdFiles.map((f) => ({ path: f.path, size: f.size })); - }, - get dirs() { - return [...sdDirs]; - }, - bytes: sdFileBytes, - }; - - // ------------------------------------------------------------------------- - // SD card: response framing - // ------------------------------------------------------------------------- - - /* The frame CRC comes from the SDK's own `sdCrc16` rather than a copy of - the firmware's ShimSwCrc: the mock and the decoder then cannot drift, and - a CRC bug shows up as a failing page rather than as two implementations - that agree with each other and with nothing else. */ - - /** `[0x8A][0xC5][sess][seq u16][len u16][payload][crc16]` */ - function sdReplyData(session, seq, payload) { - const out = new Uint8Array(7 + payload.length + 2); - out[0] = CMD.INSTREAM_CMD_RESPONSE; - out[1] = SD_TRANSFER_OPCODES.FILE_DATA_RESPONSE; - out[2] = session & 0xff; - out[3] = seq & 0xff; - out[4] = (seq >> 8) & 0xff; - out[5] = payload.length & 0xff; - out[6] = (payload.length >> 8) & 0xff; - out.set(payload, 7); - const crc = sdCrc16(out, 7 + payload.length); - out[7 + payload.length] = crc & 0xff; - out[8 + payload.length] = (crc >> 8) & 0xff; - // Bulk data, so delivered like stream data: chunked on an unframed - // transport but never spread over macrotasks, or a 293 KB file would be - // a hundred thousand timers. No LINK CRC on top of the frame's own. - replyStreamNoCrc(out); - } - - /** `[0x8A][0xC6][sess][status][nextOffset u32][crc16]` */ - function sdReplyStatus(session, status, nextOffset) { - const out = new Uint8Array(10); - out[0] = CMD.INSTREAM_CMD_RESPONSE; - out[1] = SD_TRANSFER_OPCODES.FILE_STATUS_RESPONSE; - out[2] = session & 0xff; - out[3] = status & 0xff; - new DataView(out.buffer).setUint32(4, nextOffset >>> 0, true); - const crc = sdCrc16(out, 8); - out[8] = crc & 0xff; - out[9] = (crc >> 8) & 0xff; - replyStreamNoCrc(out); - } - - // ------------------------------------------------------------------------- - // SD card: the read window - // ------------------------------------------------------------------------- - - /** Session ids increment per read, as the firmware's do. */ - let sdSession = 0; - /** The read window in flight, or null. */ - let sdRead = null; - - function sdFinishRead(status, nextOffset) { - if (!sdRead) return; - clearInterval(sdRead.timer); - const session = sdRead.session; - sdRead = null; - sdReplyStatus(session, status, nextOffset); - } - - /** - * Serve one SD_FILE_READ window. - * - * Paced at `sdKBps` rather than emitted in one go: a download that - * completes in a single macrotask never exercises a progress readout, an - * ETA or an abort, which are most of what there is to get wrong here. - */ - function sdStartRead(path, offset, windowLen, blockLen) { - // A second read supersedes the first, exactly as the firmware's single - // window does — and the stale session id is how the host tells the - // leftover frames apart. - if (sdRead) sdFinishRead(SD_XFER.SUPERSEDED, sdRead.offset + sdRead.sent); - const session = (sdSession = (sdSession + 1) & 0xff); - const file = sdFileAt(path); - if (!file) { - sdReplyStatus(session, SD_XFER.NOT_FOUND, offset); - return; - } - if (state.streaming) { - sdReplyStatus(session, SD_XFER.DENIED, offset); - return; - } - const want = Math.max(0, Math.min(windowLen, file.size - offset)); - if (want === 0) { - sdReplyStatus(session, SD_XFER.EOF, file.size); - return; - } - - const perTick = Math.max( - blockLen, - Math.round((sdKBps * 1024 * SD_TICK_MS) / 1000), - ); - sdRead = { file, offset, want, sent: 0, seq: 0, session, timer: null }; - sdRead.timer = setInterval(() => { - const r = sdRead; - if (!r) return; - let budget = perTick; - while (r.sent < r.want && budget > 0) { - const n = Math.min(blockLen, r.want - r.sent); - sdReplyData( - r.session, - r.seq++, - sdFileSlice(r.file, r.offset + r.sent, n), - ); - r.sent += n; - budget -= n; - } - if (r.sent >= r.want) { - const nextOffset = r.offset + r.sent; - sdFinishRead( - nextOffset >= r.file.size ? SD_XFER.EOF : SD_XFER.WINDOW_COMPLETE, - nextOffset, - ); - } - }, SD_TICK_MS); - } - - // ------------------------------------------------------------------------- - // SD card: the one-shot responses - // ------------------------------------------------------------------------- - - /** - * `[0xC1][status][startIdx u16][entriesLen u16][nEntries][flags][entries…]` - * with one entry as `[attr][size u32][fdate u16][ftime u16][nameLen][name]`. - * - * NOTE the directory attribute is 0x01 ({@link SD_ATTR_DIR}) — the - * firmware's own flag, not FAT's 0x10. Getting that wrong makes every - * folder list as a zero-byte file. - */ - function sdListDirResponse(path, startIdx, maxEntries) { - const header = (status, entries, hasMore) => { - const body = entries.length - ? entries.reduce((n, e) => n + e.length, 0) - : 0; - const out = new Uint8Array(8 + body); - out[0] = SD_TRANSFER_OPCODES.LIST_DIR_RESPONSE; - out[1] = status; - out[2] = startIdx & 0xff; - out[3] = (startIdx >> 8) & 0xff; - out[4] = body & 0xff; - out[5] = (body >> 8) & 0xff; - out[6] = entries.length; - out[7] = hasMore ? 0x01 : 0x00; - let at = 8; - for (const e of entries) { - out.set(e, at); - at += e.length; - } - return out; - }; - - if (state.streaming) return header(SD_STATUS.BUSY, [], false); - if (!sdDirs.includes(path)) return header(FR_NO_PATH, [], false); - - // Directories before files, which is the order a freshly written card - // hands them back and the order the tree reads best in. - const children = [ - ...sdDirs - .filter((d) => parentOf(d) === path) - .map((d) => ({ path: d, dir: true })), - ...sdFiles - .filter((f) => parentOf(f.path) === path) - .map((f) => ({ ...f, dir: false })), - ]; - const page = children.slice( - startIdx, - startIdx + Math.min(maxEntries || 1, SD_ENTRIES_PER_PAGE), - ); - const encoded = page.map((c) => { - const name = nameOf(c.path); - const entry = new Uint8Array(10 + name.length); - entry[0] = c.dir ? SD_ATTR_DIR : 0x00; - new DataView(entry.buffer).setUint32(1, c.dir ? 0 : c.size, true); - new DataView(entry.buffer).setUint16(5, c.dir ? 0 : c.fdate, true); - new DataView(entry.buffer).setUint16(7, c.dir ? 0 : c.ftime, true); - entry[9] = name.length; - for (let i = 0; i < name.length; i++) entry[10 + i] = name.charCodeAt(i); - return entry; - }); - return header( - SD_STATUS.OK, - encoded, - startIdx + page.length < children.length, - ); - } - - /** `[0xC3][status][size u32][fdate u16][ftime u16][attr]` */ - function sdStatResponse(path) { - const out = new Uint8Array(11); - out[0] = SD_TRANSFER_OPCODES.FILE_STAT_RESPONSE; - const file = sdFileAt(path); - const isDir = sdDirs.includes(path); - if (state.streaming) { - out[1] = SD_STATUS.BUSY; - return out; - } - if (!file && !isDir) { - out[1] = FR_NO_FILE; - return out; - } - out[1] = SD_STATUS.OK; - const view = new DataView(out.buffer); - view.setUint32(2, file ? file.size : 0, true); - view.setUint16(6, file ? file.fdate : 0, true); - view.setUint16(8, file ? file.ftime : 0, true); - out[10] = isDir ? SD_ATTR_DIR : 0x00; - return out; - } - - /** `[0xC9][status][freeKB u32][totalKB u32]` */ - function sdFreeSpaceResponse() { - const out = new Uint8Array(10); - out[0] = SD_TRANSFER_OPCODES.FREE_SPACE_RESPONSE; - out[1] = state.streaming ? SD_STATUS.BUSY : SD_STATUS.OK; - const usedKB = Math.ceil(sdFiles.reduce((n, f) => n + f.size, 0) / 1024); - const view = new DataView(out.buffer); - view.setUint32(2, SD_TOTAL_KB - SD_RESERVED_KB - usedKB, true); - view.setUint32(6, SD_TOTAL_KB, true); - return out; - } - - /** - * `[0xCB][status]` - * - * The firmware only permits paths strictly under `data/`, and refuses a - * directory that still holds something — which is what makes the SDK's - * "delete the emptied folders afterwards, deepest first" pass necessary. - */ - function sdDeleteResponse(path) { - const out = new Uint8Array([SD_TRANSFER_OPCODES.DELETE_RESPONSE, 0]); - if (state.streaming) { - out[1] = SD_STATUS.BUSY; - return out; - } - if (!path.startsWith("data/")) { - out[1] = SD_STATUS.BAD_ARGS; - return out; - } - if (sdFileAt(path)) { - sdFiles = sdFiles.filter((f) => f.path !== path); - out[1] = SD_STATUS.OK; - return out; - } - if (sdDirs.includes(path)) { - const populated = - sdFiles.some((f) => f.path.startsWith(path + "/")) || - sdDirs.some((d) => d !== path && d.startsWith(path + "/")); - if (populated) { - out[1] = FR_DENIED; - return out; - } - sdDirs = sdDirs.filter((d) => d !== path); - out[1] = SD_STATUS.OK; - return out; - } - out[1] = FR_NO_FILE; - return out; - } - - /** - * Every SD-transfer command. Returns true when `cmd` was one of them. - * - * Dispatched ahead of the main switch rather than as cases inside it so the - * whole feature reads as one block. - */ - function handleSdCommand(cmd) { - switch (cmd[0]) { - case SD_TRANSFER_OPCODES.LIST_DIR_COMMAND: { - // [0xCC][startIdx u16][maxEntries u8][pathLen u8][path] - const startIdx = (cmd[1] ?? 0) | ((cmd[2] ?? 0) << 8); - const maxEntries = cmd[3] ?? 0; - const path = ascii(cmd, 5, cmd[4] ?? 0); - reply(concat([ACK], sdListDirResponse(path, startIdx, maxEntries))); - return true; - } - - case SD_TRANSFER_OPCODES.FILE_STAT_COMMAND: - // [0xC2][pathLen u8][path] - reply(concat([ACK], sdStatResponse(ascii(cmd, 2, cmd[1] ?? 0)))); - return true; - - case SD_TRANSFER_OPCODES.FILE_READ_COMMAND: { - // [0xC4][offset u32][windowLen u32][blockPayloadLen u16][pathLen][path] - const view = new DataView(cmd.buffer, cmd.byteOffset, cmd.byteLength); - const offset = view.getUint32(1, true); - const windowLen = view.getUint32(5, true); - const blockLen = view.getUint16(9, true); - const path = ascii(cmd, 12, cmd[11] ?? 0); - reply([ACK]); - sdStartRead(path, offset, windowLen, blockLen); - return true; - } - - case SD_TRANSFER_OPCODES.TRANSFER_ABORT_COMMAND: - // The host has already given up on the window by the time this - // arrives; the closing frame is sent anyway, because the firmware - // does, and a mock that skipped it would hide a host that mishandled - // a late frame from the previous session. - reply([ACK]); - if (sdRead) - sdFinishRead(SD_XFER.HOST_ABORT, sdRead.offset + sdRead.sent); - return true; - - case SD_TRANSFER_OPCODES.FREE_SPACE_COMMAND: - reply(concat([ACK], sdFreeSpaceResponse())); - return true; - - case SD_TRANSFER_OPCODES.DELETE_COMMAND: - reply(concat([ACK], sdDeleteResponse(ascii(cmd, 2, cmd[1] ?? 0)))); - return true; - - default: - return false; - } - } - - // ------------------------------------------------------------------------- - // Data-rate test (SET_DATA_RATE_TEST 0xA4) - // ------------------------------------------------------------------------- - - let rateTimer = null; - let rateCounter = 0; - - function stopRateTest() { - if (!rateTimer) return; - clearInterval(rateTimer); - rateTimer = null; - } - - /** - * The firmware free-runs 5-byte counter packets — `[0xA5][counter u32]` — as - * fast as the link drains them, and the host times how many bytes arrive. - * Paced at `linkKBps` here, in one burst per tick, which is how a real link - * delivers them anyway. - */ - function handleDataRateTest(cmd) { - if (cmd[0] !== CMD.SET_DATA_RATE_TEST) return false; - stopRateTest(); - // ACK first: `reply` defers by a macrotask, so the first burst cannot - // bury the acknowledgement the host is waiting for. - reply([ACK]); - if (!cmd[1]) return true; - rateCounter = 0; - const perTick = Math.max( - 5, - Math.round((linkKBps * 1024 * SD_TICK_MS) / 1000), - ); - rateTimer = setInterval(() => { - const count = Math.floor(perTick / 5); - const out = new Uint8Array(count * 5); - const view = new DataView(out.buffer); - for (let i = 0; i < count; i++) { - out[i * 5] = CMD.DATA_RATE_TEST_RESPONSE; - view.setUint32(i * 5 + 1, rateCounter++ >>> 0, true); - } - // A raw throughput flood by design: no link CRC. - replyStreamNoCrc(out); - }, SD_TICK_MS); - return true; - } - - // Stop the timers when the link goes away, or a "disconnected" mock keeps - // pushing frames at a client that is no longer listening. - transport.onDisconnect(() => { - /* Before `state.streaming` is cleared below: firmware skips an armed - restart while sensing, and a restart that read the flag afterwards - would always think the sensor was idle. */ - applyPendingReboot(); - stopStreaming(); - stopRateTest(); - stopFactoryTest(); - if (sdRead) { - clearInterval(sdRead.timer); - sdRead = null; - } - state.streaming = false; - state.logging = false; - }); - - // ------------------------------------------------------------------------- - // Factory self-test (SET_FACTORY_TEST) - // - // The firmware ACKs, then prints its report as RAW TEXT on the same link: - // no opcode, no length, no CRC, one write per line, each write truncated - // (not split) at MAX_TEST_REPORT_LENGTH = 128 characters — which drops that - // line's own terminator and glues the next line onto it - // (log-and-stream-common `Test/shimmer_test.c:69-88`, - // `Comms/shimmer_bt_uart.c:1285-1293`). Reproduced here, truncation - // included, because a host that cannot survive it cannot read a real report. - // ------------------------------------------------------------------------- - - /** What the firmware caps a single report write at. */ - const TEST_REPORT_MAX_CHARS = 128; - /** Bytes per notification while framed — small enough to split every line. */ - const TEST_REPORT_NOTIFY_BYTES = 13; - - const TEST_START_BANNER = - "//**************************** TEST START " + - "************************************//\r\n"; - const TEST_END_BANNER = - "//***************************** TEST END " + - "*************************************//\r\n"; - - const factoryTestOpts = opts.factoryTest ?? {}; - /** Milliseconds the firmware dwells on each LED step (`DELAY_BETWEEN_LED_CHANGES_MS`). */ - const testStepMs = Math.max(1, factoryTestOpts.stepMs ?? 2000); - /** Report a failing unit: a FAIL line, an over-long line, and a fail mask. */ - const testFails = !!factoryTestOpts.fail; - - let testTimer = null; - let testQueue = []; - let testText = ""; - - function stopFactoryTest() { - if (testTimer) { - clearTimeout(testTimer); - testTimer = null; - } - testQueue = []; - state.factoryTest.running = false; - } - - /** Push one already-truncated entry onto the wire, chunked mid-line. */ - function emitTestChunkedText(text) { - testText += text; - const bytes = new Uint8Array(text.length); - for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff; - const size = framed ? TEST_REPORT_NOTIFY_BYTES : dribbleBytes; - for (let off = 0; off < bytes.length; off += size) { - transport.notify(bytes.slice(off, off + size)); - } - } - - function pumpFactoryTest() { - testTimer = null; - const entry = testQueue.shift(); - if (!entry) { - state.factoryTest.running = false; - return; - } - /* The firmware's own rule, applied before anything reaches the link: - longer than the buffer and the tail — terminator and all — is lost. */ - emitTestChunkedText( - entry.text.length > TEST_REPORT_MAX_CHARS - ? entry.text.slice(0, TEST_REPORT_MAX_CHARS) - : entry.text, - ); - testTimer = setTimeout(pumpFactoryTest, entry.delayMs); - } - - /** - * `[0xA8][type]` — run one of the firmware's four self-tests. - * - * NACKed while sensing, exactly as `ShimBt_isCmdBlockedWhileSensing` - * (`Comms/shimmer_bt_uart.c:2985`) refuses it. A type at or above - * FACTORY_TEST_COUNT is NACKed here where the firmware ACKs and silently - * runs nothing (`:1287`): a page cannot reach that case through its own - * type list, and a mock that answered nothing would look like a dead link. - */ - function handleFactoryTest(cmd) { - if (cmd[0] !== CMD.SET_FACTORY_TEST) return false; - if (state.streaming || state.logging) { - reply([NACK]); - return true; - } - const type = cmd[1] ?? 0; - if (type >= FACTORY_TEST_TYPE_COUNT) { - reply([NACK]); - return true; - } - stopFactoryTest(); - // ACK first: `reply` defers by a macrotask, so the report cannot bury the - // acknowledgement the host is waiting for — the firmware's own ordering, - // where TASK_BT_RESPOND outranks TASK_FACTORY_TEST. - reply([ACK]); - state.factoryTest.runs += 1; - state.factoryTest.lastType = type; - state.factoryTest.running = true; - testText = ""; - testQueue = buildFactoryTestReport(type); - testTimer = setTimeout(pumpFactoryTest, REPLY_DELAY_MS + 1); - return true; - } - - /** - * The report a Shimmer3R (or, with `hw=3`, a Shimmer3) prints, as a queue of - * `{ text, delayMs }` writes — one entry per firmware `sendReport` call, so - * the two-write model line and the paced LED narration reach the host the - * way they really do. - */ - function buildFactoryTestReport(type) { - const line = ( - text, - delayMs = Math.max(1, Math.round(testStepMs / 10)), - ) => ({ - text, - delayMs, - }); - const led = (text) => line(text, testStepMs); - const stateLine = (text) => line(text, Math.round(testStepMs * 2.5)); - const shimmer3 = hardwareVersion === 3; - const isMain = type === FACTORY_TEST_TYPE.MAIN; - const isIcs = type === FACTORY_TEST_TYPE.ICS; - const isLeds = type === FACTORY_TEST_TYPE.LEDS; - const isLedStates = type === FACTORY_TEST_TYPE.LED_STATES; - const id = (n, rest) => - shimmer3 ? ` - ${rest}\r\n` : ` - S3R_TEST_${n} - ${rest}\r\n`; - const out = [line(TEST_START_BANNER)]; - out.push( - line( - `Firmware version: v${fw.major}.${String(fw.minor).padStart(2, "0")}.` + - `${String(fw.patch).padStart(3, "0")}\r\n`, - ), - ); - - if (isIcs || isMain) { - const now = new Date(); - const p2 = (n) => String(n).padStart(2, "0"); - if (!shimmer3) { - out.push( - line( - `Date (yyyy-mm-dd): ${now.getUTCFullYear()}-` + - `${p2(now.getUTCMonth() + 1)}-${p2(now.getUTCDate())}\r\n`, - ), - line( - `Time (hh:mm:ss): ${p2(now.getUTCHours())}:` + - `${p2(now.getUTCMinutes())}:${p2(now.getUTCSeconds())} (UTC)\r\n`, - ), - line("\r\n"), - line("INFO: Temperature pass range set to 15-35 degC\r\n"), - ); - } - out.push(line("\r\n"), line("Shimmer model:\r\n")); - /* Two writes, no terminator on the first: the firmware prints the card - id and its SR revision separately (`hal_FactoryTest.c:414-419`), so a - host that reassembles per notification would show a broken line. */ - out.push( - line( - shimmer3 - ? " - PASS: Shimmer3 GSR+" - : " - S3R_TEST_0003 - PASS: Shimmer3R IMU", - ), - line(shimmer3 ? " (SR48-4-0)\r\n" : " (SR68-1-0)\r\n"), - ); - - out.push(line("\r\n"), line("MCU:\r\n")); - if (shimmer3) { - out.push(line(" - Last reset reason = Power on\r\n")); - } else { - out.push( - line(" - Device ID = 1126\r\n"), - line(" - Revision ID = 4104\r\n"), - line(" - Unique ID = 0x0033002E3438510B00313437\r\n"), - ); - } - out.push( - line( - id( - "0007", - testFails - ? "FAIL: VRef = 2900mV (3200-3400mV)" - : "PASS: VRef = 3301mV (3200-3400mV)", - ), - ), - ); - if (!shimmer3) { - out.push( - line(id("0008", "PASS: VCore = 1376mV (900-1800mV)")), - line(id("0009", "PASS: VBatt pin = 1802mV (1750-1850mV)")), - line(id("0010", "PASS: Temperature = 24 degC")), - /* Deliberately over 128 characters in the failing build: the - firmware would drop this line's terminator and glue the next - line onto it, which is the case a report reader must survive. */ - line( - testFails - ? id( - "0028", - "FAIL: 32k LSE vs 16M HSE error not measurable " + - "(LSE not ready, L 0/32769 H 62501/62501, retries exhausted, " + - "drive ladder walked to MEDIUMHIGH)", - ) - : id( - "0028", - "PASS: 32k LSE vs 16M HSE error = -9.3 ppm " + - "(limit +/-35.0 ppm, HSE-fixed caps rev)", - ), - ), - line(" - LSE drive applied at boot: MEDIUMLOW\r\n"), - line(" - I/O status:\r\n"), - line(" - Docked: No\r\n"), - line(" - BT connected: Yes\r\n"), - line(" - Button pressed: No\r\n"), - line(" - USB connected: No\r\n"), - line("\r\n"), - line("Battery:\r\n"), - line(id("0011", "PASS: VBatt = 4012mV (2980-4750mV)")), - line(id("0012", "PASS: Charger chip status = Charge is completed")), - line(" - Determined charging status = Fully Charged\r\n"), - ); - } - - out.push(line("\r\n"), line("SD Card:\r\n")); - out.push( - shimmer3 - ? line(" - PASS: SD card detected\r\n") - : line(" - Manufacturer: SanDisk\r\n"), - ); - if (!shimmer3) out.push(line(id("0013", "PASS: MCU read/write test"))); - - out.push(line("\r\n"), line("BT Module:\r\n")); - out.push(line(` - MAC ID: ${mac.toUpperCase()}\r\n`)); - if (shimmer3) { - out.push( - line(" - RN4678 V1.23\r\n"), - line(" - PASS\r\n"), - line(" - Counts:\r\n"), - line(" - BT data-rate test blockages = 12\r\n"), - line(" - BT disconnects while streaming = 0\r\n"), - ); - } else { - out.push( - line(" - v01.04.18.18\r\n"), - line(id("0014", "PASS: Correct BT firmware version")), - ); - } - - if (shimmer3) { - out.push( - line("\r\n"), - line("I2C:\r\n"), - line(" - PASS: CAT24C16\r\n"), - line(" - LSM303AH detected (self-test not implemented yet)\r\n"), - line(" - MPU9x50 detected (self-test not implemented yet)\r\n"), - line(" - BMP280 detected (self-test not implemented yet)\r\n"), - line("\r\n"), - line("SPI:\r\n"), - line(" - PASS: ADS1292R Chip1 detect\r\n"), - line(" - PASS: ADS1292R Chip2 detect\r\n"), - ); - } else { - out.push( - line("\r\n"), - line("SPI1:\r\n"), - line(id("0015", "PASS: ADS7028")), - line(id("0016", "PASS: LSM6DSV (27.31 degC)")), - line(id("0017", "PASS: BMP390 (26.94 degC)")), - line(id("0018", "ADXL371 test not applicable for this model")), - line("SPI2:\r\n"), - line(id("0019", "LIS3MDL test not applicable for this model")), - line(id("0020", "PASS: LIS2DW12 (27.02 degC)")), - line("SPI3:\r\n"), - line(id("0021", "PASS: ADS1292R Chip1 detect")), - line(id("0021", "PASS: ADS1292R Chip2 detect")), - line("\r\n"), - line("I2C1:\r\n"), - line(id("0022", "PASS: LIS2MDL (27.10 degC)")), - line(id("0023", "PASS: CAT24C16")), - line("I2C4:\r\n"), - line(id("0024", "I2C4 test not applicable for this model")), - line(id("0025", "WARNING: GSR - Correct test rig not detected")), - line("\r\n"), - line("Microphone:\r\n"), - line(id("0026", "PASS")), - ); - } - } - - if (isMain || isLeds) { - out.push(line("\r\n")); - out.push( - line(shimmer3 ? "LED test:\r\n" : "LED test (S3R_TEST_0027):\r\n"), - ); - const sequence = shimmer3 - ? [ - "All LEDs off", - "Lower Green LED on", - "Lower Yellow LED on", - "Lower Red LED on", - "Upper Green LED on", - "Upper Blue LED on", - "All LEDs off", - "All LEDs on", - ] - : [ - "All LEDs off", - "Lower Red LED on", - "Lower Green LED on", - "Lower Blue LED on", - "Upper Red LED on", - "Upper Green LED on", - "Upper Blue LED on", - "All LEDs off", - "All LEDs on", - ]; - for (const step of sequence) out.push(led(` - ${step}\r\n`)); - } - - if (isLedStates) { - out.push(line("Testing Operational LED states - Start\r\n")); - const groups = [ - ["BT Disabled:", ["Idle...", "SD Logging..."]], - [ - "BT Enabled:", - [ - "Idle...", - "SD Logging...", - "BT Streaming...", - "BT Streaming and SD Logging...", - "BT Connected...", - "BT Connected and SD Logging...", - ], - ], - [ - "SD Sync Enabled:", - [ - "Idle...", - "SD Logging waiting for initial sync (slave)...", - "SD Logging waiting for initial sync (master)...", - "SD Logging and BT advertising...", - "SD Logging and syncing...", - ], - ], - ["Other:", ["Configuring...", "Time not set..."]], - ]; - for (const [heading, states] of groups) { - out.push(line(`${heading}\r\n`)); - for (const s of states) out.push(stateLine(`\t-> ${s}\r\n`)); - } - out.push(line("Testing Operational LED states - End\r\n")); - } - - /* Only MAIN and ICS carry a verdict line — the two LED tests are watched, - not scored, and set no bits (`Test/shimmer_test.c:43-55`). */ - if (isMain || isIcs) { - out.push( - line( - testFails - ? "\r\nOverall Result = FAIL (0x00000040)\r\n" - : "\r\nOverall Result = PASS\r\n", - ), - ); - } - out.push(line(TEST_END_BANNER)); - return out; - } - - // ------------------------------------------------------------------------- - // Command handling - // ------------------------------------------------------------------------- - - transport.setOnWrite((bytes) => { - const cmd = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); - if (!cmd.length) return; - if (debug) console.log("[mock] <-", hex(cmd)); - const op = cmd[0]; - - if (op in REMEMBERED_SETS) { - state[REMEMBERED_SETS[op]] = cmd[1] ?? 0; - reply([ACK]); - return; - } - - /* SD file transfer and the link-speed test, both dispatched as blocks of - their own — see the SD card section above. */ - if (handleSdCommand(cmd)) return; - if (handleDataRateTest(cmd)) return; - if (handleFactoryTest(cmd)) return; - - switch (op) { - case CMD.INQUIRY: - reply(concat([ACK], inquiryResponse())); - return; - - /* SET_CRC takes effect INCLUDING its own ACK. The firmware sets the mode - while processing this command's arguments (`shimmer_bt_uart.c:944`) and - composes the ACK afterwards from the new mode (`:2422`), so that ACK - already carries a CRC. Setting it after the reply here would model a - device that does not exist, and would hide the one message a host - receives framed differently from what it expects. An unrecognised - value falls back to off rather than being rejected, as the firmware - does (`ShimBt_setCrcMode`). */ - case CMD.SET_CRC: { - const mode = cmd[1]; - crcMode = mode === 1 || mode === 2 ? mode : 0; - reply([ACK]); - return; - } - - case CMD.GET_FW_VERSION: - // fwId u16 LE = 3 (LogAndStream), major u16 LE, then minor and patch - reply([ - ACK, - CMD.FW_VERSION_RESPONSE, - 3, - 0, - fw.major & 0xff, - (fw.major >> 8) & 0xff, - fw.minor & 0xff, - fw.patch & 0xff, - ]); - return; - - case CMD.GET_DEVICE_VERSION: - /* 10 = Shimmer3R. A null `hardwareVersion` NACKs instead, which is - what a page sees from a sensor it cannot identify — and the state - every "assume a Shimmer3R" default quietly papers over. */ - if (hardwareVersion == null) { - reply([NACK]); - return; - } - reply([ACK, CMD.DEVICE_VERSION_RESPONSE, hardwareVersion & 0xff]); - return; - - case CMD.GET_STATUS: - // Status arrives wrapped in an in-stream response, because on real - // firmware it can be answered mid-stream. Same frame as an - // unsolicited push, built by the same function — see `statusResponse`. - reply(concat([ACK], statusResponse())); - return; - - case CMD.GET_PRESSURE_CALIBRATION_COEFFICIENTS: { - /* `[0xA6][1 + n][sensorId][coeffs]` — the length byte counts the id - (`Comms/shimmer_bt_uart.c:2064-2099`). A BMP581 sends the id alone, - which is a SUCCESS: it compensates on-chip, and the firmware sends - the id in-band precisely so a host can tell that from a NACK. */ - if (pressureMode === "nack") { - reply([NACK]); - return; - } - const fixture = PRESSURE_FIXTURES[pressureMode]; - if (!fixture) { - // 'silent' models firmware old enough to have no such command at - // all — it answers nothing, and the host times out. - return; - } - reply([ - ACK, - CMD.PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE, - 1 + fixture.coeffs.length, - fixture.id, - ...fixture.coeffs, - ]); - return; - } - - case CMD.GET_VBATT: { - // ~3.9 V on a Shimmer3R divider, discharging: a value a battery - // gauge can render without looking like a fault. - const adc = 2100; - reply([ - ACK, - CMD.INSTREAM_CMD_RESPONSE, - CMD.VBATT_RESPONSE, - adc & 0xff, - (adc >> 8) & 0xff, - 0xc0, // charger status byte - ]); - return; - } - - /* The two ADS1292R register banks. They live in the configuration image - (bytes 10-19 and 20-29), and the live commands are a window onto the - same ten bytes -- which is the point: a host that writes them live and - then re-reads the image must see one answer, not two. - - Worth serving even though the page only ever wrote them: as of SDK - 0.1.24 the ExG helpers READ the current banks before writing, so a - mock that only tolerated the write stopped serving the flow. */ - case CMD.GET_EXG_REGS: { - // [0x63][chip][startAddr][len] -> ACK + [0x62][len][regs...] - const chip = cmd[1] ?? 0; - const start = cmd[2] ?? 0; - const len = cmd[3] ?? 0; - const base = chip === 0 ? IM.exg1 : IM.exg2; - if (chip > 1 || start + len > IM.exgBankLength) { - reply([NACK]); - return; - } - reply( - concat( - [ACK, CMD.EXG_REGS_RESPONSE, len], - infoMem.slice(base + start, base + start + len), - ), - ); - return; - } - - case CMD.SET_EXG_REGS: { - // [0x61][chip][startAddr][len][regs...] - const chip = cmd[1] ?? 0; - const start = cmd[2] ?? 0; - const len = cmd[3] ?? 0; - const base = chip === 0 ? IM.exg1 : IM.exg2; - if ( - chip > 1 || - start + len > IM.exgBankLength || - cmd.length < 4 + len - ) { - reply([NACK]); - return; - } - for (let i = 0; i < len; i++) infoMem[base + start + i] = cmd[4 + i]; - reply([ACK]); - return; - } - - case CMD.GET_INFOMEM: { - // Request is [0x8E][len][addrLo][addrHi]; the reply is - // [0x8D][len][data…], length-prefixed so BLE reassembly can tell - // when it has the whole thing. - const len = Math.min(cmd[1] ?? 0, 128); - const addr = (cmd[2] ?? 0) | ((cmd[3] ?? 0) << 8); - const off = pageOffset(addr); - const data = infoMem.slice(off, off + len); - const out = new Uint8Array(2 + len); - out[0] = CMD.INFOMEM_RESPONSE; - out[1] = len; - out.set(data, 2); - reply(concat([ACK], out)); - return; - } - - case CMD.SET_INFOMEM: { - // [0x8C][len][addrLo][addrHi][data…] - const len = cmd[1] ?? 0; - const addr = (cmd[2] ?? 0) | ((cmd[3] ?? 0) << 8); - const off = pageOffset(addr); - infoMem.set(cmd.subarray(4, 4 + len), off); - // Keep the live state in step with what was just written, so an - // inquiry after a config write reports the new rate and sensors — - // which is exactly what the firmware does on undock. - if (off === 0 && len >= 6) { - const divisor = - infoMem[IM.samplingRate] | (infoMem[IM.samplingRate + 1] << 8); - if (divisor > 0) state.rateHz = SAMPLING_CLOCK_HZ / divisor; - state.sensors = - infoMem[IM.sensors0] | - (infoMem[IM.sensors1] << 8) | - (infoMem[IM.sensors2] << 16); - } - reply([ACK]); - return; - } - - case CMD.GET_CALIB_DUMP: { - // [0x9A][len][offsetLo][offsetHi] → [0x99][len][offsetLo][offsetHi][data…] - const len = cmd[1] ?? 0; - const off = (cmd[2] ?? 0) | ((cmd[3] ?? 0) << 8); - if (len < 1 || len > CALIB_MAX_PER_CALL || off >= calibRam.length) { - reply([NACK]); - return; - } - const out = new Uint8Array(4 + len); - out[0] = CMD.RSP_CALIB_DUMP; - out[1] = len; - out[2] = off & 0xff; - out[3] = (off >> 8) & 0xff; - /* Short at the end of RAM rather than wrapping: `subarray` stops - there and the rest of `out` stays zero, which is the flash read a - real device does. */ - out.set( - calibRam.subarray(off, Math.min(off + len, calibRam.length)), - 4, - ); - reply(concat([ACK], out)); - return; - } - - case CMD.SET_CALIB_DUMP: { - // [0x98][len][offsetLo][offsetHi][data…] - const len = cmd[1] ?? 0; - const off = (cmd[2] ?? 0) | ((cmd[3] ?? 0) << 8); - const data = cmd.subarray(4, 4 + len); - /* Always ACKed, even when dropped. The firmware's handler ignores - `ShimCalib_ramWrite`'s failure return, so a host that starts in the - middle of the dump gets an ACK for a write that went nowhere — the - single most surprising thing about this command, and worth - reproducing rather than smoothing over. */ - reply([ACK]); - if (len < 1 || len > CALIB_MAX_PER_CALL) { - calibStats.discarded++; - return; - } - if (off === 0) { - if (data.length < 2) { - calibStats.discarded++; - return; - } - // +2: the u16 length field counts the bytes after itself. - const total = (data[0] | (data[1] << 8)) + 2; - if (total <= 2 || total > calibRam.length) { - calibStaging = null; - calibStats.discarded++; - return; - } - calibStaging = { total, received: 0, buf: new Uint8Array(total) }; - } else if (!calibStaging || off !== calibStaging.received) { - /* "starting with offset > 2 is not accepted" — and neither is a - chunk that skips forward. Dropped, silently, exactly as the - firmware drops it. */ - calibStaging = null; - calibStats.discarded++; - return; - } - const room = Math.min(data.length, calibStaging.total - off); - calibStaging.buf.set(data.subarray(0, room), off); - calibStaging.received = off + room; - if (calibStaging.received >= calibStaging.total) { - /* The firmware applies the dump the moment the bytes it has add up - to the length its own header declared, without waiting for - UPD_CALIB_DUMP. */ - calibRam.fill(0); - calibRam.set(calibStaging.buf, 0); - calibStaging = null; - } - return; - } - - case CMD.UPD_CALIB_DUMP: - // Apply the in-RAM dump to the configuration bytes and the SD header. - // Nothing here models the configuration side, so this only counts. - calibStats.updates++; - reply([ACK]); - return; - - case CMD.GET_DAUGHTER_CARD_ID: { - // [0x66][len][offset] → [0x65][len][data…], capped at one page - const len = cmd[1] ?? 0; - const off = cmd[2] ?? 0; - if (len < 1 || off + len > srBoardPage.length) { - reply([NACK]); - return; - } - const out = new Uint8Array(2 + len); - out[0] = CMD.DAUGHTER_CARD_ID_RESPONSE; - out[1] = len; - out.set(srBoardPage.subarray(off, off + len), 2); - reply(concat([ACK], out)); - return; - } - - case CMD.GET_BT_VERSION_STR: { - /* [0xa1] → [0xa2][strlen][ASCII…]. No arguments, and the length is - the firmware's own `strlen()` of the module's reply — which is why - a zero-length answer is a legitimate one and not modelled as a - NACK. Sent through `reply()` whole: on a framed link that makes it - one notification the SDK has to split by the declared length, and - on an unframed one the dribble path exercises reassembly. */ - const bytes = []; - for (const ch of btVersionString) bytes.push(ch.charCodeAt(0) & 0xff); - /* Truncated to the firmware's own buffer, so the length byte always - matches the payload that follows it. Without this a `&btVersion=` - longer than 255 characters would wrap the length byte while the - full string still went out, and the host would sit waiting for the - wrong number of bytes. The firmware cannot report more than this - either - `btVerStrResponse` is `char[100]`. */ - const capped = bytes.slice(0, BT_VERSION_MAX_BYTES); - reply( - concat([ACK, CMD.BT_VERSION_STR_RESPONSE, capped.length], capped), - ); - return; - } - - case CMD.GET_DAUGHTER_CARD_MEM: { - // [0x69][len][offsetLo][offsetHi] → [0x68][len][data…] - const len = cmd[1] ?? 0; - const off = (cmd[2] ?? 0) | ((cmd[3] ?? 0) << 8); - if (len < 1 || len > EEPROM_MAX_PER_CALL || off + len > eeprom.length) { - reply([NACK]); - return; - } - const out = new Uint8Array(2 + len); - out[0] = CMD.DAUGHTER_CARD_MEM_RESPONSE; - out[1] = len; - out.set(eeprom.subarray(off, off + len), 2); - reply(concat([ACK], out)); - return; - } - - case CMD.SET_DAUGHTER_CARD_MEM: { - // [0x67][len][offsetLo][offsetHi][data…] - const len = cmd[1] ?? 0; - const off = (cmd[2] ?? 0) | ((cmd[3] ?? 0) << 8); - /* The 128-byte ceiling is the firmware's, not an arbitrary limit: the - command has to fit one receive buffer. A page that asked for more - gets the NACK a real sensor would send, rather than a mock that - silently accepts a write no device would. */ - if ( - len < 1 || - len > EEPROM_MAX_PER_CALL || - off + len > eeprom.length || - cmd.length < 4 + len - ) { - reply([NACK]); - return; - } - eeprom.set(cmd.subarray(4, 4 + len), off); - reply([ACK]); - return; - } - - case CMD.SET_FEATURE: { - // [0xB7][featureId][value] - if (cmd[1] === FEATURE.REBOOT_ON_DISCONNECT) { - state.rebootArmed = !!cmd[2]; - reply([ACK]); - return; - } - /* Every other feature id is NACKed, which is also how firmware built - before a feature existed answers — the path a page's fallback to - "power-cycle it by hand" depends on. */ - if (debug) { - console.warn(`[mock] unknown SET_FEATURE id ${cmd[1]}`); - } - reply([NACK]); - return; - } - - case CMD.GET_RWC: { - const out = new Uint8Array(9); - out[0] = CMD.RWC_RESPONSE; - let ticks = BigInt(Math.round(deviceNowMs() * 32.768)); - for (let i = 0; i < 8; i++) { - out[1 + i] = Number(ticks & 0xffn); - ticks >>= 8n; - } - reply(concat([ACK], out)); - return; - } - - case CMD.SET_RWC: { - let ticks = 0n; - for (let i = 8; i >= 1; i--) - ticks = (ticks << 8n) | BigInt(cmd[i] ?? 0); - state.rwcTicks = ticks; - // Re-seat the running clock on what the host wrote: from here the - // sensor keeps its own time, and keeps drifting at `rtc.ppm`. - rtc.devMsAtSet = Number(ticks) / 32.768; - rtc.setAtHostMs = Date.now(); - /* And re-seat the STREAM counter with it, on a Shimmer3R, because - there the two are one number: the packet timestamp is the low 24 - bits of the counter `GET_RWC` reports. Setting the clock mid-stream - is allowed by the firmware, and leaving the counter where it was - would model a sensor whose packets disagree with its own clock — - which is precisely the shape a host's aligned anchor would then be - tested against, wrongly. On a Shimmer3 the counter is independent of - the clock and correctly stays put. */ - if (hardwareVersion !== 3 && streamTimer) { - streamTicks = streamStartTicks(); - streamStartMs = deviceNowMs(); - samplesEmitted = 0; - } - // What the "Clock set" status bit means: not that the clock reads - // something, but that a host has set it since the sensor last lost - // power (`RTC_isRwcTimeSet`). - state.rwcSet = true; - reply([ACK]); - return; - } - - case CMD.SET_SAMPLING_RATE: { - const divisor = (cmd[1] ?? 0) | ((cmd[2] ?? 0) << 8); - if (divisor > 0) state.rateHz = SAMPLING_CLOCK_HZ / divisor; - infoMem[IM.samplingRate] = cmd[1] ?? 0; - infoMem[IM.samplingRate + 1] = cmd[2] ?? 0; - reply([ACK]); - return; - } - - case CMD.SET_SENSORS: { - // Payload is three bytes of the 24-bit bitmap, LEAST-significant - // first — the same order InfoMem stores them in, so the bytes go - // straight through. - state.sensors = - (cmd[1] ?? 0) | ((cmd[2] ?? 0) << 8) | ((cmd[3] ?? 0) << 16); - infoMem[IM.sensors0] = cmd[1] ?? 0; - infoMem[IM.sensors1] = cmd[2] ?? 0; - infoMem[IM.sensors2] = cmd[3] ?? 0; - reply([ACK]); - return; - } - - case CMD.SET_CONFIG_SETUP_BYTES: - state.configSetupBytes.set(cmd.subarray(1, 8)); - reply([ACK]); - return; - - case CMD.START_STREAMING: - state.streaming = true; - reply([ACK]); - startStreaming(); - return; - - case CMD.START_SDBT: - state.streaming = true; - state.logging = true; - reply([ACK]); - startStreaming(); - return; - - case CMD.STOP_STREAMING: - state.streaming = false; - // Logging outlives a stream stop on real firmware only via - // STOP_SDBT; here a stop is a stop. - state.logging = false; - stopStreaming(); - reply([ACK]); - return; - - case CMD.STOP_SDBT: - state.streaming = false; - state.logging = false; - stopStreaming(); - reply([ACK]); - return; - - case CMD.TOGGLE_LED: - // The status bit the firmware reports is the toggle state, not a - // command echo (`shimmerStatus.toggleLedRedCmd`). - state.redLedOn = !state.redLedOn; - reply([ACK]); - return; - - default: - /* NACK rather than silence. A real Shimmer3R answers an unknown - * opcode, and silence here would surface as a command timeout — - * sending whoever is debugging the page looking for a link fault - * instead of a missing mock command. */ - if (debug) - console.warn(`[mock] unhandled command 0x${op.toString(16)}`); - reply([NACK]); - return; - } - }); - - return transport; -} - -function concat(a, b) { - const first = a instanceof Uint8Array ? a : new Uint8Array(a); - const second = b instanceof Uint8Array ? b : new Uint8Array(b); - const out = new Uint8Array(first.length + second.length); - out.set(first, 0); - out.set(second, first.length); - return out; -} - -/** `len` bytes of `buf` from `at`, as ASCII — how card paths arrive. */ -function ascii(buf, at, len) { - return String.fromCharCode(...buf.subarray(at, at + len)); -} diff --git a/common/dev/verify-known-failures.json b/common/dev/verify-known-failures.json deleted file mode 100644 index ea103eb..0000000 --- a/common/dev/verify-known-failures.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "_comment": "Checks in common/dev/verify.mjs that are known to fail, each with why. EMPTY, and worth keeping that way: every check passes. A failure NOT listed here fails CI, and a listed check that starts passing fails CI too (under VERIFY_STRICT_BASELINE, which the workflow sets), so the list shrinks as things are fixed. It describes the CI environment - ubuntu-latest, the pinned Chrome - so regenerate it from a CI run rather than from a laptop: node common/dev/verify.mjs --update-baseline. Adding a name is a decision to ship a known-broken check and needs a reason written beside it.", - "known": {} -} diff --git a/common/dev/verify.mjs b/common/dev/verify.mjs deleted file mode 100644 index c5a9f27..0000000 --- a/common/dev/verify.mjs +++ /dev/null @@ -1,5797 +0,0 @@ -/** - * common/dev/verify.mjs — the full verification pass for ShimmerCapture, - * driven over CDP against the ?mock=1 transport. Zero dependencies (Node 24's - * built-in WebSocket), and it writes nothing to disk: the report is stdout, - * and the "filesystem" the SD checks download into lives inside the page. - * - * It lives in the repo rather than beside it because a good half of what it - * asserts is repo policy — which files may import the extension's private SDK - * copy, and which busy flag every panel's refusal text has to be able to - * name — and policy has to be able to change in the same commit as the code - * it polices. - * - * To run it, from the repo root: - * - * npx http-server . -p 8129 -c-1 # serves the REPO ROOT, not the demo - * chrome --headless=new --remote-debugging-port=9333 \ - * --user-data-dir= --no-first-run --disable-gpu about:blank - * node common/dev/verify.mjs [port] # port defaults to 9333 - * - * Set VERIFY_BASE if the server is somewhere else; it must end in the demo's - * folder, because the cross-demo checks walk up from it to the repo root. - * - * If a check ever does need a screenshot or a dump to look at afterwards, it - * goes in os.tmpdir() — never in the working tree, which this pass has to be - * able to run against without dirtying. - */ -import { readFile, writeFile } from "node:fs/promises"; - -const PORT = process.argv.find((a) => /^\d+$/.test(a)) ?? "9333"; -const BASE = process.env.VERIFY_BASE ?? "http://localhost:8129/ShimmerCapture/"; - -const targets = await ( - await fetch(`http://127.0.0.1:${PORT}/json/list`) -).json(); -const page = targets.find((t) => t.type === "page"); -const ws = new WebSocket(page.webSocketDebuggerUrl); -await new Promise((r, j) => { - ws.onopen = r; - ws.onerror = j; -}); - -let seq = 0; -const pending = new Map(); -const consoleErrors = []; -ws.onmessage = (e) => { - const m = JSON.parse(e.data); - if (m.id && pending.has(m.id)) { - const { resolve, reject } = pending.get(m.id); - pending.delete(m.id); - m.error ? reject(new Error(JSON.stringify(m.error))) : resolve(m.result); - return; - } - if (m.method === "Log.entryAdded" && m.params.entry.level === "error") { - consoleErrors.push(m.params.entry.text + " " + (m.params.entry.url ?? "")); - } - if (m.method === "Runtime.exceptionThrown") { - consoleErrors.push( - "EXCEPTION " + - (m.params.exceptionDetails.exception?.description ?? - m.params.exceptionDetails.text), - ); - } -}; -const send = (method, params = {}) => - new Promise((resolve, reject) => { - const id = ++seq; - pending.set(id, { resolve, reject }); - ws.send(JSON.stringify({ id, method, params })); - }); -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -async function evaluate(expression) { - const r = await send("Runtime.evaluate", { - expression: `(async () => { ${expression} })()`, - awaitPromise: true, - returnByValue: true, - }); - if (r.exceptionDetails) { - throw new Error( - "page threw: " + - (r.exceptionDetails.exception?.description ?? - JSON.stringify(r.exceptionDetails)), - ); - } - return r.result.value; -} - -await send("Page.enable"); -await send("Runtime.enable"); -await send("Log.enable"); -await send("Emulation.setDeviceMetricsOverride", { - width: 1440, - height: 1000, - deviceScaleFactor: 1, - mobile: false, -}); - -const results = []; -const check = (name, pass, detail) => { - results.push({ name, pass: !!pass, detail }); - console.log( - `${pass ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`, - ); -}; - -async function goto(url) { - await send("Page.navigate", { url }); - // Poll for the page's own module script having run rather than sleeping a - // fixed time: the module graph grew when the SD panel joined it, and a - // fixed 1.4 s stopped being enough. The probe used to be "does #sdPanel - // have children", which aged every time a panel was added ahead of it; the - // page now sets data-booted as the very last statement of startup for this, - // so the probe means "everything is up" no matter what joins the graph next. - for (let i = 0; i < 120; i++) { - await sleep(100); - const r = await send("Runtime.evaluate", { - expression: "document.documentElement.dataset.booted === 'true'", - returnByValue: true, - }); - if (r.result.value) return; - } - throw new Error(`page did not finish booting: ${url}`); -} - -/* The link CRC the pass connects with. The page defaults to 2 bytes and every - check below runs that way; `VERIFY_CRC=0` runs the whole pass with it off, - which is how a failure that only happens with the CRC on is separated from - one that happens either way. Worth having as a switch rather than a one-off - probe: eighteen checks were failing here, and the CRC-off run coming back - clean said they were defects of the CRC path rather than flaky tests -- - three separate causes, none of them reachable with the CRC off. CI runs both - variants, the CRC-off one non-blocking. */ -const CRC_MODE = process.env.VERIFY_CRC ?? "2"; - -/** - * How far down a 700px laptop viewport the tab strip may end. - * - * Two checks share it. Platform-dependent to within about 13px, because the - * system font stack resolves to different metrics on Linux and on Windows, so - * the budget carries room for the taller of the two rather than being - * calibrated on whichever machine last measured it. - */ -const TAB_STRIP_BUDGET_PX = 480; - -const CONNECT = ` - delete window.showSaveFilePicker; - { - const sel = document.getElementById('crcMode'); - if (sel && !sel.disabled) { - sel.value = '${CRC_MODE}'; - sel.dispatchEvent(new Event('change', { bubbles: true })); - } - } - window.__blobs = []; - const realCOU = URL.createObjectURL.bind(URL); - URL.createObjectURL = (b) => { window.__blobs.push(b); return realCOU(b); }; - window.confirm = (t) => { window.__confirm = t; return true; }; - document.getElementById('btnMock').click(); - for (let i = 0; i < 80 && !document.getElementById('rateHelper'); i++) - await new Promise(r => setTimeout(r, 100)); - await new Promise(r => setTimeout(r, 400)); - return document.getElementById('connPill').textContent; -`; - -/* The Test tab's two panels, reached the way the page itself labels them: - by data-role rather than by id, because both are shared modules that render - into a host element and nothing outside them should depend on their - internal ids. `opCount` counts what actually went out on the wire. */ -const TEST = ` - const P = () => document.getElementById('selfTestPanel'); - const role = (r) => P().querySelector('[data-test-role="' + r + '"]'); - const D = () => document.getElementById('driftPanel'); - const drole = (r) => D().querySelector('[data-drift-role="' + r + '"]'); - const dstat = (r) => D().querySelector('[data-drift-stat="' + r + '"]')?.textContent; - const opCount = (op) => (window.mockTransport?.writes ?? []).filter(w => (w.bytes ?? w.data)[0] === op).length; -`; - -// =========================================================================== -console.log("\n--- framed (BLE-like) ---"); -await goto(`${BASE}?mock=1`); -/* One localStorage key survives between runs and changes what the page looks - like: whether the log drawer is open. A previous run (or a hand probe) that - left it open makes the plot-width check below measure the wrong thing, so - the pass starts from the state a first visit is in. */ -await evaluate( - `try { localStorage.removeItem('shimmerCaptureLogDrawer'); } catch {} return 1;`, -); -await goto(`${BASE}?mock=1`); -check( - "mock button offered under ?mock=1", - await evaluate(`return !document.getElementById('btnMock').hidden`), -); - -// --- 1. connect button order: the two Bluetooth links sit together --------- -const linkOrder = await evaluate(` - const row = document.getElementById('btnBle').parentElement; - const kids = [...row.children]; - return { - ids: kids.filter(n => n.tagName === 'BUTTON').map(n => n.id), - sepAfterBt: kids[kids.indexOf(document.getElementById('btnBt')) + 1] - ?.className, - }; -`); -check( - "connect buttons read BLE, Classic Bluetooth, then the wired link", - linkOrder.ids.slice(0, 3).join(",") === "btnBle,btnBt,btnUsb" && - linkOrder.ids[3] === "btnMock" && - linkOrder.sepAfterBt === "link-sep", - linkOrder.ids.join(" → "), -); - -// --- 4. the throughput button's new home, before anything is connected ---- -const linkIdle = await evaluate(` - const sdk = await import('/vendor/shimmer-web-sdk.esm.js'); - const btn = document.getElementById('btnLinkTest'); - const wired = new sdk.WiredShimmerClient({ transport: new sdk.LoopbackTransport() }); - const radio = new sdk.Shimmer3RClient({ debug: false }); - return { - inTestTab: btn.closest('#tabTest') !== null, - notInLinkCard: btn.closest('.card') !== document.getElementById('btnBle').closest('.card'), - notInSdPanel: !document.getElementById('sdPanel').contains(btn) && - document.querySelector('#sdPanel [data-sd-role="linkTest"]') === null, - disabled: btn.disabled, - note: document.getElementById('linkTestNote').textContent, - pill: document.getElementById('linkSpeedPill').textContent, - cap: btn.dataset.cap, requires: btn.dataset.requires, - // Why the button is Bluetooth-only: the dock command set has no - // data-rate test, so the wired client does not carry the method. - dockHasTest: typeof wired.runDataRateTest === 'function', - radioHasTest: typeof radio.runDataRateTest === 'function', - }; -`); -check( - /* Twice moved: out of the SD panel, then out of the connect column. It is a - test that takes the link exclusively, which is what the Test tab is for, - and its result is still read by the SD panel's own estimates. */ - "the throughput button lives in the Test tab, not the SD panel or the connect column", - linkIdle.inTestTab && - linkIdle.notInLinkCard && - linkIdle.notInSdPanel && - linkIdle.cap === "linkTest" && - linkIdle.requires === "idle" && - linkIdle.pill === "not measured", - `data-requires=${linkIdle.requires} data-cap=${linkIdle.cap}`, -); -check( - /* The note names Bluetooth without promising BLE: the RN42 Shimmer3 fleet has - no BLE radio at all (see platformSupport.ts in the SDK), so "connect over - BLE" is advice that cannot be followed on a classic-only sensor. It must - still give the dock's reason, which is the part a user cannot guess. */ - "disconnected it is refused with a reason, and the dock link genuinely cannot run it", - linkIdle.disabled && - /Connect over Bluetooth/.test(linkIdle.note) && - /whichever radio this sensor carries/.test(linkIdle.note) && - /no data-rate test/.test(linkIdle.note) && - linkIdle.dockHasTest === false && - linkIdle.radioHasTest === true, - linkIdle.note, -); - -// --- 2a. the TX/RX filter and the tap that feeds it ----------------------- -// The filter selects what is SHOWN; the tap decides what is PRODUCED. Every -// combination that yields an empty log has to say which control to reach for. -const tapNote = await evaluate(` - const sev = document.getElementById('logSeverity'); - const chk = document.getElementById('chkRawBytes'); - const data = document.getElementById('chkRawData'); - const note = () => document.getElementById('tapNote').textContent; - const set = (el, v) => { el.value !== undefined && el.type !== 'checkbox' - ? (el.value = v) : (el.checked = v); el.dispatchEvent(new Event('change')); }; - const out = {}; - out.quietAtRest = note(); - out.dataDisabledAtRest = data.disabled; - set(sev, 'txrx'); - out.filterNoProducer = note(); - set(chk, true); - out.dataEnabled = !data.disabled; - out.producerNoDevice = note(); - set(sev, 'err'); - out.producerHiddenByFilter = note(); - set(sev, 'all'); - out.quietWhenAgreed = note(); - set(chk, false); - set(sev, 'all'); - out.restored = note() === '' && !chk.checked && !data.checked && data.disabled; - return out; -`); -check( - "the TX/RX filter explains itself when the tap that feeds it is off", - tapNote.quietAtRest === "" && - tapNote.dataDisabledAtRest && - /Raw TX\/RX logging is off/.test(tapNote.filterNoProducer) && - /Log raw TX\/RX bytes/.test(tapNote.filterNoProducer), - tapNote.filterNoProducer, -); -check( - "and says the other three things that can leave that filter empty", - tapNote.dataEnabled && - /Nothing is connected yet/.test(tapNote.producerNoDevice) && - /this filter hides them/.test(tapNote.producerHiddenByFilter) && - tapNote.quietWhenAgreed === "" && - tapNote.restored, - `${tapNote.producerNoDevice} || ${tapNote.producerHiddenByFilter}`, -); - -// --- 2b. the BLE transport the page now builds itself ---------------------- -// Field for field against the one Shimmer3RClient builds when it is left to -// its own devices — that is the whole risk of taking the job off it. -const bleT = await evaluate(` - const sdk = await import('/vendor/shimmer-web-sdk.esm.js'); - const { createTransportTap } = await import('/common/transport-tap.js'); - const client = new sdk.Shimmer3RClient({ debug: false }); - const reference = client._makeWebTransport(); - const mine = window.makeBleTransport(); - const tapped = createTransportTap(mine, { log: () => {} }); - const keys = Object.keys(reference).filter(k => k.startsWith('_')); - const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); - return { - keys, - differing: keys.filter(k => !same(reference[k], mine[k])), - service: mine._serviceUUID, - writeChar: mine._writeCharUUID, - notifyChar: mine._notifyCharUUID, - withResponse: mine._defaultWriteWithResponse, - fromSdkDefaults: - mine._serviceUUID === sdk.SHIMMER3R_DEFAULTS.SERVICE_UUID && - mine._writeCharUUID === sdk.SHIMMER3R_DEFAULTS.CHAR_RX_UUID && - mine._notifyCharUUID === sdk.SHIMMER3R_DEFAULTS.CHAR_TX_UUID, - // The client does \`if (t instanceof WebBluetoothTransport) this.device = - // t.device\`, so a wrapper that broke instanceof would quietly cost the - // client its device handle. - tapKeepsInstanceof: tapped instanceof sdk.WebBluetoothTransport, - tapForwardsGetters: - tapped.kind === mine.kind && - tapped._serviceUUID === mine._serviceUUID && - tapped.capabilities.framed === mine.capabilities.framed && - tapped.device === null, - tapOverridesTwo: - tapped.write !== mine.write && tapped.onNotify !== mine.onNotify && - typeof tapped.tap.setEnabled === 'function', - }; -`); -check( - "the page's own BLE transport matches the one the client would have built", - bleT.differing.length === 0 && - bleT.keys.length >= 6 && - bleT.fromSdkDefaults && - bleT.withResponse === true, - `${bleT.keys.length} fields compared, write-with-response=${bleT.withResponse}`, -); -check( - "the tap is transparent: instanceof, getters and capabilities all survive it", - bleT.tapKeepsInstanceof && bleT.tapForwardsGetters && bleT.tapOverridesTwo, - `instanceof=${bleT.tapKeepsInstanceof} getters=${bleT.tapForwardsGetters}`, -); - -check("connect pill after mock connect", (await evaluate(CONNECT)) === "mock"); - -const ident = await evaluate(` - const t = id => document.getElementById(id).textContent; - return { name:t('idName'), mac:t('idMac'), hw:t('idHw'), fw:t('idFw'), - btModule:t('idBtModule'), - batt:t('idBatt'), link:t('idLink'), imPill:t('imPill'), - rate:document.getElementById('ratePill').textContent, - fields:document.querySelectorAll('#configForm .field').length, - sensorBoxes:document.querySelectorAll('[data-sensor-bit]').length, - checked:[...document.querySelectorAll('[data-sensor-bit]')].filter(b=>b.checked).length, - hexRows:document.querySelectorAll('.hexview-row').length, - flags:document.querySelectorAll('#statusFlags .flag').length }; -`); -check( - "identity line populated", - /* The name is the sensor's OWN configured name, out of the configuration - image, not the advertising name the link reported — that one is the - fallback and says so when it is used. The hardware line is platform, - board and SR code rather than the raw hardware id, which told a reader - nothing that "Shimmer3R" does not. */ - ident.name === "Shimmer_8091" && - ident.mac === "000666668091" && - ident.hw === "Shimmer3R GSR+ (SR48-3-0)" && - ident.fw.includes("LogAndStream"), - JSON.stringify(ident), -); -check( - "and the Bluetooth module is named from what the module itself replied", - /* A Shimmer3R answers with a line the Shimmer firmware composes from the - CYW20820's version record; a Shimmer3 forwards its RN module's banner. - Both are parsed, and the version shown is the module's own. */ - ident.btModule === "CYW20820 v1.4.18.18", - ident.btModule, -); -check( - "the battery percentage is rounded, not the estimator's raw float", - /* The SDK returns the polynomial's output, as a library should. A reading - carrying fifteen decimal places reads as a fault. */ - /^\d+\.\d\d V \((<1|\d{1,3}) %\)( — charger \w+)?$/.test(ident.batt), - ident.batt, -); -check( - "form + sensor grid + hex view built", - ident.fields === 40 && - ident.sensorBoxes === 17 && - ident.checked === 3 && - ident.hexRows === 24, -); - -// ---- field edit lands on the byte the tooltip names -const edit = await evaluate(` - const tip = k => (document.querySelector('#configForm .field[data-field-key="'+k+'"] select, #configForm .field[data-field-key="'+k+'"] input')||{}).title; - const wrap = document.querySelector('#configForm .field[data-field-key="wrAccelRange"]'); - const ctl = wrap.querySelector('select'); - const tooltip = ctl.title; - const composites = { gyro: tip('gyroRange.lsm6dsv'), press: tip('pressureOversampling.bmp390_581') }; - ctl.value = [...ctl.options].map(o=>o.value).find(v => v !== ctl.value); - ctl.dispatchEvent(new Event('change', {bubbles:true})); - await new Promise(r=>setTimeout(r,80)); - return { tooltip, composites, dirty: wrap.classList.contains('dirty'), - pill: document.getElementById('dirtyPill').textContent, - changed: [...document.querySelectorAll('.hexview-byte.changed')].map(c=>c.title) }; -`); -check( - "edit marks dirty and changes the byte the in-byte-mask tooltip names", - edit.dirty && - edit.changed.length === 1 && - edit.changed[0] === "byte 6 (0x6)" && - // in-byte mask: bits 2-3 is 0x0C, not the unshifted 0x03 - edit.tooltip === "byte 6, bits 2-3 (mask 0x0C)" && - // each half of a composite is shifted by its OWN shift - edit.composites.gyro === - "byte 8, bits 0-1 (mask 0x03) + byte 130, bit 2 (mask 0x04)" && - edit.composites.press === - "byte 9, bits 4-5 (mask 0x30) + byte 130, bit 0 (mask 0x01)", - `${edit.tooltip} -> ${edit.changed.join()} / ${edit.pill} | gyro: ${edit.composites.gyro} | pressure: ${edit.composites.press}`, -); - -// ---- rate helper -const rate = await evaluate(` - const h = document.getElementById('rateHelper'); - h.value = '204.8'; h.dispatchEvent(new Event('change', {bubbles:true})); - await new Promise(r=>setTimeout(r,80)); - return { pill: document.getElementById('ratePill').textContent, - divider: document.querySelector('#configForm .field[data-field-key="samplingRate"] input').value, - changed: [...document.querySelectorAll('.hexview-byte.changed')].map(c=>c.title) }; -`); -check( - "common-rate helper writes the divider", - rate.pill === "204.8 Hz" && - rate.divider === "160" && - rate.changed.includes("byte 0 (0x0)"), - JSON.stringify(rate), -); - -// ---- sensor toggle -const sens = await evaluate(` - const b = [...document.querySelectorAll('[data-sensor-bit]')].find(x => x.closest('label').textContent.includes('Pressure')); - b.checked = true; b.dispatchEvent(new Event('change', {bubbles:true})); - await new Promise(r=>setTimeout(r,80)); - return { pill: document.getElementById('dirtyPill').textContent, - changed: [...document.querySelectorAll('.hexview-byte.changed')].map(c=>c.title) }; -`); -check( - "sensor toggle counts as a change and moves sensors2", - /* Four changes by now, not three, and five dirty bytes. The pass has edited - the wide-range accel range (byte 6) and the sampling rate (bytes 0-1), - and setting 204.8 Hz also DERIVES the shared LSM6DSV accel/gyro rate into - byte 7 — "derived Gyro/Accel Rate 5 → 7 (enabled at 204.8 Hz)" in the log. - That derivation arrived with the rate-coverage work and this pin was never - updated for it. Pressure itself moves byte 5 alone, which a fresh page - confirms: one change, one byte. */ - sens.pill === "4 changes" && - sens.changed.includes("byte 5 (0x5)") && - sens.changed.includes("byte 7 (0x7)") && - sens.changed.every((b) => - [ - "byte 0 (0x0)", - "byte 1 (0x1)", - "byte 5 (0x5)", - "byte 6 (0x6)", - "byte 7 (0x7)", - ].includes(b), - ), - JSON.stringify(sens), -); - -// ---- apply: order, confirm text, re-read -const apply = await evaluate(` - window.mockTransport.writes.length = 0; - const t0 = performance.now(); - document.getElementById('btnApply').click(); - for (let i = 0; i < 300; i++) { - await new Promise(r=>setTimeout(r,100)); - if (document.getElementById('dirtyPill').hidden) break; - } - const ops = window.mockTransport.writes.map(w => w.bytes[0]); - const seq = []; - for (const o of ops) { - const k = '0x' + o.toString(16).padStart(2,'0'); - if (!seq.length || seq[seq.length-1].op !== k) seq.push({op:k, n:1}); - else seq[seq.length-1].n++; - } - return { elapsedMs: Math.round(performance.now()-t0), seq, confirm: window.__confirm, - dirtyHidden: document.getElementById('dirtyPill').hidden, - changedAfter: document.querySelectorAll('.hexview-byte.changed').length, - row0: [...document.querySelectorAll('.hexview-row')][0].textContent }; -`); -const opString = apply.seq - .map((s) => s.op + (s.n > 1 ? "x" + s.n : "")) - .join(" "); -check( - "apply runs the firmware's order", - opString === "0x8cx6 0x9c 0x08 0x01 0x05 0x09 0x01 0x8ex3", - opString, -); -check( - "apply confirm lists changes and the 7 steps", - /Sampling Rate: 640 . 160/.test(apply.confirm) && - /WR Accel Range/.test(apply.confirm) && - /Enabled sensors: 0x0000E0 . 0x0400E0/.test(apply.confirm) && - /7\. Re-read the configuration image/.test(apply.confirm), - apply.confirm.split("\n").filter(Boolean).length + " lines", -); -check( - "apply re-reads and re-baselines", - apply.dirtyHidden && - apply.changedAfter === 0 && - apply.row0.startsWith("0000A00001E00004"), - apply.row0, -); - -// =========================================================================== -// SD card tab -// -// showDirectoryPicker cannot be driven headlessly (and cannot be driven at -// all without a user gesture), so the destination handle is STUBBED: MEMFS -// below is an in-memory FileSystemDirectoryHandle good enough for the SDK's -// ensureDirectoryPath / getFileHandle / createWritable / getFile calls. The -// bytes it collects are then compared against what the mock served, so the -// transfer itself is verified end to end even though the picker is not. -// =========================================================================== -console.log("\n--- SD card tab ---"); - -const MEMFS = ` -window.__mkfs = (label) => { - const files = new Map(); - const fileHandle = (path) => ({ - kind: 'file', name: path.slice(path.lastIndexOf('/') + 1), - async getFile() { - const b = files.get(path) ?? new Uint8Array(0); - return { size: b.length, arrayBuffer: async () => b.slice().buffer }; - }, - async createWritable(o) { - let buf = o && o.keepExistingData - ? new Uint8Array(files.get(path) ?? []) - : new Uint8Array(0); - return { - async write(c) { - const d = new Uint8Array(c.data); - const p = c.position ?? 0; - if (p + d.length > buf.length) { - const n = new Uint8Array(p + d.length); n.set(buf); buf = n; - } - buf.set(d, p); - }, - async close() { files.set(path, buf); }, - }; - }, - }); - const dirHandle = (prefix) => ({ - kind: 'directory', - name: prefix ? prefix.slice(prefix.lastIndexOf('/') + 1) : label, - async queryPermission() { return 'granted'; }, - async requestPermission() { return 'granted'; }, - async getDirectoryHandle(seg) { - return dirHandle(prefix ? prefix + '/' + seg : seg); - }, - async getFileHandle(seg, o) { - const p = prefix ? prefix + '/' + seg : seg; - if (!files.has(p)) { - if (!o || !o.create) throw new DOMException('missing', 'NotFoundError'); - files.set(p, new Uint8Array(0)); - } - return fileHandle(p); - }, - }); - return { root: dirHandle(''), files }; -}; -window.__useFs = async (label) => { - window.__fs = window.__mkfs(label); - window.showDirectoryPicker = async () => window.__fs.root; - return window.sdBrowser.pickDestination(); -}; -window.__sdRole = (r) => document.querySelector('[data-sd-role="' + r + '"]'); -window.__sdStats = () => { - const out = {}; - for (const s of document.querySelectorAll('#sdPanel [data-sd-stat]')) - out[s.dataset.sdStat] = s.textContent; - return out; -}; -window.__opCount = (op) => - window.mockTransport.writes.filter((w) => w.bytes[0] === op).length; -`; - -// ---- the capability key the whole tab hangs off -const sdCaps = await evaluate(` - const m = await import('/common/shimmer3-config-schema.js'); - const full = { sdListDir(){}, sdReadFileWindow(){} }; - return { - ble: m.describeShimmer3Caps(full, 'ble').sdTransfer, - rfcomm: m.describeShimmer3Caps(full, 'rfcomm').sdTransfer, - usb: m.describeShimmer3Caps(full, 'usb').sdTransfer, - noMethods: m.describeShimmer3Caps({}, 'ble').sdTransfer, - }; -`); -check( - "sdTransfer capability is radio-only, so the USB dock link can never offer the tab", - sdCaps.ble === true && - sdCaps.rfcomm === true && - sdCaps.usb === false && - sdCaps.noMethods === false, - JSON.stringify(sdCaps), -); - -// ---- the tree, the readouts, and that listing actually paged -const sdTree = await evaluate( - MEMFS + - ` - window.mockTransport.writes.length = 0; - document.querySelector('.tabs [data-tab="tabSd"]').click(); - await window.sdBrowser.refresh(); - const rows = [...document.querySelectorAll('[data-sd-role="tree"] > ul > li')].map(li => ({ - path: li.querySelector('label').textContent.trim(), - meta: li.querySelector(':scope > .sd-size').textContent, - files: [...li.querySelectorAll('ul > li')].map(f => f.textContent), - })); - return { rows, stats: window.__sdStats(), - listCmds: window.__opCount(0xCC), freeCmds: window.__opCount(0xC8), - tabDisabled: document.getElementById('tabBtnSd').disabled, - banner: document.getElementById('sdBanner').textContent, - card: window.mockTransport.sdCard.files }; -`, -); -check( - "the synthetic card renders as a two-session tree with per-file sizes", - sdTree.rows.length === 2 && - sdTree.rows[0].path === "data/DefaultTrial_5f2c1a90/Shimmer_8091-000" && - sdTree.rows[0].meta === "2 file(s), 327.2 KB" && - sdTree.rows[0].files.length === 2 && - sdTree.rows[0].files[0] === "000286.2 KB" && - sdTree.rows[1].files.length === 3, - sdTree.rows.map((r) => `${r.path} [${r.meta}]`).join(" | "), -); -check( - "card readouts show free space, capacity, file count and total bytes", - sdTree.stats.files === "5" && - sdTree.stats.bytes === "351.4 KB" && - /GB$/.test(sdTree.stats.free) && - sdTree.stats.capacity === "29.72 GB" && - sdTree.freeCmds === 1, - JSON.stringify(sdTree.stats), -); -check( - "listing four directories took five SD_LIST_DIR pages, so paging was followed", - sdTree.listCmds === 5 && sdTree.tabDisabled === false && sdTree.banner === "", - `${sdTree.listCmds} list commands`, -); - -// ---- download, byte-for-byte against what the mock served -const sdDownload = await evaluate(` - window.mockTransport.writes.length = 0; - const picked = await window.__useFs('MemDest'); - const dest = window.__sdRole('dest').textContent; - const preview = window.__sdRole('preview').textContent; - /* The element is created "row muted" and repainted per layout, so the class - it ends up with is worth pinning: assigning the whole string once dropped - the "row" and with it the line's spacing. (No backticks in here -- this - comment lives inside the template literal handed to Runtime.evaluate.) */ - const previewClasses = [...window.__sdRole('preview').classList]; - const session = 'data/DefaultTrial_5f2c1a90/Shimmer_8091-001'; - await window.sdBrowser.download([session], { deleteVerified: false }); - const cmp = [...window.__fs.files.keys()].map(hostPath => { - const cardPath = hostPath.slice(hostPath.indexOf('/data/') + 1); - const want = window.mockTransport.sdCard.bytes(cardPath); - const got = window.__fs.files.get(hostPath); - let mismatchAt = -1; - if (!want) mismatchAt = -3; - else if (want.length !== got.length) mismatchAt = -2; - else for (let i = 0; i < want.length; i++) - if (want[i] !== got[i]) { mismatchAt = i; break; } - return { hostPath, cardPath, bytes: got.length, mismatchAt }; - }); - return { picked, dest, preview, previewClasses, cmp, - progress: window.__sdRole('progress').textContent, - readCmds: window.__opCount(0xC4), - cardIntact: window.mockTransport.sdCard.files.length }; -`); -const stampRe = /^\d{4}-\d{2}-\d{2}_\d{2}\.\d{2}\.\d{2}$/; -check( - "a picked destination is reported with the path the layout will produce", - sdDownload.picked === true && - sdDownload.dest === "Saving into: MemDest" && - /* The device level is the sensor's MAC, lowercase, because that is where - Consensys looks — the preview shows the REAL one so that a run about to - be filed under a Shimmer name instead is noticed here rather than at - import time. */ - /^Files will be written to MemDest\/\d{4}-\d{2}-\d{2}_\d{2}\.\d{2}\.\d{2}\/000666668091\/data\/…$/.test( - sdDownload.preview, - ) && - // Keeps its layout classes, and is not wearing the missing-MAC warning. - sdDownload.previewClasses.includes("row") && - !sdDownload.previewClasses.includes("preview-warn"), - `${sdDownload.dest} — ${sdDownload.preview} [${sdDownload.previewClasses.join(" ")}]`, -); -check( - "downloading a session writes every file byte-for-byte, in the Consensys Backup layout", - sdDownload.cmp.length === 3 && - sdDownload.cmp.every((f) => f.mismatchAt === -1) && - sdDownload.cmp.every( - (f) => - stampRe.test(f.hostPath.split("/")[0]) && - f.hostPath.split("/")[1] === "000666668091", - ) && - sdDownload.cmp.map((f) => f.bytes).join() === "17622,6145,931" && - sdDownload.progress === "Done" && - sdDownload.cardIntact === 5, - sdDownload.cmp - .map((f) => `${f.hostPath} ${f.bytes}B ok=${f.mismatchAt === -1}`) - .join(" | "), -); - -// ---- rolling rate and ETA, over a file that spans three read windows -const sdRate = await evaluate(` - await window.__useFs('RateDest'); - const label = window.__sdRole('progress'); - const seen = []; - const run = window.sdBrowser.download( - ['data/DefaultTrial_5f2c1a90/Shimmer_8091-000'], { deleteVerified: false }); - for (let i = 0; i < 300; i++) { - await new Promise(r => setTimeout(r, 100)); - const t = label.textContent; - if (t && seen[seen.length - 1] !== t) seen.push(t); - if (t === 'Done' || /^Failed|^Aborted/.test(t)) break; - } - await run; - const withRate = seen.filter(t => /@ [\\d.]+ KB\\/s, ETA /.test(t)); - const rates = withRate.map(t => Number(/@ ([\\d.]+) KB/.exec(t)[1])); - const etas = withRate.map(t => /ETA (.+)$/.exec(t)[1]); - // Same defensiveness as the resume block below: a transfer that wrote - // nothing must make this check FAIL, not throw out of the whole pass. - const bigKey = [...window.__fs.files.keys()].find(k => k.endsWith('-000/000')); - return { seen, withRate, rates, etas, final: label.textContent, - big: bigKey ? window.__fs.files.get(bigKey).length : 0 }; -`); -check( - "progress reports a rolling throughput and an ETA while a multi-window file transfers", - sdRate.withRate.length >= 2 && - sdRate.rates.every((r) => r > 20 && r < 1000) && - sdRate.etas.every((e) => /^\d+m \d{2}s$|^\d+s$/.test(e)) && - sdRate.final === "Done" && - sdRate.big === 293117, - `${sdRate.withRate.length} labels with a rate: ${sdRate.rates.join("/")} KB/s, ETA ${sdRate.etas.join("/")}`, -); - -// ---- apply and streaming both refused while a transfer holds the link -const sdContend = await evaluate(` - await window.__useFs('ContendDest'); - const run = window.sdBrowser.download(['data'], { deleteVerified: false }); - // The SDK emits nothing between "enumerate" and the first completed read - // window, so wait for the download phase rather than guessing a delay. - const label = window.__sdRole('progress'); - for (let i = 0; i < 60; i++) { - await new Promise(r => setTimeout(r, 100)); - if (/^download: /.test(label.textContent)) break; - } - const during = { - apply: document.getElementById('btnApply').disabled, - read: document.getElementById('btnRead').disabled, - imWrite: document.getElementById('btnImWrite').disabled, - streamStart: document.getElementById('btnStreamStart').disabled, - note: document.getElementById('applyNote').textContent, - refresh: window.__sdRole('refresh').disabled, - dlAll: window.__sdRole('downloadAll').disabled, - abort: window.__sdRole('abort').disabled, - progress: window.__sdRole('progress').textContent, - file: window.__sdRole('file').textContent, - treeRows: document.querySelectorAll('[data-sd-role="tree"] > ul > li').length, - }; - window.sdBrowser.abort(); - await run; - await new Promise(r => setTimeout(r, 400)); - const partial = [...window.__fs.files.entries()].map(([k, v]) => [k.slice(k.indexOf('/data/') + 1), v.length]); - return { during, partial, - final: window.__sdRole('progress').textContent, - sawAbortCmd: window.mockTransport.writes.some(w => w.bytes[0] === 0xC7), - cardIntact: window.mockTransport.sdCard.files.length, - after: { apply: document.getElementById('btnApply').disabled, - note: document.getElementById('applyNote').textContent, - refresh: window.__sdRole('refresh').disabled } }; -`); -check( - "a transfer in flight refuses apply, a configuration read and a stream start, with a reason", - sdContend.during.apply && - sdContend.during.read && - sdContend.during.imWrite && - sdContend.during.streamStart && - /a download and a configuration write cannot share the link/.test( - sdContend.during.note, - ), - sdContend.during.note, -); -check( - "the panel keeps its tree and its progress while transferring, and only Abort stays live", - sdContend.during.refresh && - sdContend.during.dlAll && - !sdContend.during.abort && - sdContend.during.treeRows === 2 && - /^download: /.test(sdContend.during.progress) && - /Shimmer_8091-000\/000 \(/.test(sdContend.during.file), - `${sdContend.during.progress} — ${sdContend.during.file}`, -); -check( - "abort stops the transfer mid-file, leaves the part already written, and touches nothing on the card", - sdContend.final === "Aborted (resumable)" && - sdContend.sawAbortCmd && - sdContend.cardIntact === 5 && - sdContend.partial.length === 1 && - sdContend.partial[0][1] > 0 && - sdContend.partial[0][1] < 293117, - `${sdContend.final}; on disk ${sdContend.partial.map((p) => p.join("=")).join()} of 293117`, -); -check( - "the page is handed back once the transfer stops", - !sdContend.after.apply && - sdContend.after.note === "" && - !sdContend.after.refresh, -); - -// ---- an aborted download resumes into the same import folder -const sdResume = await evaluate(` - const partialKey = [...window.__fs.files.keys()].find(k => k.endsWith('-000/000')); - /* A check whose PREREQUISITE failed must fail, not throw: the abort above - leaves a part-written file, and when that transfer failed outright there - is nothing on disk to resume from. Reading \`.length\` off the missing - entry threw out of \`evaluate\`, which ended the whole pass on the spot - and took roughly 150 later checks with it — so one flaky area could hide - every regression after it. */ - if (!partialKey) { - return { unavailable: 'the aborted transfer left nothing on disk to resume from' }; - } - const before = window.__fs.files.get(partialKey).length; - const preview = window.__sdRole('preview').textContent; - window.mockTransport.writes.length = 0; - await window.sdBrowser.download( - ['data/DefaultTrial_5f2c1a90/Shimmer_8091-000'], { deleteVerified: false }); - const keys = [...window.__fs.files.keys()].filter(k => k.endsWith('-000/000')); - const got = window.__fs.files.get(partialKey); - const want = window.mockTransport.sdCard.bytes('data/DefaultTrial_5f2c1a90/Shimmer_8091-000/000'); - let mismatchAt = -1; - if (!want) mismatchAt = -3; - else if (want.length !== got.length) mismatchAt = -2; - else for (let i = 0; i < want.length; i++) - if (want[i] !== got[i]) { mismatchAt = i; break; } - const first = window.mockTransport.writes.find(w => w.bytes[0] === 0xC4); - return { before, copies: keys.length, size: got.length, mismatchAt, - stampReused: preview.includes(partialKey.split('/')[0]), - previewAfter: window.__sdRole('preview').textContent, - firstOffset: first ? new DataView(new Uint8Array(first.bytes).buffer).getUint32(1, true) : null }; -`); -check( - "re-running after an abort resumes into the same import folder, from the bytes on disk", - !sdResume.unavailable && - sdResume.before > 0 && - sdResume.stampReused && - sdResume.copies === 1 && - sdResume.firstOffset === sdResume.before && - sdResume.size === 293117 && - sdResume.mismatchAt === -1, - sdResume.unavailable ?? - `resumed at offset ${sdResume.firstOffset} (${sdResume.before} B on disk) → ${sdResume.size} B` + - `, mismatchAt ${sdResume.mismatchAt}, ${sdResume.copies} copy on disk`, -); - -// ---- delete only what was downloaded AND verified -const sdDelete = await evaluate(` - await window.__useFs('DelDest'); - window.mockTransport.writes.length = 0; - const before = window.mockTransport.sdCard.files.map(f => f.path); - await window.sdBrowser.download( - ['data/DefaultTrial_5f2c1a90/Shimmer_8091-001'], { deleteVerified: true }); - return { before, after: window.mockTransport.sdCard.files.map(f => f.path), - dirs: window.mockTransport.sdCard.dirs, - deleteCmds: window.__opCount(0xCA), - confirm: window.__confirm, - stats: window.__sdStats(), - logs: [...document.querySelectorAll('#log .log-line')].map(l => l.textContent) - .filter(l => /deleted from card/.test(l)) }; -`); -check( - "delete-after-verified names the scope before deleting", - /3 file\(s\), 24\.1 KB/.test(sdDelete.confirm) && - /1 session folder\(s\)/.test(sdDelete.confirm), - sdDelete.confirm.split("\n").filter(Boolean).slice(2, 5).join(" / "), -); -check( - "only the three verified files leave the card; the other session is untouched", - sdDelete.before.length === 5 && - sdDelete.after.length === 2 && - sdDelete.after.every((p) => p.includes("-000/")) && - sdDelete.deleteCmds === 3 && - sdDelete.logs.length === 3 && - sdDelete.stats.files === "2", - `${sdDelete.before.length} → ${sdDelete.after.length} files, ${sdDelete.deleteCmds} SD_DELETE commands`, -); - -// ---- the throughput test -const sdLink = await evaluate(` - await window.sdBrowser.measureLinkSpeed(1200); - // The event log flushes on an animation frame, so the last lines are not - // in the DOM the instant the call returns. - await new Promise(r => requestAnimationFrame(() => setTimeout(r, 60))); - return { link: window.__sdStats().link, - on: window.mockTransport.writes.some(w => w.bytes[0] === 0xA4 && w.bytes[1] === 1), - off: window.mockTransport.writes.some(w => w.bytes[0] === 0xA4 && w.bytes[1] === 0), - guide: [...document.querySelectorAll('#log .log-line')].map(l => l.textContent) - .filter(l => /raw throughput: |as a guide|currently on this card/.test(l)) }; -`); -const linkKBps = Number(/^([\d.]+) KB\/s$/.exec(sdLink.link)?.[1]); -check( - "the throughput test runs the firmware data-rate test and reports a plausible rate", - sdLink.on && - sdLink.off && - linkKBps > 100 && - linkKBps < 300 && - sdLink.guide.length === 3 && - /1 MB ≈ \d+s, 5 MB ≈ /.test(sdLink.guide[1]), - `${sdLink.link} — ${sdLink.guide[1]?.slice(-72) ?? "no guide line"}`, -); - -// ---- 4. and the button that starts it now lives in the Test tab -const linkBtn = await evaluate(` - const btn = document.getElementById('btnLinkTest'); - const pill = document.getElementById('linkSpeedPill'); - document.getElementById('tabBtnTest').click(); - await new Promise(r => setTimeout(r, 200)); - const before = { inTestTab: btn.closest('#tabTest') !== null, - disabled: btn.disabled, note: document.getElementById('linkTestNote').textContent, - title: btn.title }; - btn.click(); - await new Promise(r => setTimeout(r, 1200)); - const during = { pill: pill.textContent, disabled: btn.disabled, - applyNote: document.getElementById('applyNote').textContent, - note: document.getElementById('linkTestNote').textContent }; - for (let i = 0; i < 100 && document.getElementById('btnLinkTest').disabled; i++) - await new Promise(r => setTimeout(r, 100)); - await new Promise(r => requestAnimationFrame(() => setTimeout(r, 60))); - return { before, during, after: pill.textContent, - stat: window.__sdStats().link, - noteAfter: document.getElementById('linkTestNote').textContent }; -`); -check( - "the throughput button works from the Test tab and reports beside itself", - linkBtn.before.inTestTab && - !linkBtn.before.disabled && - linkBtn.before.note === "" && - /measures the pipe itself/.test(linkBtn.before.title) && - /KB\/s/.test(linkBtn.after) && - linkBtn.after === linkBtn.stat && - linkBtn.noteAfter === "", - `pill ${linkBtn.after}, card stat ${linkBtn.stat}`, -); -check( - "and while it runs it names itself as the thing holding the link", - linkBtn.during.disabled && - /^(measuring…|[\d.]+ KB\/s …)$/.test(linkBtn.during.pill) && - /throughput test/.test(linkBtn.during.applyNote) && - /Measuring/.test(linkBtn.during.note), - `${linkBtn.during.pill} | ${linkBtn.during.applyNote}`, -); - -// ---- stream, stats, plot, apply refused while streaming -const stream = await evaluate(` - document.querySelector('.tabs [data-tab="tabStream"]').click(); - document.getElementById('btnStreamStart').click(); - await new Promise(r=>setTimeout(r,4000)); - const charts = [...document.querySelectorAll('.plot-panel canvas')].map(c => { - const ch = Chart.getChart(c); - return { title: ch.options.plugins.title.text, traces: ch.data.datasets.map(d=>d.label), - points: ch.data.datasets.map(d=>d.data.length) }; - }); - const axisTitles = {}; - for (const c of document.querySelectorAll('.plot-panel canvas')) { - const ch = Chart.getChart(c); - axisTitles[ch.options.plugins.title.text] = - ch.options.scales.y.title?.display ? ch.options.scales.y.title.text : ''; - } - const cells = {}; - for (const d of document.querySelectorAll('#stats > div')) - cells[d.querySelector('.stat-label').textContent] = d.querySelector('.stat-value').textContent; - // 5. Plots keep the whole page width with the log drawer collapsed — the - // reason the log is a bottom drawer and not a right-hand rail. Scrolled to - // the very foot of the page, the last one still clears the drawer, which is - // what "never permanently covers content" means. - const plotBox = document.querySelector('.plot-panel').getBoundingClientRect(); - const pageBox = document.querySelector('.page').getBoundingClientRect(); - window.scrollTo(0, document.documentElement.scrollHeight); - await new Promise(r => requestAnimationFrame(r)); - const panels = document.querySelectorAll('.plot-panel'); - return { charts, cells, axisTitles, - drawerOpen: document.documentElement.dataset.logOpen, - plotWidth: plotBox.width, pageWidth: pageBox.width, - scrolledTo: window.scrollY, - plotBottomClear: panels[panels.length - 1].getBoundingClientRect().bottom <= - document.getElementById('logDrawer').getBoundingClientRect().top + 1, - applyDisabled: document.getElementById('btnApply').disabled, - applyNote: document.getElementById('applyNote').textContent, - btStop: !document.getElementById('btnStreamStop').disabled, - sdStop: !document.getElementById('btnSdStop').disabled, - recStart: !document.getElementById('btnRecStart').disabled }; -`); -check( - "plots keep the full page width with the drawer collapsed, and clear of it", - stream.drawerOpen === "false" && - stream.plotWidth > stream.pageWidth - 26 && - stream.plotBottomClear, - `plot ${Math.round(stream.plotWidth)}px inside a ${Math.round(stream.pageWidth)}px page; ` + - `scrolled to ${Math.round(stream.scrolledTo)}px, last panel clear=${stream.plotBottomClear}`, -); -check( - "stream draws one panel per sensor group with points", - /* Five, not three: pressure and temperature reach the plot now that the SDK - decodes them and `plot.js` gives each its own panel. They are separate - panels on purpose — kPa and °C share no axis with each other, let alone - with an accelerometer. */ - stream.charts.length === 5 && - stream.charts.every((c) => c.points.every((p) => p > 50)), - stream.charts.map((c) => `${c.title}: ${c.points.join("/")} pts`).join(", "), -); -check( - "and each panel's y axis is titled with the unit its traces share", - /* The axis title is the unit of the RESOLVED kind, so it follows the - Raw/Calibrated selector rather than being fixed at schema time. A panel - whose traces disagree on a unit gets no title, which is why pressure and - temperature cannot be one panel. */ - stream.axisTitles["Low-noise accelerometer"] === "m/s²" && - stream.axisTitles["Gyroscope"] === "deg/s" && - stream.axisTitles["Pressure"] === "kPa" && - stream.axisTitles["Temperature"] === "°C", - JSON.stringify(stream.axisTitles), -); -check( - "stats strip reads a sane rate and 0% loss", - stream.cells.Rate === "204.8 Hz" && - stream.cells.Expected === "204.8 Hz" && - stream.cells.Loss === "0.0 %", - JSON.stringify(stream.cells), -); -check( - "apply refused while streaming, with a reason", - stream.applyDisabled && - /NACKs configuration commands while sensing/.test(stream.applyNote), - stream.applyNote, -); -check( - "only the Bluetooth stop button is live while streaming without SD", - stream.btStop && !stream.sdStop, -); - -// ---- the SD tab is refused while the sensor is sensing -const sdWhileStreaming = await evaluate(` - return { tabDisabled: document.getElementById('tabBtnSd').disabled, - tabTitle: document.getElementById('tabBtnSd').title, - banner: document.getElementById('sdBanner').textContent, - refresh: window.__sdRole('refresh').disabled, - dlAll: window.__sdRole('downloadAll').disabled, - // 4. The link test is no longer a card control at all. - linkTestRoleGone: window.__sdRole('linkTest') === null, - linkTest: document.getElementById('btnLinkTest').disabled, - linkTestNote: document.getElementById('linkTestNote').textContent, - linkTestTitle: document.getElementById('btnLinkTest').title, - treeRows: document.querySelectorAll('[data-sd-role="tree"] > ul > li').length, - stats: window.__sdStats() }; -`); -check( - "the SD tab is closed off while the sensor is sensing, with the reason on it", - sdWhileStreaming.tabDisabled && - /refuses every SD command while it is/.test(sdWhileStreaming.banner) && - sdWhileStreaming.banner === sdWhileStreaming.tabTitle && - sdWhileStreaming.refresh && - sdWhileStreaming.dlAll && - sdWhileStreaming.treeRows === 0 && - sdWhileStreaming.stats.files === "–", - sdWhileStreaming.banner.slice(0, 80) + "…", -); -check( - "the throughput test is refused while streaming, and says why rather than just greying", - sdWhileStreaming.linkTestRoleGone && - sdWhileStreaming.linkTest && - /deliberately saturates the link/.test(sdWhileStreaming.linkTestNote) && - /Stop the stream first/.test(sdWhileStreaming.linkTestNote) && - sdWhileStreaming.linkTestTitle === sdWhileStreaming.linkTestNote, - sdWhileStreaming.linkTestNote.slice(0, 80) + "…", -); - -// ---- record, then a dropped link mid-stream -const rec = await evaluate(` - document.getElementById('btnRecStart').click(); - await new Promise(r=>setTimeout(r,3000)); - const mid = { pill: document.getElementById('recPill').textContent, - rows: document.getElementById('recRows').textContent }; - window.mockTransport.emitDisconnect(new Error('cable yanked (mock)')); - /* Wait for the page to have SETTLED, rather than for a length of time. The - drop unwinds several things — the stream stops, the recorder closes its - file, the identity clears, the connect buttons come back — and a fixed - 1.5 s was enough on a developer's laptop and not on a loaded CI runner, - where this failed while the behaviour was perfectly correct. The last - thing to land is the recording pill, because closing the file is async. */ - for (let i = 0; i < 120; i++) { - await new Promise(r => setTimeout(r, 50)); - if (document.getElementById('connPill').textContent === 'disconnected' - && document.getElementById('recPill').textContent === 'not recording' - && window.__blobs.length) break; - } - const after = { connPill: document.getElementById('connPill').textContent, - recPill: document.getElementById('recPill').textContent, - rows: document.getElementById('recRows').textContent, - bytes: document.getElementById('recBytes').textContent, - idName: document.getElementById('idName').textContent, - bleEnabled: !document.getElementById('btnBle').disabled, - /* Whether this BROWSER has Web Bluetooth at all. The BLE button is gated - on the capability as well as on the connection, and capability is a - floor the connected-state cannot lift — so on a browser without it the - button correctly stays disabled after a drop, and an assertion that it - comes back is asserting something about the host rather than about the - page. Headless Chrome on Linux has no Web Bluetooth; the same Chrome on - Windows does, which is why this passed locally and failed in CI. */ - bleSupported: !!navigator.bluetooth, - stopDisabled: document.getElementById('btnStreamStop').disabled, - streamTabEnabled: !document.getElementById('tabBtnStream').disabled, - toasts: [...document.querySelectorAll('.toast')].map(t=>t.textContent) }; - let csv = null; - if (window.__blobs.length) { - const txt = await window.__blobs[window.__blobs.length-1].text(); - const lines = txt.split(/\\r?\\n/).filter(Boolean); - csv = { header: lines[0], units: lines[1], cols: lines[0].split(',').length, - dataRows: lines.length - 2, lastCols: lines[lines.length-1].split(',').length }; - } - return { mid, after, csv, blobs: window.__blobs.length }; -`); -check( - "recording accepts rows while streaming", - Number(rec.mid.rows.replace(/,/g, "")) > 100 && rec.mid.pill === "recording", - JSON.stringify(rec.mid), -); -check( - "dropped link finishes the recording and keeps the CSV", - rec.blobs === 1 && - rec.csv && - rec.csv.dataRows > 100 && - rec.csv.cols === rec.csv.lastCols, - `${rec.csv?.dataRows} rows, ${rec.csv?.cols} columns, reported ${rec.after.rows} / ${rec.after.bytes}`, -); -check( - "CSV header matches the stream columns", - /* Three time columns, and they are three different numbers: the host's - arrival time, the sensor's raw tick counter (which restarts every 512 s), - and the unwrapped milliseconds those ticks amount to. `Timestamp_Unix_CAL` - is the name Consensys writes for the wall clock, so a file from this page - and one from Consensys can be compared column for column. */ - rec.csv.header.startsWith( - "HostTime_ms,TIMESTAMP,TIMESTAMP_CAL,Timestamp_Unix_CAL,", - ) && - rec.csv.units.startsWith("ms,ticks,ms,ms,") && - /,LN_ACCEL_X_RAW,/.test(rec.csv.header) && - /,LN_ACCEL_X_CAL,/.test(rec.csv.header), - rec.csv.header.slice(0, 110) + "…", -); -check( - "and a raw column's unit is 'no_units', not an empty cell", - /* An empty cell reads as "the unit was not recorded"; `no_units` says there - is no unit. Java draws the same distinction with the same word. */ - rec.csv.units.split(",")[ - rec.csv.header.split(",").indexOf("LN_ACCEL_X_RAW") - ] === "no_units", - rec.csv.units.slice(0, 110) + "…", -); -check( - "row count matches what the page reported", - String(rec.csv.dataRows) === rec.after.rows.replace(/,/g, ""), - `csv ${rec.csv.dataRows} vs page ${rec.after.rows}`, -); -check( - "UI recovers after the drop", - rec.after.connPill === "disconnected" && - rec.after.recPill === "not recording" && - rec.after.idName === "–" && - // Live again if and only if this browser can do BLE at all. - rec.after.bleEnabled === rec.after.bleSupported && - rec.after.stopDisabled && - rec.after.streamTabEnabled, - /* Every field, not just the toasts. Six things have to be true here and the - detail used to name none of them, so a failure said only that something - about the recovery was wrong — which is no use at all when it fails on a - machine you cannot attach a debugger to. */ - JSON.stringify(rec.after), -); - -// =========================================================================== -console.log("\n--- unframed (Classic-Bluetooth-like, 3-byte dribble) ---"); -await goto(`${BASE}?mock=1&framed=0&rate=102.4`); -check( - "connect over the unframed transport", - (await evaluate(CONNECT)) === "mock", -); -const un = await evaluate(` - const t = id => document.getElementById(id).textContent; - return { name:t('idName'), mac:t('idMac'), fw:t('idFw'), link:t('idLink'), - hw:t('idHw'), btModule:t('idBtModule'), - imPill:t('imPill'), rate:document.getElementById('ratePill').textContent, - fields:document.querySelectorAll('#configForm .field').length }; -`); -check( - "unframed link reads identity and the whole image", - /* The configured name, not the link's: a serial link reports no device name - at all, which is why this row read "(name not reported)" on every - classic-Bluetooth and USB connection. The two identity reads added - alongside it also have to survive the unframer here — over BLE a - notification is already one whole message, which would hide a response - the length table does not know how to end. */ - un.name === "Shimmer_8091" && - un.hw === "Shimmer3R GSR+ (SR48-3-0)" && - un.btModule === "CYW20820 v1.4.18.18" && - un.mac === "000666668091" && - un.imPill === "384 bytes read" && - un.rate === "102.4 Hz" && - un.fields === 40, - JSON.stringify(un), -); - -// ---- InfoMem save -> mutate -> load round trip -const round = await evaluate(` - document.getElementById('btnImSave').click(); - await new Promise(r=>setTimeout(r,250)); - const saved = new Uint8Array(await window.__blobs[0].arrayBuffer()); - const wr = document.querySelector('#configForm .field[data-field-key="wrAccelRange"] select'); - wr.value = [...wr.options].map(o=>o.value).find(v => v !== wr.value); - wr.dispatchEvent(new Event('change', {bubbles:true})); - await new Promise(r=>setTimeout(r,80)); - const mutated = { pill: document.getElementById('dirtyPill').textContent, - changed: document.querySelectorAll('.hexview-byte.changed').length }; - const dt = new DataTransfer(); - dt.items.add(new File([saved], 'roundtrip.bin', {type:'application/octet-stream'})); - const input = document.getElementById('imFile'); - input.files = dt.files; - input.dispatchEvent(new Event('change', {bubbles:true})); - await new Promise(r=>setTimeout(r,300)); - return { savedBytes: saved.length, mutated, - loaded: { pill: document.getElementById('dirtyPill').textContent, - hidden: document.getElementById('dirtyPill').hidden, - changed: document.querySelectorAll('.hexview-byte.changed').length, - banner: document.getElementById('imBanner').textContent } }; -`); -check( - "InfoMem hex view round-trips a Save then Load", - round.savedBytes === 384 && - round.mutated.changed === 1 && - round.loaded.changed === 0 && - round.loaded.hidden && - /0 of 384 bytes differ/.test(round.loaded.banner), - `saved ${round.savedBytes} B, after edit ${round.mutated.changed} changed byte, after load ${round.loaded.changed}`, -); - -// ---- a card download over the byte-stream link: the SD frames have to -// survive the SDK's re-framing, arriving 3 bytes at a time -const sdUnframed = await evaluate( - MEMFS + - ` - window.mockTransport.writes.length = 0; - document.querySelector('.tabs [data-tab="tabSd"]').click(); - await window.sdBrowser.refresh(); - await window.__useFs('UnframedDest'); - await window.sdBrowser.download( - ['data/DefaultTrial_5f2c1a90/Shimmer_8091-001'], { deleteVerified: false }); - const cmp = [...window.__fs.files.keys()].map(hostPath => { - const cardPath = hostPath.slice(hostPath.indexOf('/data/') + 1); - const want = window.mockTransport.sdCard.bytes(cardPath); - const got = window.__fs.files.get(hostPath); - let mismatchAt = -1; - if (!want) mismatchAt = -3; - else if (want.length !== got.length) mismatchAt = -2; - else for (let i = 0; i < want.length; i++) - if (want[i] !== got[i]) { mismatchAt = i; break; } - return { cardPath, bytes: got.length, mismatchAt }; - }); - return { cmp, stats: window.__sdStats(), - rows: document.querySelectorAll('[data-sd-role="tree"] > ul > li').length, - progress: window.__sdRole('progress').textContent }; -`, -); -check( - "the card downloads intact over the unframed link too, 3 bytes at a time", - sdUnframed.rows === 2 && - sdUnframed.stats.bytes === "351.4 KB" && - sdUnframed.cmp.length === 3 && - sdUnframed.cmp.every((f) => f.mismatchAt === -1) && - sdUnframed.cmp.map((f) => f.bytes).join() === "17622,6145,931" && - sdUnframed.progress === "Done", - sdUnframed.cmp - .map( - (f) => - `${f.cardPath.split("/").pop()} ${f.bytes}B ok=${f.mismatchAt === -1}`, - ) - .join(" | "), -); - -// ---- streaming and SD logging on the unframed link -const sd = await evaluate(` - document.querySelector('.tabs [data-tab="tabStream"]').click(); - document.getElementById('btnSdStart').click(); - await new Promise(r=>setTimeout(r,3000)); - const cells = {}; - for (const d of document.querySelectorAll('#stats > div')) - cells[d.querySelector('.stat-label').textContent] = d.querySelector('.stat-value').textContent; - const ops = window.mockTransport.writes.map(w => w.bytes[0]); - const during = { cells, - btStop: !document.getElementById('btnStreamStop').disabled, - sdStop: !document.getElementById('btnSdStop').disabled, - applyNote: document.getElementById('applyNote').textContent, - sawStartSdbt: ops.includes(0x70) }; - document.getElementById('btnSdStop').click(); - await new Promise(r=>setTimeout(r,1200)); - const ops2 = window.mockTransport.writes.map(w => w.bytes[0]); - return { during, sawStopSdbt: ops2.includes(0x97), - afterStop: { btStop: !document.getElementById('btnStreamStop').disabled, - applyDisabled: document.getElementById('btnApply').disabled, - applyNote: document.getElementById('applyNote').textContent } }; -`); -check( - "stream + SD logging sends START_SDBT (0x70) and STOP_SDBT (0x97)", - sd.during.sawStartSdbt && sd.sawStopSdbt, -); -check( - "only the SD stop button is live while logging", - !sd.during.btStop && sd.during.sdStop, -); -check( - "apply is refused while logging to the card", - /NACKs configuration commands while sensing/.test(sd.during.applyNote), - sd.during.applyNote, -); -check( - "apply is offered again once logging stops", - !sd.afterStop.btStop && sd.afterStop.applyNote === "", -); -check( - "SD stream stats are sane at 102.4 Hz", - sd.during.cells.Rate === "102.4 Hz" && sd.during.cells.Loss === "0.0 %", - JSON.stringify(sd.during.cells), -); - -// =========================================================================== -// Device naming -// -// A page load of its own, because the last check in here arms a restart and -// disconnects — and because the mock's synthetic EEPROM should start from the -// stock record for the stock-detection checks. -// =========================================================================== -console.log("\n--- device naming ---"); -await goto(`${BASE}?mock=1`); -check( - "connect for the device-naming pass", - (await evaluate(CONNECT)) === "mock", -); - -/* Shared helpers for this section. MEMFS comes along because it carries - `__opCount` and `__sdRole`, and a `goto` has wiped the previous load's - injections. */ -const BRAND = - MEMFS + - ` -window.__brandStats = () => { - const out = {}; - for (const s of document.querySelectorAll('#brandPanel [data-brand-stat]')) - out[s.dataset.brandStat] = s.textContent; - return out; -}; -window.__brandRole = (r) => document.querySelector('[data-brand-role="' + r + '"]'); -window.__brandInput = (k) => document.querySelector('[data-brand-input="' + k + '"]'); -window.__brandError = (k) => document.querySelector('[data-brand-error="' + k + '"]').textContent; -window.__brandPreview = (k) => document.querySelector('[data-brand-preview="' + k + '"]').textContent; -window.__type = (k, v) => { - const i = window.__brandInput(k); - i.value = v; - i.dispatchEvent(new Event('input', { bubbles: true })); -}; -window.__sameBytes = (a, b) => { - if (a.length !== b.length) return -2; - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return i; - return -1; -}; -`; - -// ---- the capability key the whole tab hangs off -const brandCaps = await evaluate(` - const m = await import('/common/shimmer3-config-schema.js'); - const full = { readDaughterCardMem(){}, writeDaughterCardMem(){} }; - return { - ble: m.describeShimmer3Caps(full, 'ble').branding, - rfcomm: m.describeShimmer3Caps(full, 'rfcomm').branding, - usb: m.describeShimmer3Caps(full, 'usb').branding, - readOnly: m.describeShimmer3Caps({ readDaughterCardMem(){} }, 'ble').branding, - noMethods: m.describeShimmer3Caps({}, 'ble').branding, - }; -`); -check( - "the branding capability is offered on every link, including the USB dock", - brandCaps.ble === true && - brandCaps.rfcomm === true && - brandCaps.usb === true && - brandCaps.readOnly === false && - brandCaps.noMethods === false, - JSON.stringify(brandCaps), -); - -// ---- the stock record the mock was seeded with -const brandStock = await evaluate( - BRAND + - ` - window.mockTransport.writes.length = 0; - document.querySelector('.tabs [data-tab="tabBrand"]').click(); - for (let i = 0; i < 60 && !window.brandEditor.record(); i++) - await new Promise(r => setTimeout(r, 100)); - const sdk = await import('/vendor/shimmer-web-sdk.esm.js'); - const onDevice = window.mockTransport.eeprom.brandBytes(); - return { stats: window.__brandStats(), - pill: window.__brandRole('recordPill').textContent, - pillClass: window.__brandRole('recordPill').className, - note: window.__brandRole('recordNote').textContent, - isStock: window.brandEditor.isStockRecord(), - bleCap: window.brandEditor.bleCap(), - bleMaxAttr: window.__brandInput('ble').maxLength, - reads: window.__opCount(0x69), writes: window.__opCount(0x67), - parsed: sdk.parseBrandRecord(onDevice), - matchesStock: window.__sameBytes(onDevice, window.mockTransport.eeprom.stockBrandBytes()), - banner: document.getElementById('brandBanner').textContent }; -`, -); -check( - "opening the tab reads the record once and reports the stock names as stock", - brandStock.reads === 1 && - brandStock.writes === 0 && - brandStock.isStock === true && - brandStock.pill === "valid · factory names" && - brandStock.pillClass === "pill on" && - /seeded by SHIMMER3R/.test(brandStock.note) && - brandStock.stats.btClassic === "Shimmer3R" && - brandStock.stats.ble === "Shimmer3R" && - brandStock.stats.usbProduct === "Shimmer" && - brandStock.stats.usbManufacturer === "Shimmer Research Ltd." && - brandStock.stats.device === "Shimmer3R" && - brandStock.stats.mac === "8091" && - brandStock.banner === "", - `${brandStock.pill} — ${JSON.stringify(brandStock.stats)}`, -); -check( - "an identified Shimmer3R gets the full 10-character BLE cap", - brandStock.bleCap === 10 && brandStock.bleMaxAttr === 10, - `cap ${brandStock.bleCap}, maxlength ${brandStock.bleMaxAttr}`, -); - -// ---- per-field validation, and that nothing reaches the sensor while it fails -const brandBad = await evaluate(` - window.mockTransport.writes.length = 0; - const cases = {}; - const record = (name) => { - cases[name] = { - errors: { bt: window.__brandError('btClassic'), ble: window.__brandError('ble'), - product: window.__brandError('usbProduct'), man: window.__brandError('usbManufacturer') }, - write: window.__brandRole('write').disabled, - bad: [...document.querySelectorAll('#brandPanel input.bad')].map(i => i.dataset.brandInput), - }; - }; - record('empty'); - window.__type('btClassic', 'ThisNameIsFarTooLong'); - record('tooLong'); - window.__type('btClassic', 'Has,Comma'); - record('comma'); - /* Attempted from HERE, with a name the firmware would reject: the button is - disabled, so this is the programmatic door, and it has to be shut too. */ - const attempted = await window.brandEditor.write(); - window.__type('btClassic', 'Acme'); - window.__type('ble', 'ElevenChars'); - record('bleOverCap'); - window.__type('ble', ''); - window.__type('usbManufacturer', ''); - record('noManufacturer'); - window.__type('usbManufacturer', 'Acme Instruments Ltd.'); - record('ok'); - return { cases, attempted, writes: window.__opCount(0x67), - lastLog: [...document.querySelectorAll('#log .log-line')].slice(-2).map(l => l.textContent) }; -`); -check( - "each name is refused on its own, with Write disabled and nothing sent", - brandBad.cases.empty.write && - brandBad.cases.empty.errors.bt === "enter a name" && - brandBad.cases.tooLong.write && - /longer than 16/.test(brandBad.cases.tooLong.errors.bt) && - brandBad.cases.tooLong.bad.join() === "btClassic" && - brandBad.cases.comma.write && - /comma/i.test(brandBad.cases.comma.errors.bt) && - brandBad.cases.bleOverCap.write && - /longer than 10/.test(brandBad.cases.bleOverCap.errors.ble) && - brandBad.cases.bleOverCap.bad.join() === "ble" && - brandBad.cases.noManufacturer.write && - brandBad.cases.noManufacturer.errors.man === "enter a manufacturer name" && - !brandBad.cases.ok.write && - brandBad.cases.ok.bad.length === 0 && - // and the programmatic door is shut as well - brandBad.attempted === false && - brandBad.writes === 0, - `empty:"${brandBad.cases.empty.errors.bt}" long:"${brandBad.cases.tooLong.errors.bt}" ` + - `comma:"${brandBad.cases.comma.errors.bt}" ble:"${brandBad.cases.bleOverCap.errors.ble}" ` + - `man:"${brandBad.cases.noManufacturer.errors.man}" — ${brandBad.writes} commands sent`, -); - -// ---- a name write: derived fallbacks, the confirmation, the byte compare -const brandWrite = await evaluate(` - window.mockTransport.writes.length = 0; - const sdk = await import('/vendor/shimmer-web-sdk.esm.js'); - window.__type('btClassic', 'AcmeWristband'); - window.__type('ble', ''); - window.__type('usbProduct', ''); - window.__type('usbManufacturer', 'Acme Instruments Ltd.'); - const previews = { bt: window.__brandPreview('btClassic'), ble: window.__brandPreview('ble'), - product: window.__brandPreview('usbProduct'), man: window.__brandPreview('usbManufacturer') }; - const effective = window.brandEditor.fields(); - const ok = await window.brandEditor.write(); - const cmd = window.mockTransport.writes.find(w => w.bytes[0] === 0x67); - const onDevice = window.mockTransport.eeprom.brandBytes(); - const expected = sdk.buildBrandRecord(effective); - return { ok, effective, previews, confirm: window.__confirm, - stats: window.__brandStats(), - pill: window.__brandRole('recordPill').textContent, - isStock: window.brandEditor.isStockRecord(), - writes: window.__opCount(0x67), reads: window.__opCount(0x69), - cmdOffset: cmd ? cmd.bytes[2] | (cmd.bytes[3] << 8) : null, - cmdLen: cmd ? cmd.bytes[1] : null, - sentMatchesBuilt: window.__sameBytes(cmd.bytes.slice(4), expected), - deviceMatchesBuilt: window.__sameBytes(onDevice, expected), - parsed: sdk.parseBrandRecord(onDevice), - banner: !window.__brandRole('restart-banner').hidden, - autoHidden: window.__brandRole('restartAuto').hidden, - manualHidden: window.__brandRole('restartManual').hidden, - toasts: [...document.querySelectorAll('#toasts .toast')].map(t => t.textContent) }; -`); -check( - "the BLE and USB-product prefixes derive from the classic one, capped, and that is what is written", - brandWrite.effective.btClassic === "AcmeWristband" && - brandWrite.effective.ble === "AcmeWristb" && - brandWrite.effective.usbProduct === "AcmeWristband" && - brandWrite.parsed.ble === "AcmeWristb" && - brandWrite.parsed.usbProduct === "AcmeWristband" && - /AcmeWristb-8091-BLE/.test(brandWrite.previews.ble) && - /truncated from the Classic Bluetooth prefix/.test( - brandWrite.previews.ble, - ) && - /USB product "AcmeWristband 8091"/.test(brandWrite.previews.product), - `BLE "${brandWrite.effective.ble}" (cap 10) · product "${brandWrite.effective.usbProduct}" · ${brandWrite.previews.ble}`, -); -check( - "the write goes to the record offset as one CRC'd 80-byte command and verifies byte for byte", - brandWrite.ok === true && - brandWrite.writes === 1 && - brandWrite.reads === 1 && - brandWrite.cmdOffset === 1936 && - brandWrite.cmdLen === 80 && - brandWrite.sentMatchesBuilt === -1 && - brandWrite.deviceMatchesBuilt === -1 && - brandWrite.parsed.valid === true && - brandWrite.isStock === false && - brandWrite.pill === "valid · custom names", - `${brandWrite.cmdLen} B at host offset ${brandWrite.cmdOffset}, read back and compared, now "${brandWrite.pill}"`, -); -check( - "the confirmation shows the old name next to the new one before anything is written", - /Shimmer3R → AcmeWristband/.test(brandWrite.confirm) && - /Shimmer Research Ltd\. → Acme Instruments Ltd\./.test( - brandWrite.confirm, - ) && - /read back and compared byte for byte/.test(brandWrite.confirm), - brandWrite.confirm.split("\n").filter(Boolean).slice(1, 5).join(" / "), -); -check( - "a verified write raises the restart banner, on its Bluetooth route", - brandWrite.banner && - !brandWrite.autoHidden && - brandWrite.manualHidden && - brandWrite.toasts.some((t) => /written and verified/.test(t)), - brandWrite.toasts.join(" | "), -); - -// ---- the link is shared: a name write and a stream / a card transfer cannot -const brandContend = await evaluate( - MEMFS + - ` - document.querySelector('.tabs [data-tab="tabStream"]').click(); - document.getElementById('btnStreamStart').click(); - await new Promise(r => setTimeout(r, 1200)); - window.mockTransport.writes.length = 0; - const streaming = { - tab: document.getElementById('tabBtnBrand').disabled, - title: document.getElementById('tabBtnBrand').title, - banner: document.getElementById('brandBanner').textContent, - write: window.__brandRole('write').disabled, - read: window.__brandRole('read').disabled, - attempted: await window.brandEditor.write(), - cmds: window.__opCount(0x67), - }; - document.getElementById('btnStreamStop').click(); - await new Promise(r => setTimeout(r, 800)); - - document.querySelector('.tabs [data-tab="tabSd"]').click(); - await window.sdBrowser.refresh(); - await window.__useFs('BrandContendDest'); - const run = window.sdBrowser.download(['data'], { deleteVerified: false }); - const label = window.__sdRole('progress'); - for (let i = 0; i < 60; i++) { - await new Promise(r => setTimeout(r, 100)); - if (/^download: /.test(label.textContent)) break; - } - window.mockTransport.writes.length = 0; - const transferring = { - tab: document.getElementById('tabBtnBrand').disabled, - banner: document.getElementById('brandBanner').textContent, - write: window.__brandRole('write').disabled, - attempted: await window.brandEditor.write(), - cmds: window.__opCount(0x67), - }; - window.sdBrowser.abort(); - await run; - await new Promise(r => setTimeout(r, 500)); - return { streaming, transferring, - after: { tab: document.getElementById('tabBtnBrand').disabled, - banner: document.getElementById('brandBanner').textContent, - write: window.__brandRole('write').disabled } }; -`, -); -check( - "a name write is refused while the sensor is sensing, with the restart reason given", - brandContend.streaming.tab && - brandContend.streaming.write && - brandContend.streaming.read && - brandContend.streaming.attempted === false && - brandContend.streaming.cmds === 0 && - /skips a restart while it is sensing/.test(brandContend.streaming.banner) && - brandContend.streaming.title === brandContend.streaming.banner, - brandContend.streaming.banner.slice(0, 100) + "…", -); -check( - "a name write is refused while an SD transfer holds the link, and offered again after", - brandContend.transferring.tab && - brandContend.transferring.write && - brandContend.transferring.attempted === false && - brandContend.transferring.cmds === 0 && - /cannot share it/.test(brandContend.transferring.banner) && - !brandContend.after.tab && - !brandContend.after.write && - brandContend.after.banner === "", - brandContend.transferring.banner.slice(0, 90) + "…", -); - -// ---- and the other direction: an SD transfer is refused while a name writes -const brandBlocksSd = await evaluate(` - document.querySelector('.tabs [data-tab="tabBrand"]').click(); - /* Streaming dropped the panel's floor, which cleared the record, so - re-opening the tab re-reads it — wait for that rather than racing it. */ - for (let i = 0; i < 60 && !window.brandEditor.record(); i++) - await new Promise(r => setTimeout(r, 100)); - window.__type('btClassic', 'AcmeTwo'); - const run = window.brandEditor.write(); - /* Poll rather than guess a delay: the write is two round trips, and the - mock answers on a zero-delay timer. */ - const note = document.getElementById('applyNote'); - for (let i = 0; i < 50 && !/name write/.test(note.textContent); i++) - await new Promise(r => setTimeout(r, 5)); - const during = { - apply: document.getElementById('btnApply').disabled, - applyNote: document.getElementById('applyNote').textContent, - sdRefresh: window.__sdRole('refresh').disabled, - sdDownloadAll: window.__sdRole('downloadAll').disabled, - }; - const ok = await run; - await new Promise(r => setTimeout(r, 200)); - return { ok, during, afterApply: document.getElementById('applyNote').textContent, - afterSdRefresh: window.__sdRole('refresh').disabled }; -`); -check( - "while a name is being written, Apply and the SD panel are refused with a reason", - brandBlocksSd.ok === true && - brandBlocksSd.during.apply && - brandBlocksSd.during.sdRefresh && - brandBlocksSd.during.sdDownloadAll && - /name write and a configuration write cannot share the link/.test( - brandBlocksSd.during.applyNote, - ) && - brandBlocksSd.afterApply === "" && - !brandBlocksSd.afterSdRefresh, - brandBlocksSd.during.applyNote, -); - -// ---- restore the factory names: erase, then prove the restart re-seeds them -const brandRestore = await evaluate(` - const sdk = await import('/vendor/shimmer-web-sdk.esm.js'); - window.mockTransport.writes.length = 0; - const before = window.mockTransport.eeprom.brandBytes(); - const ok = await window.brandEditor.restoreDefaults(); - const erased = window.mockTransport.eeprom.brandBytes(); - const afterErase = { - pill: window.__brandRole('recordPill').textContent, - note: window.__brandRole('recordNote').textContent, - matchesBlank: window.__sameBytes(erased, sdk.buildBlankBrandRecord()), - stats: window.__brandStats(), - inputs: { bt: window.__brandInput('btClassic').value, - man: window.__brandInput('usbManufacturer').value }, - isStock: window.brandEditor.isStockRecord(), - armed: window.mockTransport.eeprom.rebootArmed, - reboots: window.mockTransport.eeprom.reboots, - }; - return { ok, confirm: window.__confirm, wasCustom: sdk.parseBrandRecord(before).btClassic, - afterErase, writes: window.__opCount(0x67) }; -`); -check( - "restore-defaults erases the record and says the factory names come back at the restart", - brandRestore.ok === true && - brandRestore.wasCustom === "AcmeTwo" && - brandRestore.writes === 1 && - brandRestore.afterErase.matchesBlank === -1 && - /invalid: blank \(erased\) record/.test(brandRestore.afterErase.pill) && - /using its factory names/.test(brandRestore.afterErase.note) && - brandRestore.afterErase.inputs.bt === "" && - brandRestore.afterErase.inputs.man === "Shimmer Research Ltd." && - /Shimmer3R/.test(brandRestore.confirm) && - brandRestore.afterErase.reboots === 0, - `${brandRestore.afterErase.pill}; confirmed "${ - brandRestore.confirm.split("\n").filter(Boolean)[1]?.trim() ?? "" - }"`, -); - -// ---- the Bluetooth route: arm the restart, drop the link, and the firmware -// re-seeds the factory record at boot -const brandRestart = await evaluate(` - window.mockTransport.writes.length = 0; - const armedBefore = window.mockTransport.eeprom.rebootArmed; - const ok = await window.brandEditor.armRestart(); - await new Promise(r => setTimeout(r, 400)); - const feature = window.mockTransport.writes.find(w => w.bytes[0] === 0xB7); - const onDevice = window.mockTransport.eeprom.brandBytes(); - return { ok, armedBefore, - featureCmd: feature ? [...feature.bytes] : null, - reboots: window.mockTransport.eeprom.reboots, - armedAfter: window.mockTransport.eeprom.rebootArmed, - reseeded: window.__sameBytes(onDevice, window.mockTransport.eeprom.stockBrandBytes()), - connPill: document.getElementById('connPill').textContent, - bannerHidden: window.__brandRole('restart-banner').hidden, - brandTabStat: window.__brandStats().btClassic, - logs: [...document.querySelectorAll('#log .log-line')].map(l => l.textContent) - .filter(l => /restart/i.test(l)) }; -`); -check( - "the Bluetooth route arms a one-shot restart with SET_FEATURE, then drops the link", - brandRestart.ok === true && - brandRestart.armedBefore === false && - brandRestart.featureCmd?.join() === "183,2,1" && - brandRestart.connPill === "disconnected" && - brandRestart.bannerHidden && - brandRestart.brandTabStat === "–", - `SET_FEATURE ${brandRestart.featureCmd?.join(" ")} → ${brandRestart.connPill}`, -); -check( - "the restart fires once, clears itself, and the factory record is back byte for byte", - brandRestart.reboots === 1 && - brandRestart.armedAfter === false && - brandRestart.reseeded === -1, - `${brandRestart.reboots} restart, armed=${brandRestart.armedAfter}, stock bytes restored=${brandRestart.reseeded === -1}`, -); - -// =========================================================================== -// Device naming on a sensor that will not say what it is. -// -// THE trap: the page defaults an unknown hardware version to 10 (Shimmer3R) -// when it builds the InfoMem context, because the field schema needs some -// layout to render. The naming panel must NOT see that default, or an -// unidentified Shimmer3 is offered a 10-character BLE prefix its own firmware -// truncates to 8 on air — invisibly, from this page. -// =========================================================================== -console.log("\n--- device naming: a sensor that will not identify itself ---"); -await goto(`${BASE}?mock=1&hw=none`); -check( - "connect to a sensor that refuses GET_DEVICE_VERSION", - (await evaluate(CONNECT)) === "mock", -); -const brandUnknown = await evaluate( - BRAND + - ` - document.querySelector('.tabs [data-tab="tabBrand"]').click(); - for (let i = 0; i < 60 && !window.brandEditor.record(); i++) - await new Promise(r => setTimeout(r, 100)); - window.__type('btClassic', 'AcmeWristband'); - window.__type('ble', 'TenCharsXX'); - const overCap = { error: window.__brandError('ble'), write: window.__brandRole('write').disabled }; - window.__type('ble', ''); - return { idHw: document.getElementById('idHw').textContent, - contextHw: window.brandEditor.bleCap(), overCap, - maxAttr: window.__brandInput('ble').maxLength, - fields: document.querySelectorAll('#configForm .field').length, - isStock: window.brandEditor.isStockRecord(), - pill: window.__brandRole('recordPill').textContent, - note: window.__brandRole('recordNote').textContent, - stats: window.__brandStats(), - derived: window.brandEditor.fields().ble, - preview: window.__brandPreview('btClassic'), - warned: [...document.querySelectorAll('#log .log-line')] - .filter(l => /did not report its hardware version/.test(l.textContent)).length }; -`, -); -check( - "an unidentified sensor keeps the SHORT BLE cap, even though the config form defaulted to a Shimmer3R", - /* The hardware line leads with the BOARD when the sensor will not say which - platform it is: "unknown hardware GSR+" reads like a fault, and the SR - code is the useful half. That the platform is genuinely still unknown is - what the BLE cap assertions below prove — an unidentified sensor keeps the - shorter Shimmer3 cap, which is safe on either platform. */ - brandUnknown.idHw === "GSR+ (SR48-3-0)" && - brandUnknown.fields === 40 && - brandUnknown.contextHw === 8 && - brandUnknown.maxAttr === 8 && - brandUnknown.derived === "AcmeWris" && - /longer than 8 characters/.test(brandUnknown.overCap.error) && - brandUnknown.overCap.write && - brandUnknown.warned === 1, - `cap ${brandUnknown.contextHw}, maxlength ${brandUnknown.maxAttr}, derived "${brandUnknown.derived}", "${brandUnknown.overCap.error}"`, -); -check( - "and it declines to call the record stock or custom, rather than guessing", - brandUnknown.isStock === null && - brandUnknown.pill === "valid" && - /did not report which hardware it is/.test(brandUnknown.note) && - brandUnknown.stats.device === "not identified" && - /on a Shimmer3, .*on a Shimmer3R/.test(brandUnknown.preview), - `${brandUnknown.pill} — ${brandUnknown.preview}`, -); - -// =========================================================================== -// Device naming over a link with no soft restart. -// -// The dock UART has no SET_FEATURE, so the panel has to walk the user through -// a power-cycle instead. The mock is always a Bluetooth client, so this one -// mounts the panel directly on a wired-shaped client: the same two -// daughter-card calls, no setRebootOnDisconnect. -// =========================================================================== -console.log("\n--- device naming: a link that cannot ask for a restart ---"); -const brandWired = await evaluate(` - const [m, sdk] = await Promise.all([ - import('/common/brand-editor.js'), - import('/vendor/shimmer-web-sdk.esm.js'), - ]); - const store = new Uint8Array(2032).fill(0xff); - store.set(sdk.buildBrandRecord({ btClassic: 'Shimmer3', ble: 'S3BLE', - usbProduct: 'Shimmer', usbManufacturer: 'Shimmer Research Ltd.', - seededPlatform: sdk.BRAND_PLATFORM.SHIMMER3 }), sdk.BRAND_RECORD_HOST_OFFSET); - const wired = { - async readDaughterCardMem(off, len) { return store.slice(off, off + len); }, - async writeDaughterCardMem(off, data) { store.set(data, off); }, - }; - const hostEl = document.createElement('div'); - document.body.appendChild(hostEl); - const lines = []; - const panel = m.createBrandEditor(hostEl, { - client: wired, mode: 'usb', identifiedHardwareVersion: 3, macSuffix: 'AB12', - log: { log: (s) => lines.push(s), warn: (s) => lines.push(s), error: (s) => lines.push(s) }, - confirm: () => true, - }); - const beforeEnable = await panel.write(); - panel.setEnabled(true); - const rec = await panel.read(); - const role = (r) => hostEl.querySelector('[data-brand-role="' + r + '"]'); - const input = (k) => hostEl.querySelector('[data-brand-input="' + k + '"]'); - panel.setFields({ btClassic: 'DockBrand', usbManufacturer: 'Dock Ltd.' }); - const bleCap = panel.bleCap(); - const bleMax = input('ble').maxLength; - const ok = await panel.write(); - const written = sdk.parseBrandRecord(store.slice(sdk.BRAND_RECORD_HOST_OFFSET, - sdk.BRAND_RECORD_HOST_OFFSET + sdk.BRAND_RECORD_SIZE)); - const out = { - beforeEnable, ok, bleCap, bleMax, - stockOnRead: panel.isStockRecord(rec), - canSoftRestart: panel.canSoftRestart(), - bannerShown: !role('restart-banner').hidden, - autoHidden: role('restartAuto').hidden, - manualHidden: role('restartManual').hidden, - restartHidden: role('restart').hidden, - steps: [...hostEl.querySelectorAll('.brand-steps li')].map(li => li.textContent.slice(0, 40)), - manualText: role('restartManual').textContent.slice(0, 200), - written: { bt: written.btClassic, ble: written.ble, product: written.usbProduct, man: written.usbManufacturer }, - productPreview: hostEl.querySelector('[data-brand-preview="usbProduct"]').textContent, - armed: await panel.armRestart(), - refusal: lines.filter(l => /restart/i.test(l)), - }; - panel.destroy(); - hostEl.remove(); - return out; -`); -check( - "the panel mounts unchanged on a wired client and writes over it", - brandWired.beforeEnable === false && - brandWired.ok === true && - brandWired.stockOnRead === true && - brandWired.written.bt === "DockBrand" && - brandWired.written.ble === "DockBran" && - brandWired.written.product === "DockBrand" && - brandWired.written.man === "Dock Ltd.", - JSON.stringify(brandWired.written), -); -check( - "a positively identified Shimmer3 gets the 8-character cap and the Shimmer3 name shapes", - brandWired.bleCap === 8 && - brandWired.bleMax === 8 && - /unused on a Shimmer3/.test(brandWired.productPreview), - `cap ${brandWired.bleCap} — ${brandWired.productPreview}`, -); -check( - "with no soft restart the banner walks the user through a power-cycle instead", - brandWired.canSoftRestart === false && - brandWired.bannerShown && - brandWired.autoHidden && - !brandWired.manualHidden && - brandWired.restartHidden && - brandWired.steps.length === 3 && - /dock\/USB serial protocol has no such command/.test( - brandWired.manualText, - ) && - brandWired.armed === false && - brandWired.refusal.some((l) => /power-cycle it by hand/.test(l)), - brandWired.refusal.join(" | ") || brandWired.manualText, -); - -// =========================================================================== -// Calibration. -// -// Its own page loads: the tab reads a whole dump, edits it and writes it -// back, and each of the hardware variants below needs the mock rebuilt with a -// different hardware id. -// =========================================================================== -console.log("\n--- calibration ---"); -await goto(`${BASE}?mock=1`); -check("connect for the calibration pass", (await evaluate(CONNECT)) === "mock"); - -/* Shared helpers for this section: the panel plants `data-cal-*` roles rather - than ids, exactly as the SD and device-naming panels do, so a page can - mount it twice. */ -const CAL = ` - const P = () => document.getElementById('calPanel'); - const role = (r) => P().querySelector('[data-cal-role="' + r + '"]'); - const card = (g) => P().querySelector('[data-cal-sensor="' + g + '"]'); - const cell = (g, part, i) => - P().querySelector('input[data-cal-input="' + g + ':' + part + ':' + i + '"]'); - const setCell = (g, part, i, v) => { - const c = cell(g, part, i); - c.value = v; - c.dispatchEvent(new Event('input', { bubbles: true })); - }; - const pill = (g) => card(g).querySelector('[data-cal-state]').textContent; - const cells = (g, part) => - [...P().querySelectorAll('input[data-cal-input^="' + g + ':' + part + '"]')] - .map(i => i.value).join(','); - const setRange = (g, v) => { - const s = card(g).querySelector('[data-cal-range]'); - s.value = String(v); - s.dispatchEvent(new Event('change', { bubbles: true })); - }; - /* Every SET_CALIB_DUMP chunk since \`from\`, reassembled at its own offsets - — which is also the assertion that the chunks arrived in order and - covered the dump with no hole. */ - const sentDump = (from) => { - const chunks = window.mockTransport.writes.slice(from) - .map(w => Array.from(w.bytes)).filter(b => b[0] === 0x98); - if (!chunks.length) return null; - const total = chunks.reduce((n, c) => n + c.length - 4, 0); - const out = new Uint8Array(total); - for (const c of chunks) out.set(c.slice(4), c[2] | (c[3] << 8)); - return { bytes: out, sizes: chunks.map(c => c.length - 4), - offsets: chunks.map(c => c[2] | (c[3] << 8)) }; - }; -`; - -// ---- the tab exists, sits next to Configure, and Configure no longer has it -const calTab = await evaluate(` - ${CAL} - return { - order: [...document.querySelectorAll('.tabs [data-tab]')].map(b => b.dataset.tab), - label: document.querySelector('.tabs [data-tab=tabCal]').textContent.trim(), - goneFromConfig: [...document.querySelectorAll('#tabConfig .card-title')] - .map(t => t.textContent.trim()), - noOldIds: ['btnCalRead','btnCalWrite','btnCalSave','btnCalLoad','calTable','calPill'] - .filter(id => document.getElementById(id)), - ownsItsButtons: ['read','write','save','load'] - .every(r => !!P().querySelector('[data-cal-role="' + r + '"]')), - }; -`); -check( - "Calibration is a tab of its own, next to Configure, and has left it", - // The whole strip is pinned, not just Calibration's neighbour, so that a - // tab appearing in the wrong place is caught as loudly as one going - // missing. General leads it — the one-shot commands you reach for straight - // after connecting, as in the Verisense device console — and Test ends it: - // the tab you reach for once the sensor is set up, not part of setting it - // up. - calTab.order.join(",") === - "tabGeneral,tabConfig,tabCal,tabStream,tabSd,tabBrand,tabTest" && - calTab.label === "Calibration" && - !calTab.goneFromConfig.includes("Calibration") && - calTab.noOldIds.length === 0 && - calTab.ownsItsButtons, - `${calTab.order.join(" → ")}; Configure now holds ${calTab.goneFromConfig.join(", ")}`, -); - -// ---- the dump reads, and every sensor renders with its own range and date -const calRead = await evaluate(` - ${CAL} - document.querySelector('.tabs [data-tab=tabCal]').click(); - /* Sliced from HERE, not from the start of the session: the page reads the - calibration as part of its connect handshake now, so the writes already - hold one complete paged read before this click adds a second. Filtering - the whole history would assert on both at once. */ - const from = window.mockTransport.writes.length; - role('read').click(); - await new Promise(r => setTimeout(r, 2500)); - const reads = window.mockTransport.writes.slice(from) - .filter(w => w.bytes[0] === 0x9a) - .map(w => ({ len: w.bytes[1], off: w.bytes[2] | (w.bytes[3] << 8) })); - return { - pill: role('storePill').textContent, - banner: role('storeBanner').textContent, - change: role('changeNote').textContent, - reads, - sensors: [...P().querySelectorAll('[data-cal-sensor]')].map(c => ({ - key: c.dataset.calSensor, - id: Number(c.dataset.calSensorId), - state: c.dataset.calState, - avail: c.dataset.calAvailability, - pill: c.querySelector('[data-cal-state]').textContent, - range: c.querySelector('[data-cal-range]')?.value ?? null, - rangeLabel: c.querySelector('[data-cal-range] option:checked')?.textContent ?? null, - asOf: c.querySelector('[data-cal-as-of]')?.textContent ?? '', - offset: cells(c.dataset.calSensor, 'offset'), - sens: cells(c.dataset.calSensor, 'sens'), - align: cells(c.dataset.calSensor, 'align'), - })), - }; -`); -const byKey = Object.fromEntries(calRead.sensors.map((s) => [s.key, s])); -check( - "the dump reads and renders per sensor, per range, with the date it was taken", - calRead.pill === "209 bytes read" && - /6 records/.test(calRead.banner) && - byKey.lnAccel.id === 37 && - byKey.lnAccel.offset === "12,-30,4" && - byKey.lnAccel.sens === "1674,1670,1673" && - byKey.lnAccel.align === "-1,0,0,0,1,0,0,0,-1" && - /^calibrated 2026-06-11/.test(byKey.lnAccel.pill) && - /^as of 2026-06-11/.test(byKey.lnAccel.asOf) && - byKey.gyro.range === "3" && - /1000dps/.test(byKey.gyro.rangeLabel) && - byKey.mag.id === 42 && - /^calibrated 2026-04-02/.test(byKey.mag.pill), - `${calRead.pill}; ln ${byKey.lnAccel.pill}; gyro at ${byKey.gyro.rangeLabel}`, -); -check( - "sensitivity is three cells, not nine — the block stores a diagonal", - calRead.sensors - .filter((s) => s.state !== "unmodelled") - .every( - (s) => s.sens.split(",").length === 3 && s.align.split(",").length === 9, - ), - `sensitivity ${byKey.lnAccel.sens.split(",").length} cells, alignment ${byKey.lnAccel.align.split(",").length}`, -); -check( - "the dump is paged at 128 bytes, the total taken from the first page", - calRead.reads.length === 2 && - calRead.reads[0].len === 128 && - calRead.reads[0].off === 0 && - calRead.reads[1].off === 128 && - calRead.reads[1].len === 81, - JSON.stringify(calRead.reads), -); -check( - "the gyro's own values are the factory defaults, and the tab says so", - byKey.gyro.state === "defaults" && - byKey.gyro.pill === "factory defaults" && - /firmware seeded this itself/.test(byKey.gyro.asOf) && - byKey.wrAccel.state === "defaults", - `${byKey.gyro.pill} — ${byKey.gyro.asOf}`, -); - -// ---- never calibrated is its own state, both flavours of it -check( - "a sensor with no record at all reads as never calibrated, not as zeros", - byKey.altAccel.state === "never" && - byKey.altAccel.pill === "never calibrated" && - byKey.altAccel.offset === ",," && - /* The date line is on EVERY card once something has been read, saying - "no date" where there is none. It used to be blank here, which read as - "these three sensors are the only ones with a calibration date" when - what it meant was "the others have no calibration at all" — a - different fact, and one the pill beside the title already makes. */ - /^no date — nothing is stored/.test(byKey.altAccel.asOf), - `${byKey.altAccel.pill}, boxes "${byKey.altAccel.offset}", date "${byKey.altAccel.asOf}"`, -); -check( - "and so does a record whose block is all 0xFF — not 65535s", - byKey.altMag.state === "never" && - byKey.altMag.pill === "never calibrated" && - byKey.altMag.sens === ",," && - !/65535/.test(byKey.altMag.sens), - `${byKey.altMag.pill}, boxes "${byKey.altMag.sens}"`, -); -const calNever = await evaluate(` - ${CAL} - setRange('gyro', 0); - await new Promise(r => setTimeout(r, 200)); - const at0 = { pill: pill('gyro'), state: card('gyro').dataset.calState, - value: cells('gyro', 'sens'), placeholder: cell('gyro','sens',0).placeholder, - note: card('gyro').querySelector('[data-cal-note]').textContent }; - setRange('gyro', 3); - await new Promise(r => setTimeout(r, 200)); - return { at0, backAt3: cells('gyro', 'sens') }; -`); -check( - "a range with no record shows the defaults greyed and says nothing is stored", - calNever.at0.state === "never" && - calNever.at0.value === ",," && - calNever.at0.placeholder === "229" && - /Nothing is stored for this sensor at this range/.test(calNever.at0.note) && - calNever.backAt3 === "29,29,29", - `at ±125dps: "${calNever.at0.value}" with placeholder ${calNever.at0.placeholder}`, -); - -// ---- the pressure sensor is present but not a kinematic set -check( - "the pressure sensor is shown, disabled, with the reason — not hidden", - byKey["id:43"] && - byKey["id:43"].avail === "disabled" && - byKey["id:43"].range === null && - /stored 2026-04-02/.test(byKey["id:43"].pill), - byKey["id:43"] ? byKey["id:43"].pill : "(no pressure card)", -); - -// ---- a value the format cannot hold: refused, and NOTHING goes on the wire -const calBad = await evaluate(` - ${CAL} - const before = window.mockTransport.writes.length; - setCell('lnAccel', 'align', 0, '5'); - await new Promise(r => setTimeout(r, 200)); - const alignOnly = { - error: card('lnAccel').querySelector('[data-cal-error]').textContent, - header: role('changeNote').textContent, - }; - setCell('lnAccel', 'offset', 1, '40000'); - await new Promise(r => setTimeout(r, 200)); - const state = { - alignOnly, - pill: pill('lnAccel'), - cardState: card('lnAccel').dataset.calState, - error: card('lnAccel').querySelector('[data-cal-error]').textContent, - errorShown: !card('lnAccel').querySelector('[data-cal-error]').hidden, - marked: cell('lnAccel','align',0).classList.contains('bad') && - cell('lnAccel','offset',1).classList.contains('bad'), - untouchedClean: !cell('lnAccel','offset',0).classList.contains('bad'), - writeDisabled: role('write').disabled, - header: role('changeNote').textContent, - }; - role('write').click(); - await new Promise(r => requestAnimationFrame(() => setTimeout(r, 800))); - state.sent = window.mockTransport.writes.slice(before).map(w => w.bytes[0]); - state.discarded = window.mockTransport.calib.discarded; - return state; -`); -check( - "an out-of-range alignment or offset is refused, Write is disabled, nothing is sent", - calBad.cardState === "invalid" && - calBad.writeDisabled && - calBad.marked && - calBad.untouchedClean && - calBad.errorShown && - /-1\.28 to 1\.27 in steps of 0\.01/.test(calBad.alignOnly.error) && - /1 value the calibration format cannot hold/.test( - calBad.alignOnly.header, - ) && - /a whole number from -32768 to 32767/.test(calBad.error) && - /One other value also needs attention/.test(calBad.error) && - /2 values the calibration format cannot hold/.test(calBad.header) && - calBad.sent.length === 0 && - calBad.discarded === 0, - `${calBad.error.slice(0, 80)}… — ${calBad.sent.length} commands sent`, -); -const calRound = await evaluate(` - ${CAL} - setCell('lnAccel', 'align', 0, '-1'); - setCell('lnAccel', 'offset', 1, '12.5'); - await new Promise(r => setTimeout(r, 150)); - const fractionalOffset = card('lnAccel').querySelector('[data-cal-error]').textContent; - setCell('lnAccel', 'offset', 1, '-30'); - setCell('gyro', 'sens', 0, '29.005'); - await new Promise(r => setTimeout(r, 150)); - const finerThanTheScale = card('gyro').querySelector('[data-cal-error]').textContent; - setCell('gyro', 'sens', 0, '29'); - await new Promise(r => setTimeout(r, 150)); - return { fractionalOffset, finerThanTheScale, - clean: role('changeNote').textContent, writeDisabled: role('write').disabled }; -`); -check( - "a value the encoder would round or truncate is refused too, not silently changed", - /must be a whole number/.test(calRound.fractionalOffset) && - /must be a multiple of 0\.01/.test(calRound.finerThanTheScale) && - calRound.clean === "No changes since the dump was read." && - calRound.writeDisabled, - `${calRound.fractionalOffset.slice(0, 46)}… / ${calRound.finerThanTheScale.slice(0, 46)}…`, -); - -// ---- a real edit: the exact bytes on the wire, then read back and compared -const calWrite = await evaluate(` - ${CAL} - const sdk = await import('/vendor/shimmer-web-sdk.esm.js'); - setCell('lnAccel', 'offset', 0, '25'); - await new Promise(r => setTimeout(r, 200)); - const dirtyCard = card('lnAccel').dataset.calDirty; - const dirtyPill = pill('lnAccel'); - const from = window.mockTransport.writes.length; - role('write').click(); - await new Promise(r => setTimeout(r, 3000)); - const sent = sentDump(from); - const rec = sdk.parseCalibDump(sent.bytes).records - .find(r => r.sensorId === 37 && r.range === 0); - const expected = sdk.generateKinematicCalibBlock( - [25, -30, 4], [1674, 1670, 1673], [-1, 0, 0, 0, 1, 0, 0, 0, -1], - { sensitivityScale: 1 }); - const untouched = sdk.parseCalibDump(sent.bytes).records - .find(r => r.sensorId === 38); - const gyroDefault = sdk.getDefaultCalibration('shimmer3r', 'gyro', 3); - return { - dirtyCard, dirtyPill, - confirm: window.__confirm, - sizes: sent.sizes, offsets: sent.offsets, - onWire: Array.from(rec.calibBytes).join(','), - expected: Array.from(expected).join(','), - gyroUntouched: Array.from(untouched.calibBytes).join(',') === - Array.from(sdk.generateKinematicCalibBlock(gyroDefault.calibration.offset, - gyroDefault.calibration.sensitivity, gyroDefault.calibration.alignment, - { sensitivityScale: 100 })).join(','), - upd: window.mockTransport.calib.updates, - discarded: window.mockTransport.calib.discarded, - deviceHasIt: Array.from(window.mockTransport.calib.bytes()).join(',') === - Array.from(sent.bytes).join(','), - afterPill: pill('lnAccel'), - afterBoxes: cells('lnAccel', 'offset'), - afterHeader: role('changeNote').textContent, - log: [...document.querySelectorAll('#log .log-line')].map(l => l.textContent) - .filter(l => /calibration/i.test(l)).slice(-3), - }; -`); -check( - "an edit writes exactly the bytes the block encodes to, and only that record moves", - calWrite.onWire === calWrite.expected && - calWrite.onWire === - "0,25,255,226,0,4,6,138,6,134,6,137,156,0,0,0,100,0,0,0,156" && - calWrite.gyroUntouched, - `on the wire ${calWrite.onWire}`, -); -check( - "the write starts at offset 0 and runs forward, then asks the firmware to apply it", - calWrite.offsets[0] === 0 && - calWrite.offsets.every( - (o, i) => - i === 0 || o === calWrite.offsets[i - 1] + calWrite.sizes[i - 1], - ) && - calWrite.sizes.reduce((a, b) => a + b, 0) === 209 && - calWrite.upd === 1 && - calWrite.discarded === 0, - `${calWrite.sizes.join("+")} bytes at offsets ${calWrite.offsets.join(", ")}`, -); -check( - "the confirmation names the store and shows the value that moves", - /CALIBRATION DUMP/.test(calWrite.confirm) && - /Low-noise accelerometer \(LSM6DSV\) at ± 2g/.test(calWrite.confirm) && - /was\s+offset \[12, -30, 4\]/.test(calWrite.confirm) && - /now\s+offset \[25, -30, 4\]/.test(calWrite.confirm) && - /rebuild the dump from the configuration bytes/.test(calWrite.confirm), - calWrite.confirm - .split("\n") - .find((l) => /was\s+offset/.test(l)) - ?.trim(), -); -check( - "the write is read back and byte-compared, and the tab settles on what came back", - calWrite.deviceHasIt && - calWrite.afterBoxes === "25,-30,4" && - calWrite.afterHeader === "No changes since the dump was read." && - calWrite.log.some((l) => /read back byte-identical/.test(l)) && - calWrite.dirtyCard === "true" && - calWrite.dirtyPill === "edited, not written", - calWrite.log[calWrite.log.length - 1], -); - -// ---- restore defaults is the SDK's own block, byte for byte -const calDefaults = await evaluate(` - ${CAL} - const sdk = await import('/vendor/shimmer-web-sdk.esm.js'); - card('lnAccel').querySelector('[data-cal-defaults]').click(); - await new Promise(r => setTimeout(r, 200)); - const filled = cells('lnAccel', 'offset') + ' | ' + cells('lnAccel', 'sens'); - const from = window.mockTransport.writes.length; - role('write').click(); - await new Promise(r => setTimeout(r, 3000)); - const rec = sdk.parseCalibDump(sentDump(from).bytes).records - .find(r => r.sensorId === 37 && r.range === 0); - const d = sdk.getDefaultCalibration('shimmer3r', 'lnAccel', 0); - const exp = sdk.generateKinematicCalibBlock(d.calibration.offset, - d.calibration.sensitivity, d.calibration.alignment, - { sensitivityScale: d.sensitivityScale }); - return { filled, onWire: Array.from(rec.calibBytes).join(','), - expected: Array.from(exp).join(','), - pill: pill('lnAccel'), state: card('lnAccel').dataset.calState }; -`); -check( - "restore defaults produces the SDK's default block byte for byte, and says so", - calDefaults.onWire === calDefaults.expected && - calDefaults.filled === "0,0,0 | 1672,1672,1672" && - calDefaults.state === "defaults" && - calDefaults.pill === "factory defaults", - `${calDefaults.filled} → ${calDefaults.pill}`, -); - -// ---- save and load the raw dump, as the retired card did -const calFile = await evaluate(` - ${CAL} - window.__blobs = []; - role('save').click(); - await new Promise(r => setTimeout(r, 300)); - const saved = new Uint8Array(await window.__blobs.at(-1).arrayBuffer()); - /* Load it back through the panel's own entry point, which is what the file - picker calls once the bytes are read. Byte 23 is the first record's - offset-x low byte: 2 length + 8 version + 4 record header + 8 timestamp, - then the block. */ - const edited = Uint8Array.from(saved); - edited[23] = 0x63; - const ok = window.calibrationEditor.load(edited); - await new Promise(r => setTimeout(r, 300)); - return { savedLen: saved.length, ok, - pill: role('storePill').textContent, - note: role('storeNote').textContent, - offsets: cells('lnAccel', 'offset'), - loadEnabled: !role('load').disabled }; -`); -check( - "the raw dump still saves and loads, and a loaded one says it came from a file", - calFile.savedLen === 209 && - calFile.ok && - /loaded/.test(calFile.pill) && - /came from a file on this host/.test(calFile.note) && - calFile.offsets === "99,0,0" && - calFile.loadEnabled, - `${calFile.savedLen} bytes saved; reloaded offset ${calFile.offsets}`, -); - -// ---- the link is shared: calibration cannot race a stream or an SD transfer -const calContend = await evaluate(` - ${CAL} - role('read').click(); - await new Promise(r => setTimeout(r, 2000)); - document.querySelector('.tabs [data-tab=tabStream]').click(); - document.getElementById('btnStreamStart').click(); - await new Promise(r => setTimeout(r, 1200)); - const during = { - tabDisabled: document.getElementById('tabBtnCal').disabled, - banner: document.getElementById('calBanner').textContent, - readDisabled: role('read').disabled, - writeDisabled: role('write').disabled, - inputDisabled: cell('lnAccel', 'offset', 0).disabled, - }; - const from = window.mockTransport.writes.length; - /* Through the panel's own API, not the button: a disabled button proves - the gate, this proves the panel refuses even when something reaches past - it — and it is what produces the line in the log. */ - during.readAttempt = await window.calibrationEditor.read(); - during.writeAttempt = await window.calibrationEditor.write(); - during.sentWhileStreaming = window.mockTransport.writes.slice(from) - .filter(w => [0x98, 0x9a, 0x9b].includes(w.bytes[0])).length; - await new Promise(r => requestAnimationFrame(() => setTimeout(r, 120))); - during.refusal = [...document.querySelectorAll('#log .log-line')].slice(-6) - .map(l => l.textContent).find(l => /calibration cannot be reached/.test(l)) ?? ''; - document.getElementById('btnStreamStop').click(); - await new Promise(r => setTimeout(r, 1200)); - document.querySelector('.tabs [data-tab=tabCal]').click(); - return { during, - after: { tabDisabled: document.getElementById('tabBtnCal').disabled, - readDisabled: role('read').disabled, - banner: document.getElementById('calBanner').textContent } }; -`); -check( - "a calibration read or write is refused while the sensor is streaming", - calContend.during.tabDisabled && - calContend.during.readDisabled && - calContend.during.writeDisabled && - calContend.during.inputDisabled && - /firmware refuses every configuration command while it is/.test( - calContend.during.banner, - ) && - calContend.during.readAttempt === null && - calContend.during.writeAttempt === false && - calContend.during.sentWhileStreaming === 0 && - /calibration cannot be reached/.test(calContend.during.refusal), - `${calContend.during.sentWhileStreaming} calibration commands sent while streaming`, -); -check( - "and offered again the moment the stream stops", - !calContend.after.tabDisabled && - !calContend.after.readDisabled && - calContend.after.banner === "", - `tab ${calContend.after.tabDisabled ? "still closed" : "open"} after stop`, -); - -const calVsSd = await evaluate( - MEMFS + - ` - ${CAL} - document.querySelector('.tabs [data-tab="tabSd"]').click(); - await window.sdBrowser.refresh(); - await window.__useFs('CalibContendDest'); - const run = window.sdBrowser.download(['data'], { deleteVerified: false }); - const label = window.__sdRole('progress'); - for (let i = 0; i < 60; i++) { - await new Promise(r => setTimeout(r, 100)); - if (/^download: /.test(label.textContent)) break; - } - const from = window.mockTransport.writes.length; - const during = { - tabDisabled: document.getElementById('tabBtnCal').disabled, - banner: document.getElementById('calBanner').textContent, - readDisabled: role('read').disabled, - readAttempt: await window.calibrationEditor.read(), - writeAttempt: await window.calibrationEditor.write(), - }; - during.sentDuringTransfer = window.mockTransport.writes.slice(from) - .filter(w => [0x98, 0x9a, 0x9b].includes(w.bytes[0])).length; - window.sdBrowser.abort(); - await run.catch(() => {}); - await new Promise(r => setTimeout(r, 600)); - during.afterTab = document.getElementById('tabBtnCal').disabled; - return during; -`, -); -check( - "and refused while an SD transfer is holding the link", - calVsSd.tabDisabled && - calVsSd.readDisabled && - calVsSd.readAttempt === null && - calVsSd.writeAttempt === false && - /SD card transfer is using the link/.test(calVsSd.banner) && - calVsSd.sentDuringTransfer === 0 && - !calVsSd.afterTab, - `${calVsSd.sentDuringTransfer} calibration commands sent during the transfer`, -); - -// =========================================================================== -// Calibration on a Shimmer3, which has neither of the alternate sensors. -// =========================================================================== -console.log("\n--- calibration: a Shimmer3 ---"); -/* SR48-2-0 on purpose: the expansion board's revision is what separates an - old-IMU Shimmer3 from a new-IMU one (revision >= 3 on a GSR+ board), and - this pass is about the older LSM303DLHC/MPU9150 tables. It used to be left - to whatever the mock's id page happened to hold, which was nothing at all -- - the page was reading the wrong EEPROM page and inferring the generation - from unrelated bytes. */ -await goto(`${BASE}?mock=1&hw=3&srBoard=48-2-0`); -check("connect to a Shimmer3", (await evaluate(CONNECT)) === "mock"); -const calS3 = await evaluate(` - ${CAL} - document.querySelector('.tabs [data-tab=tabCal]').click(); - role('read').click(); - await new Promise(r => setTimeout(r, 2500)); - return { - hw: document.getElementById('idHw').textContent, - sensors: [...P().querySelectorAll('[data-cal-sensor]')].map(c => ({ - key: c.dataset.calSensor, id: Number(c.dataset.calSensorId), - chip: c.querySelector('.cal-chip').textContent, - state: c.dataset.calState, - range: c.querySelector('[data-cal-range] option:checked')?.textContent ?? null, - sens: cells(c.dataset.calSensor, 'sens'), - })), - records: role('storeBanner').textContent, - }; -`); -const s3keys = calS3.sensors.map((s) => s.key); -check( - "a Shimmer3 hides the high-g accel and the alternate magnetometer it does not have", - calS3.hw === "Shimmer3 GSR+ (SR48-2-0)" && - !s3keys.includes("altAccel") && - !s3keys.includes("altMag") && - s3keys.join(",") === "lnAccel,gyro,wrAccel,mag,id:36", - `cards: ${s3keys.join(", ")}`, -); -check( - "and names the Shimmer3's own chips and sensor ids, not the Shimmer3R's", - calS3.sensors.find((s) => s.key === "lnAccel").id === 2 && - calS3.sensors.find((s) => s.key === "gyro").id === 30 && - calS3.sensors.find((s) => s.key === "wrAccel").id === 31 && - calS3.sensors.find((s) => s.key === "mag").id === 32 && - calS3.sensors.find((s) => s.key === "mag").chip === "LSM303DLHC" && - calS3.sensors.find((s) => s.key === "mag").sens === "1104,1098,981", - calS3.sensors.map((s) => `${s.key}=${s.id}`).join(" "), -); -check( - "the Shimmer3 gyro's ±2000dps default parses through its ×100 sensitivity scale", - calS3.sensors.find((s) => s.key === "gyro").sens === "16.4,16.4,16.4" && - /2000dps/.test(calS3.sensors.find((s) => s.key === "gyro").range), - calS3.sensors.find((s) => s.key === "gyro").sens, -); - -// =========================================================================== -// Calibration on a sensor that will not say what it is. -// =========================================================================== -console.log("\n--- calibration: an unidentified sensor ---"); -await goto(`${BASE}?mock=1&hw=none`); -/* Sampled BEFORE connecting, which is now the only state in which nothing - has named the hardware: the page reads the calibration as part of its - connect handshake, and on this sensor — one that refuses - GET_DEVICE_VERSION — the dump's own version header is what names it. That - is the point of the fallback, and it is worth pinning that it happens - without anybody pressing Read. */ -const calUnknownBefore = await evaluate(` - ${CAL} - document.querySelector('.tabs [data-tab=tabCal]').click(); - return { - unknownCard: !!P().querySelector('[data-cal-role=unknownHardware]'), - text: P().querySelector('[data-cal-role=unknownHardware]')?.textContent ?? '', - sensors: P().querySelectorAll('[data-cal-sensor]').length, - }; -`); -check( - "connect to a sensor that refuses GET_DEVICE_VERSION", - (await evaluate(CONNECT)) === "mock", -); -const calUnknown = await evaluate(` - ${CAL} - document.querySelector('.tabs [data-tab=tabCal]').click(); - return { after: { - unknownCard: !!P().querySelector('[data-cal-role=unknownHardware]'), - sensors: [...P().querySelectorAll('[data-cal-sensor]')].map(c => c.dataset.calSensor), - lnAccel: cells('lnAccel', 'sens'), - reads: window.mockTransport.writes.filter(w => w.bytes[0] === 0x9a).length, - } }; -`); -check( - "an unidentified sensor gets its sensor cards from the dump the connect read fetched", - calUnknownBefore.unknownCard && - calUnknownBefore.sensors === 0 && - /Connect a sensor, or load a saved dump/.test(calUnknownBefore.text) && - !calUnknown.after.unknownCard && - calUnknown.after.sensors.length === 7 && - calUnknown.after.lnAccel === "1674,1670,1673" && - // Two pages, from the connect handshake alone — nobody pressed Read. - calUnknown.after.reads === 2, - `${calUnknownBefore.sensors} cards before connecting, ${calUnknown.after.sensors.length} after, ` + - `${calUnknown.after.reads} dump page reads`, -); - -// ---- the capability key, and which links actually have the dump commands -const calCaps = await evaluate(` - const m = await import('/common/shimmer3-config-schema.js'); - const sdk = await import('/vendor/shimmer-web-sdk.esm.js'); - const full = { readCalibDump(){}, writeCalibDump(){}, updateCalibDump(){} }; - const wired = new sdk.WiredShimmerClient({ transport: new sdk.LoopbackTransport() }); - const radio = new sdk.Shimmer3RClient({ debug: false }); - return { - full: m.describeShimmer3Caps(full, 'ble').calibration, - readOnly: m.describeShimmer3Caps({ readCalibDump(){} }, 'ble').calibration, - noUpdate: m.describeShimmer3Caps( - { readCalibDump(){}, writeCalibDump(){} }, 'ble').calibration, - none: m.describeShimmer3Caps({}, 'ble').calibration, - /* Not link-gated: the same stub answers the same on every link, and it - is the real clients that differ. */ - overUsb: m.describeShimmer3Caps(full, 'usb').calibration, - realRadio: m.describeShimmer3Caps(radio, 'ble').calibration, - realDock: m.describeShimmer3Caps(wired, 'usb').calibration, - dockInfomem: m.describeShimmer3Caps(wired, 'usb').infomem, - }; -`); -check( - "the calibration capability needs read and write, not the separate apply step", - calCaps.full === true && - calCaps.overUsb === true && - calCaps.readOnly === false && - /* writeCalibDump issues UPD_CALIB_DUMP itself, so a bundle that does not - expose the apply step as its own method still has a working editor. */ - calCaps.noUpdate === true && - calCaps.none === false && - calCaps.realRadio === true && - calCaps.realDock === false && - calCaps.dockInfomem === true, - JSON.stringify(calCaps), -); - -// =========================================================================== -// Calibration on a link with no calibration-dump commands, where the tab -// falls back to the six InfoMem blocks — read-only, and saying so. -// =========================================================================== -console.log("\n--- calibration: the InfoMem fallback ---"); -await goto(`${BASE}?mock=1`); -check("connect for the fallback pass", (await evaluate(CONNECT)) === "mock"); -const calFallback = await evaluate(` - ${CAL} - /* Shadow the dump commands on the connected client — prototype methods, so - an own-property set to undefined is what hides them. This is exactly what the - dock link and the classic Shimmer3Client present: an InfoMem they can - read and no calibration-dump commands at all. */ - const c = window.calibrationEditor; - window.mockClient.readCalibDump = undefined; - window.mockClient.writeCalibDump = undefined; - window.mockClient.updateCalibDump = undefined; - document.querySelector('.tabs [data-tab=tabCal]').click(); - const before = window.mockTransport.writes.length; - const read = await c.read(); - await new Promise(r => setTimeout(r, 800)); - const out = { - store: c.store(), - read: !!read, - banner: role('storeBanner').textContent, - bannerKind: role('storeBanner').className, - writeDisabled: role('write').disabled, - loadDisabled: role('load').disabled, - inputsReadOnly: [...P().querySelectorAll('input.cal-cell')].every(i => i.readOnly), - availability: card('lnAccel').dataset.calAvailability, - writeAttempt: await c.write(), - calibCmds: window.mockTransport.writes.slice(before) - .filter(w => [0x98, 0x9a, 0x9b].includes(w.bytes[0])).length, - }; - /* The log paints on an animation frame, so its newest lines are a frame - behind the call that produced them. */ - await new Promise(r => requestAnimationFrame(() => setTimeout(r, 120))); - out.refusal = [...document.querySelectorAll('#log .log-line')].slice(-6) - .map(l => l.textContent) - .find(l => /does not write the configuration image/.test(l)) ?? ''; - return out; -`); -check( - "a link with no dump commands falls back to the InfoMem blocks, read-only and labelled", - calFallback.store === "infomem" && - calFallback.read === true && - /CONFIGURATION IMAGE \(InfoMem\)/.test(calFallback.banner) && - /shown read-only/.test(calFallback.banner) && - calFallback.bannerKind === "banner warn" && - calFallback.inputsReadOnly && - calFallback.availability === "readonly" && - calFallback.writeDisabled && - calFallback.loadDisabled && - calFallback.writeAttempt === false && - calFallback.calibCmds === 0 && - /does not write the configuration image/.test(calFallback.refusal), - `${calFallback.store}; ${calFallback.banner.slice(0, 80)}…`, -); - -// =========================================================================== -// 2c. The raw byte tap against a 1024 Hz sensor: byte-exact on control -// traffic, silent on data packets until asked, and capped either way. -// =========================================================================== -console.log("\n--- raw byte tap ---"); -await goto(`${BASE}?mock=1&rate=1024`); -const tapBytes = await evaluate(` - const chk = document.getElementById('chkRawBytes'); - chk.checked = true; chk.dispatchEvent(new Event('change')); - document.getElementById('btnMock').click(); - for (let i = 0; i < 80 && !document.getElementById('rateHelper'); i++) - await new Promise(r => setTimeout(r, 100)); - await new Promise(r => setTimeout(r, 500)); - const hex = u8 => [...u8].map(b => b.toString(16).padStart(2, '0')).join(' '); - const strip = sel => [...document.querySelectorAll(sel)] - .map(l => l.textContent.replace(/^\\S+ \\S+ /, '')); - const tx = strip('#log .log-line.sev-tx'); - const rx = strip('#log .log-line.sev-rx'); - return { - writes: window.mockTransport.writes.map(w => 'TX ' + hex(w.bytes)), - tx, rxCount: rx.length, firstRx: rx[0], lastTx: tx.at(-1), - // Nothing the page says about the tap should itself land in the filter. - strays: tx.concat(rx).filter(l => !/^(TX|RX) [0-9a-f]/.test(l)), - }; -`); -check( - "with the tap on, every command the mock received appears as a TX line, byte for byte", - tapBytes.tx.length === tapBytes.writes.length && - tapBytes.tx.length > 8 && - tapBytes.tx.join("|") === tapBytes.writes.join("|") && - tapBytes.strays.length === 0, - `${tapBytes.tx.length} writes, last ${tapBytes.lastTx}`, -); -check( - "and every reply the mock sent appears as an RX line", - tapBytes.rxCount >= tapBytes.tx.length - 1 && - /^RX ff /.test(tapBytes.firstRx), - `${tapBytes.rxCount} RX lines, first ${tapBytes.firstRx}`, -); - -const tapStream = await evaluate(` - document.getElementById('btnLogClear').click(); - const t0 = performance.now(); - document.getElementById('btnStreamStart').click(); - await new Promise(r => setTimeout(r, 2500)); - document.getElementById('btnStreamStop').click(); - await new Promise(r => setTimeout(r, 600)); - const lines = [...document.querySelectorAll('#log .log-line')].map(l => l.textContent); - const excluded = { - frames: lines.filter(l => /RX 00 /.test(l)).length, - summaries: lines.filter(l => /data packets not shown/.test(l)), - total: lines.length, - }; - // Opt in and do it again. - document.getElementById('btnLogClear').click(); - const d = document.getElementById('chkRawData'); - d.checked = true; d.dispatchEvent(new Event('change')); - document.getElementById('btnStreamStart').click(); - await new Promise(r => setTimeout(r, 2500)); - document.getElementById('btnStreamStop').click(); - await new Promise(r => setTimeout(r, 600)); - const lines2 = [...document.querySelectorAll('#log .log-line')].map(l => l.textContent); - const included = { - frames: lines2.filter(l => /RX 00 /.test(l)).length, - capped: lines2.filter(l => /capped/.test(l)).length, - sample: lines2.find(l => /RX 00 /.test(l)) ?? '', - }; - // Still painting frames: rAF has to come back, and a tab click has to work. - const rafStart = performance.now(); - await new Promise(r => requestAnimationFrame(r)); - const rafMs = performance.now() - rafStart; - document.querySelector('.tabs [data-tab=tabSd]').click(); - return { excluded, included, rafMs, elapsed: performance.now() - t0, - tab: document.querySelector('.tabs [aria-selected=true]').dataset.tab, - rate: document.getElementById('ratePill').textContent }; -`); -check( - "a 1024 Hz stream logs no data packets by default, and says how many it held back", - tapStream.rate === "1024 Hz" && - tapStream.excluded.frames === 0 && - tapStream.excluded.summaries.length >= 2 && - tapStream.excluded.summaries.every((l) => - /RX — \d+ data packets not shown/.test(l), - ) && - Number(tapStream.excluded.summaries[1].match(/(\d+) data/)[1]) > 500, - tapStream.excluded.summaries[1] ?? "(none)", -); -check( - "opting in shows them, capped so a 1000-a-second burst cannot run away with the page", - tapStream.included.frames > 100 && - tapStream.included.frames < 700 && - tapStream.included.capped >= 2 && - /^\S+ \S+ RX 00 /.test(tapStream.included.sample), - `${tapStream.included.frames} frames shown over ~3 s, ${tapStream.included.capped} cap notices`, -); -check( - "and the page is still responsive with the tap wide open", - tapStream.rafMs < 200 && tapStream.tab === "tabSd", - `rAF ${tapStream.rafMs.toFixed(0)} ms, tab click landed on ${tapStream.tab}`, -); - -// =========================================================================== -// 3. The device identity panel: beside the Sensor link card, on every tab, -// above the fold, and stacked rather than squeezed on a phone. -// =========================================================================== -console.log("\n--- device identity panel ---"); -const laptop = { - width: 1366, - height: 700, - deviceScaleFactor: 1, - mobile: false, -}; -await send("Emulation.setDeviceMetricsOverride", laptop); -// testMs shortens the mock's per-step dwell so the narrow-screen half of this -// section can print a whole self-test report in a couple of seconds. -await goto(`${BASE}?mock=1&testMs=20`); -check("connect on a laptop viewport", (await evaluate(CONNECT)) === "mock"); - -const panel = await evaluate(` - const r = el => el.getBoundingClientRect(); - const link = document.getElementById('btnBle').closest('.card'); - const dev = document.getElementById('devicePanel'); - const tabs = document.querySelector('.tabs'); - const perTab = {}; - for (const b of document.querySelectorAll('.tabs [data-tab]')) { - if (b.disabled) continue; - b.click(); - await new Promise(r => requestAnimationFrame(r)); - const box = r(dev); - perTab[b.dataset.tab] = { - visible: dev.offsetParent !== null && box.width > 0 && box.height > 0, - name: document.getElementById('idName').textContent, - flags: dev.querySelectorAll('#statusFlags .flag').length, - batt: document.getElementById('idBatt').textContent, - }; - } - document.querySelector('.tabs [data-tab=tabConfig]').click(); - return { - perTab, - sideBySide: r(dev).left > r(link).right - 2 && - Math.abs(r(dev).top - r(link).top) < 4, - tabsBottom: r(tabs).bottom, - viewport: window.innerHeight, - // The clock has its own card on the GENERAL tab — it is a device - // command, not a stored setting, so it does not belong with the - // configuration image. The battery is one reading in one place rather - // than two of the same number. - clockCard: [...document.querySelectorAll('#tabGeneral .card-title')] - .map(t => t.textContent.trim()), - clockGoneFromConfig: ![...document.querySelectorAll('#tabConfig .card-title')] - .some(t => t.textContent.trim() === 'Clock'), - battDetailGone: !document.getElementById('battDetail'), - refreshInPanel: !!dev.querySelector('#btnRefreshDevice'), - }; -`); -const tabIds = Object.keys(panel.perTab); -check( - "the identity panel sits beside the Sensor link card and stays put on every tab", - panel.sideBySide && - // Seven: General, Configure, Calibration, Stream, SD card, Naming, - // Test. Counted rather than named on purpose: a tab that goes missing is - // as much a regression as one that paints wrong. - tabIds.length === 7 && - tabIds.every((t) => panel.perTab[t].visible) && - tabIds.every((t) => panel.perTab[t].name === "Shimmer_8091") && - tabIds.every((t) => panel.perTab[t].flags === 9) && - /V \(.*%\) — charger/.test(panel.perTab.tabSd.batt), - `${tabIds.join(", ")} — ${panel.perTab.tabSd.batt}`, -); -check( - "and it does not push the tab strip below the fold on a laptop", - /* 424px is where the tab strip sat before this panel existed, measured on - the same viewport against the commit before it — so the budget started as - "no lower than the identity list it replaced", not an arbitrary line. - Raised to 450 when the Bluetooth-module row was added: one more row costs - about 21px, the panel is what decides this height rather than the connect - column beside it, and the strip still ends less than two thirds of the way - down the shortest laptop viewport measured here. Any further row is a - deliberate decision, which is the point of the budget. - - Raised again to `TAB_STRIP_BUDGET_PX` because the same DOM is about 13px - taller on Linux than on Windows — the system font stack resolves - differently — so a budget calibrated on one platform failed on the other, - and a check that cannot agree with itself across platforms cannot gate - anything. The number now comes from the intent it always had: the strip - must end well clear of the fold on the shortest laptop viewport measured - here, 700px, with room for the widest font metrics seen rather than the - narrowest. It is still a budget, and one more row still breaks it. */ - panel.tabsBottom < panel.viewport && - panel.tabsBottom <= TAB_STRIP_BUDGET_PX && - panel.refreshInPanel && - panel.battDetailGone && - panel.clockCard[0] === "Clock" && - panel.clockGoneFromConfig, - `tab strip ends at ${Math.round(panel.tabsBottom)}px of ${panel.viewport}px`, -); - -await send("Emulation.setDeviceMetricsOverride", { - width: 375, - height: 812, - deviceScaleFactor: 1, - mobile: false, -}); -const narrow = await evaluate(` - const r = el => el.getBoundingClientRect(); - const link = document.getElementById('btnBle').closest('.card'); - const dev = document.getElementById('devicePanel'); - const overflow = {}; - for (const b of document.querySelectorAll('.tabs [data-tab]')) { - if (b.disabled) continue; - b.click(); - await new Promise(r => requestAnimationFrame(r)); - overflow[b.dataset.tab] = - document.documentElement.scrollWidth - document.documentElement.clientWidth; - } - document.querySelector('.tabs [data-tab=tabConfig]').click(); - await new Promise(r => requestAnimationFrame(r)); - return { overflow, - stacked: r(dev).top >= r(link).bottom - 1 && Math.abs(r(dev).left - r(link).left) < 2, - devWidth: r(dev).width, linkWidth: r(link).width, - bodyScrollWidth: document.documentElement.scrollWidth, - clientWidth: document.documentElement.clientWidth }; -`); -check( - "at 375px the panel stacks under the link card instead of squeezing beside it", - narrow.stacked && Math.abs(narrow.devWidth - narrow.linkWidth) < 2, - `${Math.round(narrow.devWidth)}px wide, top of panel below the link card`, -); -check( - "and no tab gives the page a horizontal scrollbar at 375px", - Object.values(narrow.overflow).every((n) => n <= 0), - `scrollWidth ${narrow.bodyScrollWidth} vs clientWidth ${narrow.clientWidth}; ` + - JSON.stringify(narrow.overflow), -); - -/* The sweep above measures every tab EMPTY. The Test tab is the one that - fills with content wider than the screen — an 80-column fixed-pitch report - the firmware prints, and a canvas — so it is measured again with a report - actually on it. A report that scrolls its own box sideways is a nuisance; - one that scrolls the whole page is a bug, and they look the same until you - check which element overflowed. */ -const narrowTest = await evaluate(` - ${TEST} - document.querySelector('.tabs [data-tab=tabTest]').click(); - role('run').click(); - for (let i = 0; i < 200 && window.factoryTestPanel.running(); i++) - await new Promise(r => setTimeout(r, 100)); - await new Promise(r => setTimeout(r, 250)); - return { - reportChars: role('report').textContent.length, - bodyOverflow: - document.documentElement.scrollWidth - document.documentElement.clientWidth, - reportScrolls: role('report').scrollWidth <= role('report').clientWidth + 1, - plotWidth: D().querySelector('canvas').getBoundingClientRect().width, - }; -`); -check( - "a printed report wraps at 375px rather than scrolling the page sideways", - narrowTest.reportChars > 1500 && - narrowTest.reportScrolls && - narrowTest.bodyOverflow <= 0, - `${narrowTest.reportChars} chars, page overflow ${narrowTest.bodyOverflow}px`, -); -check( - "and the drift plot fits the viewport it is drawn into", - narrowTest.plotWidth > 0 && narrowTest.plotWidth <= 375, - `${Math.round(narrowTest.plotWidth)}px of 375px`, -); - -await send("Emulation.setDeviceMetricsOverride", { - width: 1440, - height: 1000, - deviceScaleFactor: 1, - mobile: false, -}); - -// =========================================================================== -// 4. The status flags: where they come from, and whether the panel says so. -// -// Read once at connect and once per Refresh press is not enough — the flags -// have three sources now (a read, a push from the firmware, and the page's own -// last command) and the panel has to distinguish them. -// =========================================================================== -console.log("\n--- device status flags ---"); -await send("Emulation.setDeviceMetricsOverride", laptop); -await goto(`${BASE}?mock=1`); -check("connect for the status-flag pass", (await evaluate(CONNECT)) === "mock"); - -/* One reader for the whole section: the flags with their state and whether - each is marked as inferred, plus the freshness badge and where the tab strip - ends — the panel is the taller column above the tabs, so a freshness - indication that cost a line would show up here. */ -const FLAGS = ` - const snap = () => { - const fresh = document.getElementById('statusFresh'); - const flag = label => [...document.querySelectorAll('#statusFlags .flag')] - .find(s => s.textContent.replace(/ \\*$/, '') === label); - return { - count: document.querySelectorAll('#statusFlags .flag').length, - on: label => !!flag(label)?.classList.contains('on'), - starred: label => / \\*$/.test(flag(label)?.textContent ?? ''), - marks: [...document.querySelectorAll('#statusFlags .flag.inferred')] - .map(s => s.textContent), - badge: fresh.hidden ? null : fresh.textContent, - why: fresh.title, - unread: fresh.classList.contains('unread'), - tabsBottom: Math.round(document.querySelector('.tabs').getBoundingClientRect().bottom), - }; - }; - const state = () => { const s = snap(); return { - count: s.count, marks: s.marks, badge: s.badge, why: s.why, - unread: s.unread, tabsBottom: s.tabsBottom, - streaming: s.on('Streaming'), sensing: s.on('Sensing'), - sdLogging: s.on('SD logging'), docked: s.on('Docked'), - sdPresent: s.on('SD card present'), - streamingStarred: s.starred('Streaming'), - sdLoggingStarred: s.starred('SD logging'), - }; }; -`; - -const read = await evaluate(`${FLAGS} return state();`); -check( - "the flags read at connect are labelled as read, and the mock's own card shows through", - read.badge !== null && - /^read \d\d:\d\d:\d\d$/.test(read.badge) && - !read.unread && - read.marks.length === 0 && - read.sdPresent && - !read.streaming && - read.count === 9, - `${read.badge} — ${read.count} flags, ${read.marks.length} inferred`, -); - -// ---- a start lights Streaming with no GET_STATUS on the wire at all -const started = await evaluate(` - ${FLAGS} - const from = window.mockTransport.writes.length; - const lit = () => snap().on('Streaming'); - const t0 = performance.now(); - document.getElementById('btnStreamStart').click(); - for (let i = 0; i < 300 && !lit(); i++) await new Promise(r => setTimeout(r, 5)); - const s = state(); - s.ms = Math.round(performance.now() - t0); - s.statusCmds = window.mockTransport.writes.slice(from) - .filter(w => w.bytes[0] === 0x72).length; - return s; -`); -check( - "starting a stream lights Streaming without a status round trip, and marks it as inferred", - started.streaming && - started.sensing && - !started.sdLogging && - started.statusCmds === 0 && - started.streamingStarred && - started.marks.length === 2 && - /^inferred \d\d:\d\d:\d\d$/.test(started.badge) && - started.unread && - /have not been read back/.test(started.why), - `lit in ${started.ms}ms, ${started.statusCmds} GET_STATUS sent, marks: ${started.marks.join(", ")}`, -); -check( - "and saying how fresh the flags are still costs the tab strip nothing", - started.tabsBottom === read.tabsBottom && - started.tabsBottom <= TAB_STRIP_BUDGET_PX, - `tab strip ends at ${started.tabsBottom}px, same as with the flags freshly read`, -); - -// ---- and a stop clears it again -const stopped = await evaluate(` - ${FLAGS} - document.getElementById('btnStreamStop').click(); - await new Promise(r => setTimeout(r, 900)); - return state(); -`); -check( - "stopping clears Streaming, still marked as this page's word rather than the sensor's", - !stopped.streaming && - !stopped.sensing && - stopped.streamingStarred && - stopped.unread && - /^inferred /.test(stopped.badge), - `${stopped.badge} — marks: ${stopped.marks.join(", ")}`, -); - -// ---- SD logging is its own flag, and only the SD start claims it -const sdStarted = await evaluate(` - ${FLAGS} - document.getElementById('btnSdStart').click(); - await new Promise(r => setTimeout(r, 900)); - const during = state(); - document.getElementById('btnSdStop').click(); - await new Promise(r => setTimeout(r, 900)); - return { during, after: state() }; -`); -check( - "starting stream + SD logging lights the SD logging flag too, and stopping clears it", - sdStarted.during.sdLogging && - sdStarted.during.streaming && - sdStarted.during.sdLoggingStarred && - sdStarted.during.marks.length === 3 && - !sdStarted.after.sdLogging && - !sdStarted.after.streaming, - `during: ${sdStarted.during.marks.join(", ")}`, -); - -// ---- the next authoritative read replaces the guesses -const confirmed = await evaluate(` - ${FLAGS} - document.getElementById('btnRefreshDevice').click(); - await new Promise(r => setTimeout(r, 1200)); - return state(); -`); -check( - "a Refresh turns the inferred flags back into a reading and drops the marks", - confirmed.marks.length === 0 && - !confirmed.unread && - /^read \d\d:\d\d:\d\d$/.test(confirmed.badge) && - !confirmed.streaming && - confirmed.sdPresent, - confirmed.badge, -); - -// ---- a read that does not answer must not pass the guesses off as readings -const unconfirmed = await evaluate(` - ${FLAGS} - /* Started and stopped first, so there are inferred marks to survive the - failure — and Refresh is gated to an idle sensor, so the stop is also - what makes the button clickable. */ - document.getElementById('btnStreamStart').click(); - await new Promise(r => setTimeout(r, 700)); - document.getElementById('btnStreamStop').click(); - await new Promise(r => setTimeout(r, 900)); - const before = state(); - const real = window.mockClient.getStatus.bind(window.mockClient); - window.mockClient.getStatus = () => Promise.reject(new Error('Instream response 0x71 timeout')); - document.getElementById('btnRefreshDevice').click(); - await new Promise(r => setTimeout(r, 1500)); - const s = state(); - s.before = before; - window.mockClient.getStatus = real; - document.getElementById('btnRefreshDevice').click(); - await new Promise(r => setTimeout(r, 1200)); - s.recovered = state().badge; - return s; -`); -check( - "a Refresh whose read does not answer keeps the values but stops calling them read", - // The same badge it had, plus the admission — so the failed read did not - // restamp the values as freshly known either. - unconfirmed.badge === unconfirmed.before.badge + ", unconfirmed" && - unconfirmed.unread && - /did not answer/.test(unconfirmed.why) && - // Values it could not refresh are kept rather than wiped to "-", and the - // marks stay: a failed confirmation must not silently promote a guess. - unconfirmed.count === 9 && - unconfirmed.sdPresent && - unconfirmed.streamingStarred && - unconfirmed.marks.length === 2 && - /^read /.test(unconfirmed.recovered), - `${unconfirmed.before.badge} → ${unconfirmed.badge} → ${unconfirmed.recovered} once the read answers again`, -); - -// ---- and when it answers with something else, the sensor wins and says why -const disagreed = await evaluate(` - ${FLAGS} - document.getElementById('btnStreamStart').click(); - await new Promise(r => setTimeout(r, 700)); - const before = document.querySelectorAll('#log .log-line').length; - /* The sensor insists it is still streaming — which is what an ACKed stop - that did not take effect looks like from the host. */ - const real = window.mockClient.getStatus.bind(window.mockClient); - window.mockClient.getStatus = async () => ({ ...(await real()), streaming: true, sensing: true }); - document.getElementById('btnStreamStop').click(); - await new Promise(r => setTimeout(r, 900)); - document.getElementById('btnRefreshDevice').click(); - await new Promise(r => setTimeout(r, 1200)); - const s = state(); - s.warned = [...document.querySelectorAll('#log .log-line')].slice(before) - .map(l => l.textContent).filter(l => /did not take effect/.test(l)); - window.mockClient.getStatus = real; - return s; -`); -check( - "a read that contradicts what the page inferred wins, and the contradiction is logged", - disagreed.streaming && - disagreed.marks.length === 0 && - /^read /.test(disagreed.badge) && - disagreed.warned.length === 2 && - disagreed.warned.some((l) => /streaming=true/.test(l)), - disagreed.warned[0]?.slice(20, 120) ?? "(nothing logged)", -); - -// ---- the firmware's own news: a dock and an undock, unasked for -const docked = await evaluate(` - ${FLAGS} - document.getElementById('btnRefreshDevice').click(); - await new Promise(r => setTimeout(r, 1200)); - const before = state(); - const from = window.mockTransport.writes.length; - window.mockTransport.status.setDocked(true); - await new Promise(r => setTimeout(r, 400)); - const onDock = state(); - onDock.hostSent = window.mockTransport.writes.slice(from).length; - window.mockTransport.status.setDocked(false); - await new Promise(r => setTimeout(r, 400)); - const onUndock = state(); - onUndock.pushed = [...document.querySelectorAll('#log .log-line')].slice(-8) - .some(l => /the sensor pushed a status change/.test(l.textContent)); - return { before, onDock, onUndock }; -`); -check( - "an unsolicited status pushed while idle updates the panel with no command from the host", - !docked.before.docked && - docked.onDock.docked && - !docked.onUndock.docked && - docked.onDock.hostSent === 0 && - docked.onDock.count === 9 && - docked.onUndock.pushed, - `docked ${docked.before.docked} → ${docked.onDock.docked} → ${docked.onUndock.docked}, ` + - `${docked.onDock.hostSent} commands sent`, -); -check( - "and a pushed value is labelled as pushed, not as read and not as inferred", - /^pushed \d\d:\d\d:\d\d$/.test(docked.onDock.badge) && - !docked.onDock.unread && - docked.onDock.marks.length === 0 && - /without being asked/.test(docked.onDock.why) && - /^read /.test(docked.before.badge), - `${docked.before.badge} → ${docked.onDock.badge} → ${docked.onUndock.badge}`, -); - -/* The push and the answer to GET_STATUS are the same message built by the same - function — this is the assertion that they stayed that way. */ -check( - "the status the mock pushes is byte for byte the status it answers a read with", - await evaluate(` - const pushed = Array.from(window.mockTransport.status.bytes()).slice(2); - const answered = Array.from((await window.mockClient.getStatus()).raw); - return JSON.stringify(pushed) === JSON.stringify(answered); - `), -); - -/* And over a byte stream, where the push has to be re-framed out of 3-byte - dribbles before the client can see it at all — the framer has to know the - platform's status length to find the message boundary, and getting that - wrong is what swallows the ACK behind it. */ -await goto(`${BASE}?mock=1&framed=0`); -check( - "connect over the unframed transport for the push", - (await evaluate(CONNECT)) === "mock", -); -const dribbled = await evaluate(` - ${FLAGS} - const before = state(); - window.mockTransport.status.setDocked(true); - await new Promise(r => setTimeout(r, 600)); - const onDock = state(); - /* A command straight after, to prove the re-framer consumed exactly the - push and left the control channel usable. */ - onDock.stillTalks = (await window.mockClient.getStatus()).docked; - return { before, onDock }; -`); -check( - "a push re-framed out of a 3-byte-at-a-time byte stream lands, and the link still works after it", - !dribbled.before.docked && - dribbled.onDock.docked && - /^pushed /.test(dribbled.onDock.badge) && - dribbled.onDock.count === 9 && - dribbled.onDock.stillTalks === true, - `${dribbled.before.badge} → ${dribbled.onDock.badge}`, -); -await goto(`${BASE}?mock=1`); -check( - "reconnect framed for the teardown check", - (await evaluate(CONNECT)) === "mock", -); - -// ---- and a sensor the page has let go cannot paint the panel any more -const letGo = await evaluate(` - ${FLAGS} - const transport = window.mockTransport; - document.getElementById('btnDisconnect').click(); - await new Promise(r => setTimeout(r, 900)); - const after = state(); - transport.status.setDocked(true); - await new Promise(r => setTimeout(r, 400)); - return { after, still: state(), - flagsText: document.getElementById('statusFlags').textContent }; -`); -check( - "letting the sensor go clears the flags, and a late push cannot repaint them", - letGo.after.badge === null && - letGo.after.count === 0 && - letGo.flagsText === "–" && - letGo.still.count === 0 && - letGo.still.badge === null, - `flags "${letGo.flagsText}", badge ${JSON.stringify(letGo.after.badge)}`, -); -await send("Emulation.setDeviceMetricsOverride", { - width: 1440, - height: 1000, - deviceScaleFactor: 1, - mobile: false, -}); - -// =========================================================================== -// 5. The event log as a drawer docked to the bottom of the viewport. -// =========================================================================== -console.log("\n--- event log drawer ---"); -await goto(`${BASE}?mock=1`); -// Start from no remembered preference, which is the state a first visit is in. -await evaluate( - `try { localStorage.removeItem('shimmerCaptureLogDrawer'); } catch {} return 1;`, -); -await goto(`${BASE}?mock=1`); - -const shut = await evaluate(` - const d = document.getElementById('logDrawer'); - const box = d.getBoundingClientRect(); - return { attr: document.documentElement.dataset.logOpen, - expanded: document.getElementById('btnLogDrawer').getAttribute('aria-expanded'), - bodyDisplay: getComputedStyle(document.getElementById('logDrawerBody')).display, - height: box.height, bottom: box.bottom, left: box.left, - width: box.width, viewportW: document.documentElement.clientWidth, - viewportH: document.documentElement.clientHeight, - // Reserved, not overlaid: the page's own foot padding covers the bar. - pagePad: parseFloat(getComputedStyle(document.querySelector('.page')).paddingBottom), - last: document.getElementById('logLast').textContent }; -`); -check( - "with nothing remembered the drawer starts collapsed, docked to the bottom edge", - shut.attr === "false" && - shut.expanded === "false" && - shut.bodyDisplay === "none" && - shut.height > 25 && - shut.height < 45 && - Math.abs(shut.bottom - shut.viewportH) < 1 && - shut.left === 0 && - Math.abs(shut.width - shut.viewportW) < 1 && - shut.pagePad >= shut.height, - `${Math.round(shut.height)}px bar, page reserves ${Math.round(shut.pagePad)}px`, -); - -check( - "connect with the drawer collapsed", - (await evaluate(CONNECT)) === "mock", -); -const collapsed = await evaluate(` - const alerts = () => document.querySelectorAll( - '#log .log-line.sev-err, #log .log-line.sev-warn').length; - const lines = [...document.querySelectorAll('#log .log-line')].map(l => l.textContent); - const badge = document.getElementById('logBadge'); - const before = { badge: badge.textContent, hidden: badge.hidden, alerts: alerts(), - last: document.getElementById('logLast').textContent, - newest: lines.at(-1) }; - // Three warnings, one per call: disconnected, the panel has no client to - // run the firmware's data-rate test against. - document.getElementById('btnDisconnect').click(); - await new Promise(r => setTimeout(r, 400)); - const mid = alerts(); - for (let i = 0; i < 3; i++) await window.sdBrowser.measureLinkSpeed(); - await new Promise(r => requestAnimationFrame(() => setTimeout(r, 80))); - return { before, mid, after: { badge: Number(document.getElementById('logBadge').textContent), - hidden: document.getElementById('logBadge').hidden, alerts: alerts(), - last: document.getElementById('logLast').textContent } }; -`); -check( - "collapsed it shows the newest line and badges the errors and warnings behind it", - // The bar drops the date (same all session) and keeps the clock time. - collapsed.before.newest.endsWith(collapsed.before.last) && - /^\d\d:\d\d:\d\d: /.test(collapsed.before.last) && - Number(collapsed.before.badge) === collapsed.before.alerts && - collapsed.before.hidden === false && - collapsed.after.alerts === collapsed.mid + 3 && - collapsed.after.badge === collapsed.after.alerts && - /data-rate test/.test(collapsed.after.last), - `badge ${collapsed.before.badge} → ${collapsed.after.badge}, bar "${collapsed.after.last}"`, -); - -const opened = await evaluate(` - document.getElementById('btnLogDrawer').click(); - await new Promise(r => requestAnimationFrame(r)); - const d = document.getElementById('logDrawer'); - const panel = document.getElementById('log'); - const box = d.getBoundingClientRect(); - const bar = document.querySelector('#tabConfig .action-bar').getBoundingClientRect(); - const opened = { badge: document.getElementById('logBadge').hidden, - expanded: document.getElementById('btnLogDrawer').getAttribute('aria-expanded'), - height: box.height, width: box.width, viewportW: document.documentElement.clientWidth, - logWidth: panel.getBoundingClientRect().width, - // The log's own stick-to-the-tail, restored as the panel becomes visible. - atTail: panel.scrollHeight - panel.scrollTop - panel.clientHeight < 4, - scrollHeight: panel.scrollHeight, - // Nothing is covered: the sticky action bar stops above the drawer. - actionBarClear: bar.bottom <= box.top + 1, - pagePad: parseFloat(getComputedStyle(document.querySelector('.page')).paddingBottom), - everyControl: ['logFilter','logSeverity','chkRawBytes','chkRawData','btnLogClear','btnLogDownload','logCount'] - .filter(id => document.getElementById('logDrawerBody').contains(document.getElementById(id))).length }; - // While it is open the badge stays out of the way. - await window.sdBrowser.measureLinkSpeed(); - await new Promise(r => requestAnimationFrame(() => setTimeout(r, 80))); - opened.badgeStillHidden = document.getElementById('logBadge').hidden; - return opened; -`); -check( - "opening clears the badge, keeps the full page width and covers nothing", - opened.badge === true && - opened.badgeStillHidden === true && - opened.expanded === "true" && - opened.height > 200 && - Math.abs(opened.width - opened.viewportW) < 1 && - opened.logWidth > opened.viewportW * 0.9 && - opened.atTail && - opened.scrollHeight > 400 && - opened.actionBarClear && - opened.pagePad >= opened.height && - opened.everyControl === 7, - `${Math.round(opened.height)}px tall, log ${Math.round(opened.logWidth)} of ${opened.viewportW}px, all 7 controls kept`, -); - -/* ---- and an alert that arrives while the drawer is OPEN but scrolled back - through history still badges. An error nobody can see is an error nobody - scrolled to, whether the drawer is shut or merely showing older lines — - which is the whole reason the log moved to the bottom of the viewport. */ -const badgeAway = await evaluate(` - const panel = document.getElementById('log'); - const el = document.getElementById('logBadge'); - const badge = () => (el.hidden ? 0 : Number(el.textContent)); - const away = () => panel.scrollHeight - panel.scrollTop - panel.clientHeight >= 60; - panel.scrollTop = 0; - panel.dispatchEvent(new Event('scroll')); - await new Promise(r => setTimeout(r, 100)); - const out = { open: document.documentElement.dataset.logOpen, - scrolledAway: away(), before: badge() }; - // One warning: disconnected, the panel has no client to measure against. - await window.sdBrowser.measureLinkSpeed(); - await new Promise(r => requestAnimationFrame(() => setTimeout(r, 120))); - out.whileAway = badge(); - out.heldPosition = panel.scrollTop; - out.stillAway = away(); - // Coming back to the newest line is as much an acknowledgement as opening. - panel.scrollTop = panel.scrollHeight; - panel.dispatchEvent(new Event('scroll')); - await new Promise(r => setTimeout(r, 150)); - out.afterReturn = badge(); - return out; -`); -check( - "an alert badges while the drawer is open but scrolled away, and clears on return", - badgeAway.open === "true" && - badgeAway.scrolledAway && - badgeAway.before === 0 && - badgeAway.whileAway === 1 && - badgeAway.heldPosition === 0 && - badgeAway.stillAway && - badgeAway.afterReturn === 0, - `badge ${badgeAway.before} → ${badgeAway.whileAway} → ${badgeAway.afterReturn}, ` + - `scroll held at ${badgeAway.heldPosition}`, -); - -await goto(`${BASE}?mock=1`); -const remembered = await evaluate(` - const open = document.documentElement.dataset.logOpen; - document.getElementById('btnLogDrawer').click(); - return { afterReload: open, - stored: (() => { try { return localStorage.getItem('shimmerCaptureLogDrawer'); } - catch { return 'unreadable'; } })() }; -`); -await goto(`${BASE}?mock=1`); -const remembered2 = await evaluate( - `return document.documentElement.dataset.logOpen;`, -); -check( - "the drawer remembers open and closed across a reload", - remembered.afterReload === "true" && - remembered.stored === "closed" && - remembered2 === "false", - `reopened ${remembered.afterReload}, then reclosed → ${remembered2}`, -); - -await send("Emulation.setDeviceMetricsOverride", { - width: 375, - height: 812, - deviceScaleFactor: 1, - mobile: false, -}); -const sheet = await evaluate(` - document.getElementById('btnLogDrawer').click(); - await new Promise(r => requestAnimationFrame(r)); - const box = document.getElementById('logDrawer').getBoundingClientRect(); - return { left: box.left, width: box.width, height: box.height, - viewportW: document.documentElement.clientWidth, - viewportH: document.documentElement.clientHeight, - bottom: box.bottom, - overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth }; -`); -check( - "at 375px it stays a full-width bottom sheet and still scrolls nothing sideways", - sheet.left === 0 && - Math.abs(sheet.width - sheet.viewportW) < 1 && - Math.abs(sheet.bottom - sheet.viewportH) < 1 && - sheet.height > sheet.viewportH * 0.5 && - sheet.overflow <= 0, - `${Math.round(sheet.width)}x${Math.round(sheet.height)} on a ${sheet.viewportW}x${sheet.viewportH} screen`, -); -await evaluate( - `try { localStorage.removeItem('shimmerCaptureLogDrawer'); } catch {} return 1;`, -); -await send("Emulation.setDeviceMetricsOverride", { - width: 1440, - height: 1000, - deviceScaleFactor: 1, - mobile: false, -}); - -// =========================================================================== -// A firmware below the SD-transfer gate. v1.01.009 and v1.01.010 serve the -// protocol and corrupt every block, so the tab has to refuse them by version -// — the same "permanent" branch the USB dock link takes. -// =========================================================================== -console.log("\n--- firmware below the SD-transfer gate ---"); -await goto(`${BASE}?mock=1&fw=1.01.010`); -check("connect to a sensor on v1.01.010", (await evaluate(CONNECT)) === "mock"); -const oldFw = await evaluate(` - const btn = document.getElementById('tabBtnSd'); - return { fw: document.getElementById('idFw').textContent, - tabDisabled: btn.disabled, title: btn.title, - banner: document.getElementById('sdBanner').textContent, - bannerKind: document.getElementById('sdBanner').className, - selected: document.querySelector('.tabs [aria-selected="true"]').dataset.tab, - streamTab: !document.getElementById('tabBtnStream').disabled, - logs: [...document.querySelectorAll('#log .log-line')].map(l => l.textContent) - .filter(l => /SD file transfer/.test(l)) }; -`); -check( - "v1.01.010 closes the SD tab and says which firmware version is needed", - oldFw.fw === "LogAndStream v1.01.010" && - oldFw.tabDisabled && - /v1\.01\.011 or later/.test(oldFw.banner) && - /corrupt every block/.test(oldFw.banner) && - oldFw.bannerKind === "banner warn" && - oldFw.title === oldFw.banner && - oldFw.selected === "tabGeneral" && - oldFw.streamTab && - oldFw.logs.length === 1, - oldFw.banner.slice(0, 90) + "…", -); - -// =========================================================================== -// Every panel competes for one link, so every panel's "why can't I" text has -// to know about every OTHER panel's busy flag. Each was written with the -// panels that existed at the time, so each new panel silently aged the ones -// written before it, and a control could grey out with nothing on screen. -// Source-level on purpose: driving four concurrent link holders through the -// UI is slow and flaky, and what needs pinning is that adding a holder -// teaches every panel about it. -// =========================================================================== -// The ExG preset path. Nothing here touched ExG until the 0.1.24 re-vendor -// turned a working apply into a refusal -- the new helpers READ the banks -// before writing where the old ones wrote blind, and the mock only served the -// write. The bytes are asserted, not just the call: three of them were wrong -// in the shipped bundle, and a preset that reaches the wire with the wrong -// PGA gain looks exactly like one that is right. -// =========================================================================== -// Device time is a PLAIN UNIX EPOCH: the page writes Date.now() and renders -// what comes back with the host's own formatter, so the timezone is applied -// exactly once, on the way to the screen. It used to be local civil time with -// the offset baked into the value, which had to be read back with the UTC -// accessors instead -- so a half-migrated page shifts it twice. Invisible in -// UTC, which is where a CI box and most benches sit, so the checks run under -// an emulated +09:00 where a double shift is nine hours wide. -// =========================================================================== -// The SDK is vendored twice on purpose -- the Chrome extension has to carry -// its own copy, because only that folder is packed for the store -- so the one -// thing that must never happen is a page loading BOTH. Two module instances -// means two class identities, and `client.connect()` does -// `if (t instanceof WebBluetoothTransport) this.device = t.device`, which then -// silently stops matching. That is how this check found the harness importing -// the extension's copy after the shared one moved to /vendor: nothing threw, -// one instanceof just went quiet. -// =========================================================================== -console.log("\n--- one shared SDK copy, not the extension's ---"); -/* Checked over HTTP rather than on disk, so it tests what is actually served. - Extend this list when a demo is added -- and when one leaves. The standalone - drift page that used to sit here was folded into ShimmerCapture's Test tab - and deleted; the two panels that came out of it are shared modules now, and - they take its place in the list. */ -const SDK_CONSUMERS = [ - "ShimmerCapture/index.html", - "Verisense/index.html", - "break-emg/index.html", - "break-gyro/index.html", - "brick/index.html", - "punch-highG/index.html", - "spell-gyro/index.html", - "video-ppg/index.html", - "common/brand-editor.js", - "common/calibration-editor.js", - "common/csv-recorder.js", - "common/device-clock.js", - "common/factory-test-panel.js", - "common/kinematic-block-editor.js", - "common/rtc-drift-panel.js", - "common/sd-browser.js", - "common/stream-stats.js", - "common/dev/mock-shimmer3r.js", -]; -const ROOT = BASE.slice(0, BASE.indexOf("/ShimmerCapture/") + 1); -const reachingIntoExtension = []; -for (const f of SDK_CONSUMERS) { - const text = await (await fetch(ROOT + f)).text(); - if (text.includes("shimmer-extension/vendor")) reachingIntoExtension.push(f); -} -check( - "nothing outside the extension imports the extension's private SDK copy", - reachingIntoExtension.length === 0, - reachingIntoExtension.length - ? reachingIntoExtension.join(", ") - : `${SDK_CONSUMERS.length} files checked`, -); - -/* And the extension keeps its own, which is the other half of the rule. */ -const extensionCopy = await fetch( - ROOT + "shimmer-extension/vendor/shimmer-web-sdk.esm.js", -); -const sharedCopy = await fetch(ROOT + "vendor/shimmer-web-sdk.esm.js"); -/* Normalised, because this checkout has core.autocrlf=true and the two copies - reach the working tree by different routes -- a rebase checks one out, the - sync script writes the other. Git stores both blobs identically; only the - bytes on disk differ, by exactly one per line. Comparing raw text here - asserted the line-ending state of a developer's checkout, not the build. */ -const norm = (t) => - t.split(String.fromCharCode(13, 10)).join(String.fromCharCode(10)); -const extText = norm(await extensionCopy.text()); -const shrText = norm(await sharedCopy.text()); -check( - "both copies are served and are the same build", - extensionCopy.ok && sharedCopy.ok && extText === shrText, - `extension ${extensionCopy.status}/${extText.length}B, shared ${sharedCopy.status}/${shrText.length}B (line endings normalised)`, -); - -// =========================================================================== -console.log("\n--- device time is not shifted twice ---"); -await send("Emulation.setTimezoneOverride", { timezoneId: "Asia/Tokyo" }); -await goto(`${BASE}?mock=1`); -check("connect under an emulated +09:00", (await evaluate(CONNECT)) === "mock"); - -const clocks = await evaluate(` - document.getElementById('btnSetClock').click(); - await new Promise(r => setTimeout(r, 700)); - document.getElementById('btnRefreshDevice')?.click(); - await new Promise(r => setTimeout(r, 900)); - const rwc = document.getElementById('rwcValue').textContent; - return { - offsetMin: new Date().getTimezoneOffset(), - rwc, - host: document.getElementById('hostClock').textContent, - }; -`); - -/* Both lines are "YYYY-MM-DD HH:MM:SS"; the RWC one carries a tick count - after it. Compare to the minute: the two reads are a beat apart. */ -const toMinute = (text) => (text ?? "").trim().slice(0, 16); -const rwcMinute = toMinute(clocks.rwc); -const hostMinute = toMinute(clocks.host); -/* Compared with a minute of tolerance, not as strings. The two values are - read a beat apart, so a string compare fails whenever the clock happens to - tick between them -- a flake that says "timezone bug" when there is none. - The bug this is looking for is hours wide; a minute is noise. */ -const asMinutes = (t) => { - const m = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(t); - return m ? Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) / 60000 : NaN; -}; -const driftMinutes = Math.abs(asMinutes(rwcMinute) - asMinutes(hostMinute)); -check( - "the browser really is nine hours off UTC for this check", - clocks.offsetMin === -540, - `getTimezoneOffset() = ${clocks.offsetMin}`, -); -check( - "a clock set from the host reads back as the same wall clock, not offset by the timezone", - rwcMinute.length === 16 && Number.isFinite(driftMinutes) && driftMinutes <= 1, - `device ${rwcMinute || "(empty)"} vs host ${hostMinute} (${driftMinutes} min apart)`, -); -/* Same domain, same hazard, and the existing calibration check cannot see it: - it asserts the date only, so an offset that does not cross midnight leaves - it green. - - This used to assert that a stored stamp reads IDENTICALLY in Tokyo and in - UTC, which was right while device time was local civil time -- the offset - lived inside the value, so the value had to render the same everywhere. - The page now treats the sensor's real-world clock as what it actually is, - a plain Unix epoch in UTC: written with Date.now(), read back through the - host's own formatter. A true epoch renders in LOCAL time by definition, so - "identical everywhere" is now false by design and asserting it would be - asserting the bug. - The property that survives the change -- and the one that catches a double - shift just as well -- is that the same stored instant reads exactly one - timezone apart: 540 minutes later in Tokyo than in UTC. A stamp shifted - twice is 1080 minutes out, an unshifted one is 0, and neither is 540. - - THIS CHECK IS RED as it stands, and it is right to be: the migration is - half done. The page's own clock card renders through formatClock(), which - reads a Date with the LOCAL accessors, but common/calibration-editor.js - still has formatStamp() on the getUTC* accessors the old local-civil - convention required. readStamp() already hands it a true epoch, so the same - instant now comes out in local time on the Configure tab and in UTC on the - Calibration tab -- nine hours apart on a Tokyo bench, and identical on the - UTC box where nobody would notice. The fix is formatStamp's five accessors; - until then this is the one readout left on the old convention. */ -const stampTokyo = await evaluate(` - ${CAL} - document.querySelector('.tabs [data-tab=tabCal]').click(); - role('read').click(); - await new Promise(r => setTimeout(r, 2500)); - const c = P().querySelector('[data-cal-sensor="lnAccel"]'); - return c.querySelector('[data-cal-as-of]')?.textContent ?? ''; -`); -/* Explicitly UTC, not "" — clearing the override returns the page to THIS - MACHINE's zone, which is an hour off UTC for half the year, and the - assertion below would then be 480 minutes on a summer afternoon in - Dublin and 540 in January. The check is about the page, not the bench. */ -await send("Emulation.setTimezoneOverride", { timezoneId: "UTC" }); -await goto(`${BASE}?mock=1`); -await evaluate(CONNECT); -const stampUtc = await evaluate(` - ${CAL} - document.querySelector('.tabs [data-tab=tabCal]').click(); - role('read').click(); - await new Promise(r => setTimeout(r, 2500)); - const c = P().querySelector('[data-cal-sensor="lnAccel"]'); - return c.querySelector('[data-cal-as-of]')?.textContent ?? ''; -`); -/* Reuses the same "YYYY-MM-DD HH:MM" -> minutes parser as the readback check - above, so nothing about the date is hard-coded: the mock seeds whatever - instant it likes and the assertion is on the difference alone. The stamp is - rendered as "as of ", so the date is picked out of the sentence. */ -const stampDate = (t) => - /\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.exec(t ?? "")?.[0] ?? ""; -const stampApart = - asMinutes(stampDate(stampTokyo)) - asMinutes(stampDate(stampUtc)); -check( - "a calibration stamp reads exactly nine hours later in Tokyo than in UTC", - stampDate(stampTokyo).length === 16 && stampApart === 540, - `+09:00 "${stampTokyo}" vs UTC "${stampUtc}" (${stampApart} min apart, want 540)`, -); -// Back to this machine's own zone for everything after this section. -await send("Emulation.setTimezoneOverride", { timezoneId: "" }); - -// =========================================================================== -console.log("\n--- ExG presets ---"); -await goto(`${BASE}?mock=1`); -check("connect for the ExG pass", (await evaluate(CONNECT)) === "mock"); - -const exg = await evaluate(` - const t = window.mockTransport; - const sel = document.getElementById('exgMode'); - const options = Array.from(sel.options).map(o => o.value); - sel.value = options.find(v => /test/i.test(v)); - sel.dispatchEvent(new Event('change')); - await new Promise(r => setTimeout(r, 250)); - const before = t.writes.length; - document.getElementById('btnApply').click(); - for (let i=0;i<150 && document.getElementById('btnApply').disabled;i++) - await new Promise(r=>setTimeout(r,100)); - await new Promise(r => setTimeout(r, 900)); - const hex = (a) => Array.from(a, b => b.toString(16).padStart(2,'0')).join(' '); - const after = t.writes.slice(before).map(w => w.bytes); - const lines = Array.from(document.querySelectorAll('#log .log-line')).map(n => n.textContent); - return { - options, - banks: after.filter(b => b[0] === 0x61).map(b => ({ chip: b[1], regs: hex(b.slice(4)) })), - readBack: after.some(b => b[0] === 0x63), - refused: lines.some(l => /refused .*ExG registers/i.test(l)), - }; -`); - -check( - "applying the ExG test signal reaches the wire on both chips", - exg.banks.length === 2 && !exg.refused, - exg.refused - ? "the sensor refused it" - : `${exg.banks.length} SET_EXG_REGS writes`, -); -check( - "and it reads the banks before writing them, as the driver does", - exg.readBack === true, - exg.readBack ? "GET_EXG_REGS sent first" : "no GET_EXG_REGS -- blind write", -); -/* CH1SET/CH2SET are regs[3] and regs[4]. Bits 6:4 are the PGA gain: the - bundle shipped 0x15 (gain 1) where the driver has 0x05 (gain 6), a - six-fold amplitude error on the one signal used to judge an ExG board. */ -const gains = exg.banks.map((w) => { - const b = w.regs.split(" "); - return [b[3], b[4]]; -}); -check( - "with the driver's PGA gain, not the 0x15 the old bundle wrote", - gains.length === 2 && gains.every(([a, b]) => a === "05" && b === "05"), - JSON.stringify(gains), -); - -// =========================================================================== -// The Test tab: the sensor's own factory self-test, the clock-drift monitor -// that used to be a page of its own, and the red LED. -// -// The self-test is unlike every other panel here. It is one command that -// produces a MINUTE of unsolicited, unframed ASCII, printed a line at a time -// with no length up front and no acknowledgement at the end -- so what is -// being checked is mostly reassembly and release: that the text arrives whole -// and in order, that the page can tell when it is over, and that the link is -// handed back exactly once. And there is no abort: Cancel stops the page -// listening, but the sensor prints to its own end regardless, which is why -// this is the only panel that stays busy after the user has told it to stop. -// =========================================================================== -console.log("\n--- the Test tab itself ---"); -// testMs shortens the mock's per-step dwell from the firmware's real 2 s per -// LED to something a pass can wait for. -await goto(`${BASE}?mock=1&testMs=30`); - -const tabInfo = await evaluate(` - const btns = [...document.querySelectorAll('.tabs [data-tab]')]; - const t = document.getElementById('tabBtnTest'); - return { - ids: btns.map(b => b.dataset.tab), - last: btns[btns.length - 1]?.id, - role: t.getAttribute('role'), - controls: t.getAttribute('aria-controls'), - panelRole: document.getElementById('tabTest')?.getAttribute('role'), - disabled: t.disabled, - banner: document.getElementById('testBanner').textContent, - }; -`); -check( - "the Test tab is last in the strip and is a real ARIA tab", - tabInfo.ids.length === 7 && - tabInfo.last === "tabBtnTest" && - tabInfo.role === "tab" && - tabInfo.controls === "tabTest" && - tabInfo.panelRole === "tabpanel", - JSON.stringify(tabInfo.ids), -); -/* Disconnected, this tab is the one place that explains what a connection - would buy you, so it must not be greyed out before there is anything to - grey out -- an unreachable tab cannot tell you why it is unreachable. */ -check( - /* And it offers only what every link can do. It used to say all three links - would "measure the link", which the dock cannot: the throughput card's own - note carries that restriction, so the banner must not contradict it. */ - "disconnected, the tab stays open and says what a connection would offer", - !tabInfo.disabled && - /^Connect over BLE/.test(tabInfo.banner) && - /self-test/.test(tabInfo.banner) && - !/measure/.test(tabInfo.banner), - tabInfo.banner.slice(0, 60), -); - -check("connect for the Test-tab pass", (await evaluate(CONNECT)) === "mock"); - -const testOpts = await evaluate(` - ${TEST} - return { - types: [...role('type').options].map(o => [o.value, o.textContent.trim()]), - timeout: role('timeout').value, - runDisabled: role('run').disabled, - }; -`); -/* The four values are the firmware's own enum, and they go out on the wire as - the byte after SET_FACTORY_TEST -- so their ORDER is a protocol constant, - not a presentation choice. Reordering the list would silently run a - different test than the one named. */ -check( - "the four firmware test types, in firmware order", - testOpts.types.length === 4 && - testOpts.types.map((t) => t[0]).join() === "0,1,2,3", - JSON.stringify(testOpts.types), -); -check( - "Run is live once connected", - !testOpts.runDisabled, - `${testOpts.timeout} s timeout`, -); - -// =========================================================================== -console.log("\n--- running a report ---"); -const run = await evaluate(` - ${TEST} - document.querySelector('.tabs [data-tab=tabTest]').click(); - role('type').value = '0'; - role('type').dispatchEvent(new Event('change')); - const before = opCount(0xA8); - role('run').click(); - await new Promise(r => setTimeout(r, 400)); - const early = role('report').textContent.length; - const applyDisabledMidRun = document.getElementById('btnApply').disabled; - const applyNote = document.getElementById('applyNote').textContent; - const sdBanner = document.getElementById('sdBanner').textContent; - const brandBanner = document.getElementById('brandBanner').textContent; - const calBanner = document.getElementById('calBanner').textContent; - const disconnectEnabled = !document.getElementById('btnDisconnect').disabled; - for (let i = 0; i < 250 && window.factoryTestPanel.running(); i++) - await new Promise(r => setTimeout(r, 100)); - await new Promise(r => setTimeout(r, 300)); - const text = role('report').textContent; - return { - sent: opCount(0xA8) - before, - lastWrite: Array.from((window.mockTransport.writes.at(-1).bytes ?? [])), - early, finalLen: text.length, - startsWithBanner: text.startsWith('//****'), - hasEnd: /TEST END/.test(text), - matchesMock: text === window.mockTransport.factoryTest.text(), - modelLine: text.includes(' - S3R_TEST_0003 - PASS: Shimmer3R IMU (SR68-1-0)'), - pass: P().querySelectorAll('.tr-pass').length, - warn: P().querySelectorAll('.tr-warn').length, - fail: P().querySelectorAll('.tr-fail').length, - summary: role('summary').textContent, - summaryClass: role('summary').className, - copyDisabled: role('copy').disabled, - txtDisabled: role('saveTxt').disabled, - csvDisabled: role('csv')?.disabled, - parsedOverall: window.factoryTestPanel.lastReport()?.parsed?.overall?.result, - applyDisabledMidRun, applyNote, sdBanner, brandBanner, calBanner, disconnectEnabled, - applyAfter: document.getElementById('btnApply').disabled, - }; -`); -check( - "Run sends SET_FACTORY_TEST once, with the chosen type", - run.sent === 1 && run.lastWrite[0] === 0xa8, - JSON.stringify(run.lastWrite), -); -/* The report has to appear as it arrives. A minute of silence followed by a - wall of text is indistinguishable, to the person at the bench, from a - sensor that has hung -- which is the state this test exists to find. */ -check( - "the report arrives incrementally, not in one piece at the end", - run.early > 0 && run.early < run.finalLen, - `${run.early} → ${run.finalLen} chars`, -); -check( - "the whole report arrives, banner to banner", - run.startsWithBanner && run.hasEnd && run.finalLen > 1500, - `${run.finalLen} chars`, -); -/* The strong form of the same claim: not "it looks right" but "it is what the - sensor printed", compared against the mock's own copy of what it sent. */ -check( - "what is on screen is byte-for-byte what the sensor printed", - run.matchesMock, -); -/* The reassembly hazard in one line: the mock deliberately splits this one - across two writes and several notifications, because the firmware's own - output is not aligned to anything. */ -check( - "a line split across two writes and many notifications reassembles", - run.modelLine, -); -check( - "PASS and WARNING are coloured, and nothing failed in this run", - run.pass > 15 && run.warn === 1 && run.fail === 0, - `pass ${run.pass} warn ${run.warn} fail ${run.fail}`, -); -check( - "the summary reads the parsed overall verdict, not the last line printed", - /^Overall: PASS/.test(run.summary) && - run.summaryClass.includes("on") && - run.parsedOverall === "PASS", - run.summary, -); -check( - "the exports come alive once there is a report", - !run.copyDisabled && !run.txtDisabled && run.csvDisabled === false, -); -/* The link is held for the whole run, so every other panel has to say so in - words -- "self-test" specifically, not a generic "busy": a control that - greys out with no sentence anywhere is the failure mode the reason matrix - at the end of this pass exists to prevent. */ -check( - "mid-run, every other panel is refused with a sentence naming the self-test", - run.applyDisabledMidRun && - /self-test/.test(run.applyNote) && - /self-test/.test(run.sdBanner) && - /self-test/.test(run.brandBanner) && - /self-test/.test(run.calBanner), - run.applyNote.slice(0, 60), -); -/* There is no abort command, so pulling the link is the only real escape. - Disabling Disconnect during a run would trap the user for a minute. */ -check( - "Disconnect stays available during a run — the only real escape", - run.disconnectEnabled, -); -check("the link is released when the report ends", run.applyAfter === false); - -const saved = await evaluate(` - ${TEST} - window.__blobs.length = 0; - role('saveTxt').click(); - role('csv').click(); - await new Promise(r => setTimeout(r, 200)); - const out = []; - for (const b of window.__blobs) { - /* The BOM is asserted on the RAW BYTES, not on b.text(): Blob.text() - decodes as UTF-8 and strips a leading BOM, so a text compare passes - whether or not the BOM was ever written -- and the BOM is the whole - point, because Excel reads a BOM-less CSV as the local codepage. */ - const bytes = new Uint8Array(await b.arrayBuffer()); - out.push({ - type: b.type, - head: (await b.text()).slice(0, 40), - first3: Array.from(bytes.slice(0, 3)), - size: b.size, - }); - } - return out; -`); -check( - "Save .txt writes the report verbatim, and Save CSV writes a BOM'd table", - saved.length === 2 && - saved[0].type.startsWith("text/plain") && - saved[0].head.startsWith("//****") && - saved[1].type.startsWith("text/csv") && - saved[1].first3.join() === "239,187,191", - JSON.stringify(saved.map((s) => [s.type, s.size])), -); - -// =========================================================================== -console.log("\n--- a failing unit ---"); -await goto(`${BASE}?mock=1&testMs=20&testFail=1`); -check( - "connect to a sensor that fails its self-test", - (await evaluate(CONNECT)) === "mock", -); -const failRun = await evaluate(` - ${TEST} - document.querySelector('.tabs [data-tab=tabTest]').click(); - role('run').click(); - for (let i = 0; i < 250 && window.factoryTestPanel.running(); i++) - await new Promise(r => setTimeout(r, 100)); - await new Promise(r => setTimeout(r, 300)); - const text = role('report').textContent; - const lines = text.split('\\r\\n'); - return { - fail: P().querySelectorAll('.tr-fail').length, - summary: role('summary').textContent, - cls: role('summary').className, - longest: Math.max(...lines.map(l => l.length)), - gluedLine: lines.find(l => l.length > 128)?.slice(0, 50), - failedNames: window.factoryTestPanel.lastReport()?.parsed?.overall?.failedTestNames, - }; -`); -check( - "a FAIL line and the verdict are both coloured", - failRun.fail >= 2, - `${failRun.fail} spans`, -); -/* The mask is the machine-readable half of the verdict, and the only place - the failed test's NAME can come from -- the firmware prints the number. */ -check( - "the summary decodes the fail mask and names what failed", - /FAIL \(0x00000040\)/.test(failRun.summary) && failRun.cls.includes("err"), - failRun.summary, -); -check( - "the parser names the failed test", - Array.isArray(failRun.failedNames) && failRun.failedNames.length >= 1, - JSON.stringify(failRun.failedNames), -); -/* The firmware truncates its own lines at 128 characters and carries on - printing, with no newline at the seam. Nothing on this side can put the - break back, so the page must show the seam as it is rather than inventing - one -- a report that "looks tidy" here is a report that has been edited. */ -check( - "a line the firmware truncated at 128 chars is shown glued to the next", - failRun.longest > 128, - `${failRun.longest} chars: ${failRun.gluedLine}`, -); - -// =========================================================================== -console.log("\n--- cancel, and the sensor that keeps printing ---"); -await goto(`${BASE}?mock=1&testMs=120`); -check("connect for the cancel pass", (await evaluate(CONNECT)) === "mock"); -const cancelled = await evaluate(` - ${TEST} - document.querySelector('.tabs [data-tab=tabTest]').click(); - role('type').value = '3'; - role('type').dispatchEvent(new Event('change')); - role('run').click(); - await new Promise(r => setTimeout(r, 600)); - const beforeOps = opCount(0xA8); - role('cancel').click(); - await new Promise(r => setTimeout(r, 300)); - const midLen = window.mockTransport.factoryTest.text().length; - const out = { - running: window.factoryTestPanel.running(), - draining: window.factoryTestPanel.draining(), - status: role('status').textContent, - applyDisabled: document.getElementById('btnApply').disabled, - sensorStillPrinting: window.mockTransport.factoryTest.running, - opsAfterCancel: opCount(0xA8) - beforeOps, - }; - await new Promise(r => setTimeout(r, 1200)); - out.grew = window.mockTransport.factoryTest.text().length > midLen; - for (let i = 0; i < 400 && window.factoryTestPanel.draining(); i++) - await new Promise(r => setTimeout(r, 100)); - out.drainedApply = document.getElementById('btnApply').disabled; - out.finalStatus = role('status').textContent; - return out; -`); -check("Cancel stops the page listening", !cancelled.running); -/* The distinction the whole panel turns on: the page has stopped, the SENSOR - has not. Releasing the link here would let an apply go out into the middle - of a report still being printed. */ -check( - "but the page stays busy, because the sensor keeps printing", - cancelled.draining && - cancelled.applyDisabled && - /still running/i.test(cancelled.status), - cancelled.status.slice(0, 90), -); -check( - "nothing was sent to stop it — there is no such command", - cancelled.opsAfterCancel === 0, -); -check( - "the sensor really does keep printing", - cancelled.sensorStillPrinting && cancelled.grew, -); -check( - "the link frees itself once the report would have ended", - cancelled.drainedApply === false, - cancelled.finalStatus.slice(0, 80), -); - -// =========================================================================== -console.log("\n--- the firmware's own refusal ---"); -await goto(`${BASE}?mock=1&testMs=20`); -check("connect for the refusal pass", (await evaluate(CONNECT)) === "mock"); -const refused = await evaluate(` - document.getElementById('btnStreamStart').click(); - await new Promise(r => setTimeout(r, 700)); - const streamingState = { - tabDisabled: document.getElementById('tabBtnTest').disabled, - banner: document.getElementById('testBanner').textContent, - }; - document.getElementById('btnStreamStop').click(); - await new Promise(r => setTimeout(r, 700)); - return streamingState; -`); -/* The one refusal that closes the tab rather than banner it: the firmware - will not start a self-test while it is sensing, so there is nothing on the - tab that could work. Every other holder is transient and leaves it open -- - see the paintTestGating pin at the end of this pass. */ -check( - "while streaming, the tab closes and says the refusal is the firmware's", - refused.tabDisabled && /refuses a self-test/.test(refused.banner), - refused.banner.slice(0, 80), -); - -// =========================================================================== -console.log("\n--- the red LED ---"); -const led = await evaluate(` - ${TEST} - document.querySelector('.tabs [data-tab=tabTest]').click(); - const before = opCount(0x06); - document.getElementById('btnLedToggle').click(); - await new Promise(r => setTimeout(r, 700)); - const afterToggle = { - ops: opCount(0x06) - before, - bit: (window.mockTransport.status.bytes()[2] & 0x80) !== 0, - pill: document.getElementById('ledPill').textContent, - pillClass: document.getElementById('ledPill').className, - }; - document.getElementById('btnLedOn').click(); - await new Promise(r => setTimeout(r, 700)); - const idempotent = opCount(0x06) - before; - document.getElementById('btnLedOff').click(); - await new Promise(r => setTimeout(r, 900)); - return { - afterToggle, - idempotent, - offOps: opCount(0x06) - before, - offBit: (window.mockTransport.status.bytes()[2] & 0x80) !== 0, - offPill: document.getElementById('ledPill').textContent, - }; -`); -/* The pill is painted from the sensor's own status bit rather than from what - the page last asked for: the firmware's flag survives a disconnect, so the - only honest source is the sensor. */ -check( - "Toggle sends the command and the pill follows the sensor's own bit", - led.afterToggle.ops === 1 && - led.afterToggle.bit && - /red LED on/.test(led.afterToggle.pill) && - led.afterToggle.pillClass.includes("on"), - led.afterToggle.pill, -); -/* The firmware has a TOGGLE, not a set, so "on" and "off" have to be built - out of it: read the bit, and write only if it disagrees. Asking for a state - the sensor is already in must put nothing on the wire, or the buttons flip - the LED instead of setting it. */ -check( - "Red LED on is idempotent — already on, so nothing is written", - led.idempotent === 1, - `${led.idempotent} toggles`, -); -check( - "Red LED off drives it back", - led.offOps === 2 && !led.offBit && /red LED off/.test(led.offPill), - led.offPill, -); - -// =========================================================================== -console.log("\n--- clock drift ---"); -/* ppm is deliberately enormous. The SDK steps the device clock in whole - seconds, so a sample only moves when ppm × elapsed exceeds one second -- - at a realistic 20 ppm that is fourteen hours. 20000 ppm puts a step inside - every 2 s interval while staying under a second per interval, which is what - keeps the fitted slope meaningful rather than quantised to nothing. - clockBase=local starts the sensor on this host's civil time instead of UTC, - which is what a sensor last set by the old convention looks like. */ -await send("Emulation.setTimezoneOverride", { timezoneId: "Asia/Tokyo" }); -await goto(`${BASE}?mock=1&ppm=20000&clockBase=local`); -check( - "connect to a sensor with a fast clock, under +09:00", - (await evaluate(CONNECT)) === "mock", -); -const drift = await evaluate(` - ${TEST} - document.querySelector('.tabs [data-tab=tabTest]').click(); - drole('interval').value = '2'; - drole('interval').dispatchEvent(new Event('change')); - const before = opCount(0x91); - drole('start').click(); - await new Promise(r => setTimeout(r, 6500)); - const out = { - samples: dstat('samples'), - reads: opCount(0x91) - before, - ppmText: dstat('ppm'), - fit: window.rtcDriftPanel.monitor()?.ppmFit(), - base: window.rtcDriftPanel.clockBaseSec(), - baseText: drole('base')?.textContent, - running: window.rtcDriftPanel.running(), - }; - window.__blobs.length = 0; - drole('csv').click(); - await new Promise(r => setTimeout(r, 200)); - out.csvHead = window.__blobs.length ? (await window.__blobs[0].text()).slice(0, 260) : ''; - // Raw bytes again: Blob.text() would strip the BOM and hide its absence. - out.csvBom = window.__blobs.length - ? Array.from(new Uint8Array(await window.__blobs[0].arrayBuffer()).slice(0, 3)).join() === '239,187,191' - : false; - drole('stop').click(); - await new Promise(r => setTimeout(r, 200)); - out.stopped = !window.rtcDriftPanel.running(); - return out; -`); -/* The first sample is taken immediately rather than after one interval: a - monitor left running overnight is useless if it cannot show anything for - the first ten minutes. */ -check( - "sampling starts at once and keeps to its interval", - Number(drift.samples) >= 3 && drift.reads >= 3, - `${drift.samples} samples / ${drift.reads} reads`, -); -check( - "the fitted slope is the drift the sensor was actually given", - drift.fit != null && Math.abs(drift.fit - 20000) / 20000 < 0.2, - `${Math.round(drift.fit)} ppm vs 20000`, -); -/* A whole-hour offset between sensor and host is a clock BASE, not drift: a - sensor set by something that wrote local civil time. Folding it into the - error would report a 32400-second "drift" and hide the ppm entirely. */ -check( - "a +09:00 sensor clock is recognised as a clock base, not as error", - drift.base === 32400, - `base ${drift.base} s — ${String(drift.baseText).slice(0, 70)}`, -); -check( - "the CSV carries its metadata preamble and a BOM", - drift.csvBom && - /clock_base_s/.test(drift.csvHead) && - /ppm_fit/.test(drift.csvHead), - drift.csvHead.split(String.fromCharCode(10))[1], -); -check("Stop ends the run", drift.stopped); - -/* The drift panel is the one panel that WAITS instead of closing when another - panel takes the link -- so a skipped tick has to say so. A silent skip and - a dead monitor look identical on a plot. */ -const skip = await evaluate(` - ${TEST} - drole('interval').value = '2'; - drole('interval').dispatchEvent(new Event('change')); - drole('start').click(); - await new Promise(r => setTimeout(r, 300)); - const before = opCount(0x91); - document.getElementById('btnLinkTest').click(); - await new Promise(r => setTimeout(r, 3000)); - const status = drole('status').textContent; - const during = opCount(0x91) - before; - drole('stop').click(); - return { status, during }; -`); -check( - "a sample is skipped, with the reason, while another panel holds the link", - /skipped/i.test(skip.status) && /throughput/i.test(skip.status), - skip.status.slice(0, 90), -); -await send("Emulation.setTimezoneOverride", { timezoneId: "" }); - -// =========================================================================== -console.log("\n--- refusal reasons cover every other link holder ---"); - -const pageSrc = await (await fetch(`${BASE}index.html`)).text(); - -/** The body of a `function name() { … }` declaration, brace-matched. */ -function fnBody(src, name) { - const start = src.indexOf(`function ${name}(`); - if (start < 0) return null; - const i = src.indexOf("{", start); - if (i < 0) return null; - let depth = 0; - for (let j = i; j < src.length; j++) { - if (src[j] === "{") depth++; - else if (src[j] === "}" && --depth === 0) return src.slice(i, j + 1); - } - return null; -} - -/* asking panel -> the flags it must be able to explain. Its own flag is - absent by design: a panel refuses its own second operation itself. */ -const MUST_EXPLAIN = { - linkTestUnavailableReason: [ - "streaming", - "sdLogging", - "sdBusy", - "brandBusy", - "calibBusy", - "testBusy", - ], - sdUnavailableReason: [ - "streaming", - "sdLogging", - "linkTesting", - "brandBusy", - "calibBusy", - "testBusy", - ], - brandUnavailableReason: [ - "streaming", - "sdLogging", - "linkTesting", - "sdBusy", - "calibBusy", - "testBusy", - ], - calibLinkHeldReason: [ - "streaming", - "sdLogging", - "linkTesting", - "sdBusy", - "brandBusy", - "testBusy", - ], - /* The self-test is the sixth holder, and the first that outlives its own - Cancel: the firmware has no abort, so testBusy stays up while the sensor - finishes printing. That makes it exactly the flag the other five would - have aged past silently. */ - testUnavailableReason: [ - "streaming", - "sdLogging", - "linkTesting", - "sdBusy", - "brandBusy", - "calibBusy", - ], - /* The drift monitor is the one panel that waits instead of closing, so its - skip reason has to name every holder INCLUDING the self-test it shares a - tab with -- a silent skip is indistinguishable from a dead monitor. */ - driftSampleSkipReason: [ - "streaming", - "sdLogging", - "linkTesting", - "sdBusy", - "brandBusy", - "calibBusy", - "testBusy", - ], -}; - -for (const [fn, flags] of Object.entries(MUST_EXPLAIN)) { - const body = fnBody(pageSrc, fn); - const missing = body ? flags.filter((f) => !body.includes(f)) : flags; - check( - `${fn} can explain every other panel holding the link`, - !!body && missing.length === 0, - body ? `missing: ${missing.join(", ") || "none"}` : "function not found", - ); -} - -/* The board read has to be the ID page, not the card MEMORY page. Firmware - maps host offset 0 of the memory read past the first EEPROM page, so - `readDaughterCardMem(0, 16)` returns absolute bytes 16..31 and its first - three bytes are not an SR code at all. The page inferred the sensor's part - generation from them for as long as it did that, and the mistake is - invisible on a Shimmer3R (hardware id 10 decides) and silent on a Shimmer3 - (it just offers the wrong option tables). Checked at source level because - the wrong answer is still a well-formed one. */ -const radioConnect = fnBody(pageSrc, "afterConnectRadio") ?? ""; -check( - "the SR board comes from the id page, not from the card-memory page", - radioConnect.includes("readSrBoard()") && - /* The mistake itself, rather than the string: the page's own comment - quotes the old call deliberately, to say why it is not that. */ - !pageSrc.includes("parseExpansionBoard(await client.readDaughterCardMem"), - radioConnect.includes("readSrBoard()") - ? "readSrBoard" - : "still reading the wrong page", -); -check( - "and the generation is inferred from that same read, not a second one", - (pageSrc.match(/inferShimmer3Generation\(/g) ?? []).length === 2 && - (pageSrc.match(/boardId: srBoard\.boardId/g) ?? []).length === 2, - `${(pageSrc.match(/inferShimmer3Generation\(/g) ?? []).length} call sites, ` + - `${(pageSrc.match(/boardId: srBoard\.boardId/g) ?? []).length} fed from srBoard`, -); - -/* The Device row prefers the sensor's OWN name over the link's. Pinned - because the fallback is what a reader sees when it breaks, and it broke - quietly before: on a serial link there is no advertising name at all, so - the row read "(name not reported)" on every classic-Bluetooth and USB - connection. */ -const nameLabel = fnBody(pageSrc, "deviceNameLabel") ?? ""; -check( - "the Device row prefers the configured name and labels the fallback as advertised", - nameLabel.includes("configuredName") && - nameLabel.indexOf("configuredName") < nameLabel.indexOf("deviceName") && - nameLabel.includes("advertising name"), - nameLabel ? "configured first, advertising named as such" : "not found", -); -check( - "and it reads that name with a context, which is what parseInfoMem takes", - /* Handed a layout instead, parseInfoMem resolves a layout FROM it and reads - every field at an undefined offset — which fails silently, as an empty - name. */ - !pageSrc.includes("parseInfoMem(deviceImage, layout)") && - pageSrc.includes("parseInfoMem(deviceImage, context)"), - pageSrc.includes("parseInfoMem(deviceImage, context)") - ? "context" - : "not the context", -); - -/* The one that had drifted: the calibration tab's disabled state must come - from the same predicate as its sentence, not a hand-copied list. */ -const CALIB_DISABLE = "btn.disabled = !!client && !!calibLinkHeldReason()"; -const calibPaint = fnBody(pageSrc, "paintCalibGating"); -check( - "the calibration tab closes on the same predicate that explains why", - !!calibPaint && calibPaint.includes(CALIB_DISABLE), - (calibPaint ?? "") - .split(String.fromCharCode(10)) - .find((l) => l.includes("btn.disabled")) ?? "not found", -); - -/* The Test tab deliberately does NOT follow that rule, so pin the exception - too — otherwise the next person to "fix the inconsistency" closes the tab - whenever any other panel touches the link, and takes the drift plot down - with it. Only the firmware's own refusal (it will not self-test while it is - sensing) closes this tab; every other holder is a transient banner over a - tab that stays open. */ -const TEST_DISABLE = "btn.disabled = !!client && (streaming || sdLogging);"; -const testPaint = fnBody(pageSrc, "paintTestGating"); -check( - "the Test tab closes only on the refusal that is the firmware's, not on a busy link", - !!testPaint && testPaint.includes(TEST_DISABLE), - (testPaint ?? "") - .split(String.fromCharCode(10)) - .find((l) => l.includes("btn.disabled")) ?? "not found", -); - -/* The run gate and the "worth knowing" note are different things, and the - panel cannot tell them apart: it refuses on ANY sentence `canRun` hands - back. They were once one function, which read well and silently stopped - the USB-C link -- the one link this tab documents with a footnote of its - own -- from ever running a self-test. Checked at source level, because - reproducing it needs a dock on the bench. */ -const runGate = fnBody(pageSrc, "selfTestUnavailableReason") ?? ""; -const linkNote = fnBody(pageSrc, "selfTestLinkNote") ?? ""; -const USB_TEST = 'mode === "usb"'; -check( - "the self-test run gate returns refusals only, never the USB note", - runGate.length > 0 && !runGate.includes(USB_TEST), - runGate.includes(USB_TEST) - ? "selfTestUnavailableReason still returns the USB note" - : "clean", -); -check( - "and that note lives in a function the run gate does not consult", - linkNote.includes(USB_TEST), - linkNote ? "selfTestLinkNote carries it" : "selfTestLinkNote not found", -); -const canRunWiring = pageSrc.slice( - pageSrc.indexOf("canRun:"), - pageSrc.indexOf("canRun:") + 200, -); -check( - "the panel is wired to the gate, not to the note", - canRunWiring.includes("selfTestUnavailableReason") && - !canRunWiring.includes("selfTestLinkNote"), - canRunWiring.split(String.fromCharCode(10))[1] ?? "", -); - -// =========================================================================== -// The General tab, the kinematic block editor, and the log's Copy button — -// the alignment pass against the Verisense device console. -// =========================================================================== -console.log("\n--- the General tab ---"); -await goto(`${BASE}?mock=1`); - -const generalIdle = await evaluate(` - const btn = [...document.querySelectorAll('.tabs [data-tab]')][0]; - return { - first: btn.dataset.tab, - label: btn.textContent.trim(), - selected: document.querySelector('.tabs [aria-selected="true"]').dataset.tab, - cards: [...document.querySelectorAll('#tabGeneral .card-title')] - .map(t => t.textContent.trim()), - /* Every control on it is a device command, so every one is gated on a - link. A button that is live with nothing connected would fail on its - first press with a message about the client rather than about the - link. */ - live: [...document.querySelectorAll('#tabGeneral button')] - .filter(b => !b.disabled).map(b => b.id), - }; -`); -check( - "General leads the strip, is what the page opens on, and holds the three cards", - generalIdle.first === "tabGeneral" && - generalIdle.label === "General" && - generalIdle.selected === "tabGeneral" && - generalIdle.cards.join(",") === "Clock,LEDs,Device commands", - `${generalIdle.label} first, showing ${generalIdle.cards.join(" / ")}`, -); -check( - "disconnected, nothing on it is pressable", - generalIdle.live.length === 0, - generalIdle.live.length ? generalIdle.live.join(", ") : "all gated", -); - -check("connect for the General tab", (await evaluate(CONNECT)) === "mock"); - -const general = await evaluate(` - document.querySelector('.tabs [data-tab=tabGeneral]').click(); - const from = window.mockTransport.writes.length; - document.getElementById('btnReadClock').click(); - await new Promise(r => setTimeout(r, 900)); - const clockOps = window.mockTransport.writes.slice(from) - .map(w => w.bytes[0]); - const afterClock = { - rwc: document.getElementById('rwcValue').textContent, - host: document.getElementById('hostClock').textContent, - note: document.getElementById('generalNote').textContent, - }; - const from2 = window.mockTransport.writes.length; - document.getElementById('btnInquiry').click(); - await new Promise(r => setTimeout(r, 900)); - return { - clockOps, afterClock, - inquiryOps: window.mockTransport.writes.slice(from2).map(w => w.bytes[0]), - /* The LED and the clock left the tabs they were on, and nothing was left - behind on either — a duplicate id would make $() return whichever came - first in the document and quietly wire half the page to it. */ - ledOnGeneral: !!document.querySelector('#tabGeneral #btnLedToggle'), - ledGoneFromTest: !document.querySelector('#tabTest #btnLedToggle'), - clockGoneFromConfig: !document.querySelector('#tabConfig #btnSetClock'), - oneOfEach: ['btnSetClock','btnLedToggle','btnReadClock','btnInquiry'] - .map(id => document.querySelectorAll('#' + id).length), - }; -`); -check( - "Read device clock reads the clock and nothing else, and reports the skew", - // GET_RWC_COMMAND (0x91) once: the point of the button is that it is not - // Refresh, which also reads the battery and the status bytes. - general.clockOps.join(",") === "145" && - /^\d{4}-\d{2}-\d{2} /.test(general.afterClock.rwc) && - /^\d{4}-\d{2}-\d{2} /.test(general.afterClock.host) && - /within a second of|s from/.test(general.afterClock.note), - `ops [${general.clockOps.join(",")}] — ${general.afterClock.note}`, -); -check( - "Re-inquire channel list sends one INQUIRY and nothing else", - general.inquiryOps.join(",") === "1", - `ops [${general.inquiryOps.join(",")}]`, -); -check( - "the clock and the LEDs moved to General, leaving nothing behind", - general.ledOnGeneral && - general.ledGoneFromTest && - general.clockGoneFromConfig && - general.oneOfEach.every((n) => n === 1), - `one of each: ${general.oneOfEach.join(",")}`, -); - -// =========================================================================== -console.log("\n--- calibration blocks in the configuration form ---"); - -const kin = await evaluate(` - document.querySelector('.tabs [data-tab=tabConfig]').click(); - const body = document.querySelector('[data-group-body="calibration"]'); - body.closest('details').open = true; - await new Promise(r => requestAnimationFrame(r)); - const cell = (key, part, i) => document.querySelector( - '[data-field-key="' + key + '"] [data-cal-part=' + part + '][data-cal-index="' + i + '"]'); - const cells = (key, part) => [...document.querySelectorAll( - '[data-field-key="' + key + '"] [data-cal-part=' + part + ']')].map(i => i.value); - const before = { - fields: [...body.querySelectorAll('.field')].length, - /* No hex box anywhere in the group: two editors on one 21-byte field - would be two writers, and whichever committed last would win. */ - hexBoxes: [...body.querySelectorAll('input[placeholder$="hex characters"]')].length, - ranges: [...body.querySelectorAll('.cal-inline .pill')].map(p => p.textContent), - lnSens: cells('calib.lnAccel', 'sens'), - lnAlign: cells('calib.lnAccel', 'align'), - faint: [...body.querySelectorAll('input.cal-cell.faint')].length, - /* The mock's image carries no calibration, so every box holds the greyed - factory default — 90 of them across six sensors. */ - allCells: [...body.querySelectorAll('input.cal-cell')].length, - note: document.querySelector('[data-field-key="calib.lnAccel"] .field-hint.muted').textContent, - }; - - // A value the format cannot hold: alignment is i8/100, so 5 is refused. - const bad = cell('calib.lnAccel', 'align', 0); - const was = bad.value; - bad.value = '5'; - bad.dispatchEvent(new Event('change', { bubbles: true })); - await new Promise(r => requestAnimationFrame(r)); - const refused = { - error: document.querySelector('[data-field-key="calib.lnAccel"] .cal-error').textContent, - flagged: bad.classList.contains('bad'), - dirty: document.getElementById('dirtyPill').textContent, - dirtyHidden: document.getElementById('dirtyPill').hidden, - }; - - // …and one it can. Committing turns the greyed defaults into real values. - bad.value = was; - bad.dispatchEvent(new Event('change', { bubbles: true })); - const box = cell('calib.lnAccel', 'offset', 0); - box.value = '123'; - box.dispatchEvent(new Event('change', { bubbles: true })); - await new Promise(r => requestAnimationFrame(r)); - const accepted = { - dirty: document.getElementById('dirtyPill').textContent, - error: document.querySelector('[data-field-key="calib.lnAccel"] .cal-error').textContent, - faint: document.querySelectorAll('[data-field-key="calib.lnAccel"] input.cal-cell.faint').length, - offsets: cells('calib.lnAccel', 'offset'), - /* The bytes are what the form actually holds, so the hex view is where - the edit has to show up — an editor that painted boxes and wrote - nothing would look identical up to here. */ - highlighted: document.querySelectorAll('#hexview .hex-byte.changed, #hexview .changed').length > 0, - }; - - document.getElementById('btnDiscard').click(); - await new Promise(r => requestAnimationFrame(r)); - return { before, refused, accepted, after: { - offsets: cells('calib.lnAccel', 'offset'), - faint: document.querySelectorAll('[data-field-key="calib.lnAccel"] input.cal-cell.faint').length, - dirtyHidden: document.getElementById('dirtyPill').hidden, - } }; -`); -check( - "the six calibration blocks render as matrices, not as hex, one field each", - kin.before.fields === 6 && - kin.before.hexBoxes === 0 && - kin.before.allCells === 90 && - kin.before.lnSens.length === 3 && - kin.before.lnAlign.length === 9, - `${kin.before.fields} fields, ${kin.before.allCells} boxes, ${kin.before.hexBoxes} hex boxes`, -); -check( - "each block names the range the image is configured for", - kin.before.ranges.length === 6 && - kin.before.ranges.filter((r) => /^configured: /.test(r)).length === 4 && - // The Shimmer3R's LIS2MDL magnetometer has one range, so the schema - // declares no range field for it — and that is not a lookup failure. - kin.before.ranges.filter((r) => r === "single range").length === 2, - kin.before.ranges.join(" · "), -); -check( - "an empty block shows the factory defaults it falls back to, greyed and labelled", - kin.before.faint === 90 && - /every byte is 0x00 or 0xFF/.test(kin.before.note) && - /factory defaults/.test(kin.before.note) && - // LSM6DSV low-noise accel at +/- 2g - kin.before.lnSens.join(",") === "1672,1672,1672", - `${kin.before.faint} greyed boxes; sensitivity ${kin.before.lnSens.join(",")}`, -); -check( - "a value the 21-byte format cannot hold is refused, and says which box", - /align row 1 column 1/.test(kin.refused.error) && - /1\.28 to 1\.27 in steps of 0\.01/.test(kin.refused.error) && - kin.refused.flagged, - kin.refused.error, -); -check( - "a value it can hold reaches the bytes, and stops being a greyed default", - kin.accepted.error === "" && - kin.accepted.faint === 0 && - kin.accepted.offsets.join(",") === "123,0,0" && - /change/.test(kin.accepted.dirty) && - kin.accepted.highlighted, - `${kin.accepted.dirty}, offsets ${kin.accepted.offsets.join(",")}`, -); -check( - "Discard puts the greyed defaults back, exactly as it does any other field", - kin.after.offsets.join(",") === "0,0,0" && - kin.after.faint === 15 && - kin.after.dirtyHidden, - `offsets ${kin.after.offsets.join(",")}, ${kin.after.faint} greyed`, -); - -// =========================================================================== -console.log("\n--- the event log's toolbar ---"); - -const logBar = await evaluate(` - const ids = ['logFilter','logSeverity','chkRawBytes','chkRawData', - 'btnLogCopy','btnLogClear','btnLogDownload']; - const order = [...document.querySelectorAll('#logDrawerBody .log-toolbar *')] - .filter(n => ids.includes(n.id)).map(n => n.id); - /* Copied by clicking, not by calling the API: the button is the thing that - has to be wired. The clipboard is unavailable in a headless pass, so what - is asserted is that it REPORTS either way — a copy that silently does - nothing is the failure mode this replaced. */ - window.__toasts = []; - const realToast = window.showToast; - document.getElementById('btnLogCopy').click(); - await new Promise(r => setTimeout(r, 400)); - return { - order, - severities: [...document.querySelectorAll('#logSeverity option')] - .map(o => o.textContent), - copyTitle: document.getElementById('btnLogCopy').title, - toasts: [...document.querySelectorAll('.toast')].map(t => t.textContent), - themeTitle: document.getElementById('themeBtn').title, - drawerTitle: document.querySelector('.log-drawer-title').textContent, - }; -`); -check( - "the toolbar reads filter, severity, the two taps, then Copy / Clear / Download", - logBar.order.join(",") === - "logFilter,logSeverity,chkRawBytes,chkRawData,btnLogCopy,btnLogClear,btnLogDownload", - logBar.order.join(" → "), -); -check( - "the drawer title and the severity wording match the console's", - logBar.drawerTitle === "Event log" && - logBar.severities.join(",") === "All,Errors,Warnings+,TX/RX", - `"${logBar.drawerTitle}": ${logBar.severities.join(" / ")}`, -); -check( - "Copy reports what happened rather than failing silently", - logBar.toasts.some((t) => - /Copied \d+ log lines|clipboard is not available/.test(t), - ) && - /every line, not just the ones the filter is showing/.test( - logBar.copyTitle, - ), - logBar.toasts.join(" | ") || "no toast", -); -check( - "the theme toggle says which way the click goes, as the console's does", - /^Switch to (light|dark) theme$/.test(logBar.themeTitle), - logBar.themeTitle, -); - -// =========================================================================== -// The clock read, and the gate that decides whether to offer it. -// -// These have to agree. They did not: the button was gated on -// `describeShimmer3Caps(...).rtcRead`, which counts the dock's `getConfig` -// property as a way to read the clock, while the handler tested for the -// Bluetooth `getRtcTime` method and refused. So over USB the control was -// enabled and always said "This link cannot read the sensor clock" — and the -// README had been written to match the bug. Found by Copilot on #76. -// -// Source-level and stub-level on purpose: the mock transport speaks the radio -// protocol only, so there is no dock client here to drive the button with. -// What can be pinned is the agreement, which is what actually broke. -// =========================================================================== -console.log("\n--- the clock read agrees with the gate that offers it ---"); - -const clockGate = await evaluate(` - const schema = await import('/common/shimmer3-config-schema.js'); - const clock = await import('/common/device-clock.js'); - /* The four client shapes that exist, crossed with every link. "dock" is a - getConfig-only client and "both" is the stub shape that carries each - read — it cannot arise from the real clients (Shimmer3RClient has no - getConfig, WiredShimmerClient no getRtcTime) but it is what pins which - path wins on the dock link. */ - const shapes = { - radio: { async getRtcTime(){ return { ticks: 1n, unixMs: 1 }; } }, - dock: { async getConfig(){ return new Uint8Array(8); } }, - both: { async getRtcTime(){ return { ticks: 1n, unixMs: 1 }; }, - async getConfig(){ return new Uint8Array(8); } }, - mute: {}, - }; - const rows = []; - for (const [shape, client] of Object.entries(shapes)) { - for (const mode of ['ble', 'rfcomm', 'usb']) { - let read = 'threw'; - try { read = (await clock.readDeviceRwc(client, mode)).viaDock ? 'dock' : 'radio'; } - catch { /* refused, which is a result */ } - rows.push({ - shape, mode, - caps: schema.describeShimmer3Caps(client, mode).rtcRead, - gate: clock.canReadRwc(client), - read, - }); - } - } - return { - rows, - /* The bug this module was written for: a control the capability enables - that the reader then refuses. */ - enabledButRefuses: rows.filter(r => r.caps && r.read === 'threw') - .map(r => r.shape + '/' + r.mode), - /* And its sibling, one layer down: the gate and the reader disagreeing, - which is what Copilot found in the first version of this module. */ - gateDisagrees: rows.filter(r => r.gate !== (r.read !== 'threw')) - .map(r => r.shape + '/' + r.mode), - /* canReadRwc takes no mode: whether a clock can be read is a property of - the CLIENT. Pinned by call, not by reading the source. */ - modeIgnored: clock.canReadRwc(shapes.dock, 'ble') === clock.canReadRwc(shapes.dock, 'usb'), - nothingConnected: clock.canReadRwc(null), - src: await (await fetch('/ShimmerCapture/index.html')).text(), - }; -`); -const dockOnUsb = clockGate.rows.find( - (r) => r.shape === "dock" && r.mode === "usb", -); -const bothOnUsb = clockGate.rows.find( - (r) => r.shape === "both" && r.mode === "usb", -); -const bothOnBle = clockGate.rows.find( - (r) => r.shape === "both" && r.mode === "ble", -); -check( - "no client/link shape is offered the clock by the capability and refused by the reader", - clockGate.enabledButRefuses.length === 0 && - // …and the case that broke really is offered, rather than agreeing on "no". - dockOnUsb.caps === true && - dockOnUsb.read === "dock", - clockGate.enabledButRefuses.length - ? `enabled but refuses: ${clockGate.enabledButRefuses.join(", ")}` - : `${clockGate.rows.length} shapes, dock-over-usb reads via ${dockOnUsb.read}`, -); -check( - "the gate and the reader agree on every one of them, and the gate ignores the link", - clockGate.gateDisagrees.length === 0 && - clockGate.modeIgnored && - clockGate.nothingConnected === false, - clockGate.gateDisagrees.length - ? `gate disagrees: ${clockGate.gateDisagrees.join(", ")}` - : "gate === reader on all 12", -); -check( - "the link chooses the path: the dock's own command wins on the dock link", - // A client carrying both reads takes the dock property over USB — the radio - // command does not exist on that link whatever the object offers — and the - // radio command everywhere else. - bothOnUsb.read === "dock" && bothOnBle.read === "radio", - `both-reads client: usb via ${bothOnUsb.read}, ble via ${bothOnBle.read}`, -); -check( - "both of the page's clock reads go through the shared reader, not a method test", - /canReadRwc\(client\)/.test(clockGate.src) && - /readDeviceRwc\(client, mode\)/.test(clockGate.src) && - // The two call sites are the button and the Device panel's Refresh. - (clockGate.src.match(/readDeviceRwc\(client, mode\)/g) ?? []).length === - 2 && - !/typeof client\.getRtcTime === "function"/.test(clockGate.src) && - !/typeof client\?\.getRtcTime !== "function"/.test(clockGate.src), - `${(clockGate.src.match(/readDeviceRwc\(client, mode\)/g) ?? []).length} call sites, no getRtcTime property test`, -); - -// =========================================================================== -console.log("\n--- a calibration block follows the range field beside it ---"); -await goto(`${BASE}?mock=1`); -check( - "connect for the refresh-hook pass", - (await evaluate(CONNECT)) === "mock", -); - -const refreshHook = await evaluate(` - document.querySelector('.tabs [data-tab=tabConfig]').click(); - document.querySelector('[data-group-body="calibration"]').closest('details').open = true; - document.querySelector('[data-group-body="gyro"]').closest('details').open = true; - await new Promise(r => requestAnimationFrame(r)); - const cells = (k, part) => [...document.querySelectorAll( - '[data-field-key="' + k + '"] [data-cal-part=' + part + ']')].map(i => i.value); - const pill = (k) => document.querySelector('[data-field-key="' + k + '"] .pill').textContent; - const faint = (k) => document.querySelectorAll( - '[data-field-key="' + k + '"] input.cal-cell.faint').length; - const sel = document.querySelector('[data-field-key="gyroRange.lsm6dsv"] select'); - - const before = { pill: pill('calib.gyro'), sens: cells('calib.gyro', 'sens') }; - - // 1. change the range: the pill AND the greyed defaults must follow it - sel.value = '4'; // +/- 2000 dps - sel.dispatchEvent(new Event('change', { bubbles: true })); - await new Promise(r => requestAnimationFrame(r)); - const moved = { pill: pill('calib.gyro'), sens: cells('calib.gyro', 'sens'), - faint: faint('calib.gyro'), - otherPill: pill('calib.lnAccel'), - otherSens: cells('calib.lnAccel', 'sens') }; - - // 2. type a real value, then change the range again: the boxes must NOT move - const box = document.querySelector( - '[data-field-key="calib.gyro"] [data-cal-part=offset][data-cal-index="0"]'); - box.value = '777'; - box.dispatchEvent(new Event('change', { bubbles: true })); - await new Promise(r => requestAnimationFrame(r)); - sel.value = '0'; - sel.dispatchEvent(new Event('change', { bubbles: true })); - await new Promise(r => requestAnimationFrame(r)); - const kept = { pill: pill('calib.gyro'), off: cells('calib.gyro', 'offset'), - sens: cells('calib.gyro', 'sens'), faint: faint('calib.gyro') }; - return { before, moved, kept }; -`); -check( - "changing the range repaints the block's range chip and its greyed defaults", - /* The mock's stored image configures 250 dps, not 125 — so the default - sensitivity it shows is 114, the 11400 of the seed table over 100. This - pin said 125 dps and 229, which was the image's default before the mock - grew a realistic one; the rest of the check (the range change moving the - chip and the greyed defaults to 2000 dps and 14) was right all along. */ - refreshHook.before.pill === "configured: +/- 250dps" && - refreshHook.before.sens.join(",") === "114,114,114" && - refreshHook.moved.pill === "configured: +/- 2000dps" && - // 2000 dps is a far coarser LSB, so the default sensitivity really moves. - refreshHook.moved.sens.join(",") === "14,14,14" && - refreshHook.moved.faint === 15, - `${refreshHook.before.pill} ${refreshHook.before.sens[0]} → ${refreshHook.moved.pill} ${refreshHook.moved.sens[0]}`, -); -check( - "and it leaves every OTHER block alone", - refreshHook.moved.otherPill === "configured: ± 2g" && - refreshHook.moved.otherSens.join(",") === "1672,1672,1672", - `${refreshHook.moved.otherPill}, ${refreshHook.moved.otherSens.join(",")}`, -); -check( - "a block holding real values keeps them — only its chip follows the range", - refreshHook.kept.pill === "configured: +/- 125dps" && - refreshHook.kept.off.join(",") === "777,0,0" && - // Still the 2000 dps figure it was edited alongside, NOT re-defaulted. - refreshHook.kept.sens.join(",") === "14,14,14" && - refreshHook.kept.faint === 0, - `offset ${refreshHook.kept.off.join(",")}, sensitivity ${refreshHook.kept.sens.join(",")}, ${refreshHook.kept.faint} greyed`, -); - -// =========================================================================== -// What the sensor says it is: the identity rows, across the range of answers -// a sensor can actually give. -// =========================================================================== -console.log("\n--- what the sensor says it is ---"); - -const IDENT = ` - const t = (id) => document.getElementById(id).textContent; - const rows = () => ({ name: t('idName'), hw: t('idHw'), bt: t('idBtModule'), - batt: t('idBatt') }); -`; - -await goto(`${BASE}?mock=1&hw=3`); -check( - "connect a Shimmer3 for its module banner", - (await evaluate(CONNECT)) === "mock", -); -const identS3 = await evaluate(`${IDENT} - return { ...rows(), - btRaw: window.mockTransport.identity.btVersion, - board: JSON.stringify(window.mockTransport.identity.srBoard), - idReads: (window.mockTransport.writes ?? []) - .filter(w => (w.bytes ?? w.data)[0] === 0x66).length, - verReads: (window.mockTransport.writes ?? []) - .filter(w => (w.bytes ?? w.data)[0] === 0xa1).length }; -`); -check( - "a Shimmer3 reports its RN module, parsed to the version the module named", - /* The reply is the module's own banner, forwarded by the firmware with the - RN4678's trailing CMD> prompt stripped. The Java driver labelled two of - these with versions their own banner contradicts; this does not. */ - identS3.bt === "RN4678 v1.23" && - identS3.btRaw.startsWith("RN4678 V1.23 06/30/2021"), - `${identS3.bt} <- "${identS3.btRaw}"`, -); -check( - "and its hardware line is platform, board and SR code", - identS3.hw === "Shimmer3 GSR+ (SR48-3-0)" && - identS3.board === '{"boardId":48,"boardRev":3,"specialRev":0}', - identS3.hw, -); -check( - "each identity read costs exactly one round trip on connect", - identS3.idReads === 1 && identS3.verReads === 1, - `id page ${identS3.idReads}, module version ${identS3.verReads}`, -); - -await goto(`${BASE}?mock=1&srBoard=none&btVersion=`); -check( - "connect a sensor that answers neither", - (await evaluate(CONNECT)) === "mock", -); -const identBlank = await evaluate(`${IDENT} return rows();`); -check( - "an erased id page leaves the platform alone rather than inventing a board", - identBlank.hw === "Shimmer3R", - identBlank.hw, -); -check( - "and a module that never answered says so, instead of reading as a fault", - /* btVerStrResponse starts zeroed and is filled only once the module has - replied to the firmware's own query, so a zero-length answer is a real - state and not an error. */ - identBlank.bt === "not reported", - identBlank.bt, -); - -await goto(`${BASE}?mock=1&srBoard=52-1-0`); -check( - "connect a sensor on an unlisted board", - (await evaluate(CONNECT)) === "mock", -); -const identNew = await evaluate(`${IDENT} return rows();`); -check( - "an SR code newer than the name table still identifies the sensor", - /* The code is the half a support engineer needs, so it survives the name - being unknown. */ - identNew.hw === "Shimmer3R (SR52-1-0)", - identNew.hw, -); - -await goto( - `${BASE}?mock=1&btVersion=${encodeURIComponent("RN4678 V1.23 " + "x".repeat(300))}`, -); -check( - "connect a sensor whose module reply is longer than the protocol allows", - (await evaluate(CONNECT)) === "mock", -); -const identLong = await evaluate(`${IDENT} - return { ...rows(), rawLen: window.mockTransport.identity.btVersion.length }; -`); -check( - "an over-long module reply is truncated to the firmware's buffer, not wrapped", - /* The length byte is one byte. A reply longer than 255 characters would - wrap it while the full string still went out, and the host would wait for - the wrong number of bytes and time out. A real sensor cannot report more - than its own char[100] holds. */ - identLong.rawLen > 255 && identLong.bt === "RN4678 v1.23", - `${identLong.rawLen} chars offered → ${identLong.bt}`, -); - -await goto(`${BASE}?mock=1&srBoard=0-0-0`); -check( - "connect a sensor whose id page was never written", - (await evaluate(CONNECT)) === "mock", -); -const identZero = await evaluate(`${IDENT} - const page = await window.mockClient.readSrBoard(); - return { ...rows(), board: JSON.stringify(page) }; -`); -check( - "an all-zero id page is no board, not the board SR0-0-0", - /* All zeroes is as much "never written" as all 0xFF is "erased", and the - SDK has to read BOTH as absent. */ - identZero.hw === "Shimmer3R" && - !identZero.hw.includes("SR0") && - identZero.board === "null", - `${identZero.hw} (readSrBoard -> ${identZero.board})`, -); - -// =========================================================================== -console.log("\n--- the Consensys note ---"); -await goto(`${BASE}?mock=1`); -const note = await evaluate(` - const n = document.getElementById('consensysNote'); - return { text: n.textContent.replace(/\\s+/g, ' ').trim(), - next: n.nextElementSibling?.id ?? null, - card: n.closest('.card')?.querySelector('.card-title')?.textContent?.trim() ?? null, - links: n.querySelectorAll('a').length }; -`); -check( - "the page says it is an example and names Consensys, on the link card", - /* On the card a user reaches first, not buried in a tab: somebody who - arrived from a search should not mistake this for the product. */ - /example application/.test(note.text) && - /Consensys/.test(note.text) && - note.next === "platformBanner", - `${note.card}: ${note.text.slice(0, 80)}…`, -); -check( - "and it points at no URL, rather than guessing one", - /* No link, but there IS text — `links === 0` alone is satisfied by an empty - element, which is also what a broken template would leave behind. */ - note.links === 0 && note.text.length > 80, - `${note.links} links, ${note.text.length} chars`, -); - -// =========================================================================== -console.log("\n--- the sensor rules, newest choice wins ---"); -const B = { - GSR: 0x000004, - INT_A3: 0x000400, - INT_A0: 0x000200, - BRIDGE: 0x008000, -}; -/* Ticking a box the way a user does — the change event is what the page - listens to — then reading back what the form now holds. `expPower` is read - through the form's own field so this cannot pass against a page that only - updated its own variable. */ -const RULES = ` - const box = (bit) => document.querySelector('[data-sensor-bit="' + bit + '"]'); - const tick = async (bit, on) => { - const b = box(bit); - b.checked = on; - b.dispatchEvent(new Event('change', { bubbles: true })); - await new Promise(r => setTimeout(r, 120)); - }; - const expPower = () => { - const f = document.querySelector('#configForm .field[data-field-key="expPower"]'); - const c = f?.querySelector('select, input'); - return c ? (c.type === 'checkbox' ? (c.checked ? '1' : '0') : c.value) : null; - }; - const banner = () => { - const n = document.getElementById('sensorRulesNote'); - return { hidden: n.hidden, kind: n.className, - text: n.textContent.replace(/\\s+/g, ' ').trim(), - fix: !!document.getElementById('btnSensorRulesFix') }; - }; - const logLines = () => [...document.querySelectorAll('#log .log-line')] - .map(l => l.textContent).filter(t => /sensor rule/.test(t)); -`; -await goto(`${BASE}?mock=1`); -check("connect for the sensor-rule pass", (await evaluate(CONNECT)) === "mock"); -const toggles = await evaluate(`${RULES} - /* Which image bytes differ from the sensor's, right now. Sampled after - every toggle rather than once at the end: the sequence below is - deliberately reversible, so a single reading taken after the last untick - is an EMPTY array — which satisfies any "every byte is one of these" - assertion without testing a thing. */ - const moved = () => [...document.querySelectorAll('.hexview-byte.changed')] - .map(b => Number(b.title.split(' ')[1])); - await tick(${B.INT_A3}, true); - const first = { banner: banner(), expPower: expPower(), moved: moved() }; - await tick(${B.GSR}, true); - const second = { banner: banner(), expPower: expPower(), - intA3: box(${B.INT_A3}).checked, gsr: box(${B.GSR}).checked, - moved: moved(), log: logLines() }; - await tick(${B.INT_A3}, true); - const third = { expPower: expPower(), - intA3: box(${B.INT_A3}).checked, gsr: box(${B.GSR}).checked, - moved: moved(), banner: banner() }; - await tick(${B.INT_A3}, false); - const fourth = { expPower: expPower(), moved: moved() }; - return { first, second, third, fourth }; -`); -check( - "an internal ADC channel on its own breaks no rule", - toggles.first.banner.hidden && toggles.first.expPower === "0", - `banner hidden=${toggles.first.banner.hidden}, expPower=${toggles.first.expPower}`, -); -check( - "enabling GSR unticks the ADC channel it shares an input with", - /* What the firmware would do at its next write, done here so the user sees - it before pressing Apply rather than discovering it on read-back. */ - toggles.second.gsr && - !toggles.second.intA3 && - /Internal ADC A3/.test(toggles.second.banner.text), - toggles.second.banner.text.slice(0, 120), -); -check( - "and derives the expansion rail GSR needs, saying so", - toggles.second.expPower === "1" && - /Expansion-board power switched on/.test(toggles.second.banner.text), - `expPower=${toggles.second.expPower}`, -); -check( - "the explanation reaches the log as well as the banner", - /* Both changes, by content. A count alone passed on any two lines matching - `sensor rule`, including two copies of one message. */ - toggles.second.log.some((l) => /Internal ADC A3 unticked/.test(l)) && - toggles.second.log.some((l) => /Expansion-board power switched on/.test(l)), - JSON.stringify(toggles.second.log).slice(0, 200), -); -check( - "the newest choice wins in both directions", - /* Ticking the ADC channel back unticks GSR, rather than refusing the click. - A rule that only ever pushes one way makes the second sensor unreachable - without knowing which box to untick first. */ - toggles.third.intA3 && !toggles.third.gsr && toggles.third.expPower === "1", - `intA3=${toggles.third.intA3} gsr=${toggles.third.gsr} expPower=${toggles.third.expPower}`, -); -check( - "and the rail goes off again when nothing left needs it", - toggles.fourth.expPower === "0", - `expPower=${toggles.fourth.expPower}`, -); -const movedByStep = [ - toggles.first.moved, - toggles.second.moved, - toggles.third.moved, - toggles.fourth.moved, -]; -const movedEver = [...new Set(movedByStep.flat())].sort((a, b) => a - b); -check( - "only the sensor bitmap and the expansion-power byte ever moved", - /* Bytes 3-5 are the enabled-sensor bitmap and byte 9 carries the - expansion-power bit. A rule that reached any further would be editing - something the user did not touch. The first three steps must each have - moved SOMETHING, or this is asserting over an empty set. */ - movedEver.length > 0 && - movedEver.every((b) => [3, 4, 5, 9].includes(b)) && - movedByStep.slice(0, 3).every((m) => m.length > 0), - `moved ${movedEver.join(",")} across steps ` + - movedByStep.map((m) => `[${m.join(",")}]`).join(" "), -); -check( - "and the four toggles leave no residue when they undo each other", - /* Tick A, tick B (which unticks A), tick A again (which unticks B), untick - A: the image is back where it started, expansion power included. A - derived bit that latched on would show up here as a byte still dirty. */ - toggles.fourth.moved.length === 0 && toggles.fourth.expPower === "0", - `${toggles.fourth.moved.length} bytes dirty, expPower=${toggles.fourth.expPower}`, -); - -const exgRule = await evaluate(`${RULES} - const mode = document.getElementById('exgMode'); - await tick(${B.INT_A0}, true); - mode.value = [...mode.options].map(o => o.value).find(v => v !== 'off' && v !== mode.value); - mode.dispatchEvent(new Event('change', { bubbles: true })); - await new Promise(r => setTimeout(r, 200)); - return { chosen: mode.value, intA0: box(${B.INT_A0}).checked, - expPower: expPower(), banner: banner() }; -`); -check( - "choosing an ExG mode clears the ADC channels ExG cannot share with", - exgRule.chosen !== "off" && !exgRule.intA0 && exgRule.expPower === "1", - `${exgRule.chosen}: intA0=${exgRule.intA0} expPower=${exgRule.expPower}`, -); - -// ---- an image that arrived already broken gets a Fix button -await goto(`${BASE}?mock=1&sensors=0x000404`); -check( - "connect a sensor whose stored image already breaks a rule", - (await evaluate(CONNECT)) === "mock", -); -const broken = await evaluate(`${RULES} - const before = banner(); - document.getElementById('btnSensorRulesFix').click(); - await new Promise(r => setTimeout(r, 200)); - return { before, after: banner(), expPower: expPower(), - intA3: box(${B.INT_A3}).checked, gsr: box(${B.GSR}).checked, - dirty: document.getElementById('dirtyPill').textContent, - log: logLines() }; -`); -check( - "an image read off the device is explained, not silently corrected", - /* Nothing here is a "newest choice", so there is nothing to correct - automatically — the user's own stored configuration is not the page's to - rewrite behind their back. It says what is wrong and offers a Fix. */ - !broken.before.hidden && /warn/.test(broken.before.kind) && broken.before.fix, - broken.before.text.slice(0, 140), -); -check( - "and Fix applies exactly what the banner described", - /* The banner named the ADC channel and the rail before the click; the form - has to match afterwards. Reading the before-text into the assertion is - what makes this "exactly what it described" rather than "something". */ - broken.gsr && - !broken.intA3 && - broken.expPower === "1" && - /Internal ADC A3/.test(broken.before.text), - `gsr=${broken.gsr} intA3=${broken.intA3} expPower=${broken.expPower}; ` + - `banner said: ${broken.before.text.slice(0, 70)}`, -); -check( - "after which the warning is replaced by a note saying what changed", - /* Not hidden: the same rules that had a violation to report now have a - change to report, and a banner that vanished would leave a user looking - at a form they did not edit. The Fix button goes, though — there is - nothing left to fix. */ - !broken.after.hidden && - /info/.test(broken.after.kind) && - !broken.after.fix && - /Internal ADC A3/.test(broken.after.text), - `${broken.after.kind}: ${broken.after.text.slice(0, 90)}`, -); -check( - "and the form is left dirty, for the user to Apply", - /* Fix edits the working image, exactly as any other field edit does. It - does not write to the sensor. */ - /change/.test(broken.dirty), - broken.dirty, -); -check( - "Fix says what it changed, in the log, as a fix", - broken.log.some((l) => /sensor rule fix/.test(l)), - JSON.stringify(broken.log.filter((l) => /fix/.test(l))).slice(0, 160), -); - -// ---- gating: a board that cannot carry a sensor -await goto(`${BASE}?mock=1&srBoard=37-1-0`); -check( - "connect an ECG/EMG board for the gating pass", - (await evaluate(CONNECT)) === "mock", -); -const gated = await evaluate(`${RULES} - const b = (bit) => { const x = box(bit); return { disabled: x.disabled, - title: (x.closest('label')?.title ?? '').replace(/\\s+/g, ' ') }; }; - return { gsr: b(${B.GSR}), bridge: b(${B.BRIDGE}), - exgMode: document.getElementById('exgMode').disabled }; -`); -check( - "a sensor this board cannot have is greyed out, with the board named", - gated.gsr.disabled && /SR37|ECG\/EMG/.test(gated.gsr.title), - gated.gsr.title.slice(0, 130), -); -check( - "and the ExG control this board CAN carry stays live", - /* An SR37 is an ExG board, so the ExG control must NOT be gated — and the - `disabled === false` half of that is a select's default, true of a page - with no gating at all. Paired with the GSR box above, which this same - board must gate, the two together can only both hold if the gating ran - and discriminated. */ - gated.exgMode === false && gated.gsr.disabled === true, - `exgMode disabled=${gated.exgMode}, GSR disabled=${gated.gsr.disabled}`, -); -check( - "the bridge amplifier is refused on a Shimmer3R at all", - /* There is no bridge-amplifier channel in the Shimmer3R packers, so the bit - would stream nothing whatever board is fitted. */ - gated.bridge.disabled && /Shimmer3R/.test(gated.bridge.title), - gated.bridge.title.slice(0, 130), -); - -await goto(`${BASE}?mock=1&srBoard=none`); -check( - "connect a sensor with no board id", - (await evaluate(CONNECT)) === "mock", -); -const ungated = await evaluate(`${RULES} - return { disabled: [...document.querySelectorAll('[data-sensor-bit]')] - .filter(b => b.disabled).map(b => b.dataset.sensorKey), - exgMode: document.getElementById('exgMode').disabled }; -`); -check( - "an unknown board gates nothing on the board's account", - /* A blank id page is a real state — an unprovisioned board — and refusing - every sensor on one would make the page useless exactly where it is most - needed. The bridge amplifier stays refused, because that is the - GENERATION saying so: no Shimmer3R packer emits a bridge channel, whatever - board is fitted. */ - ungated.disabled.length === 1 && - ungated.disabled[0] === "SENSOR_BRIDGE_AMP" && - ungated.exgMode === false, - `disabled: ${ungated.disabled.join(", ") || "none"}`, -); - -// =========================================================================== -console.log("\n--- calibrated channels and the clock axis ---"); -await goto(`${BASE}?mock=1&sensors=0x2422e4&pressure=390&rate=51.2`); -check( - "connect a sensor with pressure, GSR, battery, ADC and both magnetometers", - (await evaluate(CONNECT)) === "mock", -); -const calStream = await evaluate(` - const log = () => [...document.querySelectorAll('#log .log-line')].map(l => l.textContent); - /* The page owns onStreamFrame; wrapping it is how a frame is read without - the page having to expose one for testing. */ - const client = window.mockClient; - let frame = null; - document.querySelector('.tabs [data-tab="tabStream"]').click(); - document.getElementById('btnStreamStart').click(); - /* Wrapped AFTER the page installs its own handler, not before: startStream - assigns onStreamFrame once its inquiry round trip returns, so a wrapper - installed first is overwritten. Waiting for the handler to APPEAR rather - than sleeping a guessed 400 ms — the round trip is a mock's, but a slow - machine still loses that race, and losing it makes every assertion below - fail on a null frame rather than on the thing being tested. */ - for (let i = 0; i < 100 && !client.onStreamFrame; i++) - await new Promise(r => setTimeout(r, 50)); - const prev = client.onStreamFrame; - client.onStreamFrame = (f) => { frame = f; prev?.call(client, f); }; - await new Promise(r => setTimeout(r, 3000)); - const oc = frame; - const val = (n, k) => oc?.get(n, k)?.value ?? null; - const unit = (n, k) => oc?.get(n, k)?.unit ?? null; - const panels = [...document.querySelectorAll('.plot-panel canvas')].map(c => { - const ch = Chart.getChart(c); - return { title: ch.options.plugins.title.text, - axis: ch.options.scales.y.title?.display ? ch.options.scales.y.title.text : '', - xTitle: ch.options.scales.x.title.display ? ch.options.scales.x.title.text : '', - firstTick: ch.scales.x.ticks?.[0]?.label ?? null, - xMin: ch.scales.x.min, xMax: ch.scales.x.max, - traces: ch.data.datasets.map(d => d.label) }; - }); - return { - kinds: oc ? [...new Set(oc.fields.map(f => f.kind))] : null, - batteryMv: val('BATTERY', 'cal'), batteryUnit: unit('BATTERY', 'cal'), - pressure: val('PRESSURE', 'cal'), pressureUnit: unit('PRESSURE', 'cal'), - temperature: val('TEMPERATURE', 'cal'), temperatureUnit: unit('TEMPERATURE', 'cal'), - gsrUnit: unit('GSR', 'cal'), gsrResistance: unit('GSR_RESISTANCE', 'cal'), - unixMs: val('Timestamp_Unix', 'cal'), - /* The page's own clock, read as the frame is read. Comparing the sensor's - wall clock against Node's would fold in the CDP round trip and make the - measurement about this script's latency. */ - hostMs: Date.now(), - rawUnit: unit('LN_ACCEL_X', 'raw'), - timeline: window.mockClient?.timelineState ?? null, - calibration: window.mockClient?.calibrationInfo ?? null, - panels, - pressureLog: log().filter(l => /pressure/i.test(l)), - clockLog: log().filter(l => /clock/i.test(l)), - }; -`); -check( - "the second magnetometer gets its own panel rather than Other channels", - /* `groupForField` matched `MAG` by prefix, which `ALT_MAG_X` does not start - with, so a Shimmer3R's second magnetometer fell into OTHER and shared an - axis with whatever else landed there. */ - calStream.panels.some((p) => p.title === "Alternate magnetometer") && - !calStream.panels.some((p) => p.title === "Other channels"), - calStream.panels.map((p) => p.title).join(", "), -); -check( - "the frame carries a calibrated value with a unit for every family", - calStream.batteryUnit === "mV" && - calStream.pressureUnit === "kPa" && - calStream.temperatureUnit === "Degrees Celsius" && - calStream.gsrUnit === "uS" && - calStream.gsrResistance === "kOhms" && - calStream.rawUnit === "no_units", - `${calStream.batteryUnit} / ${calStream.pressureUnit} / ${calStream.temperatureUnit} / ${calStream.gsrUnit} / ${calStream.gsrResistance}`, -); -check( - "and the values are physical, not counts", - /* A plausible cell voltage, sea-level-ish pressure and a room temperature. - The mock feeds the compensation the BMP390's real reference coefficients, - so the pressure and temperature here are the compensation arithmetic - rather than a passthrough — 100.9 kPa and 23.2 °C are the datasheet - vector's own answers. The battery bound is loose at the bottom because - the mock swings its ADC sine around 2048 counts, which lands just under - 3.0 V at the trough. */ - calStream.batteryMv > 2800 && - calStream.batteryMv < 4500 && - calStream.pressure > 80 && - calStream.pressure < 120 && - calStream.temperature > 5 && - calStream.temperature < 45, - `${calStream.batteryMv?.toFixed(1)} mV, ${calStream.pressure?.toFixed(3)} kPa, ${calStream.temperature?.toFixed(2)} °C`, -); -check( - "the pressure part is named in the log, having been asked for", - calStream.pressureLog.some((l) => /BMP390/i.test(l)), - JSON.stringify(calStream.pressureLog).slice(0, 160), -); -check( - "a Shimmer3R stream is pinned to the sensor's own clock, exactly", - /* `rwc-aligned` is only available because the packet timestamp IS the low - 24 bits of the counter GET_RWC returns. The mock models that identity, so - a regression to a host anchor — or to an aligned anchor against a counter - that starts at zero — shows up here. */ - calStream.timeline?.source === "rwc-aligned" && - calStream.timeline?.anchorUncertaintyMs === 0, - JSON.stringify(calStream.timeline), -); -check( - "and the wall clock it produces agrees with the host's", - /* The mock's sensor clock runs on this host's, so the two should differ by - no more than the link's own latency. Minutes apart would be an anchor - placed in the wrong wrap of the counter. */ - Math.abs(calStream.unixMs - calStream.hostMs) < 2000, - `sensor ${new Date(calStream.unixMs).toISOString()} vs host ` + - `${new Date(calStream.hostMs).toISOString()} — ` + - `${(calStream.unixMs - calStream.hostMs).toFixed(0)} ms apart`, -); -check( - "the x axis reads a local clock time, and says so", - calStream.panels.every((p) => /^\d\d:\d\d:\d\d/.test(p.firstTick ?? "")) && - calStream.panels.every((p) => /local/.test(p.xTitle)), - `${calStream.panels[0]?.firstTick} — ${calStream.panels[0]?.xTitle}`, -); -check( - "the device's own calibration is what the inertial channels are using", - /* `source` non-null was too weak: `'default'` is non-null and means the - factory seed, which is the state this whole feature exists to move off. - The low-noise accelerometer has to read `radio-dump`, because the mock - serves a dump whose record matches the range the image configures. - - The gyro is the more interesting half. The mock's dump carries a gyro - record at range 3 while the image configures range 1, and a block read at - one range says nothing about another — so the honest answer is that - range's default, and that is what this pins. A host applying the range-3 - numbers to range-1 counts would be out by a factor of four and would look - perfectly calibrated. */ - calStream.calibration?.inertial?.lnAccel?.source === "radio-dump" && - calStream.calibration?.inertial?.lnAccel?.usingDefaultCalibration === - false && - calStream.calibration?.inertial?.gyro?.source === "default" && - calStream.calibration?.inertial?.gyro?.range === 1, - `lnAccel ${JSON.stringify(calStream.calibration?.inertial?.lnAccel)}, ` + - `gyro ${JSON.stringify(calStream.calibration?.inertial?.gyro)}`, -); - -const cleared = await evaluate(` - const pts = () => [...document.querySelectorAll('.plot-panel canvas')] - .map(c => Chart.getChart(c).data.datasets[0].data.length); - const before = pts(); - const axisBefore = Chart.getChart(document.querySelector('.plot-panel canvas')) - .options.scales.x.title.text; - document.getElementById('btnClearPlots').click(); - await new Promise(r => setTimeout(r, 60)); - const emptied = pts(); - const logged = [...document.querySelectorAll('#log .log-line')] - .some(l => /plots cleared/.test(l.textContent)); - // And it fills again, so clearing did not stop the plot. - await new Promise(r => setTimeout(r, 700)); - return { before, emptied, refilled: pts(), logged, axisBefore, - axisAfter: Chart.getChart(document.querySelector('.plot-panel canvas')) - .options.scales.x.title.text, - gated: document.getElementById('btnClearPlots').disabled }; -`); -check( - "Clear plots empties every panel and says so, and the stream carries on", - /* The Verisense console's equivalent zeroes its ring buffers and repaints; - this does the same through `plot.clear`. Logged because a gap in a trace - should be distinguishable afterwards from a dropped link. */ - cleared.before.every((n) => n > 20) && - /* Not zero: frames keep arriving while this is measured, so a live stream - has a few samples back on the panel by the time it is read. The claim is - that the history went, not that the plot stopped. */ - cleared.emptied.every((n, i) => n < cleared.before[i] / 10) && - cleared.refilled.every((n, i) => n > cleared.emptied[i]) && - cleared.logged && - !cleared.gated, - `${cleared.before.join(",")} → ${cleared.emptied.join(",")} → ${cleared.refilled.join(",")}`, -); -const elapsed = await evaluate(` - const sel = document.getElementById('selTimeAxis'); - sel.value = 'elapsed'; - sel.dispatchEvent(new Event('change', { bubbles: true })); - await new Promise(r => setTimeout(r, 600)); - const ch = () => Chart.getChart(document.querySelector('.plot-panel canvas')); - /* Clear a SECOND time, here in Elapsed mode, because this is the only mode - the origin shows up in — and keeping it is the whole reason the handler - passes keepOrigin. In Clock mode the x values are unix seconds and the - axis title carries only the date, so nothing there tells the two - behaviours apart. - - Read from the TICK LABEL, not from scales.x.min. The scale holds raw x - values — unix seconds — in BOTH modes, and the Elapsed view is produced - entirely by a ticks.callback that subtracts the origin (formatXTick in - common/plot.js), so the origin is invisible to the scale's own min. - Reading x.min here gave a check that compared two epoch timestamps and - could only ever report that time had moved forward. */ - const firstTick = () => ch().scales.x.ticks?.[0]?.label ?? null; - const tickBeforeClear = firstTick(); - document.getElementById('btnClearPlots').click(); - await new Promise(r => setTimeout(r, 700)); - return { firstTick: firstTick(), tickBeforeClear, - xTitle: ch().options.scales.x.title.text }; -`); -check( - "switching the axis to Elapsed counts seconds from the stream's first sample", - /* Plain seconds, not a clock time, and small — the first tick is the left - edge of the rolling window, so it is a few seconds in on a stream that - has been running for a few seconds, and never an epoch-sized number. - Above one, too: a zero here would mean the axis had re-based itself on the - clear just performed, which is what the next check is about. */ - /^-?\d+(\.\d+)?$/.test(String(elapsed.firstTick)) && - Number(elapsed.firstTick) > 1 && - Number(elapsed.firstTick) < 60 && - /Time since start/.test(elapsed.xTitle), - `${elapsed.firstTick} — ${elapsed.xTitle}`, -); -check( - "and clearing does not re-zero it, so the plot and the CSV agree on when", - /* A view control must not make the plot disagree with the file about when - something happened: `TIMESTAMP_CAL` counts from the stream's first sample - whatever this button does, so the axis has to as well. - - Asserted as "the labelled left edge moved forward", which is the - invariant: with the origin kept, the samples after a clear sit further - along the same axis than the window that was discarded. Re-basing the - origin instead puts the left edge back at zero, so this goes backwards. - - Two earlier versions of this check could not fail, which is why it is - spelt out. The first compared the CLOCK-mode axis TITLE, which carries the - date and not the origin. The second compared `scales.x.min`, which is a - raw unix timestamp in both modes and so only ever showed that time had - passed. Both left the pass at 293/293 with keepOrigin flipped off, while - the first Elapsed tick visibly went from 2.99 to 0.00. */ - Number(elapsed.firstTick) > Number(elapsed.tickBeforeClear) && - cleared.axisAfter === cleared.axisBefore, - `elapsed first tick ${elapsed.tickBeforeClear} → ${elapsed.firstTick} s; ` + - `clock axis "${cleared.axisBefore}" kept`, -); - -// ---- pressure with no coefficients stays raw, and says so -await goto(`${BASE}?mock=1&sensors=0x0400e0&pressure=nack`); -check( - "connect a sensor whose firmware will not serve 0xA7", - (await evaluate(CONNECT)) === "mock", -); -const rawOnly = await evaluate(` - const log = () => [...document.querySelectorAll('#log .log-line')].map(l => l.textContent); - const client = window.mockClient; - let frame = null; - document.querySelector('.tabs [data-tab="tabStream"]').click(); - document.getElementById('btnStreamStart').click(); - /* Wrapped AFTER the page installs its own handler, not before: startStream - assigns onStreamFrame once its inquiry round trip returns, so a wrapper - installed first is overwritten. Waiting for the handler to APPEAR rather - than sleeping a guessed 400 ms — the round trip is a mock's, but a slow - machine still loses that race, and losing it makes every assertion below - fail on a null frame rather than on the thing being tested. */ - for (let i = 0; i < 100 && !client.onStreamFrame; i++) - await new Promise(r => setTimeout(r, 50)); - const prev = client.onStreamFrame; - client.onStreamFrame = (f) => { frame = f; prev?.call(client, f); }; - await new Promise(r => setTimeout(r, 2000)); - const oc = frame; - return { raw: oc?.get('PRESSURE', 'raw')?.value ?? null, - cal: oc?.get('PRESSURE', 'cal') ?? null, - log: log().filter(l => /pressure/i.test(l)) }; -`); -check( - "pressure streams raw-only rather than inventing a calibration", - /* Compensating against filler or defaults would produce a confident, wrong - kPa. A raw-only column is the honest answer, and the log says which it - is. */ - rawOnly.raw != null && rawOnly.cal === null, - `raw ${rawOnly.raw}, cal ${JSON.stringify(rawOnly.cal)}`, -); -check( - "and the log says the firmware would not serve the coefficients", - rawOnly.log.some((l) => /raw only|raw-only|does not serve/i.test(l)), - JSON.stringify(rawOnly.log).slice(0, 200), -); - -// ---- a counter wrap must not walk the axis backwards -await goto(`${BASE}?mock=1&rtcWrapIn=6&rate=204.8`); -check( - "connect a sensor about to roll its counter over", - (await evaluate(CONNECT)) === "mock", -); -const wrap = await evaluate(` - const client = window.mockClient; - let frame = null; - document.querySelector('.tabs [data-tab="tabStream"]').click(); - document.getElementById('btnStreamStart').click(); - /* Wrapped AFTER the page installs its own handler, not before: startStream - assigns onStreamFrame once its inquiry round trip returns, so a wrapper - installed first is overwritten. Waiting for the handler to APPEAR rather - than sleeping a guessed 400 ms — the round trip is a mock's, but a slow - machine still loses that race, and losing it makes every assertion below - fail on a null frame rather than on the thing being tested. */ - for (let i = 0; i < 100 && !client.onStreamFrame; i++) - await new Promise(r => setTimeout(r, 50)); - const prev = client.onStreamFrame; - client.onStreamFrame = (f) => { frame = f; prev?.call(client, f); }; - /* Waiting for the wrap rather than timing it: how long the connect - handshake took decides when in the stream it lands, and a fixed sleep - would make this check pass or fail on the host's speed. */ - for (let i = 0; i < 200; i++) { - await new Promise(r => setTimeout(r, 100)); - if ((client.timelineState?.wraps ?? 0) >= 1 && i > 10) break; - } - const oc = frame; - const ch = Chart.getChart(document.querySelector('.plot-panel canvas')); - const xs = ch.data.datasets[0].data.map(p => p.x); - let back = 0; - for (let i = 1; i < xs.length; i++) if (xs[i] < xs[i-1]) back++; - return { back, span: xs.length ? xs[xs.length-1] - xs[0] : 0, - hostMs: Date.now(), - wraps: window.mockClient?.timelineState?.wraps ?? null, - deviceMs: oc?.get('TIMESTAMP', 'cal')?.value ?? null, - unixMs: oc?.get('Timestamp_Unix', 'cal')?.value ?? null }; -`); -check( - "the counter wraps during the run", - wrap.wraps >= 1, - `${wrap.wraps} wrap(s), device clock ${wrap.deviceMs?.toFixed(0)} ms`, -); -check( - "and nothing on the axis goes backwards across it", - /* The `wraps >= 1` guard is the point of the check, not decoration. Where - the wrap lands inside the run depends on how long the connect handshake - took, so a slow connect can push it past the end — and without the guard - "no backwards step" then passes over a run that never wrapped at all, - which is exactly the run a broken unwrap would survive. */ - wrap.wraps >= 1 && wrap.back === 0 && wrap.span > 0, - `${wrap.wraps} wrap(s), ${wrap.back} backwards steps over ${wrap.span.toFixed(2)} s`, -); -check( - "and the wall clock stays on the sensor's own time across it", - /* Within one wrap of this host, not equal to it: `rtcWrapIn` moves the - sensor's clock forward by up to 512 s to put the counter where it is - about to roll over. Ahead by less than a wrap is the sensor's real time; - wrong by a multiple of 512 s would be an anchor in the wrong wrap. */ - wrap.unixMs > wrap.hostMs && - wrap.unixMs - wrap.hostMs < 512000 && - wrap.deviceMs > 0, - `sensor ${new Date(wrap.unixMs).toISOString()}, ` + - `${((wrap.unixMs - wrap.hostMs) / 1000).toFixed(1)} s ahead of this host`, -); - -// =========================================================================== -console.log("\n--- console ---"); -check( - "no console errors across the whole pass", - consoleErrors.length === 0, - JSON.stringify(consoleErrors), -); - -const failed = results.filter((r) => !r.pass); -console.log( - `\n${results.length - failed.length}/${results.length} checks passed`, -); -if (failed.length) - console.log("FAILED:", failed.map((f) => f.name).join(" | ")); - -/* --------------------------------------------------------------------------- - The known-failure baseline. - - This pass carries failures that predate it and that nobody has fixed, and - for a long time nothing looked at the exit code, so they were invisible - unless somebody ran the pass by hand and read the output. Two bad outcomes - from that: a regression hides among them, and the list quietly grows. - - `common/dev/verify-known-failures.json` names each one with the reason it is - still there. The exit code is then about CHANGE rather than about the total: - - - a failing check that is NOT in the file is a regression → exit 1 - - a check in the file that now PASSES is stale → exit 1, so the file shrinks - as things are fixed rather than rotting - - a failing check that is in the file is reported and tolerated - - `--update-baseline` rewrites the file from this run, which is how the list - shrinks after a fix. Read the diff before committing it: adding a name here - is a decision to ship a known-broken check, and it needs a reason. - --------------------------------------------------------------------------- */ -const baselineFile = new URL("./verify-known-failures.json", import.meta.url); -let baseline = { known: {} }; -try { - baseline = JSON.parse(await readFile(baselineFile, "utf8")); -} catch (e) { - if (e.code !== "ENOENT") throw e; - console.log("\n(no known-failure baseline; every failure counts as new)"); -} -const known = baseline.known ?? {}; - -if (process.argv.includes("--update-baseline")) { - const next = { - _comment: - "Checks in common/dev/verify.mjs that are known to fail, each with why. " + - "A failure NOT listed here fails CI; a listed check that starts passing " + - "also fails CI, so this list has to shrink as things are fixed. " + - "Regenerate with: node common/dev/verify.mjs --update-baseline", - known: Object.fromEntries( - failed.map((f) => [ - f.name, - known[f.name] ?? "TODO: say why this is still failing", - ]), - ), - }; - await writeFile(baselineFile, JSON.stringify(next, null, 2) + "\n", "utf8"); - console.log(`\nBaseline updated: ${failed.length} known failure(s).`); - ws.close(); - process.exit(0); -} - -const regressions = failed.filter((f) => !(f.name in known)); -/* A baseline entry counts as fixed only when the check RAN and passed. A run - that never reached it — the pass ends early on a page error — must not read - as "fixed" and empty the list. */ -const fixed = Object.keys(known).filter((name) => - results.some((r) => r.name === name && r.pass), -); -const stillKnown = failed.filter((f) => f.name in known); - -if (stillKnown.length) { - console.log(`\n${stillKnown.length} known failure(s), tolerated:`); - for (const f of stillKnown) - console.log(` - ${f.name}\n ${known[f.name]}`); -} -if (regressions.length) { - console.log( - `\nREGRESSION — ${regressions.length} failure(s) not in the baseline:`, - ); - for (const f of regressions) console.log(` - ${f.name}`); -} -/* A listed check that now passes has to be reported, or the list rots — but - failing on it is only sound in ONE fixed environment. Some of these failures - are environment-sensitive (a transfer that runs out of time on a loaded - machine), so a developer's laptop and CI disagree about a couple of entries, - and a hard failure there would mean nobody could run the pass locally - without editing the baseline first. CI sets `VERIFY_STRICT_BASELINE=1` and - is the authority; everywhere else this is a note to act on when convenient. */ -const strict = process.env.VERIFY_STRICT_BASELINE === "1"; -if (fixed.length) { - console.log( - ` -${strict ? "FIXED" : "note"} — ${fixed.length} baseline ` + - `entr${fixed.length === 1 ? "y" : "ies"} now passing` + - (strict - ? ". Remove them from common/dev/verify-known-failures.json:" - : ":"), - ); - for (const name of fixed) console.log(` - ${name}`); - if (!strict) { - console.log( - " (not a failure here — regenerate the baseline from a CI run, which is" + - " the environment it describes: node common/dev/verify.mjs --update-baseline)", - ); - } -} - -ws.close(); -process.exit(regressions.length || (strict && fixed.length) ? 1 : 0); diff --git a/common/device-clock.js b/common/device-clock.js deleted file mode 100644 index 08a9ffc..0000000 --- a/common/device-clock.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Reading a Shimmer3/Shimmer3R real-world clock, on whichever link is up. - * - * There are two ways to ask, and which one works is a property of the LINK, - * not of the sensor: - * - * - Over Bluetooth, `Shimmer3RClient.getRtcTime()` — GET_RWC (0x91), whose - * response is an 8-byte little-endian tick count at 32768 Hz since the - * Unix epoch. - * - Over the USB-C/dock link there is no such command. The dock protocol - * has a read-only property instead, `MAIN_PROCESSOR.CURR_LOCAL_TIME`, - * which returns the same eight bytes in the same unit. - * - * This module exists because that fact was known in exactly one place. The - * clock-drift panel had worked out the dock path and used it; the page's own - * "read the clock" paths tested `typeof client.getRtcTime === "function"` and - * gave up over USB — while `describeShimmer3Caps` reported `rtcRead` TRUE - * there, because it counts `getConfig` as a way to read the clock. So the - * button was enabled and always refused, and the README had been written to - * match the bug ("over USB the clock can be set but not read back"). Found by - * Copilot on webBLEDemos#76. - * - * One definition, so a page cannot gate a control on one rule and act on - * another. - * - * Nothing here touches `document`. - * - * import { readDeviceRwc, canReadRwc } from "../common/device-clock.js"; - * - * if (canReadRwc(client)) { - * const { unixMs } = await readDeviceRwc(client, mode); - * } - */ - -import * as sdk from "../vendor/shimmer-web-sdk.esm.js"; - -/** The real-world clock counts these per second, on both paths. */ -export const RWC_TICKS_PER_SECOND = 32768; - -/** - * The dock's read-only "what time is it now" property. - * - * Optional: a vendored bundle from before it was exported simply cannot read - * the clock over a wired link, which {@link canReadRwc} reports up front - * rather than leaving to be discovered at the first read. - */ -const DOCK_TIME_PROP = sdk.UART_PROP?.MAIN_PROCESSOR?.CURR_LOCAL_TIME ?? null; - -/** 64-bit little-endian tick count from an 8-byte answer. */ -function ticksFromBytes(u8) { - let ticks = 0n; - for (let i = 7; i >= 0; i--) ticks = (ticks << 8n) | BigInt(u8[i]); - return ticks; -} - -/** Does this client carry the Bluetooth clock read (GET_RWC)? */ -function hasRadioRead(client) { - return typeof client?.getRtcTime === "function"; -} - -/** Does this client carry the dock's clock-property read? */ -function hasDockRead(client) { - return typeof client?.getConfig === "function" && !!DOCK_TIME_PROP; -} - -/** - * Whether this client can be asked the time at all. - * - * **Takes no mode, on purpose.** The link decides WHICH read is used, not - * WHETHER one is available — a client carrying either command can be asked. - * An earlier version took a mode and answered "no" for a dock-like client on - * an undeclared link, while {@link readDeviceRwc} read it happily: the gate - * and the reader disagreed on one shape, which is the same class of bug this - * module was written to remove, one layer down. Found by Copilot on - * webBLEDemos#76. The reader now refuses exactly when this returns false, so - * the two cannot drift apart again. - * - * The invariant worth stating, because it is the one that broke: a control - * gated on `describeShimmer3Caps(...).rtcRead` must be servable here. - * `rtcRead` is `getRtcTime || (usb && getConfig)`, and both of those imply - * this — so "enabled and always refuses" is unreachable. This is deliberately - * the more permissive of the two: it says yes to a `getConfig`-only client on - * any link, which is what lets the clock-drift panel keep working for a - * dock-like client whose page never declared a mode. - * - * @param {object|null|undefined} client - * @returns {boolean} - */ -export function canReadRwc(client) { - if (!client) return false; - return hasRadioRead(client) || hasDockRead(client); -} - -/** - * Read the sensor's real-world clock. - * - * The reading is handed back UNMODIFIED, in the sensor's own base. Do not - * "correct" it here: a Shimmer3R set by the Java dock driver or by Consensys - * carries UTC, and one set by a tool using another convention carries that - * convention's base — the caller is what decides whether to treat a whole - * offset as a base or as error (the drift panel detects it; the clock readout - * simply shows it). - * - * @param {object} client - * @param {string|null|undefined} [mode] the link — `"ble"`, `"rfcomm"`, - * `"usb"`. Chooses the PATH, never whether there is one: see - * {@link canReadRwc}. On `"usb"` the dock property is used even by a client - * that also carries `getRtcTime`, because on that link the radio command - * does not exist. - * @returns {Promise<{ticks: bigint, unixMs: number, viaDock: boolean}>} - * @throws if nothing is connected, this client has no way to read the clock, - * or the answer is the wrong length. Never returns a guess. - */ -export async function readDeviceRwc(client, mode) { - if (!client) throw new Error("no sensor is connected"); - /* The gate, called rather than restated: an independent condition here is - exactly how the page came to have a button that was enabled and always - refused. */ - if (!canReadRwc(client)) { - /* `getConfig` and not `hasDockRead` here: the latter already folds in the - property check, so testing it would make this branch unreachable. The - distinction is worth keeping — a dock client on a bundle too old to - export CURR_LOCAL_TIME is a re-vendor away from working, and saying - "no way to read the clock" would send someone looking at the sensor. */ - throw new Error( - typeof client.getConfig === "function" - ? "this SDK bundle has no CURR_LOCAL_TIME property — re-vendor it to read the clock over a wired link" - : "this client has no way to read the sensor clock", - ); - } - - /* Dock first when the link is the dock's, because the radio command does - not exist there whatever methods the object happens to carry — and dock - also as the fallback for a client with no radio read, so a dock-like - client works without the caller having to declare a mode. */ - const useDock = - (mode === "usb" || !hasRadioRead(client)) && hasDockRead(client); - - if (useDock) { - const raw = await client.getConfig(DOCK_TIME_PROP); - if (!raw || raw.length < 8) { - throw new Error(`CURR_LOCAL_TIME returned ${raw?.length ?? 0} bytes`); - } - const ticks = ticksFromBytes(raw); - return { - ticks, - unixMs: (Number(ticks) / RWC_TICKS_PER_SECOND) * 1000, - viaDock: true, - }; - } - - const { ticks, unixMs } = await client.getRtcTime(); - return { ticks, unixMs, viaDock: false }; -} diff --git a/common/factory-test-panel.js b/common/factory-test-panel.js deleted file mode 100644 index a436b61..0000000 --- a/common/factory-test-panel.js +++ /dev/null @@ -1,834 +0,0 @@ -/** - * Factory self-test runner: ask a Shimmer3 or Shimmer3R to run the test suite - * its firmware runs at the factory, show the report as it prints, and hand the - * result over as text or as parsed rows. - * - * Three things about the firmware shape everything here: - * - * 1. The report is RAW TEXT on the same link, sent after the command's - * acknowledgement with no opcode, no length and no checksum. The SDK's - * `runFactoryTest` owns that switch; this panel only receives text. - * 2. The sensor stops everything else for the duration — up to about a - * minute for the LED-state walk-through — and answers nothing until the - * report ends. That is why the mounting page is told the link is busy for - * the WHOLE run, and why the copy says so before anyone clicks. - * 3. There is no abort command. Cancel stops this page listening; the sensor - * keeps printing to its own end. The link is therefore still busy after a - * cancel, and this panel keeps reporting that until it is really free. - * - * The LED suites are meant to be watched: each line names the LED that should - * be lit at that moment, so the lines arrive paced, not in a batch, and are - * shown as they arrive rather than at the end. - * - * The panel builds its own markup inside the host element and owns the - * `disabled` state of every control in it. It holds no page-specific ids and - * reads no page globals: the client it is handed, the log it writes to and its - * callbacks are the whole of its outside world. Nothing here touches - * `document` at import time. - * - * import { createFactoryTestPanel } from "../common/factory-test-panel.js"; - */ - -import { el, downloadBlob } from "./ui-chrome.js"; -/* The whole namespace rather than destructured names: a vendored bundle that - predates the factory-test exports then degrades to a message from - `createFactoryTestPanel()` instead of breaking the importing page. */ -import * as sdk from "../vendor/shimmer-web-sdk.esm.js"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** How often the elapsed/remaining readout is repainted while a test runs. */ -const TICK_MS = 250; - -/** Narrowest timeout the firmware's shortest suite can plausibly need. */ -const MIN_TIMEOUT_S = 10; -/** Widest the input accepts — past this, something is wrong with the link. */ -const MAX_TIMEOUT_S = 600; - -/** - * Stick the report to its own tail only while the reader is already there. - * Scroll back to read a line and the incoming text must not yank it away. - */ -const STICK_PX = 60; - -/** The three verdict words the firmware prints, and their classes. */ -const VERDICT_CLASS = Object.freeze({ - PASS: "tr-pass", - FAIL: "tr-fail", - WARNING: "tr-warn", -}); -const VERDICT_RE = /\b(PASS|FAIL|WARNING)\b/g; - -// --------------------------------------------------------------------------- -// Mount -// --------------------------------------------------------------------------- - -/** - * Mount the self-test panel inside `host`. - * - * @param {HTMLElement} host an empty container; its contents are replaced - * @param {object} opts - * @param {object|(() => object|null)} opts.client the connected client, or a - * getter for it. Pass the GETTER form from a page whose client comes and - * goes with the link — the panel is mounted once and reads whatever is - * current, so it can never hold a stale client. - * @param {string|(() => string|null)} [opts.mode] `"ble"`, `"rfcomm"` or - * `"usb"`, or a getter. Only used to describe the run in the log and the - * exported metadata: every link can run the test. - * @param {number|null|(() => number|null)} [opts.identifiedHardwareVersion] - * the hardware version the sensor actually reported, or a getter for it. - * Recorded in the exported metadata; never defaulted, because "we did not - * ask" and "it said 10" are different facts about a report. - * @param {{log: Function, warn: Function, error: Function}} [opts.log] - * @param {(message: string, kind?: string) => void} [opts.toast] - * @param {(busy: boolean) => void} [opts.onBusyChange] true from the moment - * Run is pressed until the link is genuinely free again — which is AFTER a - * cancel, because the sensor goes on printing. A host page folds this into - * its own busy state so everything else that shares the link is refused - * with a reason while the sensor is unreachable. - * @param {() => string|null} [opts.canRun] the page's reason a run must be - * refused right now, or null to go ahead. Asked on every click, so a page - * never has to keep this panel's enabled state in step with its own. - * @param {string|(() => string)} [opts.fileNamePrefix] leading part of saved - * file names - * @param {() => string} [opts.deviceLabel] what to call the sensor in the - * exported metadata - * @returns {{ - * run: (type?: number) => Promise, - * cancel: (reason?: string) => void, - * running: () => boolean, - * draining: () => boolean, - * lastReport: () => object|null, - * setEnabled: (enabled: boolean) => void, - * destroy: () => void, - * }} - */ -export function createFactoryTestPanel(host, opts = {}) { - const getClient = - typeof opts.client === "function" ? opts.client : () => opts.client ?? null; - const getMode = - typeof opts.mode === "function" ? opts.mode : () => opts.mode ?? null; - const getHardwareVersion = - typeof opts.identifiedHardwareVersion === "function" - ? opts.identifiedHardwareVersion - : () => opts.identifiedHardwareVersion ?? null; - const log = opts.log ?? { log() {}, warn() {}, error() {} }; - const toast = opts.toast ?? (() => {}); - const onBusyChange = opts.onBusyChange ?? (() => {}); - const canRun = opts.canRun ?? (() => null); - const namePrefix = - typeof opts.fileNamePrefix === "function" - ? opts.fileNamePrefix - : () => opts.fileNamePrefix ?? "shimmer"; - const deviceLabel = opts.deviceLabel ?? (() => ""); - - /* A vendored bundle from before the self-test shipped. Say so here, once, - rather than throwing from the first Run: the page mounts this panel - unconditionally and gets a banner where the runner would have been. - The CSV helper is deliberately NOT required — without it the panel drops - one button and still runs the test and saves the text, which is the part - that cannot be reproduced later. */ - const missingSdk = []; - if ( - !Array.isArray(sdk.SHIMMER3_FACTORY_TEST_TYPES) || - sdk.SHIMMER3_FACTORY_TEST_TYPES.length === 0 - ) - missingSdk.push("SHIMMER3_FACTORY_TEST_TYPES"); - if (typeof sdk.parseShimmerFactoryTestReport !== "function") - missingSdk.push("parseShimmerFactoryTestReport"); - if (missingSdk.length) { - host.replaceChildren( - el( - "div", - { class: "banner err" }, - `This page is running an SDK bundle with no factory self-test support (missing ${missingSdk.join(", ")}). Re-vendor the SDK to run the sensor's self-test.`, - ), - ); - log.error( - `Factory self-test unavailable: the vendored SDK has no ${missingSdk.join(", ")}`, - ); - return inertPanel(); - } - - const TYPES = sdk.SHIMMER3_FACTORY_TEST_TYPES; - const canCsv = typeof sdk.shimmerFactoryTestReportToCsvRows === "function"; - - /** Non-null only while a run is in flight. */ - let abortCtl = null; - /** True from Run until the report ends (or the drain after a cancel does). */ - let busy = false; - /** True while the sensor is still printing to a page that stopped reading. */ - let drainingNow = false; - /** The last completed report, whatever its verdict. */ - let report = null; - /** The floor the host page sets: may a run be STARTED at all. */ - let enabled = false; - /** The user has typed a timeout, so a type change must not overwrite it. */ - let timeoutTouched = false; - let destroyed = false; - let ticker = null; - let startedAtMs = 0; - let expectedMs = 0; - /** Text received but not yet terminated by a newline. */ - let pending = ""; - /** The node holding `pending`, replaced in place as more arrives. */ - let tailNode = null; - - // ------------------------------------------------------------------------- - // Markup - // ------------------------------------------------------------------------- - - const typeSelect = el( - "select", - { dataset: { testRole: "type" } }, - ...TYPES.map((t) => - el("option", { value: String(t.value) }, t.label ?? t.name), - ), - ); - - const timeoutInput = el("input", { - type: "number", - min: String(MIN_TIMEOUT_S), - max: String(MAX_TIMEOUT_S), - step: "5", - dataset: { testRole: "timeout" }, - style: { width: "6em" }, - }); - - const btnRun = el( - "button", - { type: "button", class: "primary", dataset: { testRole: "run" } }, - "Run self-test", - ); - const btnCancel = el( - "button", - { type: "button", dataset: { testRole: "cancel" } }, - "Cancel", - ); - const btnCopy = el( - "button", - { type: "button", dataset: { testRole: "copy" } }, - "Copy", - ); - const btnTxt = el( - "button", - { type: "button", dataset: { testRole: "saveTxt" } }, - "Save .txt", - ); - const btnCsv = canCsv - ? el("button", { type: "button", dataset: { testRole: "csv" } }, "Save CSV") - : null; - - const typeHint = el("div", { class: "field-hint" }); - const statusLine = el("span", { - class: "muted", - dataset: { testRole: "status" }, - }); - const progressPill = el( - "span", - { class: "pill", dataset: { testRole: "progress" }, hidden: true }, - "", - ); - const summaryLine = el("span", { - class: "pill", - dataset: { testRole: "summary" }, - hidden: true, - }); - - const reportBlock = el("div", { - class: "test-report", - dataset: { testRole: "report" }, - tabindex: "0", - }); - - host.replaceChildren( - el( - "div", - { class: "row" }, - el("label", {}, "Test", typeSelect), - el("label", {}, "Give up after (s)", timeoutInput), - btnRun, - btnCancel, - progressPill, - ), - typeHint, - el("div", { class: "row" }, statusLine, summaryLine), - reportBlock, - el("div", { class: "row" }, btnCopy, btnTxt, btnCsv), - ); - - // ------------------------------------------------------------------------- - // Controls - // ------------------------------------------------------------------------- - - function selectedType() { - const value = Number(typeSelect.value); - return TYPES.find((t) => t.value === value) ?? TYPES[0]; - } - - /** Seconds below which a timeout would cut the chosen suite off mid-report. */ - function floorSecondsFor(info) { - const expected = Number(info?.expectedDurationMs) || 0; - return Math.max(MIN_TIMEOUT_S, Math.ceil((expected * 1.5) / 1000)); - } - - function applyTypeToTimeout() { - const info = selectedType(); - const floor = floorSecondsFor(info); - timeoutInput.min = String(floor); - const preferred = Math.round((Number(info.defaultTimeoutMs) || 0) / 1000); - if (!timeoutTouched && preferred > 0) - timeoutInput.value = String(preferred); - /* A timeout under the suite's own expected duration is not a shorter - wait, it is a report that ends in the middle. Raise it rather than - letting the run be set up to fail. */ - if (Number(timeoutInput.value) < floor) { - timeoutInput.value = String(floor); - if (timeoutTouched) - log.warn( - `the ${info.label ?? info.name} test takes about ${Math.round((Number(info.expectedDurationMs) || 0) / 1000)} s, so the timeout was raised to ${floor} s`, - ); - } - typeHint.textContent = info.description ?? ""; - } - - function timeoutMs() { - const info = selectedType(); - const s = Math.min( - MAX_TIMEOUT_S, - Math.max(floorSecondsFor(info), Number(timeoutInput.value) || 0), - ); - timeoutInput.value = String(s); - return s * 1000; - } - - function syncControls() { - const idle = !busy; - typeSelect.disabled = !enabled || !idle; - timeoutInput.disabled = !enabled || !idle; - btnRun.disabled = !enabled || !idle; - /* Cancel and the exports do NOT take the page's floor: cancelling is how - a run is escaped, and a report already on screen stays saveable even - after the link has gone. */ - btnCancel.disabled = !abortCtl; - btnCancel.hidden = !abortCtl; - const hasReport = !!report?.text; - btnCopy.disabled = !hasReport; - btnTxt.disabled = !hasReport; - if (btnCsv) btnCsv.disabled = !hasReport || !report?.parsed; - } - - function setBusy(next) { - if (busy === next) return; - busy = next; - syncControls(); - try { - onBusyChange(busy); - } catch (e) { - log.warn(`busy callback failed: ${String(e)}`); - } - } - - // ------------------------------------------------------------------------- - // Rendering the report as it arrives - // ------------------------------------------------------------------------- - - function clearReport() { - reportBlock.replaceChildren(); - pending = ""; - tailNode = null; - summaryLine.hidden = true; - summaryLine.textContent = ""; - summaryLine.className = "pill"; - } - - /** One complete line, with its verdict words picked out. */ - function appendLine(line) { - const frag = document.createDocumentFragment(); - let at = 0; - VERDICT_RE.lastIndex = 0; - for (let m = VERDICT_RE.exec(line); m; m = VERDICT_RE.exec(line)) { - if (m.index > at) - frag.appendChild(document.createTextNode(line.slice(at, m.index))); - frag.appendChild(el("span", { class: VERDICT_CLASS[m[1]] ?? "" }, m[1])); - at = m.index + m[1].length; - } - if (at < line.length) - frag.appendChild(document.createTextNode(line.slice(at))); - reportBlock.appendChild(frag); - } - - /** - * Add received text. Complete lines are coloured; the unterminated tail is - * shown plain and replaced as it grows, so a paced LED test reads as it - * happens instead of appearing a line at a time after the fact. - * - * Text nodes throughout, never `innerHTML`: a report is device output, and - * device output containing `<` must render as `<`. - */ - function pushText(chunk) { - if (!chunk) return; - const stick = - reportBlock.scrollHeight - - reportBlock.scrollTop - - reportBlock.clientHeight <= - STICK_PX; - if (tailNode) { - tailNode.remove(); - tailNode = null; - } - pending += chunk; - let nl = pending.indexOf("\n"); - while (nl >= 0) { - appendLine(pending.slice(0, nl + 1)); - pending = pending.slice(nl + 1); - nl = pending.indexOf("\n"); - } - if (pending) { - tailNode = document.createTextNode(pending); - reportBlock.appendChild(tailNode); - } - if (stick) reportBlock.scrollTop = reportBlock.scrollHeight; - } - - // ------------------------------------------------------------------------- - // Progress and status - // ------------------------------------------------------------------------- - - function startTicker() { - stopTicker(); - ticker = setInterval(paintProgress, TICK_MS); - paintProgress(); - } - - function stopTicker() { - if (ticker) clearInterval(ticker); - ticker = null; - progressPill.hidden = true; - } - - function paintProgress() { - const elapsed = Math.max(0, Date.now() - startedAtMs); - const s = Math.round(elapsed / 1000); - const expectedS = Math.round(expectedMs / 1000); - progressPill.hidden = false; - progressPill.textContent = expectedS - ? `${s} s of about ${expectedS} s` - : `${s} s`; - if (drainingNow) return; - if (expectedMs && elapsed > expectedMs * 1.5) { - statusLine.textContent = - "Taking longer than expected. The report ends with a TEST END line — " + - "if it never comes, cancel and reconnect."; - } - } - - function paintSummary(parsed, info) { - summaryLine.hidden = false; - const failed = parsed?.overall?.failedTestNames ?? []; - if (parsed?.overall?.result === "PASS") { - summaryLine.className = "pill on"; - summaryLine.textContent = "Overall: PASS"; - } else if (parsed?.overall?.result === "FAIL") { - summaryLine.className = "pill err"; - const mask = parsed.overall.failMaskHex - ? ` (${parsed.overall.failMaskHex})` - : ""; - summaryLine.textContent = - `Overall: FAIL${mask}` + - (failed.length ? ` — failed: ${failed.join(", ")}` : ""); - } else if (parsed && !parsed.complete) { - summaryLine.className = "pill warn"; - summaryLine.textContent = - "Incomplete — the report ended before its TEST END line."; - } else if (info && info.hasOverall === false) { - summaryLine.className = "pill"; - summaryLine.textContent = - "Completed — this test prints no overall verdict; check the LEDs against the lines above."; - } else { - summaryLine.className = "pill warn"; - summaryLine.textContent = "Completed — no overall verdict was printed."; - } - } - - // ------------------------------------------------------------------------- - // Running - // ------------------------------------------------------------------------- - - /** - * Ask the sensor to run one self-test and collect its report. - * - * @param {number} [type] a `SHIMMER3_FACTORY_TEST_TYPES` value; defaults to - * whatever the select shows - * @returns {Promise} true when a report was received in full - */ - async function run(type) { - if (busy) { - toast("A self-test is already running.", "warn"); - return false; - } - if (type != null) { - const wanted = TYPES.find((t) => t.value === Number(type)); - if (wanted) typeSelect.value = String(wanted.value); - } - const info = selectedType(); - - const refusal = canRun(); - if (refusal) { - statusLine.textContent = refusal; - toast(refusal, "warn"); - return false; - } - const client = getClient(); - if (!client) { - toast("Connect to a sensor first.", "warn"); - return false; - } - if (typeof client.runFactoryTest !== "function") { - const why = - "This link's client cannot run the self-test (no runFactoryTest). Re-vendor the SDK bundle."; - statusLine.textContent = why; - log.error(why); - toast(why, "err"); - return false; - } - - /* Clear the previous report BEFORE anything can await: a run that is - cancelled or fails must not leave the exports offering the last - sensor's result under this one's name. */ - report = null; - clearReport(); - abortCtl = new AbortController(); - startedAtMs = Date.now(); - expectedMs = Number(info.expectedDurationMs) || 0; - drainingNow = false; - statusLine.textContent = "Waiting for the report to start…"; - setBusy(true); - syncControls(); - startTicker(); - - const ms = timeoutMs(); - log.log( - `self-test (${info.label ?? info.name}) started over ${getMode() ?? "the link"}; the sensor answers nothing else until its report ends`, - ); - - let text = ""; - try { - text = await client.runFactoryTest(info.value, { - timeoutMs: ms, - signal: abortCtl.signal, - onChunk: (chunk) => pushText(chunk), - }); - } catch (e) { - await finishFailed(e, info); - return false; - } - - stopTicker(); - abortCtl = null; - /* The SDK resolves with everything it received; the panel may have - rendered less if a chunk arrived without a trailing newline. */ - if (text.length > renderedLength()) pushText(text.slice(renderedLength())); - if (pending) { - pushText("\n"); - pending = ""; - } - - let parsed = null; - try { - parsed = sdk.parseShimmerFactoryTestReport(text); - } catch (e) { - log.warn(`the report could not be parsed: ${String(e)}`); - } - const finishedAt = new Date(); - report = { - text, - parsed, - type: info.value, - typeName: info.name, - typeLabel: info.label ?? info.name, - startedAtIso: new Date(startedAtMs).toISOString(), - finishedAtIso: finishedAt.toISOString(), - durationMs: Date.now() - startedAtMs, - mode: getMode(), - hardwareVersion: getHardwareVersion(), - device: deviceLabel(), - }; - paintSummary(parsed, info); - const seconds = Math.round(report.durationMs / 1000); - statusLine.textContent = `Finished in ${seconds} s.`; - setBusy(false); - syncControls(); - - const overall = parsed?.overall?.result; - if (overall === "FAIL") { - const failed = parsed.overall.failedTestNames ?? []; - log.error( - `self-test (${report.typeLabel}) FAILED${parsed.overall.failMaskHex ? ` ${parsed.overall.failMaskHex}` : ""}${failed.length ? `: ${failed.join(", ")}` : ""}`, - ); - /* Only the failing lines reach the event log. Mirroring all ~75 lines - of a report into it would bury everything else the log is for, and - the report itself is on screen and saveable. */ - for (const t of parsed.tests ?? []) { - if (t.verdict === "FAIL") - log.error(` ${t.label ?? t.name}: ${t.detail ?? "FAIL"}`); - } - toast("Self-test finished: FAIL. See the report.", "err"); - } else if (!parsed?.complete) { - log.warn( - `self-test (${report.typeLabel}) ended before its TEST END line after ${seconds} s`, - ); - toast("The report ended early — see the panel.", "warn"); - } else { - log.log( - `self-test (${report.typeLabel}) finished in ${seconds} s${overall ? `: ${overall}` : ""}`, - ); - } - return true; - } - - /** Characters already rendered, tail included. */ - function renderedLength() { - return reportBlock.textContent.length; - } - - /** Turn a failed or cancelled run into a sentence, then wait out the sensor. */ - async function finishFailed(err, info) { - stopTicker(); - abortCtl = null; - const message = String(err?.message ?? err ?? ""); - const reason = err?.reason ?? null; - const aborted = err?.name === "AbortError" || /abort|cancel/i.test(message); - - if (reason === "nack" || /NACK|refus/i.test(message)) { - const why = - "The sensor refused the self-test. Its firmware refuses one while it is sensing — " + - "stop the stream, or a recording started from the sensor's own button, and try again."; - statusLine.textContent = why; - log.error(`self-test refused by the sensor: ${message}`); - toast(why, "err"); - setBusy(false); - syncControls(); - return; - } - if ( - reason === "disconnected" || - /disconnect|not connected/i.test(message) - ) { - statusLine.textContent = "The link dropped during the self-test."; - log.error(`self-test lost the link: ${message}`); - setBusy(false); - syncControls(); - return; - } - if (aborted) { - log.warn( - `self-test cancelled; the sensor keeps printing until its report ends`, - ); - await drain(info, "Cancelled"); - return; - } - if (reason === "timeout" || /timeout/i.test(message)) { - const secs = Math.round(timeoutMs() / 1000); - statusLine.textContent = - `No TEST END within ${secs} s. The sensor may still be printing — ` + - "if the next command fails, disconnect and reconnect."; - log.error(`self-test timed out after ${secs} s`); - toast("The self-test timed out.", "err"); - await drain(info, "Timed out"); - return; - } - statusLine.textContent = `The self-test failed: ${message}`; - log.error(`self-test failed: ${message}`); - toast("The self-test failed. See the event log.", "err"); - setBusy(false); - syncControls(); - } - - /** - * Hold the link busy until the sensor has really finished. - * - * There is no abort command: after a cancel or a timeout the sensor goes on - * printing to its own TEST END. Releasing the page's busy flag now would let - * the next command go out into a link that cannot answer, which presents as - * a dead sensor rather than as a test still running. The SDK keeps swallowing - * the text and tells us when it is idle; on a bundle without that, fall back - * to the time the chosen suite still had to run. - */ - async function drain(info, prefix) { - const client = getClient(); - const expected = Number(info?.expectedDurationMs) || 0; - const leftMs = Math.max(0, expected * 1.2 - (Date.now() - startedAtMs)); - drainingNow = true; - syncControls(); - const say = (msLeft) => { - const s = Math.max(0, Math.round(msLeft / 1000)); - statusLine.textContent = - `${prefix} — the sensor is still running the test and this page has stopped listening. ` + - `The link stays busy until its report ends${s ? `, about ${s} s more` : ""}.`; - }; - say(leftMs); - - if (client && typeof client.whenFactoryTestIdle === "function") { - const countdown = setInterval( - () => say(leftMs - (Date.now() - startedAtMs)), - TICK_MS * 4, - ); - try { - await client.whenFactoryTestIdle(); - } catch { - /* An idle promise never rejects, but a stale bundle might. */ - } - clearInterval(countdown); - } else if (leftMs > 0) { - await new Promise((r) => setTimeout(r, leftMs)); - } - if (destroyed) return; - drainingNow = false; - statusLine.textContent = `${prefix}. The link is free again.`; - setBusy(false); - syncControls(); - } - - /** - * Stop listening to a running report. - * - * `reason === "disconnect"` skips the drain: there is no link left to wait - * for, so holding the page busy would strand it. - */ - function cancel(reason) { - if (!abortCtl && !busy) return; - if (reason === "disconnect") { - abortCtl?.abort(new Error("disconnected")); - abortCtl = null; - stopTicker(); - drainingNow = false; - setBusy(false); - syncControls(); - return; - } - abortCtl?.abort(new Error("cancelled by the user")); - } - - // ------------------------------------------------------------------------- - // Exports - // ------------------------------------------------------------------------- - - function stamp() { - return new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, ""); - } - - function baseName() { - return `${namePrefix()}_selftest-${(report?.typeName ?? "main").toLowerCase()}_${stamp()}`; - } - - async function copy() { - if (!report?.text) return; - try { - await navigator.clipboard.writeText(report.text); - toast("Report copied.", "ok"); - } catch (e) { - log.warn(`copy failed: ${String(e)}`); - toast( - "This browser would not let the page copy to the clipboard.", - "warn", - ); - } - } - - function saveTxt() { - if (!report?.text) return; - /* The report is saved exactly as the sensor sent it, CR LF and all: it is - a record of what a device printed, not a document to tidy up. */ - downloadBlob( - `${baseName()}.txt`, - new Blob([report.text], { type: "text/plain;charset=utf-8" }), - ); - log.log(`self-test report saved as ${baseName()}.txt`); - } - - function saveCsv() { - if (!report?.parsed || !canCsv) return; - const rows = sdk.shimmerFactoryTestReportToCsvRows(report.parsed, { - device: report.device || "", - link: report.mode ?? "", - hardware_version: - report.hardwareVersion == null ? "" : report.hardwareVersion, - factory_test_type: report.type, - factory_test_label: report.typeName, - started_at: report.startedAtIso, - finished_at: report.finishedAtIso, - exported: new Date().toISOString(), - }); - /* Typed escape, never a literal BOM character: a formatter or an editor - that "cleans up" the file would silently drop a pasted one, and then - Excel reads the degree signs as mojibake. */ - downloadBlob( - `${baseName()}.csv`, - new Blob(["\ufeff", rows.join("\n")], { - type: "text/csv;charset=utf-8", - }), - ); - log.log(`self-test report saved as ${baseName()}.csv`); - } - - // ------------------------------------------------------------------------- - // Wiring - // ------------------------------------------------------------------------- - - typeSelect.addEventListener("change", applyTypeToTimeout); - timeoutInput.addEventListener("input", () => { - timeoutTouched = true; - }); - timeoutInput.addEventListener("change", applyTypeToTimeout); - btnRun.addEventListener("click", () => void run()); - btnCancel.addEventListener("click", () => cancel("user")); - btnCopy.addEventListener("click", () => void copy()); - btnTxt.addEventListener("click", saveTxt); - btnCsv?.addEventListener("click", saveCsv); - - applyTypeToTimeout(); - syncControls(); - - return { - run, - cancel, - running: () => !!abortCtl, - draining: () => drainingNow, - lastReport: () => report, - setEnabled(next) { - enabled = !!next; - /* Deliberately does NOT touch a run in progress. This is the floor for - STARTING one: a page that folded its own "a self-test is busy" state - back in here would disable Cancel at the moment it is needed. */ - syncControls(); - }, - destroy() { - destroyed = true; - cancel("disconnect"); - stopTicker(); - host.replaceChildren(); - }, - }; -} - -/** - * The same surface, doing nothing, for a bundle that cannot run the test. - * - * Returned rather than throwing so a page can mount the panel unconditionally - * and get a banner where the runner would have been, instead of losing every - * tab built after this one. - */ -function inertPanel() { - return { - run: async () => false, - cancel() {}, - running: () => false, - draining: () => false, - lastReport: () => null, - setEnabled() {}, - destroy() {}, - }; -} diff --git a/common/kinematic-block-editor.js b/common/kinematic-block-editor.js deleted file mode 100644 index d6c7835..0000000 --- a/common/kinematic-block-editor.js +++ /dev/null @@ -1,553 +0,0 @@ -/** - * The configuration form's editor for a 21-byte kinematic calibration block. - * - * A `bytes21` calibration field renders by default as forty-two hex - * characters in one text box. That is what the bytes are, and it is unreadable: - * the same numbers on the Calibration tab are three labelled grids — an offset - * vector, a sensitivity vector and a 3x3 alignment matrix — and there is no - * reason the configuration image's own copy of them should be harder to read - * than the calibration dump's. This module makes the Configure tab's - * Calibration section look like the Calibration tab, range and all. - * - * It is a separate module from `config-form.js` because that module has, by - * design, NO dependency on the SDK bundle (see its header), and decoding these - * bytes needs the SDK's codec for this encoding. So config-form takes an - * `editorFor` hook and this is what a page hands it: - * - * import { createKinematicBlockEditorFactory } from "../common/kinematic-block-editor.js"; - * - * const editorFor = createKinematicBlockEditorFactory({ - * fields: sdk.infoMemFieldsFor(generation), - * generation: () => generation, - * }); - * createConfigForm(host, { fields, groups, layout, codec, editorFor }); - * - * WHY IT IS EDITABLE, and the Calibration tab's InfoMem view is not: the - * configuration image belongs to this form. The Calibration tab says so in as - * many words when it falls back to showing the image — it stays read-only - * there precisely so that two panels are never writing one image. Here the - * boxes go through config-form's ordinary commit path, so what reaches the - * bytes is one writer, validated first, and the dirty pill, Discard, Apply and - * the hex view need to know nothing about this module at all. - */ - -import { el } from "./ui-chrome.js"; -import * as sdk from "../vendor/shimmer-web-sdk.esm.js"; - -/** Kinematic block length, and the field kind that names it. */ -const BLOCK_BYTES = 21; -const BLOCK_KIND = `bytes${BLOCK_BYTES}`; - -/** Prefix every calibration-block field key carries in the schema. */ -const KEY_PREFIX = "calib."; - -/** Axis labels down the side of every grid, as on the Calibration tab. */ -const AXES = Object.freeze(["x", "y", "z"]); - -/** - * What the three parts can hold, from the byte layout: offset and sensitivity - * are big-endian i16, alignment is i8. Mirrors the same constants in - * `calibration-editor.js` — the format is the format, and both editors have to - * refuse the same values. - */ -const I16_MIN = -32768; -const I16_MAX = 32767; -const I8_MIN = -128; -const I8_MAX = 127; - -/** A number as short as it can be written without changing it. */ -function num(v) { - if (!Number.isFinite(v)) return ""; - return String(Number(v.toFixed(6))); -} - -/** - * What one box may hold, in the units shown on screen — so a refusal names - * the number the user typed rather than the integer it scales to. - */ -function limitsFor(part, sensitivityScale) { - if (part === "offset") { - return { - scale: 1, - min: I16_MIN, - max: I16_MAX, - describe: `a whole number from ${I16_MIN} to ${I16_MAX}`, - }; - } - if (part === "sens") { - const scale = sensitivityScale || 1; - return { - scale, - min: I16_MIN / scale, - max: I16_MAX / scale, - describe: - scale === 1 - ? `a whole number from ${I16_MIN} to ${I16_MAX}` - : `${num(I16_MIN / scale)} to ${num(I16_MAX / scale)} in steps of ${num(1 / scale)}`, - }; - } - return { - scale: 100, - min: I8_MIN / 100, - max: I8_MAX / 100, - describe: `${num(I8_MIN / 100)} to ${num(I8_MAX / 100)} in steps of 0.01`, - }; -} - -/** - * Validate one typed value against what the format can hold. - * - * Refuses rather than clamps, for the same reason the Calibration tab does: a - * silently clamped offset is a calibration nobody asked for, written under the - * name of one somebody did. - * - * @returns {{value: number}|{problem: string}} - */ -function checkValue(text, part, sensitivityScale) { - const raw = String(text ?? "").trim(); - if (!raw) return { problem: "needs a value" }; - if (!/^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(raw)) { - return { problem: "not a number" }; - } - const v = Number(raw); - if (!Number.isFinite(v)) return { problem: "not a number" }; - const lim = limitsFor(part, sensitivityScale); - if (v < lim.min || v > lim.max) { - return { problem: `out of range — ${lim.describe}` }; - } - /* The encoder rounds sensitivity and alignment and TRUNCATES offset. A value - that would not survive that round trip is refused here, so the number in - the box is always the number that reaches the device. */ - const settled = Math.round(v * lim.scale) / lim.scale; - if (Math.abs(settled - v) > 1e-9) { - return { - problem: - lim.scale === 1 - ? "must be a whole number" - : `must be a multiple of ${num(1 / lim.scale)}`, - }; - } - return { value: v }; -} - -/** - * The defaults-table family for a generation string. - * - * `inferShimmer3Generation` and `getGroupDefaults` name the same three parts - * differently — `shimmer3-new-imu` against `shimmer3-new` — so somebody has - * to translate. Returns null for anything else, including the null a page - * passes when the sensor never said what it is: the defaults are then simply - * not shown, which is better than showing another part's. - * - * @param {string|null|undefined} generation - * @returns {"shimmer3-old"|"shimmer3-new"|"shimmer3r"|null} - */ -export function calibrationFamilyFor(generation) { - if (generation === "shimmer3r") return "shimmer3r"; - if (generation === "shimmer3-new-imu") return "shimmer3-new"; - if (generation === "shimmer3-old-imu") return "shimmer3-old"; - return null; -} - -/** - * The range field that governs a calibration group, by key. - * - * Found by pattern rather than tabulated, because the schema names these - * per-part: the wide-range accelerometer's range is `wrAccelRange` on one - * generation and `wrAccelRange.lsm303ah` on another, and the gyro's is - * `gyroRange.mpu9x50` or `gyroRange.lsm6dsv`. A table would need an entry per - * part and would silently stop finding the range for the next one added. - * - * Returns null where the part genuinely has no range field — the Shimmer3R's - * LIS2MDL magnetometer has exactly one range, so the schema declares none, and - * that is not a lookup failure. - * - * @param {readonly object[]} fields the schema fields for THIS generation - * @param {string} group e.g. `lnAccel` - * @returns {string|null} - */ -function rangeFieldKeyFor(fields, group) { - const re = new RegExp(`^${group}Range(\\.|$)`); - const hit = (fields ?? []).find((f) => re.test(String(f.key))); - return hit ? String(hit.key) : null; -} - -/** - * Build the `editorFor` hook `createConfigForm` takes. - * - * @param {object} opts - * @param {readonly object[]} opts.fields the schema fields handed to the same - * form, used only to find each group's range field - * @param {string|null|(() => string|null)} [opts.generation] what - * `inferShimmer3Generation` returned — `"shimmer3r"`, `"shimmer3-new-imu"` - * or `"shimmer3-old-imu"`. A getter, so the boxes follow a - * re-identification without the form being rebuilt. It is needed for one - * thing only: which factory defaults a block with nothing stored falls back - * to. Pass nothing and such a block shows empty boxes instead. - * @returns {(field: object, api: object) => object|null} - */ -export function createKinematicBlockEditorFactory(opts = {}) { - const fields = opts.fields ?? []; - const getGeneration = - typeof opts.generation === "function" - ? opts.generation - : () => opts.generation ?? null; - - return function editorFor(field, api) { - /* Only the calibration blocks. Another `bytes21` field added to the schema - tomorrow is not a kinematic block just because it is the same length, - so the key prefix has to agree as well as the kind. */ - if (field?.kind !== BLOCK_KIND) return null; - if (!String(field.key ?? "").startsWith(KEY_PREFIX)) return null; - - const group = String(field.key).slice(KEY_PREFIX.length); - const rangeKey = rangeFieldKeyFor(fields, group); - - // ---- the parts - - const rangePill = el("span", { class: "pill" }, "range unknown"); - const chip = el("span", { class: "cal-chip" }, "kinematic block"); - const problemNode = el("div", { class: "cal-error", hidden: true }); - const stateNote = el("div", { class: "field-hint muted" }); - - /** part -> the three (or nine) inputs, in row-major order. */ - const cells = { offset: [], sens: [], align: [] }; - - const makeInput = (part, i) => { - const input = el("input", { - type: "text", - class: "mono cal-cell", - inputmode: "decimal", - autocomplete: "off", - spellcheck: "false", - dataset: { calPart: part, calIndex: String(i) }, - "aria-label": `${field.label} ${part} ${ - part === "align" - ? `row ${Math.floor(i / 3) + 1} column ${(i % 3) + 1}` - : AXES[i] - }`, - }); - /* Committed on `change`, exactly as every other control in the form is: - "1" on the way to "1.5" is a different calibration, and committing it - per keystroke would write it. `input` only restyles. */ - input.addEventListener("change", () => api.onEdit()); - input.addEventListener("input", () => paintProblems(collect().problems)); - cells[part].push(input); - return input; - }; - - const makeGrid = (part, title, unitText, count) => { - const body = el("div", { - class: count === 9 ? "cal-matrix cols-3" : "cal-matrix cols-1", - }); - for (let r = 0; r < 3; r++) { - body.appendChild(el("div", { class: "cal-axis" }, AXES[r])); - for (let c = 0; c < count / 3; c++) { - body.appendChild(makeInput(part, r * (count / 3) + c)); - } - } - return el( - "div", - { class: "cal-block" }, - el( - "div", - { class: "cal-block-title" }, - title, - el("span", { class: "cal-unit" }, unitText), - ), - body, - ); - }; - - const offsetGrid = makeGrid("offset", "Offset", "raw counts", 3); - const sensGrid = makeGrid("sens", "Sensitivity", "counts per unit", 3); - const alignGrid = makeGrid("align", "Alignment", "unitless, −1.28…1.27", 9); - /* Kept so the unit can be corrected once the family is known — the - sensitivity unit is per sensor group (m/(s^2), deg/s, local_flux) and - the family is what says which. */ - const sensUnitNode = sensGrid.querySelector(".cal-unit"); - - const node = el( - "div", - { - class: "cal-inline", - dataset: { calInlineGroup: group }, - id: api.id, - }, - el("div", { class: "row cal-inline-head" }, chip, rangePill), - el("div", { class: "cal-grids" }, offsetGrid, sensGrid, alignGrid), - problemNode, - stateNote, - ); - - // ---- state the boxes are painted from - - /** The scale sensitivity is stored at: 100 for the gyro, 1 elsewhere. */ - let sensitivityScale = 1; - /** True while the boxes hold factory defaults rather than stored values. */ - let showingDefaults = false; - - function groupDefaults() { - const fam = calibrationFamilyFor(getGeneration()); - if (!fam) return null; - try { - return sdk.getGroupDefaults(fam, group) ?? null; - } catch { - /* A group this family does not have. Not an error: the form has - already decided this field applies, and the defaults are a - nicety. */ - return null; - } - } - - /** The range the image is configured for, or null when unknowable. */ - function configuredRange() { - if (!rangeKey) return null; - const d = api.describe(rangeKey); - if (!d) return null; - const n = Number(d.value); - return Number.isFinite(n) ? { value: n, label: d.label } : null; - } - - function paintRange() { - const cfg = configuredRange(); - if (!rangeKey) { - rangePill.textContent = "single range"; - rangePill.className = "pill"; - rangePill.title = - "This part has one range, so the configuration declares none and " + - "the block below is the calibration for it."; - return; - } - if (!cfg) { - rangePill.textContent = "range unknown"; - rangePill.className = "pill"; - rangePill.title = - "The configuration image has not been read, so which range this " + - "block belongs to is not yet known."; - return; - } - rangePill.textContent = `configured: ${cfg.label}`; - rangePill.className = "pill on"; - rangePill.title = - `Taken from the ${rangeKey} field of this same image, so it follows ` + - "an edit to the range immediately. The configuration image holds ONE " + - "block per sensor, and this is the range the sensor is set to — " + - "which is what makes the numbers below meaningful."; - } - - /** Read every box, validating as it goes. */ - function collect() { - const problems = []; - const values = { offset: [], sens: [], align: [] }; - for (const part of ["offset", "sens", "align"]) { - for (let i = 0; i < cells[part].length; i++) { - const r = checkValue(cells[part][i].value, part, sensitivityScale); - if ("problem" in r) { - problems.push({ part, index: i, problem: r.problem }); - values[part].push(0); - } else { - values[part].push(r.value); - } - } - } - return { values, problems }; - } - - function paintProblems(problems) { - const bad = new Set(problems.map((p) => `${p.part}:${p.index}`)); - for (const part of ["offset", "sens", "align"]) { - cells[part].forEach((input, i) => { - input.classList.toggle("bad", bad.has(`${part}:${i}`)); - }); - } - if (!problems.length) { - problemNode.hidden = true; - problemNode.textContent = ""; - return; - } - const first = problems[0]; - problemNode.hidden = false; - problemNode.textContent = - `${problems.length} value${problems.length === 1 ? "" : "s"} the ` + - `calibration format cannot hold — ${first.part} ${ - first.part === "align" - ? `row ${Math.floor(first.index / 3) + 1} column ${(first.index % 3) + 1}` - : AXES[first.index] - } ${first.problem}.`; - } - - // ---- the config-form contract - - return { - node, - - set(value) { - const bytes = - value instanceof Uint8Array ? value : new Uint8Array(BLOCK_BYTES); - const defaults = groupDefaults(); - sensitivityScale = defaults?.sensitivityScale ?? 1; - if (sensUnitNode) { - sensUnitNode.textContent = defaults?.unit - ? `counts per ${defaults.unit}` - : "counts per unit"; - } - paintRange(); - - const parsed = sdk.parseKinematicCalibBlock(bytes, { - sensitivityScale, - }); - - if (parsed) { - showingDefaults = false; - fill(parsed); - stateNote.textContent = ""; - paintFaint(); - } else { - showFallback(); - } - paintProblems([]); - }, - - /** - * A SIBLING field changed — repaint whatever is derived from one. - * - * Two things here are: the chip naming the configured range, and, when - * the block holds no calibration, the factory defaults shown greyed — - * which are per range, so changing the range changed which ones the - * firmware would fall back to. - * - * Deliberately does NOT touch boxes holding real values. `set()` is the - * only thing allowed to overwrite those, and it is called with the - * bytes; a refresh that helpfully repainted an edit in progress would - * throw away work on every keystroke-commit elsewhere in the form. - */ - refresh() { - paintRange(); - if (showingDefaults) showFallback(); - }, - - get() { - const { values, problems } = collect(); - paintProblems(problems); - if (problems.length) { - const first = problems[0]; - return { - ok: false, - error: `${first.part} ${ - first.part === "align" - ? `row ${Math.floor(first.index / 3) + 1} column ${(first.index % 3) + 1}` - : AXES[first.index] - } ${first.problem}`, - }; - } - /* The moment anything is committed these are real values, not the - greyed placeholder — the whole block goes to the bytes. */ - showingDefaults = false; - paintFaint(); - stateNote.textContent = ""; - return { - ok: true, - value: sdk.generateKinematicCalibBlock( - values.offset, - values.sens, - values.align, - { sensitivityScale }, - ), - }; - }, - - setDisabled(disabled, reason) { - for (const part of ["offset", "sens", "align"]) { - for (const input of cells[part]) { - input.disabled = disabled; - input.title = reason || ""; - } - } - node.dataset.calDisabled = disabled ? "true" : "false"; - }, - - setInvalid(message) { - /* config-form's own error line is already showing `message`; the - per-box styling is this module's job and `collect` has just done - it, so there is nothing to add. Present so the hook is complete. */ - if (!message) paintProblems([]); - }, - - focus() { - cells.offset[0]?.focus({ preventScroll: true }); - }, - }; - - /** - * Paint one `KinematicCalibration` into the boxes. - * - * Takes the same shape whether it came from `parseKinematicCalibBlock` or - * out of the defaults table: both go through `makeKinematicCalibration`, - * so both are `{offset, sensitivity, alignment}` with flat arrays. - */ - function fill(cal) { - const put = (part, list) => { - cells[part].forEach((input, i) => { - input.value = num(Number(list?.[i] ?? 0)); - }); - }; - put("offset", cal.offset); - put("sens", cal.sensitivity); - put("align", cal.alignment); - } - - function clear() { - for (const part of ["offset", "sens", "align"]) { - for (const input of cells[part]) input.value = ""; - } - } - - /** Grey the boxes, or un-grey them, to match `showingDefaults`. */ - function paintFaint() { - for (const part of ["offset", "sens", "align"]) { - for (const input of cells[part]) - input.classList.toggle("faint", showingDefaults); - } - } - - /** - * Show what the firmware would fall back to for the CONFIGURED range. - * - * Reached when the block is all 0x00 or all 0xFF — the firmware reads that - * as "nothing stored" and uses its own defaults, so those are what the - * sensor would actually apply and what the boxes show. Greyed, exactly as - * the Calibration tab greys them, so nobody mistakes them for measured - * values. Forty-two zeroes instead would be literally true about the bytes - * and misleading about the sensor. - * - * Also the refresh path, which is why it is a function: the fallback is - * per range, so an edit to the range field changes it. - */ - function showFallback() { - const defaults = groupDefaults(); - const cfg = configuredRange(); - const fallback = defaults - ? (defaults.byRange[cfg?.value ?? defaults.fallbackRange] ?? - defaults.byRange[defaults.fallbackRange]) - : null; - if (fallback) { - showingDefaults = true; - fill(fallback); - stateNote.textContent = - "This block holds no calibration — every byte is 0x00 or 0xFF — " + - "so the greyed values above are the factory defaults the " + - "firmware falls back to for this range. Editing any box writes " + - "the whole block, defaults and all."; - } else { - showingDefaults = false; - clear(); - stateNote.textContent = - "This block holds no calibration — every byte is 0x00 or 0xFF. " + - "Which factory defaults the firmware falls back to depends on " + - "the hardware, and the sensor has not said what it is."; - } - paintFaint(); - } - }; -} diff --git a/common/plot.js b/common/plot.js deleted file mode 100644 index 5dce4fd..0000000 --- a/common/plot.js +++ /dev/null @@ -1,873 +0,0 @@ -/** - * Live stream plotting for the webBLEDemos pages: one Chart.js line panel per - * sensor group, fed from ring buffers, redrawn on an animation frame at a - * self-limiting frame rate. - * - * Extracted and generalised from: - * - video-ppg/index.html — the Chart.js configuration that survives a live - * stream (`parsing:false`, `normalized:true`, `animation:false`, linear x, - * min-max decimation) at L462-486 and L527-537, and its ~30 fps rAF gate; - * - verisense-device-console/console.js — `SHIMMER_TRACE_PALETTE` - * (L1383-1394) and `padRange` (L4454). - * - * Chart.js is NOT imported. It is read off the global `Chart`, which the page - * loads first: - * - * - * - * The global is read lazily (inside `createStreamPlot`), so this module still - * imports cleanly on a page that never loads the script. - * - * import { createStreamPlot, groupForField } from "../common/plot.js"; - */ - -import { onThemeChange } from "./theme.js"; - -/** - * Standard Shimmer brand trace colours, ordered so the first few traces stay - * easy to tell apart (orange / blue / grey lead, since the triaxial panels use - * indices 0-2). A panel assigns these to its traces in order; any trace beyond - * the list gets a random (but readable) colour. No sensor group has more than - * six traces, so the random path is a safety net. - * - * Copied verbatim from console.js L1383-1394. - */ -export const SHIMMER_TRACE_PALETTE = Object.freeze([ - "#F15D22", // Shimmer orange (241, 93, 34) - "#0081C6", // Shimmer blue (0, 129, 198) - "#77787C", // Shimmer grey (119, 120, 124) - "#00994C", // green (0, 153, 76) - "#660000", // maroon (102, 0, 0) - "#6600CC", // purple (102, 0, 204) - "#009999", // cyan / aqua (0, 153, 153) - "#994C00", // brown (153, 76, 0) -]); - -/** Panel order and titles. `OTHER` collects anything unrecognised. */ -export const PLOT_GROUPS = Object.freeze([ - { id: "LN_ACCEL", label: "Low-noise accelerometer" }, - { id: "WR_ACCEL", label: "Wide-range accelerometer" }, - { id: "HG_ACCEL", label: "High-g accelerometer" }, - { id: "GYRO", label: "Gyroscope" }, - { id: "MAG", label: "Magnetometer" }, - { id: "ALT_MAG", label: "Alternate magnetometer" }, - { id: "EXG", label: "ExG" }, - { id: "GSR", label: "GSR" }, - { id: "PPG", label: "PPG" }, - /* Battery, pressure, temperature, the ADC lines and the bridge amplifier - used to share OTHER's single y axis. That was tolerable while they were all - raw ADC counts; calibrated they are millivolts, kilopascals and degrees - Celsius, and one axis cannot show three units. The ADC lines keep a panel - between them because they really are the same scale: 0-3000 mV. */ - { id: "BATTERY", label: "Battery" }, - { id: "PRESSURE", label: "Pressure" }, - { id: "TEMPERATURE", label: "Temperature" }, - { id: "ADC", label: "ADC (expansion connector)" }, - { id: "BRIDGE_AMP", label: "Bridge amplifier" }, - { id: "OTHER", label: "Other channels" }, -]); - -/** - * Fields that are never a trace. TIMESTAMP is the x axis — `push()` takes the - * time as its own argument, so plotting it would draw a straight ramp across - * whichever panel it landed in. - */ -const NOT_A_TRACE = new Set(["TIMESTAMP", "Timestamp_Unix"]); - -/** - * Recorded but not plotted. - * - * GSR's resistance is the reciprocal of its conductance and its range is a code - * from 0 to 3; on the conductance panel's axis the first swamps the trace and - * the second is a flat line at the bottom. Both go in the CSV, where an - * analysis can use them. - */ -const CSV_ONLY = new Set(["GSR_RESISTANCE", "GSR_RANGE"]); - -/** The SDK's name for "this value has no unit". Never an axis label. */ -const NO_UNITS = "no_units"; - -/** - * Prettier spellings for an axis, for display only. - * - * The recorded strings are the Java driver's, so a CSV from this page and one - * from Consensys agree; an axis label has no such constraint and can use the - * symbols a reader expects. - */ -const AXIS_UNIT_ALIASES = Object.freeze({ - "Degrees Celsius": "°C", - "m/(s^2)": "m/s²", -}); - -const axisUnitLabel = (unit) => AXIS_UNIT_ALIASES[unit] ?? unit; - -/** Hard ceiling on ring length, whatever the rate × window works out to. */ -const MAX_RING = 65536; - -/** Above this rate the window is capped (see `setWindow`). */ -const HIGH_RATE_HZ = 512; -/** …to this many seconds. */ -const HIGH_RATE_WINDOW_SEC = 10; - -/** Redraw budget. A slower update than this halves the frame rate. */ -const SLOW_UPDATE_MS = 25; -/** Floor for the self-limiting frame rate. */ -const MIN_FPS = 5; - -/** Fallback ring length when the caller does not say what rate to expect. */ -const ASSUMED_RATE_HZ = 128; - -/** - * Which panel a signal belongs in. - * - * @param {string} name e.g. `"LN_ACCEL_X"`, `"Exg1_CH2_24Bit"` - * @returns {string} one of the `PLOT_GROUPS` ids - */ -export function groupForField(name) { - const n = String(name ?? ""); - if (n.startsWith("LN_ACCEL")) return "LN_ACCEL"; - if (n.startsWith("WR_ACCEL")) return "WR_ACCEL"; - if (n.startsWith("HG_ACCEL")) return "HG_ACCEL"; - if (n.startsWith("GYRO")) return "GYRO"; - /* Before MAG, and before the generic prefix tests: the second magnetometer - is `ALT_MAG_*`, which `startsWith("MAG")` does not catch and `OTHER` was - silently collecting. Its own panel rather than MAG's, because the two - parts have different ranges and a Shimmer3R streams both. */ - if (n.startsWith("ALT_MAG")) return "ALT_MAG"; - if (n.startsWith("MAG")) return "MAG"; - if (n === "BATTERY") return "BATTERY"; - if (n === "PRESSURE") return "PRESSURE"; - if (n === "TEMPERATURE") return "TEMPERATURE"; - if (/^(EXT|INT)_(EXP_)?ADC/.test(n)) return "ADC"; - if (n.startsWith("BRIDGE_AMP")) return "BRIDGE_AMP"; - // The ExG status byte is a register readback, not a signal, so it is not an - // ExG trace — it falls through to OTHER with the rest of the odds and ends. - if ( - (n.startsWith("Exg1_") || n.startsWith("Exg2_")) && - !n.endsWith("_Status") - ) - return "EXG"; - if (n.startsWith("GSR")) return "GSR"; - if (n.startsWith("PPG")) return "PPG"; - return "OTHER"; -} - -/** - * Widen a min/max pair by `factor` so a trace never rides the frame edge. - * Source: console.js L4454. - * - * @param {number} minY - * @param {number} maxY - * @param {number} [factor=0.05] - * @param {number} [minPad=0.1] used when the range is flat (min === max) - * @returns {{minY: number, maxY: number}} - */ -export function padRange(minY, maxY, factor = 0.05, minPad = 0.1) { - const pad = (maxY - minY) * factor || minPad; - return { minY: minY - pad, maxY: maxY + pad }; -} - -/** i-th trace colour: brand palette in order, then a random readable hue. */ -function traceColor(i, palette) { - const list = palette ?? SHIMMER_TRACE_PALETTE; - if (i < list.length) return list[i]; - return `hsl(${Math.floor(Math.random() * 360)}, 70%, 45%)`; -} - -/** Read a CSS custom property off an element, with a fallback. */ -function cssVar(node, name, fallback) { - try { - const v = getComputedStyle(node).getPropertyValue(name).trim(); - return v || fallback; - } catch { - return fallback; - } -} - -/** - * Create a stream plot inside `host`. - * - * @param {HTMLElement} host container; a `.plot-panels` grid is appended to it - * @param {object} [opts] - * @param {number} [opts.windowSec=10] visible span, in seconds - * @param {number} [opts.maxFps=30] redraw ceiling; lowered automatically when - * a redraw costs more than 25 ms - * @param {string[]} [opts.palette] trace colours, defaults to - * {@link SHIMMER_TRACE_PALETTE} - * @param {number} [opts.rateHz] expected sample rate, used to size the rings. - * `setSchema(fields, rateHz)` overrides it once the real rate is known. - * @returns {{ - * setSchema: (fields: unknown[], rateHz?: number) => void, - * push: (oc: {fields: {name: string, value: number, kind: string|null}[]}, tSec: number) => void, - * setWindow: (sec: number) => number, - * setKind: (kind: "raw"|"cal") => void, - * pause: () => void, - * resume: () => void, - * clear: () => void, - * destroy: () => void, - * panels: Record, - * }} - */ -export function createStreamPlot(host, opts = {}) { - const ChartCtor = globalThis.Chart; - if (!ChartCtor) { - throw new Error( - 'Chart.js is not loaded — add before importing common/plot.js', - ); - } - - const palette = opts.palette ?? SHIMMER_TRACE_PALETTE; - const maxFpsRequested = opts.maxFps ?? 30; - let maxFps = maxFpsRequested; - let windowSec = opts.windowSec ?? 10; - let rateHz = opts.rateHz ?? ASSUMED_RATE_HZ; - let preferredKind = "cal"; - /** "clock" = local time of day; "elapsed" = seconds since the first sample. */ - let timeAxis = "clock"; - /** The first sample's x value, for the elapsed axis. */ - let originSec = null; - /** Whether the x values are unix seconds — set by `push`, per stream. */ - let xIsUnix = false; - let paused = false; - let destroyed = false; - - const grid = document.createElement("div"); - grid.className = "plot-panels"; - host.appendChild(grid); - - /** @type {Record} */ - const panels = {}; - /** - * One entry per plotted signal name. - * @type {{name: string, group: string, unit: string, kinds: Set, - * kind: string|null, buf: Float32Array, pool: {x: number, y: number}[], - * dataset: object}[]} - */ - let series = []; - /** `name|kind` → series index, rebuilt whenever the schema or kind changes. */ - let routeByKey = new Map(); - - // Shared time ring: every series is sampled from the same frame, so one - // clock ring serves them all. - let maxPoints = ringLength(); - let times = new Float64Array(maxPoints); - let write = 0; - let count = 0; - - let rafId = 0; - let lastDrawMs = 0; - let dirty = false; - - function ringLength() { - return Math.max( - 64, - Math.min(MAX_RING, Math.ceil((rateHz || ASSUMED_RATE_HZ) * windowSec)), - ); - } - - // ------------------------------------------------------------------------- - // Panels - // ------------------------------------------------------------------------- - - function themeColors() { - return { - grid: cssVar(host, "--line", "#e2e8eb"), - tick: cssVar(host, "--muted", "#64747e"), - ink: cssVar(host, "--ink", "#17232a"), - }; - } - - /** Two decimals of a second, when the visible span is short enough to need it. */ - const subSecond = () => windowSec <= 10; - - /** - * One x tick label. - * - * `clock` shows the local time of day, because that is what a wall-clock axis - * is for — the date goes in the axis title, once, rather than on every tick. - * `elapsed` shows seconds since this stream's first sample. - */ - function formatXTick(value) { - const v = Number(value); - if (!Number.isFinite(v)) return ""; - if (timeAxis === "clock" && xIsUnix) { - const d = new Date(v * 1000); - const hh = String(d.getHours()).padStart(2, "0"); - const mm = String(d.getMinutes()).padStart(2, "0"); - const ss = String(d.getSeconds()).padStart(2, "0"); - if (!subSecond()) return `${hh}:${mm}:${ss}`; - return `${hh}:${mm}:${ss}.${String(d.getMilliseconds()).padStart(3, "0").slice(0, 2)}`; - } - const base = originSec ?? 0; - const elapsed = v - base; - return subSecond() ? elapsed.toFixed(2) : elapsed.toFixed(1); - } - - /** - * The axis title. - * - * On a clock axis it carries the date, since the tick labels do not — a - * recording that crosses midnight would otherwise be unreadable. - */ - function xAxisTitle() { - if (timeAxis !== "clock" || !xIsUnix) return "Time since start (s)"; - const at = originSec ?? Date.now() / 1000; - const d = new Date(at * 1000); - const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( - d.getDate(), - ).padStart(2, "0")}`; - return `Time — ${iso}, local`; - } - - function applyXAxisTitle() { - const colors = themeColors(); - for (const panel of Object.values(panels)) { - panel.chart.options.scales.x.title = { - display: true, - text: xAxisTitle(), - color: colors.tick, - }; - } - } - - function makePanel(def) { - const colors = themeColors(); - const wrap = document.createElement("div"); - wrap.className = "plot-panel"; - const canvas = document.createElement("canvas"); - // A canvas needs a role and a name to be anything but noise to a screen - // reader; the live numbers live in the stats strip, not here. - canvas.setAttribute("role", "img"); - canvas.setAttribute("aria-label", def.label + " plot"); - wrap.appendChild(canvas); - grid.appendChild(wrap); - - const chart = new ChartCtor(canvas.getContext("2d"), { - type: "line", - data: { datasets: [] }, - options: { - // The fast path: no parsing (points are already {x,y}), no - // normalisation pass (they are sorted ascending), no animation. All - // three matter at 512 Hz — with animation on, Chart.js re-tweens the - // whole window on every frame. - parsing: false, - normalized: true, - animation: false, - responsive: true, - maintainAspectRatio: false, - spanGaps: false, - elements: { - point: { radius: 0 }, - line: { borderWidth: 1.4, tension: 0 }, - }, - scales: { - x: { - /* Still a linear scale, deliberately. The vendored Chart.js build - carries no date adapter (there is no build step here to add one), - and the min-max decimation that makes a 512 Hz stream affordable - only applies to a linear x axis. So the values stay numeric and - only the tick LABELS are formatted — see `formatXTick`. */ - type: "linear", - title: { display: true, text: "Time", color: colors.tick }, - grid: { color: colors.grid }, - ticks: { - color: colors.tick, - maxTicksLimit: 8, - callback: (value) => formatXTick(value), - }, - }, - y: { - grid: { color: colors.grid }, - ticks: { color: colors.tick, maxTicksLimit: 6 }, - }, - }, - plugins: { - // min-max keeps the visible extremes of a decimated window, so a - // spike survives the reduction instead of being averaged away. - decimation: { - enabled: true, - algorithm: "min-max", - samples: Math.max(64, canvas.width || 512), - }, - legend: { - display: true, - position: "top", - labels: { color: colors.ink, boxWidth: 10, boxHeight: 10 }, - }, - tooltip: { enabled: false }, - title: { display: true, text: def.label, color: colors.ink }, - }, - }, - }); - - return { id: def.id, label: def.label, wrap, canvas, chart }; - } - - function applyTheme() { - const colors = themeColors(); - for (const panel of Object.values(panels)) { - const o = panel.chart.options; - o.scales.x.grid.color = colors.grid; - o.scales.x.ticks.color = colors.tick; - o.scales.x.title.color = colors.tick; - o.scales.y.grid.color = colors.grid; - o.scales.y.ticks.color = colors.tick; - o.plugins.legend.labels.color = colors.ink; - o.plugins.title.color = colors.ink; - panel.chart.update("none"); - } - } - - const detachTheme = onThemeChange(() => { - if (!destroyed) applyTheme(); - }); - - // ------------------------------------------------------------------------- - // Schema - // ------------------------------------------------------------------------- - - /** - * Resolve which kind to plot for a name: the preferred kind when the device - * offers it, else whatever it does offer. A raw-only channel (an ADC count - * with no calibration) must still draw, and a page that asked for raw - * should not lose a cal-only derived channel. - */ - function resolveKind(kinds) { - if (kinds.has(preferredKind)) return preferredKind; - if (kinds.has("cal")) return "cal"; - if (kinds.has("raw")) return "raw"; - return kinds.values().next().value ?? null; - } - - function rebuildRoutes() { - routeByKey = new Map(); - series.forEach((s, i) => { - s.kind = resolveKind(s.kinds); - routeByKey.set(`${s.name}|${s.kind ?? ""}`, i); - const unit = s.units?.get(s.kind ?? null) ?? ""; - /* The unit belongs in the legend as well as on the axis: a panel can - hold two traces the device reports in different units, and then the - axis has to stay blank. */ - s.dataset.label = s.kind - ? `${s.name} (${s.kind}${unit ? `, ${axisUnitLabel(unit)}` : ""})` - : s.name; - }); - - /* One axis title per panel, and only when every trace on it agrees — which - is why this lives here rather than in setSchema: the answer changes when - `setKind` switches between raw counts and calibrated units. */ - const colors = themeColors(); - for (const panel of Object.values(panels)) { - const units = new Set( - series - .filter((s) => s.group === panel.id) - .map((s) => s.units?.get(s.kind ?? null) ?? ""), - ); - const shared = units.size === 1 ? [...units][0] : ""; - panel.chart.options.scales.y.title = shared - ? { display: true, text: axisUnitLabel(shared), color: colors.tick } - : { display: false }; - panel.chart.update("none"); - } - } - - /** - * Declare the signals to plot. Call once per stream, typically from the - * first frame: `plot.setSchema(oc.fields, rateHz)`. - * - * `fields` may be field objects (`{name, unit, kind}` — an ObjectCluster's - * `fields` array) or plain names. Duplicated names with different kinds - * collapse into one trace whose kind `setKind()` selects. - * - * @param {unknown[]} fields - * @param {number} [nextRateHz] expected sample rate, used to size the rings - */ - function setSchema(fields, nextRateHz) { - // Charts are rebuilt from scratch, so tear the old traces down first. - for (const panel of Object.values(panels)) { - panel.chart.destroy(); - panel.wrap.remove(); - delete panels[panel.id]; - } - series = []; - - if (Number.isFinite(nextRateHz) && nextRateHz > 0) rateHz = nextRateHz; - // Re-apply the high-rate cap now that the real rate is known. - setWindow(windowSec); - - /** @type {Map, kinds: Set}>} */ - const byName = new Map(); - for (const f of fields ?? []) { - const name = typeof f === "string" ? f : f?.name; - if (!name || NOT_A_TRACE.has(name) || CSV_ONLY.has(name)) continue; - const kind = typeof f === "string" ? null : (f?.kind ?? null); - const entry = byName.get(name) ?? { units: new Map(), kinds: new Set() }; - /* Per KIND, because they differ: a raw field is counts and a calibrated - one is millivolts, and the axis has to follow whichever `setKind` - resolved. `no_units` is a unit name meaning "there isn't one", so it - must not become an axis label. */ - const unit = typeof f === "string" ? "" : (f?.unit ?? ""); - if (unit && unit !== NO_UNITS) entry.units.set(kind, unit); - entry.kinds.add(kind); - byName.set(name, entry); - } - - // Group first, so the panels appear in PLOT_GROUPS order regardless of - // the order the device reported its channels in. - /** @type {Map} */ - const namesByGroup = new Map(); - for (const name of byName.keys()) { - const g = groupForField(name); - if (!namesByGroup.has(g)) namesByGroup.set(g, []); - namesByGroup.get(g).push(name); - } - - times = new Float64Array(maxPoints); - write = 0; - count = 0; - - for (const def of PLOT_GROUPS) { - const names = namesByGroup.get(def.id); - if (!names?.length) continue; - const panel = makePanel(def); - panels[def.id] = panel; - names.forEach((name, i) => { - const entry = byName.get(name); - const pool = new Array(maxPoints); - for (let k = 0; k < maxPoints; k++) pool[k] = { x: 0, y: 0 }; - const color = traceColor(i, palette); - const dataset = { - label: name, - data: [], - borderColor: color, - backgroundColor: color, - borderWidth: 1.4, - pointRadius: 0, - tension: 0, - }; - panel.chart.data.datasets.push(dataset); - series.push({ - name, - group: def.id, - units: entry.units, - kinds: entry.kinds, - kind: null, - buf: new Float32Array(maxPoints), - pool, - dataset, - }); - }); - } - - rebuildRoutes(); - } - - // ------------------------------------------------------------------------- - // Data in - // ------------------------------------------------------------------------- - - /** - * Append one decoded frame. - * - * @param {{fields: {name: string, value: number, kind: string|null}[]}} oc - * @param {number} tSec frame time in seconds (device clock, unwrapped) - */ - function push(oc, tSec, opts) { - if (destroyed || !series.length) return; - /* Whether these x values are unix seconds is a property of the stream, not - of one sample, and only the caller knows it — it depends on whether the - client's timeline found an anchor. */ - if (opts && typeof opts.unix === "boolean" && opts.unix !== xIsUnix) { - xIsUnix = opts.unix; - applyXAxisTitle(); - } - if (originSec === null) { - originSec = tSec; - applyXAxisTitle(); - } - const w = write; - times[w] = tSec; - // A channel absent from this frame reads NaN, which breaks the line - // rather than drawing a straight segment across the gap (spanGaps:false). - for (const s of series) s.buf[w] = NaN; - const fields = oc?.fields; - if (fields) { - for (const f of fields) { - const idx = routeByKey.get(`${f.name}|${f.kind ?? ""}`); - if (idx === undefined) continue; - series[idx].buf[w] = f.value; - } - } - write = (w + 1) % maxPoints; - if (count < maxPoints) count++; - dirty = true; - if (!paused) schedule(); - } - - // ------------------------------------------------------------------------- - // Redraw - // ------------------------------------------------------------------------- - - function schedule() { - if (rafId || destroyed) return; - rafId = requestAnimationFrame(draw); - } - - function draw() { - rafId = 0; - if (destroyed || paused || !dirty) return; - const now = performance.now(); - const minGap = 1000 / maxFps; - if (now - lastDrawMs < minGap) { - // Too soon: come back next frame rather than dropping the update. - schedule(); - return; - } - lastDrawMs = now; - dirty = false; - - const tLast = times[(write - 1 + maxPoints) % maxPoints]; - const tMin = tLast - windowSec; - /* While the buffer holds less than a full window, the axis starts at the - * oldest sample instead of tLast - windowSec. Otherwise the first seconds - * of a stream draw a trace squeezed against the right edge of a mostly - * empty frame, with negative seconds on the axis where the device clock - * had not started yet. */ - const tOldest = times[(write - count + maxPoints) % maxPoints]; - const xMin = Math.max(tMin, tOldest); - - // One pass per panel: fill each of its series' point arrays from the ring - // and take the panel's y extent as we go. - for (const panel of Object.values(panels)) { - let minY = Infinity; - let maxY = -Infinity; - let points = 0; - for (const s of series) { - if (s.group !== panel.id) continue; - const pool = s.pool; - const data = s.dataset.data; - let k = 0; - for (let i = 0; i < count; i++) { - const idx = (write - count + i + maxPoints) % maxPoints; - const t = times[idx]; - if (t < tMin) continue; - const v = s.buf[idx]; - const p = pool[k]; - p.x = t; - p.y = v; - if (data[k] !== p) data[k] = p; - k++; - if (v < minY) minY = v; - if (v > maxY) maxY = v; - } - if (data.length !== k) data.length = k; - points = Math.max(points, k); - } - const y = panel.chart.options.scales.y; - if (Number.isFinite(minY) && Number.isFinite(maxY)) { - const padded = padRange(minY, maxY); - y.min = padded.minY; - y.max = padded.maxY; - } else { - // Every sample in the window was NaN — let Chart.js pick, rather than - // pinning the axis to a stale range. - delete y.min; - delete y.max; - } - const x = panel.chart.options.scales.x; - if (points > 1) { - x.min = xMin; - x.max = tLast; - } - // Decimate to roughly one sample per device pixel; the canvas size is - // only known after layout, and changes when the pane is resized. - panel.chart.options.plugins.decimation.samples = Math.max( - 64, - panel.canvas.width || 512, - ); - panel.chart.update("none"); - } - - /* Self-limiting frame rate: on a slow host (or a very wide window) a - * redraw can cost more than the frame it is drawn in, at which point the - * page stops responding to clicks. Halving the target rate trades plot - * smoothness — which nobody is measuring — for a UI that still works. */ - const cost = performance.now() - now; - if (cost > SLOW_UPDATE_MS && maxFps > MIN_FPS) { - maxFps = Math.max(MIN_FPS, Math.floor(maxFps / 2)); - } - if (dirty) schedule(); - } - - // ------------------------------------------------------------------------- - // Controls - // ------------------------------------------------------------------------- - - /** - * Set the visible span. - * - * Capped at 10 s above 512 Hz: a 60 s window at 1024 Hz is 61k points per - * trace, which no amount of decimation makes affordable to refill 30 times - * a second, and which nobody can read anyway. - * - * @param {number} sec - * @returns {number} the span actually applied - */ - function setWindow(sec) { - let next = Number(sec); - if (!Number.isFinite(next) || next <= 0) return windowSec; - if (rateHz > HIGH_RATE_HZ) next = Math.min(next, HIGH_RATE_WINDOW_SEC); - if (next === windowSec && maxPoints === ringLength()) return windowSec; - windowSec = next; - resizeRings(ringLength()); - return windowSec; - } - - /** Re-allocate the rings, keeping the newest `min(count, next)` samples. */ - function resizeRings(next) { - if (next === maxPoints) return; - const keep = Math.min(count, next); - const newTimes = new Float64Array(next); - for (let i = 0; i < keep; i++) { - newTimes[i] = times[(write - keep + i + maxPoints) % maxPoints]; - } - for (const s of series) { - const buf = new Float32Array(next); - for (let i = 0; i < keep; i++) { - buf[i] = s.buf[(write - keep + i + maxPoints) % maxPoints]; - } - s.buf = buf; - const pool = new Array(next); - for (let k = 0; k < next; k++) pool[k] = s.pool[k] ?? { x: 0, y: 0 }; - s.pool = pool; - s.dataset.data.length = 0; - } - times = newTimes; - maxPoints = next; - count = keep; - write = keep % next; - dirty = true; - } - - /** - * Choose what the x axis reads. - * - * @param {"clock"|"elapsed"} mode `clock` = local time of day, which needs - * the frames to carry wall-clock time (the client's stream timeline has to - * have found an anchor); `elapsed` = seconds since this stream's first - * sample, which always works. - * @returns {string} the mode actually applied - */ - function setTimeAxis(mode) { - const next = mode === "elapsed" ? "elapsed" : "clock"; - if (next === timeAxis) return timeAxis; - timeAxis = next; - // Only the labels change, so the buffered history stays valid — unlike a - // units change, which `setKind` has to clear for. - applyXAxisTitle(); - for (const panel of Object.values(panels)) panel.chart.update("none"); - return timeAxis; - } - - /** Which x axis is in use. */ - function getTimeAxis() { - return timeAxis; - } - - /** - * Choose raw or calibrated traces. Falls back per name to whatever the - * device actually offers. - * - * @param {"raw"|"cal"} kind - */ - function setKind(kind) { - const next = kind === "raw" ? "raw" : "cal"; - if (next === preferredKind) return; - preferredKind = next; - // The buffered history is in the old units, so it cannot be re-labelled — - // start the window again rather than splicing two scales into one trace. - // The stream itself has not restarted, so the elapsed axis keeps its zero. - clear({ keepOrigin: true }); - rebuildRoutes(); - } - - /** Stop redrawing. Frames still buffer, so resume() shows the interval. */ - function pause() { - paused = true; - if (rafId) { - cancelAnimationFrame(rafId); - rafId = 0; - } - } - - /** Resume redrawing, and restore the requested frame rate. */ - function resume() { - paused = false; - maxFps = maxFpsRequested; - if (dirty) schedule(); - } - - /** - * Drop all buffered samples and empty the panels. - * - * @param {{keepOrigin?: boolean}} [opts] `keepOrigin` leaves the elapsed - * axis's zero where it is. Set it whenever the STREAM has not restarted — - * switching raw/cal clears the buffers because their units changed, and - * re-basing the axis there made a five-minute-old recording restart at - * 0.00 s on screen while the CSV kept counting from the real start. - */ - function clear(opts = {}) { - write = 0; - count = 0; - // A new stream has its own first sample, so the elapsed axis re-bases. - if (!opts.keepOrigin) originSec = null; - times.fill(0); - for (const s of series) { - s.buf.fill(NaN); - s.dataset.data.length = 0; - } - for (const panel of Object.values(panels)) { - delete panel.chart.options.scales.y.min; - delete panel.chart.options.scales.y.max; - /* The x range too. `draw` pins it whenever it has more than one point, - and dropping only the y range left the axis showing the time window of - the samples just discarded — so switching raw/cal, which clears, - painted an empty plot against the old window until enough new samples - arrived to re-pin it. */ - delete panel.chart.options.scales.x.min; - delete panel.chart.options.scales.x.max; - panel.chart.update("none"); - } - dirty = false; - } - - /** Tear down the charts, the DOM and the theme subscription. */ - function destroy() { - if (destroyed) return; - destroyed = true; - if (rafId) cancelAnimationFrame(rafId); - rafId = 0; - detachTheme(); - for (const panel of Object.values(panels)) { - panel.chart.destroy(); - delete panels[panel.id]; - } - series = []; - routeByKey = new Map(); - grid.remove(); - } - - return { - setSchema, - push, - setWindow, - setKind, - setTimeAxis, - getTimeAxis, - pause, - resume, - clear, - destroy, - panels, - }; -} diff --git a/common/rtc-drift-panel.js b/common/rtc-drift-panel.js deleted file mode 100644 index 30cdb10..0000000 --- a/common/rtc-drift-panel.js +++ /dev/null @@ -1,1077 +0,0 @@ -/** - * Real-world-clock drift monitor: read the sensor's clock against this host's - * on a timer, fit the slope in ppm, plot it, and export the series as CSV. - * - * Folded in from the standalone `rtc-drift-test` page, which this module - * replaces. That page owned its own connect buttons, log and dark-only CSS; - * everything that actually measured anything is here, unchanged in substance: - * the two read paths, the round-trip-midpoint host timestamps, the one-sample- - * at-a-time guard, the quarter-hour clock-base detection, the least-squares - * plot, the screen wake lock and the BOM'd CSV. - * - * Why the measurement is worth having: the Shimmer3R real-world clock is - * driven by the 32 kHz LSE crystal, so the crystal's error shows up here - * directly. That is the LSE-only ground truth the firmware self-test cannot - * give on its own — its crystal check measures the LSE against the HSE, and a - * differential of two oscillators cannot say which one is off. First hardware - * runs (2026-08-10, docked): a stock 12 pF Shimmer3R came out at about - * -9 ppm — near spec, unlike the Verisense, whose identical BOM ran +40 to - * +65 ppm fast — while a 22 pF-reworked unit came out at about -98 ppm, i.e. - * over-loaded. Docked units read a few ppm low from charge self-heating, so a - * battery/BLE run at ambient is the spec-comparable figure. A usable estimate - * takes 1-2 hours; longer runs tighten it. - * - * Prefer a wired link when there is one: dock/USB round trips jitter far less - * than BLE, and the midpoint estimate below is only as good as the assumption - * that the two halves of a round trip take the same time. - * - * The panel emits no `.card` of its own — unlike `sd-browser.js`, which is a - * whole tab, this one is a section inside the mounting page's card, so the - * page keeps its heading and its explanatory note. - * - * Nothing here touches `document` at import time. - * - * import { createRtcDriftPanel } from "../common/rtc-drift-panel.js"; - */ - -import { el, downloadBlob, fmtDuration } from "./ui-chrome.js"; -import { onThemeChange } from "./theme.js"; -import { readDeviceRwc, canReadRwc } from "./device-clock.js"; -/* The whole namespace rather than destructured names: a vendored bundle that - predates `RtcDriftMonitor` then degrades to a banner from - `createRtcDriftPanel()` instead of throwing at import time and taking the - whole page with it. */ -import * as sdk from "../vendor/shimmer-web-sdk.esm.js"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** Sampling interval bounds and default, in seconds. */ -const MIN_INTERVAL_S = 2; -const MAX_INTERVAL_S = 600; -const DEFAULT_INTERVAL_S = 30; - -/** The real-world clock counts 32768 Hz ticks, on both read paths. */ -const TICKS_PER_SEC = 32768; - -/** - * Clock-base detection grid. Every civil time zone in use is a whole number of - * quarter-hours from UTC, and so is every time-set convention worth absorbing, - * so a device-minus-host offset that lands within {@link CLOCK_BASE_SLACK_S} - * of a 900 s multiple is a base rather than an error. - */ -const CLOCK_BASE_GRID_S = 900; -const CLOCK_BASE_SLACK_S = 120; - -/** Beyond this the fit is called out as out of spec. */ -const PPM_BAD = 25; - -/** Below this the series is too short for the slope to mean anything. */ -const MIN_FIT_MINUTES = 2; - -/** Logical plot height. Mirrors `.drift-plot` in `common/theme.css`. */ -const PLOT_HEIGHT_PX = 180; -const PLOT_PAD_PX = 26; - -/** Backing-store cap: past 3x the plot costs memory for no visible gain. */ -const MAX_PLOT_DPR = 3; - -// --------------------------------------------------------------------------- -// Small helpers -// --------------------------------------------------------------------------- - -function asGetter(value, fallback = null) { - if (typeof value === "function") return value; - return () => value ?? fallback; -} - -/** `+1.23` / `-0.40` — a leading sign, because these are all differences. */ -function signed(value, digits) { - return `${value >= 0 ? "+" : ""}${value.toFixed(digits)}`; -} - -/** A clock base as `+9:00 h`. */ -function fmtClockBase(sec) { - const sign = sec < 0 ? "-" : "+"; - const abs = Math.abs(sec); - const minutes = String(Math.round((abs % 3600) / 60)).padStart(2, "0"); - return `${sign}${Math.floor(abs / 3600)}:${minutes} h`; -} - -/** Read a CSS custom property off an element, with a fallback. */ -function cssVar(node, name, fallback) { - try { - const value = getComputedStyle(node).getPropertyValue(name).trim(); - return value || fallback; - } catch { - /* A detached node has no computed style; the fallback still draws. */ - return fallback; - } -} - -// --------------------------------------------------------------------------- -// The panel -// --------------------------------------------------------------------------- - -/** - * Mount the clock-drift monitor inside `host`. - * - * @param {HTMLElement} host a container; its contents are replaced - * @param {object} [opts] - * @param {object|(() => object|null)} [opts.client] the connected client, or a - * getter for it. Pass the GETTER form from a page whose client comes and - * goes with the link — the panel is mounted once and then reads whatever is - * current, so it can never hold a stale client. - * @param {string|(() => string|null)} [opts.mode] `"ble"`, `"rfcomm"` or - * `"usb"`; a getter for the same reason. Only `"usb"` changes behaviour: it - * selects the dock read and the dock clock write. - * @param {{log: Function, warn: Function, error: Function}} [opts.log] - * @param {(message: string, kind?: string) => void} [opts.toast] - * @param {() => string|null} [opts.canSample] the mounting page's reason to - * skip this tick — "the sensor is streaming", "a self-test is running" — - * or null to go ahead. Asked once per tick BEFORE the link is touched, - * because a page that refuses a busy link by toasting would otherwise toast - * on every tick of a long run. - * @param {(fn: () => Promise) => Promise} [opts.withLink] - * run one round trip under the page's busy flag. Defaults to calling `fn` - * directly. A page wrapper that tolerates failures by resolving `undefined` - * (`withDevice(..., {tolerate: true})`) is handled: that is a skipped - * sample, not a reading. - * @param {(utcMs: number) => number} [opts.hostToDeviceMillis] convert host - * Unix ms to whatever the sensor's clock counts. Defaults to the identity, - * which is correct for Shimmer3/3R: the firmware's real-world clock is a - * true Unix epoch, as Consensys and the Java dock driver write it. The one - * hook exists so a page whose sensors follow the other convention (civil - * local time) can shift the write in ONE place instead of scattering - * timezone arithmetic through the panel. - * @param {() => void} [opts.onClockWritten] called after a successful Sync, so - * the page can refresh whatever it shows about the sensor's clock. - * @param {string|(() => string|null)} [opts.fileNamePrefix="shimmer"] leading - * part of the exported CSV's name; a getter, so a page can name the file - * after a sensor that was not connected when the panel was mounted. - * @param {string|(() => string|null)} [opts.deviceLabel] what to record as the - * device in the CSV preamble. - * @returns {{ - * start: () => boolean, - * stop: () => boolean, - * sample: () => Promise, - * sync: () => Promise, - * rebaseline: () => void, - * noteClockWritten: () => void, - * running: () => boolean, - * sampling: () => boolean, - * monitor: () => object|null, - * clockBaseSec: () => number|null, - * supports: (client?: object|null, mode?: string|null) => boolean, - * setEnabled: (enabled: boolean) => void, - * destroy: () => void, - * }} - */ -export function createRtcDriftPanel(host, opts = {}) { - const getClient = asGetter(opts.client); - const getMode = asGetter(opts.mode); - const log = opts.log ?? { log() {}, warn() {}, error() {} }; - const toast = opts.toast ?? (() => {}); - const canSample = opts.canSample ?? (() => null); - const withLink = opts.withLink ?? ((fn) => fn()); - const hostToDeviceMillis = opts.hostToDeviceMillis ?? ((utcMs) => utcMs); - const onClockWritten = opts.onClockWritten ?? (() => {}); - const getFilePrefix = asGetter(opts.fileNamePrefix, "shimmer"); - const getDeviceLabel = asGetter(opts.deviceLabel); - - /* A vendored bundle from before the drift monitor shipped: say so here, - once, rather than throwing from the first Start. Only the monitor is - required — the dock time property below is optional, and its absence - costs the wired read path, not the panel. */ - if (typeof sdk.RtcDriftMonitor !== "function") { - host.replaceChildren( - el( - "div", - { class: "banner err" }, - "This page is running an SDK bundle with no clock-drift support (missing RtcDriftMonitor). Re-vendor the SDK to measure the sensor's clock against this host.", - ), - ); - log.error( - "clock drift unavailable: the vendored SDK has no RtcDriftMonitor", - ); - return inertDriftPanel(); - } - - const monitor = new sdk.RtcDriftMonitor(); - - /** Non-null only while sampling on a timer. */ - let timer = null; - /** One read is in flight. */ - let sampleInFlight = false; - /** The screen wake lock, while it is held. */ - let wakeLock = null; - /** The visibilitychange listener, while sampling. */ - let onVisibility = null; - /** - * Whole-quarter-hour base between the sensor's clock and this host's, - * detected from the first sample of a series; null until then, 0 once - * detection has run and found none. - */ - let clockBase = null; - /** The raw device-minus-host offset detection ran on, for the base line. */ - let clockBaseRawSec = null; - /** The last skip reason logged, so a recurring one is not logged per tick. */ - let lastSkipReason = null; - /** The client the base was detected against. */ - let lastClient = null; - /** The host page's floor: is this link able to read a clock at all? */ - let enabled = false; - /** CSS width the plot was last sized to, so a resize is a real change. */ - let lastPlotWidth = 0; - let destroyed = false; - - // ------------------------------------------------------------------------- - // Markup - // ------------------------------------------------------------------------- - - /* `data-drift-role` and `data-drift-stat` on everything the panel owns. Not - decoration: it is how a mounting application (or a test) addresses one of - these without the panel having to plant ids that would collide if it were - mounted twice on one page. */ - - const intervalInput = el("input", { - type: "number", - min: String(MIN_INTERVAL_S), - max: String(MAX_INTERVAL_S), - value: String(DEFAULT_INTERVAL_S), - dataset: { driftRole: "interval" }, - "aria-label": "Sampling interval in seconds", - }); - - const btnStart = el( - "button", - { type: "button", class: "primary", dataset: { driftRole: "start" } }, - "Start", - ); - const btnStop = el( - "button", - { type: "button", dataset: { driftRole: "stop" } }, - "Stop", - ); - const btnSample = el( - "button", - { type: "button", dataset: { driftRole: "sample" } }, - "Sample now", - ); - const btnSync = el( - "button", - { - type: "button", - dataset: { driftRole: "sync" }, - title: - "Write this host's clock to the sensor and restart the fit from the next sample", - }, - "Sync clock to host", - ); - const btnRebaseline = el( - "button", - { - type: "button", - dataset: { driftRole: "rebaseline" }, - title: "Drop the collected samples and restart the fit", - }, - "Reset baseline", - ); - const btnCsv = el( - "button", - { type: "button", dataset: { driftRole: "csv" } }, - "Save CSV", - ); - const wakePill = el( - "span", - { class: "pill", dataset: { driftRole: "wake" } }, - "wake lock off", - ); - - const statusLine = el( - "div", - { class: "field-hint", dataset: { driftRole: "status" } }, - "Not sampling.", - ); - const baseLine = el("div", { - class: "field-hint", - dataset: { driftRole: "base" }, - }); - - const stat = (key, label) => - el( - "div", - {}, - el("span", { class: "stat-label" }, label), - el("span", { class: "stat-value", dataset: { driftStat: key } }, "–"), - ); - - const statsStrip = el( - "div", - { class: "stats" }, - stat("samples", "Samples"), - stat("elapsed", "Elapsed"), - stat("ppm", "Drift fit"), - stat("perDay", "Per day"), - stat("offset", "Offset (sensor − host)"), - stat("rtt", "Last round trip"), - stat("deviceSteps", "Sensor clock steps"), - stat("hostSteps", "Host clock steps"), - ); - - const statNode = (key) => - statsStrip.querySelector(`[data-drift-stat="${key}"]`); - const setStat = (key, text) => { - const node = statNode(key); - if (node) node.textContent = text; - }; - - const canvas = el("canvas", { - class: "drift-plot", - dataset: { driftRole: "plot" }, - "aria-label": "Clock offset drift since the first sample", - }); - /* The canvas sits in a wrapper and is measured through it. A canvas with no - stylesheet rule takes its layout size from the width/height attributes, - so measuring the canvas itself would make every draw multiply its own - width by devicePixelRatio. The wrapper is a plain block box, so its width - is the container's and cannot run away. */ - const plotBox = el("div", {}, canvas); - - host.replaceChildren( - el( - "div", - { class: "row" }, - el("label", { class: "muted" }, "Sample every ", intervalInput, " s"), - btnStart, - btnStop, - btnSample, - btnSync, - btnRebaseline, - btnCsv, - wakePill, - ), - statusLine, - baseLine, - statsStrip, - plotBox, - el( - "div", - { class: "field-hint" }, - "Offset drift since the first sample (dots) with its least-squares fit (line).", - ), - ); - - intervalInput.addEventListener("change", () => { - intervalInput.value = String(intervalSeconds()); - }); - btnStart.addEventListener("click", () => start()); - btnStop.addEventListener("click", () => { - if (stop()) log.log("clock drift: sampling stopped"); - }); - btnSample.addEventListener("click", () => { - sample(); - }); - btnSync.addEventListener("click", () => { - sync(); - }); - btnRebaseline.addEventListener("click", () => rebaseline()); - btnCsv.addEventListener("click", () => saveCsv()); - - // ------------------------------------------------------------------------- - // Capability - // ------------------------------------------------------------------------- - - /** - * Can this client, on this link, have its clock read at all? - * - * `canReadRwc` is the same predicate `readDeviceRwc` refuses on, from the - * same module — so a panel that says yes here never fails at the first - * sample with "undefined is not a function". They used to be two copies of - * the rule side by side, which is survivable; the page's third copy was not - * (see `common/device-clock.js`). - * - * No mode: whether the clock can be read is a property of the client, and - * which of the two reads is used is the mode's business, inside the reader. - * - * @param {object|null} [client] - * @returns {boolean} - */ - function supports(client = getClient()) { - return canReadRwc(client); - } - - /** Can the sensor's clock be WRITTEN over this link? Sync needs this. */ - function supportsWrite(client = getClient(), mode = getMode()) { - if (!client) return false; - const wired = mode === "usb" || typeof client.setRtcTime !== "function"; - if (!wired) return true; - return typeof client.writeRtcFromHostTime === "function"; - } - - // ------------------------------------------------------------------------- - // Reading the sensor's clock - // ------------------------------------------------------------------------- - - /** - * One clock reading, in seconds, RAW — no epoch assumed. - * - * Different tools set the real-world clock from different bases: the Java - * dock driver and Consensys write UTC ms x 32.768, while the Verisense - * console writes local civil time. The clock-base detection below absorbs - * whichever base the sensor in front of us happens to carry, so the offset - * readout always shows the true clock error — but only if this function - * hands it the reading unmodified. - */ - async function readDeviceSeconds() { - /* Which of the two reads works is a property of the LINK, and that - knowledge now lives in `common/device-clock.js` — this panel had it - first and was the only place that did, which is how the page's own - clock readouts came to give up over USB on a link that can in fact - answer. Ticks rather than `unixMs` so the seconds keep their full - resolution through the division. */ - const { ticks } = await readDeviceRwc(getClient(), getMode()); - return Number(ticks) / TICKS_PER_SEC; - } - - /** - * A sensor swap (or a reconnect) may bring a different clock base — a unit - * set by a tool that writes civil local time, say. Re-detect it from the - * next sample rather than carrying the previous sensor's base over. - */ - function noteClientChange() { - const client = getClient() ?? null; - if (client === lastClient) return; - lastClient = client; - clockBase = null; - clockBaseRawSec = null; - paintBase(); - } - - /** The page's reason to skip, then the panel's own floor. */ - function skipReason() { - const pageReason = canSample(); - if (pageReason) return pageReason; - if (!supports()) - return "this link cannot read the sensor's real-world clock"; - return null; - } - - function noteSkipped(reason) { - setStatus(`Sample skipped — ${reason}`); - /* Once per distinct reason. A 30 s tick against a link that is busy for - an hour would otherwise write 120 identical lines into a shared log. */ - if (reason !== lastSkipReason) { - lastSkipReason = reason; - log.log(`clock drift: sample skipped — ${reason}`); - } - } - - /** - * Take one reading and fold it into the fit. - * - * @returns {Promise} true when a sample was actually recorded - */ - async function sample() { - /* One sample at a time: a read slower than the interval (BLE latency, - retries) must not overlap the next tick and interleave monitor - updates. */ - if (sampleInFlight || destroyed) return false; - noteClientChange(); - const reason = skipReason(); - if (reason) { - noteSkipped(reason); - return false; - } - sampleInFlight = true; - try { - return await sampleInner(); - } finally { - sampleInFlight = false; - paintControls(); - } - } - - async function sampleInner() { - let rttMs = 0; - let hostEndMs = 0; - let perfMs = 0; - let devSec; - try { - devSec = await withLink(async () => { - /* Timestamped INSIDE the link wrapper, around the round trip itself: - a page's withLink also flips its busy flag and re-gates its - controls, and folding that DOM work into the measurement would bias - the midpoint by however long the re-gate took. */ - const p0 = performance.now(); - const seconds = await readDeviceSeconds(); - const p1 = performance.now(); - rttMs = p1 - p0; - hostEndMs = Date.now(); - perfMs = p0 + rttMs / 2; - return seconds; - }); - } catch (err) { - const message = err?.message ?? String(err); - setStatus(`Sample failed — ${message}`); - log.warn(`clock drift: sample failed: ${message}`); - return false; - } - if (!Number.isFinite(devSec)) { - /* A page's withLink can refuse or tolerate a round trip and resolve - with undefined rather than throwing. That is a skipped sample, not a - reading of zero. */ - noteSkipped("the link was busy with another command"); - return false; - } - - /* Host wall time at the MIDPOINT of the round trip. The reading was taken - somewhere inside the round trip, so pairing it with either end would - bias every sample by up to a whole round trip; the midpoint bounds the - error to +/- rtt/2 and, being symmetric, averages out of the slope. */ - const hostSec = (hostEndMs - rttMs / 2) / 1000; - - if (clockBase === null) { - const raw = devSec - hostSec; - const base = Math.round(raw / CLOCK_BASE_GRID_S) * CLOCK_BASE_GRID_S; - /* Only treat the quarter-hour multiple as a timezone / time-set - convention when the residual is small: a genuinely wrong clock (more - than ~2 min off the grid) must SHOW as wrong, not be folded into the - base — otherwise a 10-minute error would display as its rounding - remainder and read as a healthy sensor. */ - clockBaseRawSec = raw; - if (base !== 0 && Math.abs(raw - base) <= CLOCK_BASE_SLACK_S) { - clockBase = base; - log.log( - `clock drift: clock base ${fmtClockBase(base)} detected (timezone or time-set convention) — offsets are shown relative to it`, - ); - } else { - clockBase = 0; - if (Math.abs(raw) > CLOCK_BASE_SLACK_S) { - log.warn( - `clock drift: the sensor's clock is ${signed(raw, 0)} s off this host's, too far off the quarter-hour grid to be a timezone — consider "Sync clock to host" before a long run (the drift fit itself is unaffected)`, - ); - } - } - paintBase(); - } - devSec -= clockBase; - - const event = monitor.addSample({ - hostSec, - devSec, - rttMs: Math.round(rttMs), - perfMs, - }); - if (event.kind === "host-step") { - log.warn( - `clock drift: THIS HOST's clock stepped ${event.hostStepSec.toFixed(2)} s (NTP?) — the fit was rebaselined and the series restarts from this sample`, - ); - } else if (event.kind === "device-step") { - log.warn( - `clock drift: the SENSOR's clock stepped ${event.deltaSec.toFixed(2)} s — something wrote it mid-run`, - ); - } - - lastSkipReason = null; - setStatus( - timer - ? `Sampling every ${intervalSeconds()} s — ${monitor.samples.length} sample${monitor.samples.length === 1 ? "" : "s"} in this series.` - : `Sampled — ${monitor.samples.length} sample${monitor.samples.length === 1 ? "" : "s"} in this series.`, - ); - render(); - return true; - } - - // ------------------------------------------------------------------------- - // Sampling control - // ------------------------------------------------------------------------- - - function intervalSeconds() { - const raw = Number(intervalInput.value); - const seconds = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_INTERVAL_S; - return Math.min( - MAX_INTERVAL_S, - Math.max(MIN_INTERVAL_S, Math.round(seconds)), - ); - } - - /** - * Start sampling on a timer, with one immediate sample so the run is not a - * blank plot for the first interval. - * - * @returns {boolean} true when this call started it - */ - function start() { - if (timer || destroyed) return false; - const seconds = intervalSeconds(); - intervalInput.value = String(seconds); - timer = setInterval(() => { - sample(); - }, seconds * 1000); - subscribeVisibility(); - acquireWakeLock(); - log.log( - `clock drift: sampling the sensor's clock every ${seconds} s — leave it running (1-2 h for a first estimate, longer is tighter)`, - ); - setStatus(`Sampling every ${seconds} s.`); - paintControls(); - sample(); - return true; - } - - /** - * Stop the timer and release the wake lock. Idempotent, and deliberately - * NOT gated on {@link setEnabled}: whatever made the page disable the panel, - * the user must still be able to stop a run. - * - * @returns {boolean} true when this call stopped a run - */ - function stop() { - const wasRunning = !!timer; - if (timer) { - clearInterval(timer); - timer = null; - } - unsubscribeVisibility(); - releaseWakeLock(); - if (wasRunning) setStatus("Not sampling."); - paintControls(); - return wasRunning; - } - - // ------------------------------------------------------------------------- - // Wake lock - // ------------------------------------------------------------------------- - - async function acquireWakeLock() { - try { - if (!("wakeLock" in navigator)) return; - const lock = await navigator.wakeLock.request("screen"); - if (destroyed || !timer) { - /* The run ended while the request was in flight. */ - lock.release().catch(() => {}); - return; - } - wakeLock = lock; - paintWake(); - lock.addEventListener("release", () => { - /* Clear the handle: the UA releases the lock whenever the tab is - backgrounded, and re-acquisition below is gated on !wakeLock. */ - wakeLock = null; - paintWake(); - }); - } catch { - /* Not fatal. A run without a wake lock still measures — the host may - just sleep and leave a gap in the series, which the fit survives - because it uses real host timestamps rather than a tick count. */ - } - } - - function releaseWakeLock() { - wakeLock?.release().catch(() => {}); - wakeLock = null; - paintWake(); - } - - /* Subscribed only while sampling, so a mounted-but-idle panel is not a - permanent document listener on a page that mounts several of these. */ - function subscribeVisibility() { - if (onVisibility) return; - onVisibility = () => { - if (document.visibilityState === "visible" && timer && !wakeLock) - acquireWakeLock(); - }; - document.addEventListener("visibilitychange", onVisibility); - } - - function unsubscribeVisibility() { - if (!onVisibility) return; - document.removeEventListener("visibilitychange", onVisibility); - onVisibility = null; - } - - // ------------------------------------------------------------------------- - // Writing the sensor's clock - // ------------------------------------------------------------------------- - - /** - * Write this host's clock to the sensor and restart the fit. - * - * @returns {Promise} true when the write succeeded - */ - async function sync() { - const client = getClient(); - if (!client) { - toast("Connect a sensor first.", "warn"); - return false; - } - if (!supportsWrite(client)) { - toast("This link has no command to set the sensor's clock.", "warn"); - return false; - } - /* The convention lives in this ONE hook. By default the value written is - plain Unix ms, which is what the Shimmer3/3R firmware keeps: a true - epoch, the same thing Consensys and the Java dock driver write. */ - const deviceMs = hostToDeviceMillis(Date.now()); - try { - await withLink(async () => { - if (getMode() === "usb" || typeof client.setRtcTime !== "function") { - /* Wired: the clock is SET by writing RTC_CFG_TIME. CURR_LOCAL_TIME, - the property the read path uses, is read-only and answers a write - with BAD_CMD. */ - await client.writeRtcFromHostTime(deviceMs); - } else { - await client.setRtcTime(deviceMs); - } - }); - } catch (err) { - const message = err?.message ?? String(err); - log.error(`clock drift: setting the sensor's clock failed: ${message}`); - toast(`Setting the sensor's clock failed: ${message}`, "err"); - return false; - } - resetSeries( - "clock drift: the sensor's clock was set from this host — the fit was rebaselined and the clock base will be re-detected", - ); - onClockWritten(); - return true; - } - - /** - * Drop the samples and re-detect the clock base, without writing anything. - * The Reset baseline button, and what {@link noteClockWritten} does. - */ - function resetSeries(message) { - monitor.rebaseline(); - /* A time write moves the epoch under the series, so the previously - detected base belongs to the old epoch. Null, not 0: the next sample - re-runs detection. */ - clockBase = null; - clockBaseRawSec = null; - paintBase(); - log.log(message); - setStatus( - timer - ? `Sampling every ${intervalSeconds()} s — the fit restarts from the next sample.` - : "Baseline reset — the fit restarts from the next sample.", - ); - render(); - } - - function rebaseline() { - resetSeries("clock drift: baseline reset — the fit restarts from scratch"); - } - - /** - * The mounting page wrote the sensor's clock with its own control: same - * bookkeeping as Sync, without the write. Without this the fit would - * straddle the discontinuity and report a slope that is really one step. - */ - function noteClockWritten() { - resetSeries( - "clock drift: the sensor's clock was written — the fit was rebaselined and the clock base will be re-detected", - ); - } - - // ------------------------------------------------------------------------- - // Export - // ------------------------------------------------------------------------- - - function saveCsv() { - if (!monitor.samples.length) { - toast("No samples to save yet.", "warn"); - return false; - } - const ppm = monitor.ppmFit(); - const rows = monitor.toCsvRows({ - device: getDeviceLabel() ?? "unknown", - link: getMode() ?? "unknown", - exported: new Date().toISOString(), - ppm_fit: ppm === null ? "n/a" : ppm.toFixed(2), - device_steps: monitor.deviceSteps, - host_steps: monitor.hostSteps, - clock_base_s: clockBase ?? 0, - interval_s: intervalSeconds(), - }); - const prefix = String(getFilePrefix() ?? "shimmer").replace( - /[^\w.-]+/g, - "_", - ); - const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); - /* UTF-8 BOM as an explicit escape, never a literal character, so a - formatter or an editor cannot silently strip it: without it Excel opens - the file as ANSI and the preamble's non-ASCII characters render as - mojibake. */ - downloadBlob( - `${prefix}_rtc-drift_${stamp}.csv`, - new Blob(["\ufeff", rows.join("\n")], { - type: "text/csv;charset=utf-8", - }), - ); - log.log(`clock drift: saved ${monitor.samples.length} samples as CSV`); - return true; - } - - // ------------------------------------------------------------------------- - // Rendering - // ------------------------------------------------------------------------- - - function setStatus(text) { - statusLine.textContent = text; - } - - function paintWake() { - wakePill.textContent = wakeLock ? "wake lock on" : "wake lock off"; - wakePill.classList.toggle("on", !!wakeLock); - } - - function paintBase() { - if (clockBase === null) { - baseLine.textContent = - "Clock base: not yet detected — it is read from the first sample of a run."; - return; - } - if (clockBase !== 0) { - baseLine.textContent = `Clock base: ${fmtClockBase(clockBase)} (local civil time, or another time-set convention) — offsets are shown relative to it.`; - return; - } - if ( - clockBaseRawSec !== null && - Math.abs(clockBaseRawSec) > CLOCK_BASE_SLACK_S - ) { - baseLine.textContent = `Clock base: none — the sensor's clock is ${signed(clockBaseRawSec, 0)} s off this host's, too far off the quarter-hour grid to be a timezone. Sync it before a long run; the drift fit itself is unaffected.`; - return; - } - /* "None" means no whole-quarter-hour base was detected — NOT that the two - clocks agree. Anything inside the slack window lands here, so the sensor - can still be a minute or so out, and the offset readout is the thing - that says by how much. Claiming they are in step would contradict the - stat beside it. */ - baseLine.textContent = - "Clock base: none — no timezone-shaped offset, so the sensor's clock is compared with this host's directly. See the offset for how far apart they are."; - } - - function paintControls() { - const readable = enabled && !destroyed && supports(); - intervalInput.disabled = !!timer; - btnStart.disabled = !readable || !!timer; - /* Stop and Save CSV are never gated by the page's floor: whatever made it - withdraw the link, stopping a run and keeping the data collected so far - must stay possible. */ - btnStop.disabled = !timer; - btnSample.disabled = !readable; - btnSync.disabled = !enabled || destroyed || !supportsWrite(); - btnRebaseline.disabled = monitor.samples.length === 0; - btnCsv.disabled = monitor.samples.length === 0; - } - - function render() { - const samples = monitor.samples; - setStat("samples", String(samples.length)); - setStat( - "elapsed", - samples.length ? fmtDuration(monitor.elapsedMinutes() * 60000) : "–", - ); - setStat("deviceSteps", String(monitor.deviceSteps)); - setStat("hostSteps", String(monitor.hostSteps)); - - if (samples.length) { - const last = samples[samples.length - 1]; - setStat("offset", `${signed(last.offsetSec, 3)} s`); - setStat("rtt", `${Math.round(last.rttMs)} ms`); - } else { - setStat("offset", "–"); - setStat("rtt", "–"); - } - - const ppm = monitor.ppmFit(); - const ppmNode = statNode("ppm"); - /* Under a couple of minutes the slope is mostly round-trip noise, so the - number is withheld rather than shown and disbelieved. */ - if (ppm === null || monitor.elapsedMinutes() < MIN_FIT_MINUTES) { - setStat("ppm", samples.length >= 2 ? "collecting…" : "–"); - setStat("perDay", "–"); - ppmNode?.classList.remove("bad"); - } else { - setStat("ppm", `${signed(ppm, 1)} ppm`); - setStat("perDay", `${signed((ppm * 86400) / 1e6, 2)} s/day`); - ppmNode?.classList.toggle("bad", Math.abs(ppm) > PPM_BAD); - } - - drawPlot(); - paintControls(); - } - - /** - * Offset drift since the first sample, with the least-squares fit. - * - * Colours are read from CSS custom properties AT DRAW TIME rather than - * baked in: the page this now lives on has a light theme as well as a dark - * one, and the standalone page's hard-coded hex was invisible on white. - */ - function drawPlot() { - const ctx = canvas.getContext?.("2d"); - if (!ctx) return; - - const cssWidth = Math.round(plotBox.clientWidth); - /* Zero while the panel sits in a display:none tab, which is where it is - mounted. The ResizeObserver below brings the first real width. */ - if (cssWidth <= 0) return; - const dpr = Math.min(MAX_PLOT_DPR, window.devicePixelRatio || 1); - const backingWidth = Math.round(cssWidth * dpr); - const backingHeight = Math.round(PLOT_HEIGHT_PX * dpr); - if (canvas.width !== backingWidth) canvas.width = backingWidth; - if (canvas.height !== backingHeight) canvas.height = backingHeight; - lastPlotWidth = cssWidth; - - /* Draw in CSS pixels and let the transform handle the backing store, so - the line widths and the 11 px labels are the same size on every - display and merely sharper on a dense one. */ - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - const W = cssWidth; - const H = PLOT_HEIGHT_PX; - ctx.clearRect(0, 0, W, H); - ctx.fillStyle = cssVar(canvas, "--surface-2", "#fafbfc"); - ctx.fillRect(0, 0, W, H); - - const samples = monitor.samples; - if (samples.length < 2) return; - - const t0 = samples[0].hostSec; - const y0 = samples[0].offsetSec; - const xs = samples.map((p) => p.hostSec - t0); - const ys = samples.map((p) => p.offsetSec - y0); - const xMax = Math.max(xs[xs.length - 1], 1); - let yMin = Math.min(...ys); - let yMax = Math.max(...ys); - /* A series that has not moved yet would divide by zero; give it a 10 ms - window so the dots sit on a line rather than on top of each other. */ - if (yMax - yMin < 0.01) { - yMax += 0.005; - yMin -= 0.005; - } - const X = (x) => PLOT_PAD_PX + (x / xMax) * (W - 2 * PLOT_PAD_PX); - const Y = (y) => - H - PLOT_PAD_PX - ((y - yMin) / (yMax - yMin)) * (H - 2 * PLOT_PAD_PX); - - // Zero line and axis labels. - ctx.strokeStyle = cssVar(canvas, "--line-strong", "#d3dce0"); - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(PLOT_PAD_PX, Y(0)); - ctx.lineTo(W - PLOT_PAD_PX, Y(0)); - ctx.stroke(); - ctx.fillStyle = cssVar(canvas, "--muted", "#64747e"); - ctx.font = "11px system-ui, sans-serif"; - ctx.fillText(`${(yMax * 1000).toFixed(0)} ms`, 4, Y(yMax) + 4); - ctx.fillText(`${(yMin * 1000).toFixed(0)} ms`, 4, Y(yMin) + 4); - const spanLabel = fmtDuration(xMax * 1000); - ctx.fillText( - spanLabel, - Math.max(PLOT_PAD_PX, W - PLOT_PAD_PX - ctx.measureText(spanLabel).width), - H - 8, - ); - - // Fit line. - const ppm = monitor.ppmFit(); - if (ppm !== null) { - ctx.strokeStyle = cssVar(canvas, "--accent", "#fe5000"); - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(X(0), Y(0)); - ctx.lineTo(X(xMax), Y((ppm / 1e6) * xMax)); - ctx.stroke(); - } - - // Samples. - ctx.fillStyle = cssVar(canvas, "--accent-2", "#17506e"); - for (let i = 0; i < xs.length; i++) { - ctx.beginPath(); - ctx.arc(X(xs[i]), Y(ys[i]), 2.2, 0, Math.PI * 2); - ctx.fill(); - } - } - - /* Width only. This callback resizes the canvas, and the canvas drives the - wrapper's height, so redrawing on a height change would observe its own - write and loop. */ - let resizeObserver = null; - if (typeof ResizeObserver === "function") { - resizeObserver = new ResizeObserver((entries) => { - const width = Math.round( - entries[0]?.contentRect?.width ?? plotBox.clientWidth, - ); - if (width <= 0 || width === lastPlotWidth) return; - drawPlot(); - }); - resizeObserver.observe(plotBox); - } - - const detachTheme = onThemeChange(() => drawPlot()); - - // ------------------------------------------------------------------------- - // Initial paint - // ------------------------------------------------------------------------- - - paintBase(); - paintWake(); - render(); - - return { - start, - stop, - sample, - sync, - rebaseline, - noteClockWritten, - running: () => !!timer, - sampling: () => sampleInFlight, - monitor: () => monitor, - clockBaseSec: () => clockBase, - supports, - setEnabled(next) { - enabled = !!next; - noteClientChange(); - /* Deliberately does NOT stop a running monitor. A page withdraws the - link for all sorts of transient reasons — a self-test, an SD - download — and each tick then skips with that page's own reason and - the series simply has a gap. Throwing away an hour of samples - because the user started a download would be the worse answer. */ - paintControls(); - }, - destroy() { - destroyed = true; - stop(); - resizeObserver?.disconnect(); - detachTheme(); - host.replaceChildren(); - }, - }; -} - -/** - * The same surface, doing nothing, for a bundle with no drift monitor. - * - * Returned rather than throwing so a page can mount the panel unconditionally - * and get a banner where the monitor would have been, instead of losing every - * tab that happens to be built after this one. - */ -function inertDriftPanel() { - return { - start: () => false, - stop: () => false, - sample: async () => false, - sync: async () => false, - rebaseline() {}, - noteClockWritten() {}, - running: () => false, - sampling: () => false, - monitor: () => null, - clockBaseSec: () => null, - supports: () => false, - setEnabled() {}, - destroy() {}, - }; -} diff --git a/common/sd-browser.js b/common/sd-browser.js deleted file mode 100644 index 774329e..0000000 --- a/common/sd-browser.js +++ /dev/null @@ -1,1143 +0,0 @@ -/** - * SD-card browser and downloader: list a Shimmer3R's card, choose a folder on - * this host, and pull sessions off the card with progress, a rolling - * throughput readout, an ETA and a resumable abort. - * - * The one thing in here that is NOT about the card is `measureLinkSpeed`: it - * measures the Bluetooth link, reads the same whether or not a card is - * fitted, and the dock protocol cannot run it at all. The method stays here - * because its result belongs in the card's stats strip and its ETA guidance, - * but the BUTTON lives with the connect buttons on the mounting page. - * - * Extracted from the retired `sd-download` demo: the card readouts and the - * link-speed test (L182-211, L433-463), the selectable tree (L655-715), the - * destination folder remembered in IndexedDB (L304-369, L717-740), the layout - * choice and its path preview (L401-424), and the download run with its - * rolling-rate/ETA maths and delete-after-verified guard (L742-867). The ~350 - * lines of connect, platform-advice and event-log plumbing that demo also - * carried are deliberately NOT here — `common/connect-ui.js` and - * `common/ui-chrome.js` own those now. - * - * The panel builds its own markup inside the host element and owns the - * `disabled` state of every control in it, so a page mounts it with one - * `
` and one call. It holds no page-specific ids and reads no page - * globals: the only things it knows about the outside world are the client it - * is handed, the log it writes to, and the two callbacks below. That is what - * lets a combined Verisense + Shimmer3 application mount it unchanged. - * - * Nothing here touches `document` at import time. - * - * import { createSdBrowser } from "../common/sd-browser.js"; - */ - -import { el, fmtBytes as defaultFmtBytes } from "./ui-chrome.js"; -/* The whole namespace rather than destructured names: a vendored bundle that - predates one of the SD-transfer exports then degrades to a message from - `createSdBrowser()` instead of breaking the importing page. Destructuring - would throw at import time and take the whole page with it. */ -import * as sdk from "../vendor/shimmer-web-sdk.esm.js"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** The only tree the firmware lets a host read or delete under. */ -const CARD_ROOT = "data"; - -/** - * The picked folder handle is persisted so the user chooses once. A browser - * NEVER exposes the absolute path of a directory it handed out, and - * `showDirectoryPicker` cannot be given one, so remembering the handle itself - * is the only way to avoid re-picking on every visit. - */ -const DB_NAME = "shimmer-sd-download"; -const DB_STORE = "handles"; -const DEST_KEY = "destRoot"; - -/** - * Picker id. The browser reopens a picker with the same `id` in the place it - * was last used — the closest thing to a default location there is, given an - * absolute path cannot be supplied. - */ -const PICKER_ID = "shimmer-consensys-backup"; - -/** Rolling-throughput window. Long enough to ride out one stalled window. */ -const RATE_WINDOW_MS = 5000; - -/** Default length of the firmware data-rate test. */ -const LINK_TEST_MS = 5000; - -/** - * Windows is the only platform where the Consensys workspace has a - * predictable location worth suggesting. What keeps mobile out of this panel - * is `showDirectoryPicker`, which is Chromium-desktop only — not the link - * layer: Android Chrome has both Web Bluetooth and (for RFCOMM) Web Serial, - * and on iOS a bundled-stack browser can run a page like this over BLE. - * Either way there is no destination folder to pick. - */ -const CONSENSYS_BACKUP_HINT = - String.raw`Tip: choose %USERPROFILE%\Shimmer_Workspace\Backup so Consensys can ` + - "import this directly (Application Settings → Manage Data → Import Data " + - "From Backup Directory). The folder is remembered for next time."; - -const LAYOUTS = Object.freeze([ - { id: "consensysBackup", label: "Consensys Backup (importable)" }, - { id: "card", label: "Mirror SD card structure" }, -]); - -// --------------------------------------------------------------------------- -// The remembered destination folder -// --------------------------------------------------------------------------- - -function openDb() { - return new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, 1); - req.onupgradeneeded = () => req.result.createObjectStore(DB_STORE); - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); -} - -/** - * Remember a directory handle for the next visit. Best-effort: a browser in - * private mode, or one that refuses to structured-clone a file handle, is a - * reason to re-pick, not a reason to fail the download. - */ -async function rememberDest(handle) { - const db = await openDb(); - try { - await new Promise((res, rej) => { - const tx = db.transaction(DB_STORE, "readwrite"); - tx.objectStore(DB_STORE).put(handle, DEST_KEY); - tx.oncomplete = res; - tx.onerror = () => rej(tx.error); - }); - } finally { - db.close(); - } -} - -async function recallDest() { - try { - const db = await openDb(); - try { - return ( - (await new Promise((res, rej) => { - const tx = db.transaction(DB_STORE, "readonly"); - const r = tx.objectStore(DB_STORE).get(DEST_KEY); - r.onsuccess = () => res(r.result); - r.onerror = () => rej(r.error); - })) ?? null - ); - } finally { - db.close(); - } - } catch { - return null; - } -} - -/** - * Re-grant write access to a remembered handle if the browser has forgotten - * it. MUST be called from inside a user gesture — which is why it runs on the - * download click rather than when the handle is restored on load. - */ -async function ensureWritable(handle) { - const opts = { mode: "readwrite" }; - if (typeof handle.queryPermission !== "function") return true; - if ((await handle.queryPermission(opts)) === "granted") return true; - return (await handle.requestPermission(opts)) === "granted"; -} - -// --------------------------------------------------------------------------- -// Formatters -// --------------------------------------------------------------------------- - -/** - * Seconds as `4s` / `12m 07s`. Kept here rather than in `ui-chrome.js`'s - * `fmtDuration` because an ETA reads better coarse: a download that will take - * `7m 42s` does not become clearer as `7:42.3`. - * - * @param {number} seconds - * @returns {string} - */ -export function fmtEta(seconds) { - if (!Number.isFinite(seconds) || seconds < 0) return "–"; - const m = Math.floor(seconds / 60); - const s = Math.round(seconds % 60); - return m ? `${m}m ${String(s).padStart(2, "0")}s` : `${s}s`; -} - -// --------------------------------------------------------------------------- -// The panel -// --------------------------------------------------------------------------- - -/** - * Mount the SD browser inside `host`. - * - * @param {HTMLElement} host an empty container; its contents are replaced - * @param {object} opts - * @param {object|(() => object|null)} opts.client the connected Shimmer3R - * client, or a getter for it. Pass the GETTER form from a page whose client - * comes and goes with the link — the panel is mounted once and then reads - * whatever is current, so it can never hold a stale client. - * @param {{log: Function, warn: Function, error: Function}} [opts.log] - * @param {(busy: boolean) => void} [opts.onBusyChange] called when a transfer - * or a link test starts and finishes. A host page folds this into its own - * busy state, so the controls that share the link (Apply, a configuration - * read) are refused while a download is in flight. - * @param {(kBps: number|null, final: boolean) => void} [opts.onLinkSpeed] - * the running and then final figure from `measureLinkSpeed`, for a page - * that shows it somewhere of its own; null when the test failed. The panel - * has no button for that test — see the note on `measureLinkSpeed`. - * @param {(n: number) => string} [opts.fmtBytes] byte formatter - * @param {string|null|(() => string|null)} [opts.macId] the connected - * sensor's MAC id, in any hex format. Names the device level of the - * Consensys Backup tree — see {@link sdk.consensysBackupSegments}. A getter, - * so the panel reads whatever the page has NOW rather than whatever it knew - * when it was mounted, which for a MAC read during the connect handshake is - * the difference between the real address and nothing. - * - * Without it a Consensys download still completes, but under a folder named - * after the Shimmer rather than the MAC — a tree Consensys walks straight - * past. So the panel says so, out loud, rather than letting somebody find - * out at import time. - * @param {string} [opts.rootPath="data"] tree to walk on the card - * @returns {{ - * refresh: () => Promise, - * renderTree: () => void, - * selectedPaths: () => string[], - * setLayout: (layout: string) => void, - * pickDestination: () => Promise, - * download: (paths: string[], opts?: {deleteVerified?: boolean}) => Promise, - * abort: () => void, - * measureLinkSpeed: (durationMs?: number) => Promise, - * setEnabled: (enabled: boolean) => void, - * destroy: () => void, - * }} - */ -export function createSdBrowser(host, opts = {}) { - const getClient = - typeof opts.client === "function" ? opts.client : () => opts.client ?? null; - const log = opts.log ?? { log() {}, warn() {}, error() {} }; - const fmtBytes = opts.fmtBytes ?? defaultFmtBytes; - const getMacId = - typeof opts.macId === "function" ? opts.macId : () => opts.macId ?? null; - const rootPath = opts.rootPath ?? CARD_ROOT; - - /** - * The MAC as Consensys names its device folders, or null. - * - * Through the SDK's own normaliser rather than a local regex, so this and - * the download agree byte for byte about what counts as a usable MAC — a - * preview promising `e8eb1b9767a0` while the transfer files under - * `Shimmer_5AA4` would be worse than no preview. - */ - const macFolder = () => { - try { - return sdk.consensysMacFolderName?.(getMacId()) ?? null; - } catch { - /* A vendored bundle from before the helper existed. */ - return null; - } - }; - - /* A vendored bundle from before SD-over-Bluetooth shipped: say so here, - once, rather than throwing from the first Refresh. The sibling brand - editor has always done this and the comment at the import above has - always claimed this module did too -- it did not, so a stale bundle - reached `enumerateSdTree` as `undefined is not a function`, at a moment - when the user had just asked to read their card. `SdTransferError` is - included because the error paths below branch on it; without it every - transfer failure would report as an unknown error. */ - const missingSdk = [ - "enumerateSdTree", - "downloadSdTree", - "formatSdImportStamp", - ].filter((name) => typeof sdk[name] !== "function"); - if (typeof sdk.SdTransferError !== "function") - missingSdk.push("SdTransferError"); - if (missingSdk.length) { - host.replaceChildren( - el( - "div", - { class: "banner err" }, - `This page is running an SDK bundle with no SD file-transfer support (missing ${missingSdk.join(", ")}). Re-vendor the SDK to browse the sensor's card or download from it.`, - ), - ); - log.error( - `SD card transfer unavailable: the vendored SDK has no ${missingSdk.join(", ")}`, - ); - return inertSdPanel(); - } - - /** The last card listing, or null. */ - let tree = null; - /** The destination directory handle, or null. */ - let destRoot = null; - /** Non-null only while a download is running. */ - let abortCtl = null; - /** - * The Consensys import folder an aborted or partly-failed run left behind, - * so the next run continues into it. Cleared by a run that completes. - */ - let pendingStamp = null; - /** A transfer or a link test is in flight. */ - let busy = false; - /** The floor the host page sets: is the link able to do this at all? */ - let enabled = false; - let layout = LAYOUTS[0].id; - let destroyed = false; - - // ------------------------------------------------------------------------- - // Markup - // ------------------------------------------------------------------------- - - const stat = (key, label) => - el( - "div", - {}, - el("span", { class: "stat-label" }, label), - el("span", { class: "stat-value", dataset: { sdStat: key } }, "–"), - ); - - const statsStrip = el( - "div", - { class: "stats" }, - stat("free", "Free on card"), - stat("capacity", "Card capacity"), - stat("files", "Files"), - stat("bytes", "Total size"), - stat("link", "Link throughput"), - ); - - const setStat = (key, text) => { - const node = statsStrip.querySelector(`[data-sd-stat="${key}"]`); - if (node) node.textContent = text; - }; - - /* `data-sd-role` on every control the panel owns. Not decoration: it is - how a mounting application (or a test) addresses one of these without - the panel having to plant ids that would collide if it were mounted - twice on one page. */ - const btnRefresh = el( - "button", - { type: "button", dataset: { sdRole: "refresh" } }, - "Refresh card contents", - ); - const treeState = el("span", { - class: "muted", - dataset: { sdRole: "state" }, - }); - const treeHost = el("div", { class: "sd-tree", dataset: { sdRole: "tree" } }); - - const layoutSelect = el( - "select", - { - "aria-label": "Folder layout", - dataset: { sdRole: "layout" }, - onchange: (e) => setLayout(e.target.value), - }, - LAYOUTS.map((l) => el("option", { value: l.id }, l.label)), - ); - const pathHint = el("div", { - class: "banner info", - dataset: { sdRole: "hint" }, - }); - const btnPickDest = el( - "button", - { type: "button", dataset: { sdRole: "pickDest" } }, - "Choose destination folder…", - ); - const destLabel = el( - "span", - { class: "muted", dataset: { sdRole: "dest" } }, - "No folder selected", - ); - const destPreview = el("div", { - class: "row muted", - dataset: { sdRole: "preview" }, - }); - const chkDelete = el("input", { - type: "checkbox", - checked: true, - dataset: { sdRole: "delete" }, - }); - - const btnDownloadSel = el( - "button", - { - type: "button", - class: "primary", - dataset: { sdRole: "downloadSelected" }, - }, - "Download selected", - ); - const btnDownloadAll = el( - "button", - { type: "button", dataset: { sdRole: "downloadAll" } }, - "Download all", - ); - const btnAbort = el( - "button", - { type: "button", class: "danger", dataset: { sdRole: "abort" } }, - "Abort", - ); - const progress = el("progress", { - class: "sd-progress", - dataset: { sdRole: "bar" }, - max: "100", - value: "0", - }); - const progLabel = el( - "div", - { class: "row muted", dataset: { sdRole: "progress" } }, - "Idle", - ); - const fileLabel = el("div", { - class: "row muted", - dataset: { sdRole: "file" }, - }); - - host.replaceChildren( - el( - "div", - { class: "card" }, - el("div", { class: "card-title" }, "Card"), - statsStrip, - el("div", { class: "row" }, btnRefresh, treeState), - treeHost, - ), - el( - "div", - { class: "card" }, - el("div", { class: "card-title" }, "Download"), - /* The select sits INSIDE its label rather than being tied to it by id: - a panel that a page can mount twice must not plant a fixed id. */ - el( - "div", - { class: "row" }, - el("label", { class: "muted" }, "Folder layout ", layoutSelect), - ), - pathHint, - el("div", { class: "row" }, btnPickDest, destLabel), - destPreview, - el( - "div", - { class: "row" }, - el("label", {}, chkDelete, " Delete from card after verified download"), - ), - el("div", { class: "row" }, btnDownloadSel, btnDownloadAll, btnAbort), - el("div", { class: "row" }, progress), - progLabel, - fileLabel, - ), - ); - - // ------------------------------------------------------------------------- - // Control state - // ------------------------------------------------------------------------- - - /** - * One writer for every `disabled` in this panel. - * - * `enabled` is the host page's floor — is this link and this firmware able - * to transfer files at all — and nothing here lifts it. The destination - * controls are the exception: choosing (or being reminded of) a folder is - * host state, not device state, so it stays available while disconnected, - * exactly as saving a configuration image does. - */ - function syncControls() { - if (destroyed) return; - const usable = enabled && !!getClient() && !busy; - const ready = usable && !!destRoot && !!tree; - btnRefresh.disabled = !usable; - btnDownloadSel.disabled = !ready || selectedPaths().length === 0; - btnDownloadAll.disabled = !ready || !tree?.files.length; - btnAbort.disabled = abortCtl === null; - btnPickDest.disabled = busy || !("showDirectoryPicker" in window); - layoutSelect.disabled = busy; - chkDelete.disabled = busy; - for (const box of treeHost.querySelectorAll("input[type=checkbox]")) { - box.disabled = !usable; - } - } - - function setBusy(next) { - if (busy === next) return; - busy = next; - syncControls(); - try { - opts.onBusyChange?.(next); - } catch (err) { - log.warn(`SD busy handler failed: ${err?.message ?? err}`); - } - } - - // ------------------------------------------------------------------------- - // The tree - // ------------------------------------------------------------------------- - - /** - * Group the flat file list by session directory. - * - * The session folder is the selectable unit because that is the unit the - * SDK's `downloadSdTree({rootPath})` transfers, and the unit a researcher - * thinks in: one recording. Files are listed underneath, unselectable, so - * what a tick is about to pull down is visible without a second click. - */ - function renderTree() { - if (destroyed) return; - const bySession = new Map(); - for (const f of tree?.files ?? []) { - const dir = f.path.slice(0, f.path.lastIndexOf("/")); - if (!bySession.has(dir)) bySession.set(dir, { files: [], bytes: 0 }); - const s = bySession.get(dir); - s.files.push(f); - s.bytes += f.size; - } - - if (!bySession.size) { - treeHost.replaceChildren( - el("div", { class: "muted" }, "No data files on card."), - ); - syncControls(); - return; - } - - const list = el( - "ul", - {}, - Array.from(bySession, ([dir, info]) => - el( - "li", - {}, - el( - "label", - {}, - el("input", { - type: "checkbox", - dataset: { sdPath: dir }, - onchange: syncControls, - }), - dir, - ), - el( - "span", - { class: "sd-size" }, - `${info.files.length} file(s), ${fmtBytes(info.bytes)}`, - ), - el( - "ul", - {}, - info.files.map((f) => - el( - "li", - { class: "sd-file" }, - f.path.slice(f.path.lastIndexOf("/") + 1), - el("span", { class: "sd-size" }, fmtBytes(f.size)), - ), - ), - ), - ), - ), - ); - treeHost.replaceChildren(list); - syncControls(); - } - - /** Forget the card: no listing, no readouts, no progress. */ - function clearCard() { - tree = null; - renderTree(); - for (const key of ["free", "capacity", "files", "bytes", "link"]) { - setStat(key, "–"); - } - treeState.textContent = ""; - progLabel.textContent = "Idle"; - fileLabel.textContent = ""; - progress.max = 100; - progress.value = 0; - } - - /** The card paths whose checkbox is ticked. */ - function selectedPaths() { - return Array.from( - treeHost.querySelectorAll("input[type=checkbox]:checked"), - (box) => box.dataset.sdPath, - ); - } - - /** Free space, then the tree. Both tolerate a refusal with a reason. */ - async function refresh() { - const client = getClient(); - if (!client) return; - /* Listing the card is real traffic on the one link everything shares: a - free-space query, then a directory walk that pages until the card is - exhausted. Without holding the busy flag for it the page happily let a - stream start, an Apply run or a name be written on top of a listing - already in flight -- the very contention the flag exists to prevent, and - the reason download and the speed test have always taken it. Re-entry is - refused rather than queued: two listings would interleave their requests - on the link and neither result would be trustworthy. */ - if (busy) { - log.warn( - "The card is already busy; wait for the current operation to finish.", - ); - return; - } - setBusy(true); - try { - await refreshImpl(client); - } finally { - setBusy(false); - } - } - - async function refreshImpl(client) { - if (typeof client.sdGetFreeSpace === "function") { - try { - const space = await client.sdGetFreeSpace(); - setStat("free", fmtBytes(space.freeKB * 1024)); - setStat("capacity", fmtBytes(space.totalKB * 1024)); - } catch (err) { - log.warn(`Free-space query failed: ${err?.message ?? err}`); - } - } - - treeState.textContent = "Listing card…"; - try { - tree = await sdk.enumerateSdTree(client, rootPath); - renderTree(); - setStat("files", String(tree.files.length)); - setStat("bytes", fmtBytes(tree.totalBytes)); - treeState.textContent = ""; - log.log( - `card listed: ${tree.files.length} file(s) under ${rootPath}/, ` + - `${fmtBytes(tree.totalBytes)} in ${tree.dirs.length} folder(s)`, - ); - } catch (err) { - tree = null; - treeHost.replaceChildren(); - /* An SdTransferError carries the firmware's own in-band status byte — - "SD unavailable (docked, USB-C plugged, no card or bad card)" is a - different problem from a link that stopped answering, and the reader - can act on the difference. */ - treeState.textContent = - err instanceof sdk.SdTransferError - ? `Cannot list card: ${err.message}` - : `Listing failed: ${err?.message ?? err}`; - log.error(treeState.textContent); - /* Deliberately NOT `renderTree()`: with no listing it would print "No - data files on card", which is a claim about the card next to a - message saying the card could not be read. */ - } - syncControls(); - } - - // ------------------------------------------------------------------------- - // Destination - // ------------------------------------------------------------------------- - - function setLayout(next) { - layout = LAYOUTS.some((l) => l.id === next) ? next : LAYOUTS[0].id; - if (layoutSelect.value !== layout) layoutSelect.value = layout; - refreshDestPreview(); - } - - /** Show the path the current layout will actually produce. */ - function refreshDestPreview() { - if (!destRoot) { - destPreview.textContent = ""; - return; - } - // The folder a download would go into RIGHT NOW, which after an aborted - // run is the one it left unfinished rather than a fresh one. - const stamp = pendingStamp ?? sdk.formatSdImportStamp(); - /* `classList`, not `className`: the element is created `row muted`, and - assigning the whole class string dropped the `row` — which is what gives - it its spacing under the destination line. */ - const warn = (on) => destPreview.classList.toggle("preview-warn", on); - if (layout !== "consensysBackup") { - destPreview.textContent = `Files will be written to ${destRoot.name}/${rootPath}/…`; - warn(false); - return; - } - /* The device level is the MAC, and the preview shows the REAL one rather - than a placeholder: this line is the only chance anyone gets to notice - that the tree is about to be named after the Shimmer instead — which - looks like a successful download and cannot be imported. */ - const mac = macFolder(); - destPreview.textContent = mac - ? `Files will be written to ${destRoot.name}/${stamp}/${mac}/${rootPath}/…` - : `Files will be written to ${destRoot.name}/${stamp}//${rootPath}/… — ` + - "the sensor's MAC address could not be read, and Consensys expects it " + - "where the name will be, so this tree will not import."; - warn(!mac); - } - - function setDestLabel(remembered) { - destLabel.textContent = destRoot - ? `Saving into: ${destRoot.name}${remembered ? " (remembered)" : ""}` - : "No folder selected"; - refreshDestPreview(); - } - - /** @returns {Promise} true when a folder is now chosen */ - async function pickDestination() { - if (!("showDirectoryPicker" in window)) { - log.error( - "File System Access API unavailable in this browser — a destination folder cannot be chosen here.", - ); - return false; - } - try { - destRoot = await window.showDirectoryPicker({ - mode: "readwrite", - id: PICKER_ID, - startIn: destRoot ?? undefined, - }); - setDestLabel(false); - log.log(`destination folder chosen: ${destRoot.name}`); - try { - await rememberDest(destRoot); - } catch (err) { - log.warn( - `Could not remember the destination folder: ${err?.message ?? err}`, - ); - } - return true; - } catch { - /* the user cancelled the picker */ - return false; - } finally { - syncControls(); - } - } - - // ------------------------------------------------------------------------- - // Download - // ------------------------------------------------------------------------- - - /** - * Rolling throughput over the last ~5 s of progress events. - * - * Instantaneous rate from one window is unreadable and an average over the - * whole run under-reports a link that has since sped up, so this keeps a - * short sample window. Returns null — meaning "show no rate" — until there - * is a sample pair far enough apart to divide by. - */ - function makeRollingRate() { - const samples = []; - return (bytesDone) => { - const now = Date.now(); - samples.push({ t: now, b: bytesDone }); - while (samples.length >= 2 && now - samples[0].t > RATE_WINDOW_MS) { - samples.shift(); - } - if (samples.length < 2) return null; - const dt = (now - samples[0].t) / 1000; - return dt > 0.5 ? (bytesDone - samples[0].b) / 1024 / dt : null; - }; - } - - /** - * Ask before deleting, naming the scope rather than warning generically. - * - * Deleting is the default, so a vague warning is one nobody reads. The - * counts come from the last listing; only files this host has downloaded - * AND verified by size are ever deleted, and that guard lives in the SDK's - * `downloadSdTree` — this dialog only reports what it is about to attempt. - */ - function confirmDelete(rootPaths) { - const doomed = (tree?.files ?? []).filter((f) => - rootPaths.some((r) => f.path === r || f.path.startsWith(r + "/")), - ); - const sessions = new Set( - doomed.map((f) => f.path.slice(0, f.path.lastIndexOf("/"))), - ); - const bytes = doomed.reduce((n, f) => n + f.size, 0); - return window.confirm( - "After each file is downloaded and verified it will be DELETED " + - "from the SD card.\n\nAbout to transfer, then delete:\n" + - ` ${doomed.length} file(s), ${fmtBytes(bytes)}\n` + - ` ${sessions.size} session folder(s)\n\n` + - 'Untick "Delete from card after verified download" to keep the ' + - "data on the card.\n\nContinue?", - ); - } - - /** - * Download `rootPaths` (card paths) into the chosen destination. - * - * @param {string[]} rootPaths - * @param {{deleteVerified?: boolean}} [runOpts] - */ - async function download(rootPaths, runOpts = {}) { - const client = getClient(); - if (!client) { - log.warn("Connect a sensor before downloading from its card."); - return; - } - if (!destRoot) { - log.warn("Choose a destination folder before downloading."); - return; - } - if (!rootPaths.length) { - log.warn("Nothing selected to download."); - return; - } - if (busy) { - log.warn("A card operation is already running."); - return; - } - - const deleteAfterVerify = runOpts.deleteVerified ?? chkDelete.checked; - if (deleteAfterVerify && !confirmDelete(rootPaths)) return; - - /* A remembered handle may need write access re-granted, and the browser - only grants it from inside a user gesture — which is why this sits on - the click path with nothing slow awaited in front of it. */ - if (!(await ensureWritable(destRoot))) { - log.error("Write access to the destination folder was not granted."); - return; - } - - /* One import folder for the whole run, so selecting several sessions - still produces a single Consensys import rather than one per session. - An unfinished run keeps its folder for the NEXT run: the stamp is what - decides the destination path, so minting a fresh one after an abort - would file the rest of the transfer in a second folder beside the - first — resuming nothing and leaving two half-imports for Consensys to - find. `pendingStamp` is cleared only by a run that completes. */ - const importStamp = pendingStamp ?? sdk.formatSdImportStamp(); - const macId = macFolder(); - if (layout === "consensysBackup") { - log.log( - pendingStamp - ? `resuming into the Consensys import folder ${importStamp}/ that the last run left unfinished` - : `writing Consensys import folder ${importStamp}/`, - ); - /* Warned at the start of the run, not only in the preview: a resumed - run reuses a stamp from before the link dropped, and the MAC is read - during the connect handshake — so a re-connect that failed to read it - would otherwise silently change where the rest of the transfer - lands. */ - if (macId) log.log(`filing under the sensor's MAC ${macId}/`); - else - log.warn( - "the sensor's MAC address is not known, so the device folder will " + - "be named after the Shimmer instead. Consensys expects the MAC " + - "there and will not import this tree — reconnect and try again " + - "if an import is what this download is for.", - ); - } - - abortCtl = new AbortController(); - setBusy(true); - syncControls(); - progress.max = 100; - progress.value = 0; - progLabel.textContent = "Listing card…"; - fileLabel.textContent = ""; - - const startedAt = Date.now(); - let downloaded = 0; - let skipped = 0; - let failed = 0; - let bytes = 0; - /* Assume the worst until the run says otherwise, so every path out of the - try — including one added later — keeps the import folder for a - re-run rather than orphaning it. */ - let unfinished = true; - - try { - for (const root of rootPaths) { - log.log(`downloading ${root}…`); - const rollingRate = makeRollingRate(); - const summary = await sdk.downloadSdTree(client, destRoot, { - rootPath: root, - deleteAfterVerify, - layout, - importStamp, - macId, - signal: abortCtl.signal, - onProgress: (p) => { - /* The enumerate event fires before anything is known — every - count in it is zero — and it stands until the first read - window completes, which on a slow link is a whole second. - "enumerate: 0/0 files, 0 B / 0 B" is a worse thing to leave on - screen for that second than the sentence already there. */ - if (p.phase === "enumerate") return; - if (p.bytesTotal > 0) { - progress.max = p.bytesTotal; - progress.value = p.bytesDone; - } - const rate = - p.phase === "download" ? rollingRate(p.bytesDone) : null; - const eta = rate - ? fmtEta((p.bytesTotal - p.bytesDone) / 1024 / rate) - : "–"; - progLabel.textContent = - `${p.phase}: ${p.filesDone}/${p.filesTotal} files, ` + - `${fmtBytes(p.bytesDone)} / ${fmtBytes(p.bytesTotal)}` + - (rate ? ` @ ${rate.toFixed(1)} KB/s, ETA ${eta}` : ""); - fileLabel.textContent = p.currentFile - ? `${p.currentFile} (${fmtBytes(p.fileBytesDone)} / ${fmtBytes(p.fileBytesTotal)})` - : ""; - }, - }); - downloaded += summary.filesDownloaded; - skipped += summary.filesSkipped; - failed += summary.filesFailed.length; - bytes += summary.bytesDownloaded; - for (const f of summary.filesFailed) { - log.error(`FAILED ${f.path}: ${f.error}`); - } - for (const d of summary.deletedFromCard) { - log.log(`deleted from card: ${d}`); - } - } - const elapsedS = (Date.now() - startedAt) / 1000; - const avg = - bytes > 0 && elapsedS > 0 - ? ` (avg ${(bytes / 1024 / elapsedS).toFixed(1)} KB/s)` - : ""; - log.log( - `done in ${elapsedS.toFixed(1)}s — ${downloaded} downloaded, ` + - `${skipped} already up to date, ${failed} failed, ` + - `${fmtBytes(bytes)} transferred${avg}.`, - ); - progLabel.textContent = "Done"; - fileLabel.textContent = ""; - // Nothing left over, so the next run starts its own import folder. - unfinished = failed > 0; - } catch (err) { - /* An abort is not a failure: the firmware is stateless per read window, - so the next run resumes from the size already on disk — into this - same import folder, which is what `pendingStamp` preserves. */ - if (err?.name === "AbortError") { - log.log("download aborted — re-run to resume from where it stopped."); - progLabel.textContent = "Aborted (resumable)"; - } else { - log.error(`Download failed: ${err?.message ?? err}`); - progLabel.textContent = `Failed: ${err?.message ?? err}`; - } - } finally { - pendingStamp = unfinished ? importStamp : null; - abortCtl = null; - setBusy(false); - refreshDestPreview(); - syncControls(); - } - - // The card has changed under us if anything was deleted, and the file - // sizes on it are what the next run resumes against. - await refresh(); - } - - function abort() { - if (!abortCtl) return; - log.warn("aborting the SD transfer…"); - try { - abortCtl.abort(); - } catch { - /* already settled */ - } - } - - // ------------------------------------------------------------------------- - // Link speed - // ------------------------------------------------------------------------- - - /** - * Measure the raw link with the firmware's data-rate test. - * - * This measures the pipe — BLE connection interval and MTU, or RFCOMM - * buffering — not the file-transfer protocol, so it is the honest upper - * bound to quote before a long download, and a direct A/B between BLE and - * Classic Bluetooth on the same host. - * - * NO BUTTON IN THIS PANEL DRIVES THIS. What it measures is the link, not - * the card: the number is the same whether or not a card is fitted, and it - * is a Bluetooth-only test because the dock protocol has no data-rate - * command at all — so the button belongs with the connect buttons, and the - * mounting page owns it. The result still lands in this panel's stats strip - * and in the ETA guidance below, because that is where it gets used. - * - * The panel's own busy flag IS taken for the duration: the test saturates - * the link deliberately, so a download must not start underneath it. What - * the panel cannot know is whether the sensor is streaming, or whether some - * other panel holds the link — the mounting page gates the button on that. - * - * @param {number} [durationMs] - * @returns {Promise<{kBps: number, bytesReceived: number, - * durationMs: number}|null>} null when the test was refused or failed - */ - async function measureLinkSpeed(durationMs = LINK_TEST_MS) { - const client = getClient(); - /* Every path that returns null tells the owner of the button, not just - the one that throws. The mounting page puts the pill into "measuring…" - before calling and has nothing else that clears it, so a refusal that - stayed quiet left the pill running for the rest of the session. Both - refusals are unreachable through the button as it is gated today — - which is exactly why they would not have been noticed. */ - if (typeof client?.runDataRateTest !== "function") { - log.warn("This SDK build has no data-rate test."); - reportLinkSpeed(null, true); - return null; - } - if (busy) { - log.warn("A card operation is already running."); - reportLinkSpeed(null, true); - return null; - } - setBusy(true); - log.log( - `measuring raw throughput (${(durationMs / 1000).toFixed(0)} s, firmware data-rate test)…`, - ); - try { - const res = await client.runDataRateTest(durationMs, (bytes, ms) => { - const kBps = bytes / 1024 / (ms / 1000); - setStat("link", `${kBps.toFixed(1)} KB/s`); - reportLinkSpeed(kBps, false); - }); - setStat("link", `${res.kBps.toFixed(1)} KB/s`); - reportLinkSpeed(res.kBps, true); - log.log( - `raw throughput: ${res.kBps.toFixed(1)} KB/s ` + - `(${fmtBytes(res.bytesReceived)} in ${(res.durationMs / 1000).toFixed(1)}s).`, - ); - if (res.kBps > 0) { - log.log( - "as a guide (file transfer adds ~2% framing overhead): " + - `1 MB ≈ ${fmtEta(1024 / res.kBps)}, ` + - `5 MB ≈ ${fmtEta((5 * 1024) / res.kBps)}, ` + - `20 MB ≈ ${fmtEta((20 * 1024) / res.kBps)}.`, - ); - if (tree && tree.totalBytes > 0) { - log.log( - `everything currently on this card (${fmtBytes(tree.totalBytes)}) ≈ ` + - `${fmtEta(tree.totalBytes / 1024 / res.kBps)}.`, - ); - } - } - return res; - } catch (err) { - log.error(`Throughput test failed: ${err?.message ?? err}`); - reportLinkSpeed(null, true); - return null; - } finally { - setBusy(false); - } - } - - /** Hand the running (and then final) figure to whoever owns the button. */ - function reportLinkSpeed(kBps, final) { - try { - opts.onLinkSpeed?.(kBps, final); - } catch (err) { - log.warn(`SD link-speed handler failed: ${err?.message ?? err}`); - } - } - - // ------------------------------------------------------------------------- - // Wiring - // ------------------------------------------------------------------------- - - btnRefresh.addEventListener("click", () => { - refresh().catch((err) => log.error(`refresh failed: ${err?.message}`)); - }); - /* NOT deferred behind anything: showDirectoryPicker is gesture-gated, so it - has to be reached straight off the click. */ - btnPickDest.addEventListener("click", () => { - pickDestination().catch(() => {}); - }); - btnDownloadSel.addEventListener("click", () => { - download(selectedPaths()).catch(() => {}); - }); - btnDownloadAll.addEventListener("click", () => { - download([rootPath]).catch(() => {}); - }); - btnAbort.addEventListener("click", abort); - - if (!("showDirectoryPicker" in window)) { - pathHint.className = "banner warn"; - pathHint.textContent = - "This browser has no File System Access API, so a destination folder " + - "cannot be chosen — downloading needs a Chromium-based desktop browser."; - } else if ( - /Windows/i.test(navigator.userAgentData?.platform || navigator.userAgent) - ) { - pathHint.textContent = CONSENSYS_BACKUP_HINT; - } - - setLayout(layout); - syncControls(); - - /* Restore the folder chosen on a previous visit. Write access is - re-confirmed on the first download click rather than now, because the - browser only grants a permission from inside a user gesture. */ - void (async () => { - const saved = await recallDest(); - if (destroyed || !saved) return; - destRoot = saved; - setDestLabel(true); - log.log(`destination folder restored: ${saved.name}`); - syncControls(); - })(); - - return { - refresh, - renderTree, - selectedPaths, - setLayout, - pickDestination, - download, - abort, - measureLinkSpeed, - setEnabled(next) { - const was = enabled; - enabled = !!next; - /* On the falling edge only. A link that can no longer transfer has no - card to show, and dropping the listing keeps a stale tree from being - downloaded against the next sensor to connect — but this runs on - every re-gate, so it must not fire while already disabled or it - would wipe the panel continuously. - NOTE for the host page: do NOT fold this panel's own transfer-busy - state back into what you pass here, or the first progress event - clears the very progress it is reporting. The panel already refuses - a second operation itself. */ - if (was && !enabled) clearCard(); - syncControls(); - }, - destroy() { - destroyed = true; - abort(); - host.replaceChildren(); - }, - }; -} - -/** - * The same surface, doing nothing, for a bundle that cannot transfer files. - * - * Returned rather than throwing so a page can mount the panel unconditionally - * and get a banner where the card would have been, instead of losing every - * tab that happens to be built after this one. - */ -function inertSdPanel() { - const no = async () => false; - return { - refresh: async () => {}, - renderTree() {}, - selectedPaths: () => [], - setLayout() {}, - pickDestination: no, - download: no, - abort() {}, - measureLinkSpeed: async () => null, - setEnabled() {}, - destroy() {}, - }; -} diff --git a/common/shimmer3-config-schema.js b/common/shimmer3-config-schema.js deleted file mode 100644 index 3453193..0000000 --- a/common/shimmer3-config-schema.js +++ /dev/null @@ -1,553 +0,0 @@ -/** - * Shimmer3 / Shimmer3R glue between the SDK's declarative InfoMem field - * schema and a page that renders it with `common/config-form.js`. - * - * The generic form knows nothing about Shimmer3: it renders whatever fields - * it is given and edits bytes. Everything that is specific to this device - * family — which capabilities a client actually has, which settings ALSO have - * an immediate-effect Bluetooth setter, the order the firmware needs a config - * write performed in, the ExG mode presets, and the friendly sensor grouping - * — lives here, in one module, so a page is left with layout and wiring. - * - * NO OPTION TABLES LIVE HERE. The value/label pairs (accel ranges, mag rates, - * GSR ranges, baud rates …) are currently inline in the SDK's - * `devices/infomem/schema.ts` behind a `TODO(next PR)` that moves them to - * `devices/shimmer3/sensorOptions.ts`. They are transcribed there from the - * Java `Listof…ConfigValues` pairs with a citation per table. Duplicating any - * of them here would create a second, silently divergent source of truth for - * what a register code means, so this module deliberately holds none and the - * page takes them from the schema it already has. - * - * Nothing here touches `document`, and nothing here imports the SDK: a client - * object is passed in and feature-detected. - * - * import { - * describeShimmer3Caps, LIVE_OVERLAYS, buildApplyPlan, EXG_MODES, - * SENSOR_GROUPS, - * } from "../common/shimmer3-config-schema.js"; - */ - -// --------------------------------------------------------------------------- -// Capabilities -// --------------------------------------------------------------------------- - -/** - * What the connected client can actually do, feature-detected. - * - * Detected from the client object rather than from a firmware version table - * because the SDK is the thing that gained (or has not yet gained) each - * method: a page vendored against an older bundle then degrades to a greyed - * control with a reason, instead of throwing when the user presses it. - * - * The keys are exactly the `data-cap` values `createGate` in `ui-chrome.js` - * consumes, so a page tags a control once in the markup and never gates it - * again in JS: - * - * - * - * ONE KEY HERE IS ONLY HALF THE ANSWER. `sdTransfer` reports that this client - * and this link CAN carry SD file transfer; it cannot report whether the - * connected firmware transfers files intact, because that needs a firmware - * version read and this function is synchronous. A caller that offers a - * download must narrow it afterwards with the client's own asynchronous gate: - * - * caps.sdTransfer = caps.sdTransfer && (await client.supportsSdTransfer()); - * - * v1.01.009 and v1.01.010 speak the protocol and corrupt every block, so - * skipping that step hands the user silently wrong data. - * - * @param {object|null} client a Shimmer3Client / Shimmer3RClient / wired client - * @param {"ble"|"rfcomm"|"usb"|string} [mode] the link the client is on - * @returns {{ - * stream: boolean, sdbt: boolean, infomem: boolean, sdlog: boolean, - * calib: boolean, calibration: boolean, pressureCalibration: boolean, - * rtc: boolean, ranges: boolean, - * exg: boolean, sensors: boolean, battery: boolean, status: boolean, - * sdTransfer: boolean, branding: boolean, factoryTest: boolean, - * ledToggle: boolean, rtcRead: boolean, - * }} - */ -export function describeShimmer3Caps(client, mode) { - const has = (name) => typeof client?.[name] === "function"; - - // The dock/wired client carries the whole 384-byte image in one call - // (readInfoMemBytes); the radio clients expose the paged primitive - // (readInfoMem(address, length)) and the page assembles the three pages. - // Either is enough to drive the form, so accept both. - const infomemRead = has("readInfoMemBytes") || has("readInfoMem"); - const infomemWrite = has("writeInfoMemBytes") || has("writeInfoMem"); - - return { - // Streaming is radio-only: the wired dock link is a configuration and - // file-transfer channel, it does not carry a sample stream. - stream: has("startStreaming") && mode !== "usb", - sdbt: has("startStreamingAndLogging") && mode !== "usb", - infomem: infomemRead && infomemWrite, - // The 0x9C SD-header rebuild. A separate capability from `infomem` - // because an older bundle can read and write the image without being able - // to ask the firmware to regenerate the card's configuration file. - sdlog: has("updateSdLogConfig"), - calib: has("readCalibration"), - /* The whole-dump calibration path: the store that keeps a record per - sensor AND range, each with the date it was taken, which is what - `common/calibration-editor.js` edits. Both commands are required, not - just the read — a page that could read the dump but not write it would - offer an editor with nowhere to send the result. UPD_CALIB_DUMP (0x9B) - is deliberately NOT part of the gate: `writeCalibDump` issues it itself - unless asked not to, so the panel never calls it directly, and naming - it here would disable the editor on a bundle that applies the dump - without exposing the step as its own method. - Deliberately NOT link-gated. It is method detection that decides this - one: the dock/USB client and the classic `Shimmer3Client` genuinely do - not carry these commands, so the panel falls back to showing the - InfoMem calibration blocks read-only there — and naming a link would - claim the reason is the protocol when it is the client. */ - calibration: has("readCalibDump") && has("writeCalibDump"), - /* The fitted pressure part and its factory trim - (GET_PRESSURE_CALIBRATION_COEFFICIENTS, 0xA7). Radio-only: the dock - protocol has no such command, so asking over USB would spend a timeout - to learn nothing. Without it PRESSURE and TEMPERATURE stream raw-only — - the firmware relays the chip's raw registers and leaves kPa and °C to - the host. */ - pressureCalibration: has("readPressureCalibration") && mode !== "usb", - rtc: has("setRtcTime") && has("getRtcTime"), - ranges: has("setWrAccelRange") && has("setGyroRange"), - exg: has("enableEMG16Bit") && has("enableECG16Bit"), - sensors: has("setSensors"), - battery: has("readBattery") || has("getBattery"), - status: has("readStatus") || has("getStatus"), - /* SD file transfer, and radio-only for the same reason streaming is: the - Shimmer3R's USB-C port speaks the DOCK protocol, which has no - SD_LIST_DIR/SD_FILE_READ at all — the wired client therefore does not - carry these methods either, but naming the link keeps the reason - visible rather than making it an accident of feature detection. */ - sdTransfer: has("sdListDir") && has("sdReadFileWindow") && mode !== "usb", - /* The expansion-board EEPROM, which is where the brand record lives — the - names a sensor advertises over Classic Bluetooth and BLE, and presents - over USB. Deliberately NOT link-gated, unlike `sdTransfer` and - `stream`: every client carries these two calls and every link reaches - the same EEPROM, because the dock protocol has a daughter-card memory - property of its own (`UART_PROP.DAUGHTER_CARD.CARD_MEM`) that takes the - same host offsets. A docked sensor is in fact the easiest one to - rebrand — no pairing needed. */ - branding: has("readDaughterCardMem") && has("writeDaughterCardMem"), - /* The firmware's own factory self-test. Deliberately NOT link-gated: the - dock protocol addresses the same suite through its TEST component, so a - docked or USB-C sensor runs it too — the only difference is that the - ExG chip test cannot pass from the dock, which the report says itself. */ - factoryTest: has("runFactoryTest"), - /* The red-LED override, and this one IS link-gated: the dock protocol has - no LED command at all (the property exists in the header but only under - the GQ build), so it is Bluetooth-only however capable the client is. */ - ledToggle: has("toggleLed") && mode !== "usb", - /* Reading the real-world clock, which is all a drift measurement needs. - Broader than `rtc` above, which also requires the setter: the wired - client reads the clock through a config property rather than a method - of its own, and can measure drift without being able to set it. */ - rtcRead: has("getRtcTime") || (mode === "usb" && has("getConfig")), - }; -} - -// --------------------------------------------------------------------------- -// Live overlays -// --------------------------------------------------------------------------- - -/** - * Settings that exist BOTH in the InfoMem and as an immediate-effect - * Bluetooth setter, keyed by the schema field's `configKey`. - * - * Writing the InfoMem alone is not enough for these: the running firmware - * holds its own copy of the sampling rate, sensor bitmap and sensor ranges, - * and only reloads them from the InfoMem on a reboot. A page that wrote the - * image and then started streaming would stream at the OLD rate. So after the - * image is written, the dirty subset that appears here is replayed through the - * corresponding setter — the "live overlay" over the stored configuration. - * - * `arg(value)` adapts the schema's stored encoding to what the setter wants. - * The only one that is not the identity is `samplingRateHz`: the InfoMem - * stores the 32768 Hz divider, `setSamplingRate` takes hertz. - * - * ONE KEY HERE IS NOT A SCHEMA FIELD. `enabledSensors` has no entry in the - * InfoMem field schema on purpose — the schema's scope note excludes the - * Sensors0-4 bitmaps, because they are per-channel enable maps rather than - * scalar settings and get their own checkbox grid ({@link SENSOR_GROUPS}). So - * the form's dirty set can never contain it: the page's sensor grid must pass - * `sensorsChanged: true` to {@link buildApplyPlan}, or add the key to the - * dirty list itself. Every other key here is a real `configKey`. - * - * @type {Readonly number, - * label: string}>>} - */ -export const LIVE_OVERLAYS = Object.freeze({ - samplingRateHz: { - method: "setSamplingRate", - // The stored value is the divider (32768 / divider = Hz); the setter takes - // hertz and re-derives the divider itself. Guard against a zero divider - // rather than handing the setter Infinity. - arg: (v) => (v > 0 ? 32768 / v : 0), - label: "sampling rate", - }, - enabledSensors: { - method: "setSensors", - arg: (v) => v, - label: "enabled sensors", - }, - "imu.wrAccelRange": { - method: "setWrAccelRange", - arg: (v) => v, - label: "wide-range accel range", - }, - "imu.gyroRange": { - method: "setGyroRange", - arg: (v) => v, - label: "gyro range", - }, - gsrRange: { - method: "setGSRRange", - arg: (v) => v, - label: "GSR range", - }, - expPowerEnabled: { - method: "setInternalExpPower", - arg: (v) => (v ? 1 : 0), - label: "expansion-board power", - }, -}); - -/** - * Order the live setters must run in. Later entries depend on earlier ones - * having already been applied: - * - * sensors first, because it decides which channels exist at all, and - * because the ExG helper below reads the current bitmap and - * ORs its own bits into it - * sampling rate next, because the ExG oversampling ratio is DERIVED from it - * ranges then, since a range only means anything for a sensor that - * is enabled - * exp power last of the plain setters — it switches a rail, so it is the - * one most worth doing once everything else has settled - */ -const LIVE_ORDER = Object.freeze([ - "enabledSensors", - "samplingRateHz", - "imu.wrAccelRange", - "imu.gyroRange", - "gsrRange", - "expPowerEnabled", -]); - -// --------------------------------------------------------------------------- -// ExG mode presets -// --------------------------------------------------------------------------- - -/** - * The ExG configurations the SDK can install, as a single-choice preset. - * - * The ADS1292R register banks are 10 opaque bytes per chip. The schema - * surfaces them as raw fields so they survive round-trip, but nobody - * configures an ExG front end by typing register values: the SDK ships three - * known-good banks and this list is the control that picks one. - * - * Choosing a mode also changes the sensor bitmap — `_writeExgPages` ORs - * SENSOR_EXG1_16BIT | SENSOR_EXG2_16BIT into the current mask and calls - * `setSensors` itself. That is why the ExG bits are excluded from - * {@link SENSOR_GROUPS}: two controls owning the same bits would fight. - * - * `off` has no method — it means "leave the register banks alone", which is - * what the raw ExG fields in the form are for. - * - * @type {readonly {id: string, label: string, method: string|null}[]} - */ -export const EXG_MODES = Object.freeze([ - { id: "off", label: "Leave ExG registers as stored", method: null }, - { id: "emg16", label: "EMG, 16-bit", method: "enableEMG16Bit" }, - { id: "ecg16", label: "ECG, 16-bit", method: "enableECG16Bit" }, - { - id: "test16", - label: "Test signal, 16-bit", - method: "enableEXGTestSignal16Bit", - }, -]); - -// --------------------------------------------------------------------------- -// Sensor grouping -// --------------------------------------------------------------------------- - -/** - * `SensorBitmapShimmer3` keys arranged for a checkbox grid. - * - * The bitmap itself is the SDK's; only the grouping and the wording are here. - * A page reads the numeric masks from `SensorBitmapShimmer3` and uses these - * lists purely for layout, so a bit that is added to the SDK and not to this - * table simply does not get a checkbox — it never gets a wrong one. - * - * The four ExG bits (SENSOR_EXG1/2_16BIT, SENSOR_EXG1/2_24BIT) are - * deliberately absent: {@link EXG_MODES} owns them (see above). - * - * @type {readonly {id: string, title: string, keys: readonly string[]}[]} - */ -export const SENSOR_GROUPS = Object.freeze([ - { - id: "inertial", - title: "Inertial", - keys: [ - "SENSOR_A_ACCEL", - "SENSOR_D_ACCEL", - "SENSOR_ACCEL_ALT", - "SENSOR_GYRO", - "SENSOR_MAG", - "SENSOR_MAG_ALT", - ], - }, - { - id: "environment", - title: "Environment and power", - keys: ["SENSOR_PRESSURE", "SENSOR_VBATT"], - }, - { - id: "bio", - title: "Biophysical", - keys: ["SENSOR_GSR", "SENSOR_BRIDGE_AMP"], - }, - { - id: "external", - title: "External ADC (expansion connector)", - keys: ["SENSOR_EXT_A0", "SENSOR_EXT_A1", "SENSOR_EXT_A2"], - }, - { - id: "internal", - title: "Internal ADC", - keys: ["SENSOR_INT_A0", "SENSOR_INT_A1", "SENSOR_INT_A2", "SENSOR_INT_A3"], - }, -]); - -/** Friendly label per bitmap key, for the checkbox next to it. */ -export const SENSOR_LABELS = Object.freeze({ - SENSOR_A_ACCEL: "Low-noise accelerometer", - SENSOR_D_ACCEL: "Wide-range accelerometer", - SENSOR_ACCEL_ALT: "Alt accelerometer (high-g)", - SENSOR_GYRO: "Gyroscope", - SENSOR_MAG: "Magnetometer", - SENSOR_MAG_ALT: "Alt magnetometer", - SENSOR_PRESSURE: "Pressure / temperature", - SENSOR_VBATT: "Battery voltage", - SENSOR_GSR: "GSR", - SENSOR_BRIDGE_AMP: "Bridge amplifier", - SENSOR_EXT_A0: "External ADC A0", - SENSOR_EXT_A1: "External ADC A1", - SENSOR_EXT_A2: "External ADC A2", - SENSOR_INT_A0: "Internal ADC A0", - SENSOR_INT_A1: "Internal ADC A1", - SENSOR_INT_A2: "Internal ADC A2", - SENSOR_INT_A3: "Internal ADC A3", -}); - -// --------------------------------------------------------------------------- -// Apply plan -// --------------------------------------------------------------------------- - -/* - * Step names are the SDK client's own method names, so a caller dispatches on - * them directly (`await client[step]()`) instead of maintaining a second - * lookup table that can drift from this list. - * - * The two InfoMem steps name the WHOLE-IMAGE methods, because the working - * document is the whole 384-byte image. A bundle old enough to expose only the - * paged `readInfoMem(address, length)` / `writeInfoMem(address, data)` - * primitives has no `readInfoMemBytes`, so `describeShimmer3Caps` reports - * `infomem` from the paged pair and the page assembles the three pages itself - * — the step name then reads as the operation, not as a callable method. - * - * `updateSdLogConfig` is command 0x9C (`UPD_SDLOG_CFG_COMMAND`), which makes - * the firmware regenerate the SD configuration file from the config bytes it - * has just been given. `exgMode` is the only name here that is NOT a client - * method: the ExG preset picks one of {@link EXG_MODES}, whose `method` field - * carries the actual helper to call. - */ -const STEP_INFOMEM = "writeInfoMemBytes"; -const STEP_SDLOG = "updateSdLogConfig"; -const STEP_EXG = "exgMode"; -const STEP_INQUIRY = "inquiry"; -const STEP_REREAD = "readInfoMemBytes"; - -/** - * Turn a set of dirty field keys into the ordered list of operations that - * actually applies them to a sensor. - * - * ORDER IS NOT COSMETIC — every step below is placed where the firmware needs - * it (verified against `log-and-stream-common`): - * - * 1. `writeInfoMem` — the whole 384-byte image, page by page - * (SET_INFOMEM_COMMAND 0x8C). This is the stored configuration, the one - * that survives a reboot and that the SD header is built from. - * 2. `updateSdLogConfig` — 0x9C. `ShimBt_processGeneralCmd` answers it with - * `ShimTask_set(TASK_SDLOG_CFG_UPDATE)` (shimmer_bt_uart.c:1099-1103), so - * the firmware rewrites the SD header from the bytes just written. It is - * harmless when nothing the header carries changed, so the plan lists it - * unless `caps.sdlog` is positively `false`; a caller that has not wired - * up caps gets it rather than silently losing the rebuild. Whether it can - * actually run is settled where the client is, by the executor, which - * feature-detects every step and says so when one is missing. - * 3. the live setters, in {@link LIVE_ORDER} — sensors, then sampling rate, - * then ranges, then expansion power. These overwrite the RUNNING config; - * without them the stored image and the streaming behaviour disagree - * until the next reboot. - * 4. `exgMode` LAST of the setters, because `_writeExgPages` derives the - * ADS1292R oversampling ratio from `client.samplingRateHz` - * (getOversamplingRatioADS1292R) and then ORs the ExG bits into the - * current sensor mask. Run before the rate is set and it bakes in the old - * rate's ratio; run before `setSensors` and its bits are overwritten. - * 5. `inquiry` then a fresh InfoMem read — so the page's channel schema and - * its baseline image both come from the device rather than from what the - * page believes it wrote. - * - * SET COMMANDS ARE NACKED WHILE SENSING. `ShimBt_processPacket` blocks every - * command in `ShimBt_isCmdBlockedWhileSensing` while `shimmerStatus.sensing` - * is set (shimmer_bt_uart.c:770-771), and that list contains SET_INFOMEM - * (0x8C), UPD_SDLOG_CFG (0x9C), SET_SENSORS (0x08), SET_SAMPLING_RATE (0x05), - * every range setter and SET_EXG_REGS (0x61) — i.e. every step of this plan. - * So a plan built while the sensor is streaming or logging comes back - * `blocked` with a reason instead of throwing: the caller shows the reason and - * disables the Apply button, which is a better answer than a run that NACKs - * halfway and leaves the stored and running configurations disagreeing. - * - * @param {readonly string[]} dirtyKeys schema field `configKey`s that changed - * (the form's `dirtyKeys()` returns field KEYS; map them through - * `field.configKey` before calling this) - * @param {object} ctx - * @param {Record} [ctx.caps] from {@link describeShimmer3Caps} - * @param {"ble"|"rfcomm"|"usb"|string} [ctx.mode] - * @param {boolean} [ctx.streaming] sensor is streaming right now - * @param {boolean} [ctx.recording] sensor is logging to the card right now - * @param {boolean} [ctx.exgModeChanged] the ExG preset control was changed - * @param {boolean} [ctx.sensorsChanged] the sensor checkbox grid was changed. - * Needed as its own flag because the sensor bitmap is not a schema field, so - * it can never appear in the form's dirty set (see {@link LIVE_OVERLAYS}) - * @param {boolean} [ctx.samplingRateChanged] force the rate overlay even when - * the rate field itself is not dirty (an ExG mode change needs the ratio - * re-derived from the current rate) - * @returns {{ - * steps: {step: string, label: string, kind: "infomem"|"live"|"refresh"}[], - * blocked: string|null, - * }} - */ -export function buildApplyPlan(dirtyKeys, ctx = {}) { - const dirty = new Set(dirtyKeys ?? []); - const caps = ctx.caps ?? {}; - const steps = []; - - const blocked = applyBlockedReason(ctx, caps); - - // 1-2. Stored configuration. - steps.push({ - step: STEP_INFOMEM, - label: "Write the configuration image (InfoMem pages D, C, B)", - kind: "infomem", - }); - if (caps.sdlog !== false) { - steps.push({ - step: STEP_SDLOG, - label: "Rebuild the SD header from the new configuration (0x9C)", - kind: "infomem", - }); - } - - // 3. Live overlays, in firmware-mandated order, for the dirty subset only. - for (const configKey of LIVE_ORDER) { - const overlay = LIVE_OVERLAYS[configKey]; - if (!overlay) continue; - const needed = - dirty.has(configKey) || - (configKey === "samplingRateHz" && ctx.samplingRateChanged) || - (configKey === "enabledSensors" && ctx.sensorsChanged); - if (!needed) continue; - // A setter the vendored SDK does not have is dropped from the plan rather - // than queued to fail: the stored image still carries the setting, so it - // takes effect on the next reboot. - if (configKey === "enabledSensors" && caps.sensors === false) continue; - if ( - (configKey === "imu.wrAccelRange" || configKey === "imu.gyroRange") && - caps.ranges === false - ) { - continue; - } - steps.push({ - step: overlay.method, - label: `Apply the ${overlay.label} to the running configuration`, - kind: "live", - }); - } - - // 4. ExG last — it reads the sampling rate and the sensor mask set above. - if (ctx.exgModeChanged && caps.exg !== false) { - steps.push({ - step: STEP_EXG, - label: - "Install the ExG register banks (oversampling ratio derived from the " + - "sampling rate just applied)", - kind: "live", - }); - } - - // 5. Read back what the device now says, rather than trusting the write. - steps.push({ - step: STEP_INQUIRY, - label: "Re-inquire the channel list and rate", - kind: "refresh", - }); - steps.push({ - step: STEP_REREAD, - label: "Re-read the configuration image and re-baseline the form", - kind: "refresh", - }); - - return { steps, blocked }; -} - -/** - * Why this plan cannot run right now, or null. - * - * Separate from the step list on purpose: the caller still wants to SHOW the - * plan while it is blocked ("this is what Apply would do, and here is why it - * is greyed out"), which a thrown error would not allow. - */ -function applyBlockedReason(ctx, caps) { - if (caps.infomem === false) { - return "this SDK build cannot read and write the configuration memory"; - } - if (ctx.streaming) { - return ( - "the sensor is streaming — it NACKs configuration commands while " + - "sensing, so stop the stream first" - ); - } - if (ctx.recording) { - return ( - "the sensor is logging to its card — it NACKs configuration commands " + - "while sensing, so stop logging first" - ); - } - return null; -} - -/** - * Map the form's dirty FIELD keys to the `configKey`s {@link buildApplyPlan} - * expects, using the same field definitions the form was given. - * - * Two fields can share a `configKey` (each part generation declares its own - * `wrAccelRate`, all writing `imu.wrAccelRate`), which is exactly why the plan - * is keyed on `configKey` and not on the field key. - * - * @param {readonly string[]} dirtyFieldKeys - * @param {readonly {key: string, configKey: string}[]} fields - * @returns {string[]} unique config keys, in the order the fields declare them - */ -export function configKeysForDirtyFields(dirtyFieldKeys, fields) { - const wanted = new Set(dirtyFieldKeys ?? []); - const out = []; - for (const f of fields ?? []) { - if (!wanted.has(f.key)) continue; - if (!out.includes(f.configKey)) out.push(f.configKey); - } - return out; -} diff --git a/common/stream-stats.js b/common/stream-stats.js deleted file mode 100644 index 9623fb7..0000000 --- a/common/stream-stats.js +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Live stream statistics strip for the webBLEDemos pages: achieved rate, - * expected rate, packet loss, CRC state, throughput, frame count and duration. - * - * A thin wrapper over the SDK's `StreamStatsTracker`, which already owns the - * hard parts — loss derived from gaps in the *device* clock rather than host - * receive time (host Bluetooth buffering bunches packets together and would - * otherwise invent gaps), and throughput measured over a sliding window of - * host receive time. This module's job is to turn one Shimmer3/Shimmer3R - * frame into the one `recordPacket` call the tracker wants, and to render the - * snapshot. - * - * import { createStreamStats } from "../common/stream-stats.js"; - * - * No DOM access at import time. - */ - -import { StreamStatsTracker } from "../vendor/shimmer-web-sdk.esm.js"; -import { el, fmtDuration, fmtHz } from "./ui-chrome.js"; - -/** - * Device clock: the Shimmer3/Shimmer3R stream timestamp counts 32.768 kHz - * ticks, so one tick is 1/32.768 ms. - */ -const TICKS_PER_MS = 32.768; - -/** The stream timestamp is 24-bit, so it wraps every 2^24 ticks (512 s). */ -const TIMESTAMP_MODULO = 1 << 24; - -/** - * Half the modulo. A backwards step larger than this is a wrap; a smaller one - * is an out-of-order or duplicated frame, which must NOT add 512 s to the - * clock — that would read as a colossal gap and drive loss to 100%. - */ -const WRAP_THRESHOLD = TIMESTAMP_MODULO / 2; - -/** Redraw cadence. Faster than this is unreadable and costs frame time. */ -const RENDER_INTERVAL_MS = 500; - -/** - * The tracker keys sub-streams so a multi-FIFO sensor can be accounted for - * separately. A Shimmer3 frame is one interleaved packet carrying exactly one - * sample of everything, so there is a single sub-stream. - */ -const STREAM_KEY = "shimmer3"; -const STREAM_LABEL = "Shimmer3"; -const SENSOR_ID = 0; - -const CELLS = [ - { key: "rate", label: "Rate" }, - { key: "expected", label: "Expected" }, - { key: "loss", label: "Loss" }, - { key: "crc", label: "CRC" }, - { key: "throughput", label: "Throughput" }, - { key: "frames", label: "Frames" }, - { key: "duration", label: "Duration" }, -]; - -/** - * Create the stats strip. - * - * @param {HTMLElement} container a `.stats` element; its cells are built here - * @param {object} [opts] - * @param {number} [opts.windowMillis=2000] sliding window for rate/throughput - * @returns {{ - * reset: (rateHz?: number|null, frameBytes?: number) => void, - * onFrame: (oc: {fields: {name: string, value: number}[], raw?: Uint8Array|null}, recvMillis?: number) => void, - * render: () => void, - * snapshot: () => object, - * }} - */ -export function createStreamStats(container, opts = {}) { - const tracker = new StreamStatsTracker({ - windowMillis: opts.windowMillis ?? 2000, - }); - - /** Configured rate, for the "Expected" cell and the tracker's loss maths. */ - let expectedHz = null; - /** Fallback frame size when a frame does not carry its own bytes. */ - let defaultFrameBytes = 0; - - // Device-clock unwrapping state. - let lastTicks = null; - let wrapOffsetTicks = 0; - - let frames = 0; - let lastRenderMs = 0; - /** Last frame's CRC verdict; null means the link carries no CRC. */ - let lastCrcOk = null; - - container.classList.add("stats"); - /** @type {Record} */ - const values = {}; - container.replaceChildren( - ...CELLS.map((c) => { - const value = el("div", { class: "stat-value", text: "–" }); - values[c.key] = value; - return el( - "div", - null, - el("div", { class: "stat-label", text: c.label }), - value, - ); - }), - ); - - /** - * Clear all counters. Call whenever streaming (re)starts. - * - * @param {number|null} [rateHz] the configured rate; loss is measured - * against it, so without it the loss cell reads "–" - * @param {number} [frameBytes] the schema's frame size, used when a frame - * arrives without its raw bytes attached - */ - function reset(rateHz = null, frameBytes = 0) { - tracker.reset(); - expectedHz = Number.isFinite(rateHz) && rateHz > 0 ? rateHz : null; - defaultFrameBytes = Number.isFinite(frameBytes) ? frameBytes : 0; - lastTicks = null; - wrapOffsetTicks = 0; - frames = 0; - lastRenderMs = 0; - lastCrcOk = null; - render(); - } - - /** - * Device time for this frame, in milliseconds on a monotonic clock. - * - * @param {number|undefined} ticks raw 24-bit TIMESTAMP value - * @returns {number|null} null when the frame carries no timestamp - */ - function deviceMillis(ticks) { - if (!Number.isFinite(ticks)) return null; - const raw = ticks % TIMESTAMP_MODULO; - if (lastTicks !== null && raw < lastTicks - WRAP_THRESHOLD) { - wrapOffsetTicks += TIMESTAMP_MODULO; - } - lastTicks = raw; - return (wrapOffsetTicks + raw) / TICKS_PER_MS; - } - - /** - * Account for one decoded frame. - * - * @param {{ - * fields: {name: string, value: number}[], - * raw?: Uint8Array|null, - * crcOk?: boolean|null, - * }} oc `crcOk` is the frame's link-CRC verdict: true verified, false - * failed, and null or absent means the link carries no CRC — which is not - * the same as a pass and is rendered differently. - * @param {number} [recvMillis] host receive time; defaults to `performance.now()` - */ - function onFrame(oc, recvMillis) { - const recv = Number.isFinite(recvMillis) ? recvMillis : performance.now(); - let ticks; - for (const f of oc?.fields ?? []) { - if (f.name === "TIMESTAMP") { - ticks = f.value; - break; - } - } - const tsMillis = deviceMillis(ticks); - const byteLength = oc?.raw?.byteLength ?? defaultFrameBytes; - - tracker.recordPacket({ - sensorId: SENSOR_ID, - byteLength, - // The sensor appends a CRC only after SET_CRC_COMMAND, so this is null - // on an unchecked link — null, not false, which would report every - // frame as a CRC failure. - crcOk: oc?.crcOk ?? null, - recvMillis: recv, - contributions: [ - { - key: STREAM_KEY, - label: STREAM_LABEL, - samplingRateHz: expectedHz, - // One interleaved sample of every enabled channel per frame. - sampleCount: 1, - firstSampleMillis: tsMillis, - lastSampleMillis: tsMillis, - }, - ], - }); - frames++; - lastCrcOk = oc?.crcOk ?? null; - /* The tracker counts CRC failures through recordCrcFail, NOT from the - `crcOk` passed to recordPacket above - that field is carried for the - per-packet record and does not feed the counter (StreamStats.ts:203). - Without this call `totalCrcFails` stays 0 and the CRC cell reads "ok" - however many frames failed, which is the one thing the cell exists to - report. */ - if (oc?.crcOk === false) tracker.recordCrcFail(SENSOR_ID); - - if (recv - lastRenderMs >= RENDER_INTERVAL_MS) render(); - } - - /** - * Read the tracker's current numbers. - * - * @returns {object} a `StreamStatsSnapshot` - */ - function snapshot() { - return tracker.snapshot(performance.now()); - } - - /** - * Paint the strip now. `onFrame` calls this at most every 500 ms; a page - * should call it once more after stopping, so the final numbers are shown - * rather than whatever the last throttled frame left behind. - */ - function render() { - lastRenderMs = performance.now(); - const snap = snapshot(); - const stream = snap.perSensor?.[SENSOR_ID]?.streams?.[0]; - - values.rate.textContent = fmtHz(stream?.windowSampleRateHz ?? null); - values.expected.textContent = fmtHz(expectedHz); - - // No expected rate means loss is unknowable, not zero — the tracker - // reports 0 in that case, which would be a lie on screen. - const lossKnown = expectedHz !== null && snap.totalPackets > 1; - values.loss.textContent = lossKnown ? `${snap.lossPct.toFixed(1)} %` : "–"; - values.loss.classList.toggle("bad", lossKnown && snap.lossPct >= 5); - values.loss.classList.toggle( - "warn", - lossKnown && snap.lossPct >= 1 && snap.lossPct < 5, - ); - - // Distinguishes "no CRC on this link" from "CRC on and every frame good", - // which is the whole reason for turning it on. - const anyChecked = frames > 0 && lastCrcOk !== null; - values.crc.textContent = !anyChecked - ? "off" - : snap.totalCrcFails - ? `${snap.totalCrcFails} bad` - : "ok"; - values.crc.classList.toggle("bad", anyChecked && snap.totalCrcFails > 0); - - values.throughput.textContent = snap.throughputBps - ? `${(snap.throughputBps / 1024).toFixed(1)} kB/s` - : "–"; - values.frames.textContent = frames.toLocaleString(); - values.duration.textContent = frames - ? fmtDuration(snap.durationMillis) - : "–"; - } - - return { reset, onFrame, render, snapshot }; -} diff --git a/common/theme.css b/common/theme.css deleted file mode 100644 index 4cf5262..0000000 --- a/common/theme.css +++ /dev/null @@ -1,1399 +0,0 @@ -/* ========================================================================= - Shared design tokens + base components for the webBLEDemos pages. - - Extracted from verisense-device-console/console.css (tokens, cards, - buttons, tabs, collapsible groups, dirty fields, log lines, toasts) so - every demo in this repo can look like one product without a build step. - - Import from any demo folder: - - - Token layering — three blocks, in this order: - :root light values (the baseline) - :root[data-theme="dark"] explicit dark choice - @media (prefers-color-scheme: dark) - :root:not([data-theme="light"]) OS dark, unless light was chosen - - Component rules below reference tokens ONLY. Adjust colours here, not in - the component section. - ========================================================================= */ - -:root { - color-scheme: light; - - /* surfaces */ - --bg: #f4f6f8; - --card: #ffffff; - --surface-2: #fafbfc; - --surface-3: #f1f5f6; - --surface-inset: #eef1f3; - - /* text */ - --ink: #17232a; - --ink-strong: #101c22; - --muted: #64747e; - --faint: #9aa7ae; - - /* borders */ - --line: #e2e8eb; - --line-soft: #eaeff2; - --line-strong: #d3dce0; - - /* Brand accent — Pantone Orange 021C (product colour). Pure #fe5000 is - max-chroma: fine for thin/small accents (tab underline, badge dots, - meters, focus) but a solid button block of it vibrates on white and - white text on it is only 3.3:1. So button surfaces use --accent-fill, - an "ember" deepened one step for white ink at 4.8:1, and --accent-text - is likewise deepened for AA on light surfaces. - NEVER use #fe5000 as a button fill. */ - --accent: #fe5000; - --accent-fill: #cf3f00; - --accent-hover: #b83800; - --accent-ink: #ffffff; - --accent-text: #c13c00; - --accent-soft: #ffede3; - --accent-soft-border: #ffcdb3; - --accent-2: #17506e; - - /* controls */ - --control-bg: #ffffff; - --control-hover: #f2f6f7; - - /* states */ - --warn: #9a5d06; - --warn-bg: #fdf6ea; - --warn-border: #eed9b4; - --danger: #c0392b; - --danger-bg: #fdefed; - --danger-border: #f0c4bf; - --ok: #1e7a1e; - --ok-bg: #eef7ee; - --ok-border: #c3e0c3; - --info-bg: #eef4f9; - --info-border: #c8dbea; - - /* log / terminal surfaces (dark in BOTH themes, as in the console: a - terminal that flips to white is unreadable next to a dark plot and - breaks the "this is machine output" reading) */ - --log-bg: #10161d; - --log-ink: #cfdae4; - --log-line: #273444; - --log-ok: #3ddc84; - --log-bad: #ff6b6b; - /* Bright amber-orange, tuned to pop on --log-bg like --log-ok/--log-bad - (the softer #e5b567 was hard to read on the dark log surface). */ - --log-warn: #ffb454; - --log-tx: #79b8ff; - --log-rx: #7ee2a8; - --log-dim: #7d8b96; - - /* effects */ - --shadow-card: 0 1px 2px rgba(16, 28, 34, 0.05), - 0 4px 12px rgba(16, 28, 34, 0.04); - --focus-ring: 0 0 0 3px rgba(254, 80, 0, 0.28); - --backdrop: rgba(10, 18, 24, 0.5); - - /* Inter is NOT vendored here (no font file in this repo) — the stack - falls straight through to the platform UI face. */ - --font-ui: Inter, "Segoe UI Variable Text", system-ui, "Segoe UI", sans-serif; - --font-mono: "Cascadia Code", Consolas, "Courier New", monospace; -} - -:root[data-theme="dark"] { - color-scheme: dark; - - --bg: #0d1117; - --card: #151b23; - --surface-2: #11161d; - --surface-3: #1a212b; - --surface-inset: #10151b; - - --ink: #dbe4ec; - --ink-strong: #f0f5f9; - --muted: #8b98a5; - --faint: #5f6d78; - - --line: #232c37; - --line-soft: #1d2530; - --line-strong: #2d3844; - - /* on dark surfaces the lifted orange doesn't vibrate — fill = accent */ - --accent: #ff6a2e; - --accent-fill: #ff6a2e; - --accent-hover: #ff8049; - --accent-ink: #2a0f00; - --accent-text: #ff8656; - --accent-soft: rgba(255, 106, 46, 0.13); - --accent-soft-border: rgba(255, 106, 46, 0.32); - --accent-2: #5aa7d0; - - --control-bg: #1c242e; - --control-hover: #232d39; - - --warn: #e8b04b; - --warn-bg: #241d10; - --warn-border: #4a3a1c; - --danger: #ff6b6b; - --danger-bg: #2a1618; - --danger-border: #4d2529; - --ok: #3ddc84; - --ok-bg: #10241a; - --ok-border: #1e4630; - --info-bg: #121c24; - --info-border: #22323f; - - --log-bg: #0a0e13; - --log-line: #1d2632; - - --shadow-card: none; - --focus-ring: 0 0 0 3px rgba(255, 106, 46, 0.35); - --backdrop: rgba(0, 0, 0, 0.6); -} - -/* OS preference, honoured only when the page has NOT pinned light. Values - are the same as the [data-theme="dark"] block above; CSS cannot share one - declaration block across a media-query boundary, so it is duplicated. */ -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { - color-scheme: dark; - - --bg: #0d1117; - --card: #151b23; - --surface-2: #11161d; - --surface-3: #1a212b; - --surface-inset: #10151b; - - --ink: #dbe4ec; - --ink-strong: #f0f5f9; - --muted: #8b98a5; - --faint: #5f6d78; - - --line: #232c37; - --line-soft: #1d2530; - --line-strong: #2d3844; - - --accent: #ff6a2e; - --accent-fill: #ff6a2e; - --accent-hover: #ff8049; - --accent-ink: #2a0f00; - --accent-text: #ff8656; - --accent-soft: rgba(255, 106, 46, 0.13); - --accent-soft-border: rgba(255, 106, 46, 0.32); - --accent-2: #5aa7d0; - - --control-bg: #1c242e; - --control-hover: #232d39; - - --warn: #e8b04b; - --warn-bg: #241d10; - --warn-border: #4a3a1c; - --danger: #ff6b6b; - --danger-bg: #2a1618; - --danger-border: #4d2529; - --ok: #3ddc84; - --ok-bg: #10241a; - --ok-border: #1e4630; - --info-bg: #121c24; - --info-border: #22323f; - - --log-bg: #0a0e13; - --log-line: #1d2632; - - --shadow-card: none; - --focus-ring: 0 0 0 3px rgba(255, 106, 46, 0.35); - --backdrop: rgba(0, 0, 0, 0.6); - } -} - -/* ========================================================================= - Base - ========================================================================= */ - -* { - box-sizing: border-box; -} - -body { - margin: 0; - background: var(--bg); - color: var(--ink); - font: 14px/1.5 var(--font-ui); - min-height: 100vh; -} - -.page { - max-width: 1500px; - margin: 14px auto; - padding: 0 12px 18px; -} - -h1, -h2, -h3 { - margin: 0 0 10px; -} - -h1 { - font-size: 20px; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--ink-strong); -} - -h2 { - font-size: 16px; - font-weight: 600; - color: var(--ink-strong); -} - -a { - color: var(--accent-text); -} - -.muted { - color: var(--muted); -} - -.mono { - font-family: var(--font-mono); - font-size: 12px; -} - -code, -kbd { - font-family: var(--font-mono); - font-size: 0.92em; -} - -/* ========================================================================= - Layout - ========================================================================= */ - -.card { - background: var(--card); - border: 1px solid var(--line); - border-radius: 12px; - padding: 14px; - box-shadow: var(--shadow-card); - margin-bottom: 12px; -} - -.card-title { - font-size: 15px; - font-weight: 600; - color: var(--ink-strong); - margin: 0 0 10px; -} - -.row { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; - margin-bottom: 8px; -} - -.row:last-child { - margin-bottom: 0; -} - -/* min(100%, 300px): columns are 300px wide when there's room, but shrink - to fit (never overflow) once the container is narrower than 300px. */ -.grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); - gap: 10px; -} - -.kv { - display: grid; - grid-template-columns: 140px 1fr; - gap: 5px 10px; - margin-top: 4px; - font-variant-numeric: tabular-nums; -} - -/* ========================================================================= - Pills and banners - ========================================================================= */ - -.pill { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 3px 10px; - border-radius: 999px; - border: 1px solid var(--line-strong); - background: var(--surface-3); - color: var(--muted); - font-size: 12px; - font-weight: 600; -} - -.pill.on { - border-color: var(--ok-border); - background: var(--ok-bg); - color: var(--ok); -} - -.pill.err { - border-color: var(--danger-border); - background: var(--danger-bg); - color: var(--danger); -} - -/* Not a fault — something the reader has to know. "Never calibrated" and - "edited, not written" are both this, and neither is an error. */ -.pill.warn { - border-color: var(--warn-border); - background: var(--warn-bg); - color: var(--warn); -} - -.banner { - border: 1px solid var(--line); - border-left: 3px solid var(--line-strong); - background: var(--surface-2); - color: var(--ink); - border-radius: 10px; - padding: 9px 12px; - margin: 8px 0; -} - -/* An empty banner takes no space, so a page can keep one in the markup and - only fill in textContent when there is something to say. */ -.banner:empty { - display: none; -} - -.banner.ok { - border-color: var(--ok-border); - border-left-color: var(--ok); - background: var(--ok-bg); -} - -.banner.err { - border-color: var(--danger-border); - border-left-color: var(--danger); - background: var(--danger-bg); -} - -.banner.warn { - border-color: var(--warn-border); - border-left-color: var(--warn); - background: var(--warn-bg); - color: var(--warn); -} - -.banner.info { - border-color: var(--info-border); - border-left-color: var(--accent-2); - background: var(--info-bg); -} - -/* ========================================================================= - Controls - ========================================================================= */ - -button, -input, -select, -textarea { - border: 1px solid var(--line-strong); - border-radius: 8px; - padding: 8px 10px; - font: inherit; - background: var(--control-bg); - color: var(--ink); - max-width: 100%; -} - -textarea { - font-family: var(--font-mono); - font-size: 12px; - resize: vertical; -} - -button:focus-visible, -input:focus-visible, -select:focus-visible, -textarea:focus-visible, -a:focus-visible, -[role="tab"]:focus-visible, -summary:focus-visible { - outline: none; - box-shadow: var(--focus-ring); -} - -input[type="checkbox"], -input[type="radio"] { - accent-color: var(--accent); -} - -input[readonly], -textarea[readonly] { - background: var(--surface-inset); - color: var(--muted); - border-color: var(--line-strong); - cursor: not-allowed; -} - -button { - padding: 8px 13px; - cursor: pointer; - font-weight: 600; - transition: - background 0.12s, - border-color 0.12s, - color 0.12s; -} - -button:hover:not(:disabled) { - background: var(--control-hover); -} - -/* Solid accent block — --accent-fill, never --accent (see the token note). */ -button.primary { - color: var(--accent-ink); - border-color: transparent; - background: var(--accent-fill); -} - -button.primary:hover:not(:disabled) { - background: var(--accent-hover); -} - -button.secondary { - color: var(--accent-text); - border-color: var(--accent-soft-border); - background: var(--accent-soft); -} - -button.secondary:hover:not(:disabled) { - background: var(--accent-soft); - border-color: var(--accent); -} - -button.danger { - color: var(--danger); - border-color: var(--danger-border); - background: var(--danger-bg); -} - -button.danger:hover:not(:disabled) { - background: var(--danger-bg); - border-color: var(--danger); -} - -button.warn { - border-color: var(--warn-border); - background: var(--warn-bg); - color: var(--warn); -} - -button.warn:hover:not(:disabled) { - background: var(--warn-bg); - border-color: var(--warn); -} - -button:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -/* Light/dark switch (initThemeToggle in common/theme.js sets the glyph). */ -.theme-toggle { - width: 34px; - height: 34px; - padding: 0; - border-radius: 999px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 16px; - line-height: 1; - color: var(--muted); -} - -.theme-toggle:hover:not(:disabled) { - color: var(--ink); -} - -label { - display: inline-flex; - align-items: center; - gap: 6px; -} - -/* ========================================================================= - Collapsible groups:
… - (console .op-group, renamed for reuse outside the op-config editor) - ========================================================================= */ - -details.group { - border: 1px solid var(--line-soft); - border-radius: 10px; - background: var(--surface-2); - margin-bottom: 8px; - overflow: hidden; -} - -details.group > summary { - cursor: pointer; - list-style: none; - padding: 8px 10px; - font-weight: 600; - color: var(--ink); - background: var(--surface-3); - border-bottom: 1px solid var(--line-soft); - display: flex; - align-items: center; - gap: 8px; -} - -details.group > summary::before { - content: "+"; - width: 16px; - height: 16px; - border-radius: 999px; - border: 1px solid var(--line-strong); - color: var(--muted); - background: var(--card); - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 12px; - line-height: 1; - font-weight: 700; - flex: 0 0 16px; -} - -details.group[open] > summary::before { - content: "-"; -} - -details.group > summary::marker { - content: ""; -} - -details.group > summary::-webkit-details-marker { - display: none; -} - -.group-body { - padding: 10px; -} - -/* ========================================================================= - Config fields - ========================================================================= */ - -.field { - border: 1px solid var(--line-soft); - border-radius: 10px; - background: var(--card); - padding: 8px; - display: flex; - flex-direction: column; - gap: 5px; - min-width: 0; -} - -.field > label { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 600; - color: var(--muted); -} - -.field input, -.field select, -.field textarea { - width: 100%; -} - -.field-hint { - font-size: 11px; - color: var(--faint); -} - -/* Settings search, above the configuration groups. */ -.config-search { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 10px; - flex-wrap: wrap; -} - -.config-search > label { - font-size: 12px; - font-weight: 600; - color: var(--muted); -} - -.config-search > input { - flex: 1 1 200px; - min-width: 0; -} - -/* Shown and round-tripped, but never editable — a deprecated option, or one - belonging to a licensed feature. Deliberately quiet rather than alarming: - nothing is wrong, the setting simply is not changed from here. */ -.field.readonly { - background: var(--card-soft, var(--card)); - border-style: dashed; -} - -/* Field differs from the device's last-read value. */ -.field.dirty { - border-color: var(--warn); -} - -.field.dirty > label::after { - content: "\25cf"; /* ● */ - color: var(--warn); - font-size: 9px; - margin-left: 6px; - vertical-align: 2px; -} - -.dirty-pill { - padding: 2px 10px; - border-radius: 999px; - background: var(--warn-bg); - border: 1px solid var(--warn-border); - color: var(--warn); - font-size: 12px; - font-weight: 600; -} - -/* ========================================================================= - Tabs — WAI-ARIA tablist, wired by initTabs() in common/ui-chrome.js. - Markup: .tabs > button[data-tab="panelId"], plus .tab[id] panels. - ========================================================================= */ - -.tabs { - display: flex; - gap: 4px; - flex-wrap: wrap; - border-bottom: 1px solid var(--line); - margin-bottom: 12px; -} - -.tabs > button, -.tabs > [role="tab"] { - border: 0; - border-radius: 0; - background: none; - padding: 8px 12px 10px; - font-weight: 600; - color: var(--muted); - border-bottom: 2px solid transparent; - margin-bottom: -1px; -} - -.tabs > button:hover:not(:disabled), -.tabs > [role="tab"]:hover:not(:disabled) { - background: none; - color: var(--ink); -} - -/* aria-selected is the single source of truth for the active tab — initTabs - sets it, so the underline cannot drift out of step with the ARIA state. */ -.tabs [role="tab"][aria-selected="true"] { - color: var(--accent-text); - background: none; - border-bottom-color: var(--accent); -} - -.tab { - display: none; -} - -.tab.active, -.tab[aria-selected="true"] { - display: block; -} - -/* ========================================================================= - Toasts — bottom-right stack, auto-dismissing. - showToast() in common/ui-chrome.js creates .toast-stack#toasts on demand. - ========================================================================= */ - -.toast-stack { - position: fixed; - right: 14px; - bottom: 14px; - z-index: 60; - display: flex; - flex-direction: column; - gap: 8px; - max-width: min(360px, calc(100vw - 28px)); -} - -.toast { - background: var(--card); - color: var(--ink); - border: 1px solid var(--line); - border-left: 3px solid var(--accent); - border-radius: 10px; - padding: 10px 14px; - font-size: 13px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); - cursor: pointer; - animation: toast-in 0.16s ease-out; -} - -.toast.ok { - border-left-color: var(--ok); -} - -.toast.err { - border-left-color: var(--danger); -} - -.toast.warn { - border-left-color: var(--warn); -} - -.toast.info { - border-left-color: var(--accent-2); -} - -.toast.leaving { - opacity: 0; - transition: opacity 0.18s; -} - -@keyframes toast-in { - from { - opacity: 0; - transform: translateY(6px); - } - - to { - opacity: 1; - transform: none; - } -} - -@media (prefers-reduced-motion: reduce) { - .toast { - animation: none; - } - - button { - transition: none; - } -} - -/* ========================================================================= - Event log — a terminal surface, so it stays dark in BOTH themes. - ========================================================================= */ - -.log { - width: 100%; - height: 340px; - border-radius: 10px; - border: 1px solid var(--log-line); - background: var(--log-bg); - color: var(--log-ink); - overflow: auto; - white-space: pre-wrap; - word-break: break-word; - padding: 10px; - font-family: var(--font-mono); - font-size: 12px; - line-height: 1.35; -} - -.log-line { - white-space: pre-wrap; - word-break: break-word; -} - -.log-line.sev-info { - color: var(--log-ink); -} - -.log-line.sev-err { - color: var(--log-bad); -} - -.log-line.sev-warn { - color: var(--log-warn); -} - -.log-line.sev-tx { - color: var(--log-tx); -} - -.log-line.sev-rx { - color: var(--log-rx); -} - -.log-line.sev-data { - color: var(--log-dim); -} - -.log-toolbar { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; - margin-bottom: 8px; -} - -.log-toolbar input[type="search"] { - flex: 1; - min-width: 120px; -} - -.log-count { - font-size: 12px; - color: var(--muted); - font-variant-numeric: tabular-nums; -} - -/* ========================================================================= - Hex view — a monospace byte grid (InfoMem is 3 pages of 128 bytes, so the - row width is fixed at 16 and the whole 384-byte dump scrolls in place). - ========================================================================= */ - -.hexview { - font-family: var(--font-mono); - font-size: 12px; - line-height: 1.5; - background: var(--surface-inset); - border: 1px solid var(--line); - border-radius: 10px; - padding: 8px 10px; - max-height: 420px; - overflow: auto; -} - -.hexview-row { - display: grid; - grid-template-columns: 5.5ch repeat(16, 2.5ch); - gap: 0 2px; - white-space: nowrap; -} - -.hexview-addr { - color: var(--faint); - user-select: none; -} - -.hexview-byte { - text-align: center; - color: var(--ink); - border-radius: 3px; -} - -/* 0x00 and 0xFF are the two "nothing here" values — dimming them makes the - real payload readable at a glance in a 384-byte dump. */ -.hexview-byte.zero, -.hexview-byte.blank { - color: var(--faint); -} - -.hexview-byte.changed { - background: var(--accent-soft); - color: var(--accent-text); - font-weight: 600; -} - -.hexview-byte.sel { - background: var(--accent-fill); - color: var(--accent-ink); -} - -/* ========================================================================= - Stats strip — live Hz / loss / throughput while streaming. - ========================================================================= */ - -.stats { - display: flex; - flex-wrap: wrap; - gap: 6px 18px; - align-items: baseline; - padding: 8px 10px; - border: 1px solid var(--line); - border-radius: 10px; - background: var(--surface-2); - font-variant-numeric: tabular-nums; -} - -.stats > div { - display: flex; - flex-direction: column; - min-width: 72px; -} - -.stat-label { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--faint); -} - -.stat-value { - font-size: 15px; - font-weight: 600; - color: var(--ink-strong); -} - -.stat-value.bad { - color: var(--danger); -} - -.stat-value.warn { - color: var(--warn); -} - -/* ========================================================================= - Plots — one canvas panel per sensor group (common/plot.js). The canvas - needs a sized block parent because Chart.js runs with - maintainAspectRatio:false. - ========================================================================= */ - -.plot-panels { - display: grid; - gap: 10px; -} - -.plot-panel { - position: relative; - height: 200px; - border: 1px solid var(--line); - border-radius: 10px; - background: var(--card); - padding: 6px; -} - -.plot-panel > canvas { - width: 100%; - height: 100%; - display: block; -} - -/* ========================================================================= - SD card browser — the selectable on-card tree and the transfer progress - bar (common/sd-browser.js). Monospace, because a card path is a path. - ========================================================================= */ - -.sd-tree { - font-family: var(--font-mono); - font-size: 12.5px; - line-height: 1.7; - max-height: 420px; - overflow: auto; - border: 1px solid var(--line); - border-radius: 10px; - background: var(--surface-2); - padding: 8px 10px; -} - -.sd-tree ul { - list-style: none; - margin: 0; - padding-left: 20px; -} - -/* The outermost list sits flush: the checkbox is the indent. */ -.sd-tree > ul { - padding-left: 0; -} - -.sd-tree label { - display: inline-flex; - align-items: center; - gap: 6px; - cursor: pointer; - color: var(--ink-strong); - font-weight: 600; -} - -.sd-tree label input { - width: auto; - flex: 0 0 auto; -} - -.sd-tree .sd-size { - color: var(--muted); - margin-left: 8px; - font-weight: 400; -} - -.sd-tree .sd-file { - color: var(--muted); -} - -/* Painted from the tokens rather than left to the UA widget: a - follows the OPERATING SYSTEM colour scheme, not the page's data-theme, so - an explicitly-light page on a dark host was drawing a black bar. */ -.sd-progress { - width: 100%; - height: 14px; - appearance: none; - border: 1px solid var(--line-strong); - border-radius: 999px; - background: var(--surface-inset); - overflow: hidden; -} - -.sd-progress::-webkit-progress-bar { - background: var(--surface-inset); -} - -.sd-progress::-webkit-progress-value { - background: var(--accent); -} - -.sd-progress::-moz-progress-bar { - background: var(--accent); -} - -/* ========================================================================= - Device naming — the per-field preview and error under each name box - (common/brand-editor.js). Both live inside a .field, so they inherit its - column and only need their own type and colour. - ========================================================================= */ - -/* The panel puts its input INSIDE its label rather than tying the two - together by id, because a panel a page can mount twice must not plant - fixed ids. `.field > label` is a flex ROW, though, so a full-width input - next to the label text squeezes the text into three lines — block here - stacks them, which is what every other field on the page looks like. */ -.field[data-brand-field] > label { - display: block; -} - -.field[data-brand-field] > label input { - margin-top: 5px; -} - -.brand-preview { - font-family: var(--font-mono); - font-size: 11px; - color: var(--faint); - /* A preview is a name plus a MAC suffix plus, on an unidentified sensor, - both platforms' forms — so it wraps rather than widening the column. */ - overflow-wrap: anywhere; -} - -.brand-error { - font-size: 11px; - font-weight: 600; - color: var(--danger); -} - -/* Neither takes any space when there is nothing to say, so a field with a - valid name is exactly as tall as one with no preview at all. */ -.brand-preview:empty, -.brand-error:empty { - display: none; -} - -/* The box a name cannot be written from. Also on :focus-visible, or the - focus ring's border colour hides the very field it is reporting. */ -.field input.bad, -.field input.bad:focus-visible { - border-color: var(--danger); -} - -.brand-steps { - margin: 6px 0 0 18px; - padding: 0; -} - -.brand-steps li { - margin: 2px 0; -} - -/* ========================================================================= - Calibration editor (common/calibration-editor.js) - - One card per sensor, each holding three labelled number grids: offset - (3x1), sensitivity (3x1) and alignment (3x3). The three sit side by side - and wrap as a unit, so a narrow window stacks whole grids rather than - breaking a matrix across a line — a 3x3 with a row hanging under it is - unreadable as a matrix. - ========================================================================= */ - -.cal-grids { - display: flex; - flex-wrap: wrap; - gap: 16px; - margin-top: 8px; -} - -.cal-block { - min-width: 0; -} - -.cal-block-title { - display: flex; - align-items: baseline; - gap: 6px; - font-size: 12px; - font-weight: 600; - color: var(--muted); - margin-bottom: 5px; -} - -.cal-unit { - font-weight: 400; - font-size: 11px; - color: var(--faint); -} - -.cal-matrix { - display: grid; - gap: 4px; - align-items: center; -} - -/* Leading column is the axis label; the rest are boxes. */ -.cal-matrix.cols-1 { - grid-template-columns: 14px 104px; -} - -.cal-matrix.cols-3 { - grid-template-columns: 14px repeat(3, 74px); -} - -.cal-axis { - font-family: var(--font-mono); - font-size: 11px; - color: var(--faint); - text-align: right; -} - -/* Numbers, so they line up column-wise and a sign never shifts the digits. */ -input.cal-cell { - font-family: var(--font-mono); - font-size: 12px; - text-align: right; - padding: 4px 6px; - width: 100%; -} - -input.cal-cell:read-only { - background: var(--surface-inset); - color: var(--muted); -} - -input.cal-cell.bad, -input.cal-cell.bad:focus-visible { - border-color: var(--danger); -} - -.cal-error { - margin-top: 8px; - font-size: 11px; - font-weight: 600; - color: var(--danger); -} - -.cal-chip { - font-size: 11px; - font-weight: 400; - color: var(--faint); -} - -.cal-range { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 600; - color: var(--muted); -} - -/* Eats the slack in the row, so Restore defaults sits at the far end. */ -.cal-push { - flex: 1 1 auto; -} - -/* A sensor whose stored calibration is not what the boxes now say. Matches - the dirty-field treatment on the configuration form, deliberately: it is - the same statement — "this differs from the device". */ -.card[data-cal-dirty="true"] { - border-color: var(--warn-border); -} - -/* ========================================================================= - Kinematic block editor inside the configuration form - (common/kinematic-block-editor.js) - - The same three grids as the cards above, rendered as one field's control - instead of as a card of its own — so the Configure tab's Calibration - section reads like the Calibration tab rather than like forty-two hex - characters. Everything visual is reused from the block above; only the - wrapper and the "these are defaults, not measurements" treatment are new. - ========================================================================= */ - -.cal-inline { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -.cal-inline-head { - align-items: center; - gap: 8px; -} - -/* Three grids side by side need about 520px. The field grid's columns are - 300px minimum, so a calibration field takes the whole row rather than - wrapping its matrices into a column one box wide. */ -.field:has(.cal-inline) { - grid-column: 1 / -1; -} - -/* Boxes holding the factory defaults the firmware would fall back to, not - values read off the device. Greyed for the same reason the cards grey - them: they are what the sensor WOULD use, and mistaking them for a stored - calibration is the one misreading that matters here. */ -input.cal-cell.faint { - color: var(--faint); - font-style: italic; -} - -/* The destination preview when the download is about to produce a tree - Consensys cannot import — the MAC could not be read, so the device folder - will be named after the Shimmer instead. A colour rather than a `.banner`, - because the element is a `.row` and has to keep its spacing: this is the one - line that gets to say so before the transfer starts. */ -.preview-warn { - color: var(--warn); - font-weight: 600; -} - -/* A custom editor supplies its own labels for its own boxes, so the field - name above it is a heading rather than a