From 39dd0b314454efc1ef48065602e258566190a894 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 14:02:18 +0000 Subject: [PATCH 01/10] feat(exg): ADS1292R register codec + SD-header EXG banks (EX1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pure EXG (ECG/EMG/respiration) register codec under src/devices/exg/: decodeExgRegisters / encodeExgRegisters (10-byte per-chip bank ↔ structured labelled settings, must-be bits enforced), detectExgPreset (tolerant match, ecg/emg/test-signal/respiration/custom/off), and exgResolutionFromSensors (16/24-bit from the sensor bitmap, not the registers). Ported from SensorEXG.java, ExGConfigBytesDetails.java, and ShimmerObject.exgBytesGetConfigFrom. Also extract the exg1/exg2 register banks from the SD-log header (bytes 56-65 / 66-75, ShimmerSDLog.java:252-253/322-323) into SdLogHeader. Golden-vector round-trip tests against the Java preset arrays + the hardcoded 3R arrays, must-be-bit enforcement, resolution/bitmap derivation, and malformed-length rejection. Version 0.1.10-exg.1 for vendoring. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012pin56yhefi5Mihjf3vTpL --- CHANGELOG.md | 3 + package-lock.json | 4 +- package.json | 2 +- src/devices/exg/index.ts | 58 ++++ src/devices/exg/presets.ts | 185 ++++++++++++ src/devices/exg/registers.ts | 540 +++++++++++++++++++++++++++++++++++ src/devices/sdlog/header.ts | 10 + src/devices/sdlog/types.ts | 8 + src/index.ts | 42 +++ tests/exg/registers.test.ts | 258 +++++++++++++++++ tests/sdlog/fixtures.ts | 8 + tests/sdlog/header.test.ts | 43 +++ 12 files changed, 1158 insertions(+), 3 deletions(-) create mode 100644 src/devices/exg/index.ts create mode 100644 src/devices/exg/presets.ts create mode 100644 src/devices/exg/registers.ts create mode 100644 tests/exg/registers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b173c..a8f00d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ This project follows [Semantic Versioning](https://semver.org/). ### Added +- **ADS1292R (EXG / ECG-EMG-respiration) register codec, phase EX1.** A new pure, transport-free module (`src/devices/exg/`) that decodes and encodes the 10-byte-per-chip ADS1292R register bank and recognises the whole-device preset — the read/display half of EXG configuration. Ported from the desktop Java oracle: `decodeExgRegisters(bank)` mirrors `ShimmerObject.exgBytesGetConfigFrom` (`ShimmerObject.java:6894-6940`) + `ExGConfigBytesDetails.updateFromRegisterArray`, returning every register field as `{ value, label }` (data rate, conversion mode, reference buffer/voltage, per-channel PGA gain + numeric gain value + input-selection mux + power-down, reference-electrode, lead-off detection/current/comparator-threshold + per-lead sense bits, RLD routing, respiration mod/demod/phase/frequency, and the read-only REG8 status bits) with the human-readable labels taken verbatim from `SensorEXG.java:116-149`. `encodeExgRegisters(settings)` is the inverse (`generateExgByteArray`) and always re-applies the hardware "must-be" bits (`applyExgMustBeBits`, ported from `setExgByteArrayConstants`, `ExGConfigBytesDetails.java:507-525`). `detectExgPreset(exg1, exg2, enabledSensors?)` → `'ecg' | 'emg' | 'test-signal' | 'respiration' | 'custom' | 'off'` is a **tolerant** match keyed only off the CH1/CH2 input-selection nibbles + chip-2 respiration bits (+ the resolution flags when the bitmap is supplied), exactly as `SensorEXG.isEXGUsingDefault*` (`SensorEXG.java:2680-2763`) — so the fields firmware rewrites (rate/oversampling in byte0, oscillator-clock in byte1, gain) do not affect detection, and both the 24-bit Java reference arrays and the 16-bit hardcoded 3R arrays detect correctly. **Resolution is not a register field**: `exgResolutionFromSensors(enabledSensors)` → `'16bit' | '24bit' | null` derives it from the sensor bitmap (`ShimmerObject.checkExgResolutionFromEnabledSensorsVar`, `:7255-7279`). Covers Shimmer3 and Shimmer3R (same ADS1292R). New exports: `decodeExgRegisters`, `encodeExgRegisters`, `applyExgMustBeBits`, `detectExgPreset`, `exgResolutionFromSensors`, `exgPresetLabel`, `EXG_PRESET_ARRAYS`, `EXG_BANK_LENGTH`, the option-label lists, and the `DecodedExgRegisters` / `ExgPreset` / `ExgResolution` types. +- **SD-log header EXG register banks.** `SdLogHeader` now carries the raw `exg1` / `exg2` register banks, extracted from SD header bytes 56-65 / 66-75 (`ShimmerSDLog.java:252-253` Shimmer3R, `:322-323` Shimmer3 — both device families use the same offsets). Decode them with the new EXG codec. All-zero on non-EXG logs. + - **RTC-from-host write in the docked config-write flow (`WiredShimmerClient` / `SmartDockClient`), phase P2.** Brings the docked config write to parity with desktop Consensys's `CallableWriteConfig.call()` (`BasicDock.java:1556-1587`), which writes the device's real-world clock from the current PC time **before** the chunked InfoMem write. `writeInfoMemConfig(config, opts)` gains `opts.setRtc` (**default `true`**, matching desktop): when set — and the identity supports RTC-via-UART — it writes the RTC first, then the InfoMem, as one atomic queued unit. Ordering, skip, and failure semantics are ported from the Java: the RTC write is **skipped (not failed)** on identities that do not support it (`isSupportedRtcConfigViaUart`, ported from `ShimmerVerObject.java:405` — Shimmer3 needs SDLog/LogAndStream/StroKare firmware, Shimmer3R is supported on any firmware), and a **RTC-write failure ABORTS the config write** (the InfoMem write is not attempted), exactly as desktop rethrows the RTC `ExecutionException` before reaching the InfoMem write (`BasicDock.java:1564-1573`) — deliberately not best-effort. A new low-level `writeRtcFromHostTime(nowMs?)` (default `Date.now()`, injectable for testability) writes the 8-byte, LSB-first 32.768 kHz tick payload to `MAIN_PROCESSOR.RTC_CFG_TIME` (0x04) — ported byte-for-byte from `CommsProtocolWiredShimmerViaDock.writeRealWorldClockFromPcTime` + `UtilShimmer.convertMilliSecondsToShimmerRtcDataBytesLSB`. `SmartDockClient.writeInfoMemConfig(slot, config, opts)` threads `setRtc` through unchanged. Finalization of a plain config write is unchanged and identical for Shimmer3/3R (the device regenerates its SD config on the next undock / power-cycle; the DFU reboot-then-rewrite dance is out of scope). New exports: `msToRtcBytesLE`, `isSupportedRtcConfigViaUart`. **HARDWARE-VERIFY: the RTC payload format and the `RTC_CFG_TIME` write have not been exercised against a real dock.** - **SmartDock multi-slot base support (`SmartDockClient`), phase D2.** Adds Base-6 / Base-15 multi-slot bases on top of D1's single-device `WiredShimmerClient`. A SmartDock exposes two channels: a **base control** channel speaking short ASCII `SDx$` commands (used to read the base version, query per-slot occupancy, and switch the _active_ slot), and a **per-Shimmer** UART channel onto which the base routes the active slot (spoken with the D1 binary `$`-header protocol) — two distinct FTDI serial ports on real hardware, as in the Java driver (`SmartDock.java:226-229`). Ported from `com.shimmerresearch.managers.dockManager.SmartDockUart` / `SmartDockUartListener` and `com.shimmerresearch.comms.wiredProtocol.SmartDockActiveSlotDetails`. **Scope: READ-ONLY** — dock info, occupancy, slot select, and per-slot identify/status. No config writes, no SD/mass-storage (the `SDC` with-SD-access connect exists in the oracle but is not driven), no bootloader/flashing. Public API: `getDockInfo()` → `{ hardwareType (base6/base15), firmwareVersion, slotCount }` (from the `SDV$` → `V,hw,fwId,major,minor,internal` reply); `getSlotOccupancy()` → per-slot occupied/empty (from `SDQ$` → `Q,`); `selectSlot(n)` → issues `SDP,NN$`, awaits the `P,NN` confirmation with the **ported ~10 s slot-change timeout** (`SMARTDOCK_RESPONSE_TIMEOUT_SLOT_CHANGE`), verifies the slot, then applies the **1500 ms** without-SD settle delay (`SLOT_CHANGEOVER_DELAY_WITHOUT_SD_CARD`) and resyncs the per-Shimmer stream; and `identifyDockedShimmer(n)` / `getDockedShimmerStatus(n)` which select the slot then **compose (do not duplicate)** `WiredShimmerClient` against the now-active slot. Normal base-command reads use the ported **1000 ms** timeout (`SMARTDOCK_RESPONSE_TIMEOUT`). Like D1, the base UART is an **unframed byte stream**, so the client accumulates bytes and extracts `\r\n`-terminated lines (`extractBaseLine`), ignoring unrecognised/partial lines to resync after garbage; an `E` error line rejects. Transport injection is **required** (base transport; throws without one). A one-line addition to `WiredShimmerClient` — `resyncStream()` — lets the composed D1 client drop stale bytes after a slot re-route. **Verification status: code-complete pending hardware** (see README for slot-change timing, occupancy semantics, and base6-vs-15 detection items). New exports: `SmartDockClient`, `SmartDockClientOptions`, `SmartDockInfo`, `SlotOccupancy`, the base command / connection-type / hardware-id tables (`SMARTDOCK_BASE_CMD`, `SMARTDOCK_CONNECTION_TYPE`, `SMARTDOCK_DEFAULTS`, `BASE_HARDWARE_IDS`), and the pure codec (`buildBaseCommand`, `buildSelectSlotCommand`, `extractBaseLine`, `classifyBaseResponse`, `parseSmartDockVersion`, `parseSlotOccupancy`, `parseActiveSlot`, `baseHardwareType`). diff --git a/package-lock.json b/package-lock.json index 2f4d1ce..523be53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.9", + "version": "0.1.10-exg.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.9", + "version": "0.1.10-exg.1", "license": "BSD-3-Clause", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 7befd55..7811fdb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.9", + "version": "0.1.10-exg.1", "description": "Web Bluetooth and Web Serial API for Shimmer sensor devices (Shimmer3R, Verisense IMU/Pulse+)", "type": "module", "main": "dist/shimmer-web-sdk.cjs", diff --git a/src/devices/exg/index.ts b/src/devices/exg/index.ts new file mode 100644 index 0000000..9e9a288 --- /dev/null +++ b/src/devices/exg/index.ts @@ -0,0 +1,58 @@ +/** + * ADS1292R (EXG / ECG-EMG-respiration) register codec. + * + * Pure, transport-free port of the desktop Java EXG configuration + * (com.shimmerresearch.sensors.SensorEXG, + * com.shimmerresearch.exgConfig.ExGConfigBytesDetails, and the EXG accessors + * in com.shimmerresearch.driver.ShimmerObject). Decodes/encodes the 10-byte + * per-chip register bank, detects the whole-device preset, and derives the + * 16/24-bit resolution from the sensor bitmap. + * + * @packageDocumentation + */ + +export { + EXG_BANK_LENGTH, + decodeExgRegisters, + encodeExgRegisters, + applyExgMustBeBits, + // option label lists (verbatim from SensorEXG.java:116-149) + CONVERSION_MODE_LABELS, + DATA_RATE_LABELS, + VOLTAGE_REFERENCE_LABELS, + TEST_SIGNAL_FREQUENCY_LABELS, + COMPARATOR_THRESHOLD_LABELS, + LEAD_OFF_CURRENT_LABELS, + LEAD_OFF_FREQUENCY_LABELS, + GAIN_LABELS, + GAIN_VALUES, + POWER_DOWN_LABELS, + INPUT_SELECTION_LABELS, + CHOP_FREQUENCY_LABELS, + RESPIRATION_PHASE_32KHZ_LABELS, + RESPIRATION_PHASE_64KHZ_LABELS, + RESPIRATION_FREQUENCY_LABELS, + RLD_REFERENCE_SIGNAL_LABELS, + RESPIRATION_CONTROL_LABELS, +} from './registers.js'; + +export type { + ExgFieldValue, + ExgGainValue, + ExgChannelSettings, + ExgLeadOffSettings, + ExgRespirationSettings, + ExgRldSettings, + ExgTestSignalSettings, + ExgStatusBits, + DecodedExgRegisters, +} from './registers.js'; + +export { + EXG_PRESET_ARRAYS, + exgResolutionFromSensors, + detectExgPreset, + exgPresetLabel, +} from './presets.js'; + +export type { ExgPreset, ExgResolution } from './presets.js'; diff --git a/src/devices/exg/presets.ts b/src/devices/exg/presets.ts new file mode 100644 index 0000000..0c681da --- /dev/null +++ b/src/devices/exg/presets.ts @@ -0,0 +1,185 @@ +/** + * ADS1292R (EXG) preset detection and resolution derivation. + * + * Pure port of the Java oracle: + * - preset register arrays : SensorEXG.setDefault... / setEXG... (the commented + * reference byte arrays in SensorEXG.java:1782-1783 ECG, 1813-1814 EMG, + * 1845 test, 1872-1873 respiration, 1921-1922 custom) + * - preset detection : SensorEXG.isEXGUsingDefault* + * (SensorEXG.java:2680-2763), invoked in the order + * Respiration → ECG → EMG → TestSignal → Custom + * (ShimmerObject.java:1791-1820) + * - resolution ↔ bitmap : ShimmerObject.checkExgResolutionFromEnabledSensorsVar + * (ShimmerObject.java:7255-7279), using the ConfigByteLayoutShimmer3 + * sensor-bitmap masks (ConfigByteLayoutShimmer3.java:300-303). + */ + +import { EXG_BANK_LENGTH } from './registers.js'; + +/** A recognised EXG preset, or 'custom' / 'off'. */ +export type ExgPreset = 'ecg' | 'emg' | 'test-signal' | 'respiration' | 'custom' | 'off'; + +/** EXG resolution derived from the sensor bitmap. */ +export type ExgResolution = '16bit' | '24bit'; + +/** + * Canonical per-chip register arrays for each preset, verbatim from the Java + * oracle's reference byte arrays (SensorEXG.java, decimal values). The register + * bytes are identical for the 16-bit and 24-bit variants of a preset — only the + * sensor bitmap differs (see {@link exgResolutionFromSensors}). + */ +export const EXG_PRESET_ARRAYS = Object.freeze({ + /** SensorEXG.java:1782-1783 */ + ecg: { + exg1: Object.freeze([2, 160, 16, 64, 64, 45, 0, 0, 2, 3]), + exg2: Object.freeze([2, 160, 16, 64, 71, 0, 0, 0, 2, 1]), + }, + /** SensorEXG.java:1813-1814 */ + emg: { + exg1: Object.freeze([2, 160, 16, 105, 96, 32, 0, 0, 2, 3]), + exg2: Object.freeze([2, 160, 16, 129, 129, 0, 0, 0, 2, 1]), + }, + /** SensorEXG.java:1845 */ + 'test-signal': { + exg1: Object.freeze([2, 163, 16, 5, 5, 0, 0, 0, 2, 1]), + exg2: Object.freeze([2, 163, 16, 5, 5, 0, 0, 0, 2, 1]), + }, + /** SensorEXG.java:1872-1873 */ + respiration: { + exg1: Object.freeze([2, 160, 16, 64, 64, 32, 0, 0, 2, 3]), + exg2: Object.freeze([2, 160, 16, 64, 71, 0, 0, 0, 234, 1]), + }, + /** SensorEXG.java:1921-1922 */ + custom: { + exg1: Object.freeze([2, 163, 16, 7, 7, 0, 0, 0, 2, 1]), + exg2: Object.freeze([2, 163, 16, 7, 7, 0, 0, 0, 2, 1]), + }, +} as const); + +// Sensor-bitmap masks (ConfigByteLayoutShimmer3.java:300-303 == SensorBitmapShimmer3). +const MASK_EXG1_24BIT = 0x000010; +const MASK_EXG2_24BIT = 0x000008; +const MASK_EXG1_16BIT = 0x100000; +const MASK_EXG2_16BIT = 0x080000; + +interface ResolutionFlags { + exg1_16: boolean; + exg2_16: boolean; + exg1_24: boolean; + exg2_24: boolean; +} + +function resolutionFlags(enabledSensors: number): ResolutionFlags { + const es = enabledSensors >>> 0; + return { + exg1_24: (es & MASK_EXG1_24BIT) !== 0, + exg2_24: (es & MASK_EXG2_24BIT) !== 0, + exg1_16: (es & MASK_EXG1_16BIT) !== 0, + exg2_16: (es & MASK_EXG2_16BIT) !== 0, + }; +} + +/** + * Derive EXG resolution from the enabled-sensors bitmap. Resolution is not a + * register field — it lives entirely in the sensor bitmap. Mirrors + * ShimmerObject.checkExgResolutionFromEnabledSensorsVar (:7255-7279): the + * 16-bit flags take precedence over the 24-bit flags. + * + * @returns '16bit' | '24bit', or null when no EXG chip is enabled. + */ +export function exgResolutionFromSensors(enabledSensors: number): ExgResolution | null { + const f = resolutionFlags(enabledSensors); + if (f.exg1_16 || f.exg2_16) return '16bit'; + if (f.exg1_24 || f.exg2_24) return '24bit'; + return null; +} + +function allZero(bank: Uint8Array): boolean { + for (const b of bank) if (b !== 0) return false; + return true; +} + +/** + * Detect which preset a pair of register banks represents. + * + * Detection is TOLERANT, exactly as the Java oracle's isEXGUsingDefault* + * checks (SensorEXG.java:2680-2763): it keys only off the CH1/CH2 + * input-selection nibbles (byte3/byte4 low nibble) plus the chip-2 respiration + * modulation/demodulation bits, and — when {@link enabledSensors} is supplied + * — the resolution flags. It does NOT compare the full byte arrays, so the + * fields the firmware rewrites (the data-rate/oversampling bits in byte0, the + * oscillator-clock bit in byte1, PGA gain, etc.) do not affect the result. + * This is why the 16-bit-only hardcoded 3R preset arrays — which differ from + * the Java reference arrays in byte1 (oscillator-clock bit) and the rate bits — + * still detect correctly. + * + * The resolution gate mirrors Java: ECG/TestSignal/Respiration require both + * chips at the same resolution; EMG requires chip 1 only. When + * {@link enabledSensors} is omitted the resolution gate is relaxed (detection + * proceeds purely on the input-selection/respiration fields), and empty banks + * report 'off'. + * + * Evaluation order is Respiration → ECG → EMG → TestSignal → Custom + * (ShimmerObject.java:1791-1820): respiration shares the ECG input selections + * and is distinguished only by its modulation/demodulation bits, so it must be + * tested first. + * + * @throws RangeError when either bank is not exactly 10 bytes. + */ +export function detectExgPreset( + exg1: Uint8Array, + exg2: Uint8Array, + enabledSensors?: number, +): ExgPreset { + if (exg1.length !== EXG_BANK_LENGTH || exg2.length !== EXG_BANK_LENGTH) { + throw new RangeError( + `EXG register banks must be exactly ${EXG_BANK_LENGTH} bytes each, got ${exg1.length}/${exg2.length}.`, + ); + } + + const flags = enabledSensors != null ? resolutionFlags(enabledSensors) : null; + const bothChips = flags + ? (flags.exg1_16 && flags.exg2_16) || (flags.exg1_24 && flags.exg2_24) + : true; + const chip1Only = flags + ? (flags.exg1_16 && !flags.exg2_16) || (flags.exg1_24 && !flags.exg2_24) + : true; + const anyExg = flags + ? flags.exg1_16 || flags.exg2_16 || flags.exg1_24 || flags.exg2_24 + : !(allZero(exg1) && allZero(exg2)); + + if (!anyExg) return 'off'; + + const c1ch1 = exg1[3] & 0x0f; + const c1ch2 = exg1[4] & 0x0f; + const c2ch1 = exg2[3] & 0x0f; + const c2ch2 = exg2[4] & 0x0f; + // Chip-2 REG9: demod (bit7) and mod (bit6) circuitry on (SensorEXG.java:2733-2734). + const respirationOn = ((exg2[8] >> 7) & 1) === 1 && ((exg2[8] >> 6) & 1) === 1; + + // NORMAL=0, SHORTED=1, TEST_SIGNAL=5, RLDIN_TO_NEG=7, ROUTE_CH3_TO_CH1=9 + // (ExGConfigBytesDetails.java:196-234). + if (bothChips && respirationOn) return 'respiration'; + if (bothChips && c1ch1 === 0 && c1ch2 === 0 && c2ch1 === 0 && c2ch2 === 7) return 'ecg'; + if (chip1Only && c1ch1 === 9 && c1ch2 === 0 && c2ch1 === 1 && c2ch2 === 1) return 'emg'; + if (bothChips && c1ch1 === 5 && c1ch2 === 5 && c2ch1 === 5 && c2ch2 === 5) return 'test-signal'; + return 'custom'; +} + +/** Human-readable label for a detected preset (for read-only display). */ +export function exgPresetLabel(preset: ExgPreset): string { + switch (preset) { + case 'ecg': + return 'ECG'; + case 'emg': + return 'EMG'; + case 'test-signal': + return 'ExG Test Signal'; + case 'respiration': + return 'Respiration'; + case 'custom': + return 'Custom'; + case 'off': + return 'Off'; + } +} diff --git a/src/devices/exg/registers.ts b/src/devices/exg/registers.ts new file mode 100644 index 0000000..4d46ec7 --- /dev/null +++ b/src/devices/exg/registers.ts @@ -0,0 +1,540 @@ +/** + * ADS1292R (EXG) register-bank codec — decode a 10-byte per-chip register + * bank into structured, human-readable settings and encode it back, enforcing + * the hardware "must-be" bits. + * + * Pure port of the Java oracle: + * - decode : ShimmerObject.exgBytesGetConfigFrom + * (ShimmerObject.java:6894-6940) + * - field bit-layout : ExGConfigBytesDetails.mMapOfExGSettingsChip1 + * (ExGConfigBytesDetails.java:369-451) — every field's + * byteIndex / bitShift / mask + * - encode : ExGConfigBytesDetails.generateExgByteArray + * (ExGConfigBytesDetails.java:487-502) + * - must-be bits : ExGConfigBytesDetails.setExgByteArrayConstants + * (ExGConfigBytesDetails.java:507-525) + * - option labels : SensorEXG.java:116-149 (GUI value lists) and the + * EXG_SETTING_OPTIONS enums (ExGConfigBytesDetails.java:116-360) + * - gain value map : SensorEXG.convertEXGGainSettingToValue + * (SensorEXG.java:2637) + * + * Resolution (16- vs 24-bit) is NOT a register field — it lives in the sensor + * bitmap. See {@link exgResolutionFromSensors} in ./presets.ts + * (ShimmerObject.checkExgResolutionFromEnabledSensorsVar, :7255-7279). + */ + +/** Number of register bytes in one ADS1292R chip bank (InfoMem EXG_BANK_LENGTH). */ +export const EXG_BANK_LENGTH = 10; + +// -------------------------------------------------------------------------- +// Option label lists (verbatim from the Java oracle; the array index is the +// register field's config value). +// -------------------------------------------------------------------------- + +/** REG1 conversion mode (ExGConfigBytesDetails.java:119-120). */ +export const CONVERSION_MODE_LABELS = ['Continuous Conversion Mode', 'Single-shot mode'] as const; +/** REG1 data rate 0-6 (SensorEXG.java:148 ListOfExGRate). */ +export const DATA_RATE_LABELS = [ + '125 Hz', + '250 Hz', + '500 Hz', + '1 kHz', + '2 kHz', + '4 kHz', + '8 kHz', +] as const; +/** REG2 voltage reference (ExGConfigBytesDetails.java:142-143). */ +export const VOLTAGE_REFERENCE_LABELS = ['2.42 V', '4.033 V'] as const; +/** REG2 test-signal frequency (ExGConfigBytesDetails.java:154-155). */ +export const TEST_SIGNAL_FREQUENCY_LABELS = ['DC', '1 kHz Square Wave'] as const; +/** REG3 lead-off comparator threshold 0-7 (SensorEXG.java:136). */ +export const COMPARATOR_THRESHOLD_LABELS = [ + 'Pos:95%-Neg:5%', + 'Pos:92.5%-Neg:7.5%', + 'Pos:90%-Neg:10%', + 'Pos:87.5%-Neg:12.5%', + 'Pos:85%-Neg:15%', + 'Pos:80%-Neg:20%', + 'Pos:75%-Neg:25%', + 'Pos:70%-Neg:30%', +] as const; +/** REG3 lead-off current 0-3 (SensorEXG.java:134). */ +export const LEAD_OFF_CURRENT_LABELS = ['6 nA', '22 nA', '6 uA', '22 uA'] as const; +/** REG3 lead-off frequency (ExGConfigBytesDetails.java:177-178). */ +export const LEAD_OFF_FREQUENCY_LABELS = [ + 'DC lead-off detect', + 'AC lead-off detect (fs / 4)', +] as const; +/** REG4/REG5 PGA gain setting 0-6 → GUI label (SensorEXG.java:116 ListOfExGGain). */ +export const GAIN_LABELS = ['6', '1', '2', '3', '4', '8', '12'] as const; +/** REG4/REG5 PGA gain setting 0-6 → numeric gain (SensorEXG.convertEXGGainSettingToValue, :2637). */ +export const GAIN_VALUES = [6, 1, 2, 3, 4, 8, 12] as const; +/** REG4/REG5 channel power-down (ExGConfigBytesDetails.java:184-185). */ +export const POWER_DOWN_LABELS = ['Normal operation', 'Power-down'] as const; +/** REG4/REG5 input selection 0-9 (ExGConfigBytesDetails.java:196-234). */ +export const INPUT_SELECTION_LABELS = [ + 'Normal electrode input', + 'Input shorted', + 'RLD_MEASURE', + 'Supply measurement', + 'Temperature sensor', + 'Test signal', + 'RLD_DRP (positive side connected to RLDIN)', + 'RLD_DRM (negative side connected to RLDIN)', + 'RLD_DRPM (both connected to RLDIN)', + 'Route IN3P/IN3N to channel 1 inputs', +] as const; +/** REG6 PGA chop frequency (ExGConfigBytesDetails.java:240-242; value 1 is reserved). */ +export const CHOP_FREQUENCY_LABELS = ['fMOD / 16', 'reserved', 'fMOD / 2', 'fMOD / 4'] as const; +/** REG9 respiration phase at 32 kHz, 0-15 (SensorEXG.java:143). */ +export const RESPIRATION_PHASE_32KHZ_LABELS = [ + '0°', + '11.25°', + '22.5°', + '33.75°', + '45°', + '56.25°', + '67.5°', + '78.75°', + '90°', + '101.25°', + '112.5°', + '123.75°', + '135°', + '146.25°', + '157.5°', + '168.75°', +] as const; +/** REG9 respiration phase at 64 kHz, 0-7 (SensorEXG.java:145). */ +export const RESPIRATION_PHASE_64KHZ_LABELS = [ + '0°', + '22.5°', + '45°', + '67.5°', + '90°', + '112.5°', + '135°', + '157.5°', +] as const; +/** REG10 respiration control frequency (SensorEXG.java:140). */ +export const RESPIRATION_FREQUENCY_LABELS = ['32 kHz', '64 kHz'] as const; +/** REG10 RLD reference signal (ExGConfigBytesDetails.java:357-358). */ +export const RLD_REFERENCE_SIGNAL_LABELS = ['Fed externally', '(AVDD - AVSS) / 2'] as const; +/** REG9 respiration control clock (ExGConfigBytesDetails.java:342-343). */ +export const RESPIRATION_CONTROL_LABELS = ['Internal clock', 'External Clock'] as const; + +const ON_OFF = ['Off', 'On'] as const; + +/** + * Reference-electrode config value → label (byte5 & 0x0F). The four + * meaningful values come from ListOfExGReferenceElectrodeConfigValuesAll + * {0,3,13,7} (SensorEXG.java:123-124); any other value is reported as custom. + */ +const REFERENCE_ELECTRODE_LABELS: Record = { + 0: 'Fixed Potential', + 3: 'Inverse of Ch1', + 13: 'Inverse Wilson CT', + 7: '3-Ch Single-ended', +}; + +// -------------------------------------------------------------------------- +// Field bit-layout table — the sole source of truth for both decode and +// encode, keeping the two directions symmetric (ExGConfigBytesDetails.java:369-451). +// -------------------------------------------------------------------------- + +interface FieldSpec { + byteIndex: number; + bitShift: number; + mask: number; +} + +/** Every ADS1292R register field, keyed by a stable name. */ +const FIELDS = { + conversionMode: { byteIndex: 0, bitShift: 7, mask: 0x01 }, + dataRate: { byteIndex: 0, bitShift: 0, mask: 0x07 }, + + leadOffComparators: { byteIndex: 1, bitShift: 6, mask: 0x01 }, + referenceBuffer: { byteIndex: 1, bitShift: 5, mask: 0x01 }, + voltageReference: { byteIndex: 1, bitShift: 4, mask: 0x01 }, + oscillatorClockConnection: { byteIndex: 1, bitShift: 3, mask: 0x01 }, + testSignalSelection: { byteIndex: 1, bitShift: 1, mask: 0x01 }, + testSignalFrequency: { byteIndex: 1, bitShift: 0, mask: 0x01 }, + + comparatorThreshold: { byteIndex: 2, bitShift: 5, mask: 0x07 }, + leadOffCurrent: { byteIndex: 2, bitShift: 2, mask: 0x03 }, + leadOffFrequency: { byteIndex: 2, bitShift: 0, mask: 0x01 }, + + ch1PowerDown: { byteIndex: 3, bitShift: 7, mask: 0x01 }, + ch1Gain: { byteIndex: 3, bitShift: 4, mask: 0x07 }, + ch1InputSelection: { byteIndex: 3, bitShift: 0, mask: 0x0f }, + + ch2PowerDown: { byteIndex: 4, bitShift: 7, mask: 0x01 }, + ch2Gain: { byteIndex: 4, bitShift: 4, mask: 0x07 }, + ch2InputSelection: { byteIndex: 4, bitShift: 0, mask: 0x0f }, + + chopFrequency: { byteIndex: 5, bitShift: 6, mask: 0x03 }, + rldBufferPower: { byteIndex: 5, bitShift: 5, mask: 0x01 }, + rldLeadOffSenseFunction: { byteIndex: 5, bitShift: 4, mask: 0x01 }, + ch2RldNegInputs: { byteIndex: 5, bitShift: 3, mask: 0x01 }, + ch2RldPosInputs: { byteIndex: 5, bitShift: 2, mask: 0x01 }, + ch1RldNegInputs: { byteIndex: 5, bitShift: 1, mask: 0x01 }, + ch1RldPosInputs: { byteIndex: 5, bitShift: 0, mask: 0x01 }, + + ch2FlipCurrent: { byteIndex: 6, bitShift: 5, mask: 0x01 }, + ch1FlipCurrent: { byteIndex: 6, bitShift: 4, mask: 0x01 }, + ch2LeadOffDetectNegInputs: { byteIndex: 6, bitShift: 3, mask: 0x01 }, + ch2LeadOffDetectPosInputs: { byteIndex: 6, bitShift: 2, mask: 0x01 }, + ch1LeadOffDetectNegInputs: { byteIndex: 6, bitShift: 1, mask: 0x01 }, + ch1LeadOffDetectPosInputs: { byteIndex: 6, bitShift: 0, mask: 0x01 }, + + clockDividerSelection: { byteIndex: 7, bitShift: 6, mask: 0x01 }, + rldLeadOffStatus: { byteIndex: 7, bitShift: 4, mask: 0x01 }, + ch2NegElectrodeStatus: { byteIndex: 7, bitShift: 3, mask: 0x01 }, + ch2PosElectrodeStatus: { byteIndex: 7, bitShift: 2, mask: 0x01 }, + ch1NegElectrodeStatus: { byteIndex: 7, bitShift: 1, mask: 0x01 }, + ch1PosElectrodeStatus: { byteIndex: 7, bitShift: 0, mask: 0x01 }, + + respirationDemodCircuitry: { byteIndex: 8, bitShift: 7, mask: 0x01 }, + respirationModCircuitry: { byteIndex: 8, bitShift: 6, mask: 0x01 }, + respirationPhase: { byteIndex: 8, bitShift: 2, mask: 0x0f }, + respirationControl: { byteIndex: 8, bitShift: 0, mask: 0x01 }, + + respirationCalibration: { byteIndex: 9, bitShift: 7, mask: 0x01 }, + respirationControlFrequency: { byteIndex: 9, bitShift: 2, mask: 0x01 }, + rldReferenceSignal: { byteIndex: 9, bitShift: 1, mask: 0x01 }, +} as const satisfies Record; + +type FieldName = keyof typeof FIELDS; + +/** A decoded register field: raw config value plus a human-readable label. */ +export interface ExgFieldValue { + /** Raw config value as read from the register bits. */ + value: number; + /** Human-readable label from the Java GUI value lists. */ + label: string; +} + +/** A decoded PGA-gain field with its numeric gain value. */ +export interface ExgGainValue extends ExgFieldValue { + /** Numeric PGA gain (6,1,2,3,4,8,12) — SensorEXG.convertEXGGainSettingToValue. */ + gain: number; +} + +/** Per-channel (CH1/CH2) settings from REG4/REG5. */ +export interface ExgChannelSettings { + powerDown: ExgFieldValue; + gain: ExgGainValue; + inputSelection: ExgFieldValue; +} + +/** Lead-off detection settings (REG2 comparator enable + REG3 + REG7 per-lead). */ +export interface ExgLeadOffSettings { + /** True when the lead-off comparators are powered on (REG2 bit6). */ + detectionEnabled: boolean; + comparators: ExgFieldValue; + current: ExgFieldValue; + comparatorThreshold: ExgFieldValue; + frequency: ExgFieldValue; + ch1: { posInput: ExgFieldValue; negInput: ExgFieldValue; flipCurrent: ExgFieldValue }; + ch2: { posInput: ExgFieldValue; negInput: ExgFieldValue; flipCurrent: ExgFieldValue }; +} + +/** Respiration circuitry settings (REG9/REG10 — only meaningful on chip 2). */ +export interface ExgRespirationSettings { + /** True when both the modulation and demodulation circuits are on. */ + enabled: boolean; + demod: ExgFieldValue; + mod: ExgFieldValue; + phase: ExgFieldValue; + control: ExgFieldValue; + calibration: ExgFieldValue; + frequency: ExgFieldValue; +} + +/** Right-leg-drive (RLD) routing (REG6 + REG10 reference). */ +export interface ExgRldSettings { + bufferPower: ExgFieldValue; + leadOffSenseFunction: ExgFieldValue; + chopFrequency: ExgFieldValue; + referenceSignal: ExgFieldValue; + ch1: { posInput: ExgFieldValue; negInput: ExgFieldValue }; + ch2: { posInput: ExgFieldValue; negInput: ExgFieldValue }; +} + +/** Test-signal settings (REG2). */ +export interface ExgTestSignalSettings { + enabled: ExgFieldValue; + frequency: ExgFieldValue; +} + +/** Read-only lead-off status bits (REG8). */ +export interface ExgStatusBits { + clockDivider: ExgFieldValue; + rldLeadOff: ExgFieldValue; + ch1PosElectrode: ExgFieldValue; + ch1NegElectrode: ExgFieldValue; + ch2PosElectrode: ExgFieldValue; + ch2NegElectrode: ExgFieldValue; +} + +/** Fully decoded ADS1292R register bank for one chip. */ +export interface DecodedExgRegisters { + conversionMode: ExgFieldValue; + dataRate: ExgFieldValue; + referenceBuffer: ExgFieldValue; + voltageReference: ExgFieldValue; + oscillatorClockConnection: ExgFieldValue; + testSignal: ExgTestSignalSettings; + /** CH1 settings (REG4). */ + ch1: ExgChannelSettings; + /** CH2 settings (REG5). */ + ch2: ExgChannelSettings; + /** Reference-electrode selection (byte5 & 0x0F, ShimmerObject.java:6912). */ + referenceElectrode: ExgFieldValue; + leadOff: ExgLeadOffSettings; + rld: ExgRldSettings; + respiration: ExgRespirationSettings; + /** Read-only status bits (REG8) — cleared on write by the must-be constants. */ + status: ExgStatusBits; + /** The 10 raw register bytes this was decoded from. */ + raw: number[]; +} + +// -------------------------------------------------------------------------- +// Decode +// -------------------------------------------------------------------------- + +function readField(bank: Uint8Array, name: FieldName): number { + const f = FIELDS[name]; + return (bank[f.byteIndex] >> f.bitShift) & f.mask; +} + +function labelled(value: number, labels: readonly string[]): ExgFieldValue { + return { value, label: labels[value] ?? `Unknown (${value})` }; +} + +function onOff(value: number): ExgFieldValue { + return { value, label: ON_OFF[value] ?? `Unknown (${value})` }; +} + +function gainField(value: number): ExgGainValue { + return { + value, + label: GAIN_LABELS[value] ?? `Unknown (${value})`, + gain: GAIN_VALUES[value] ?? -1, + }; +} + +function channelSettings(bank: Uint8Array, chan: 1 | 2): ExgChannelSettings { + const pd = chan === 1 ? 'ch1PowerDown' : 'ch2PowerDown'; + const gn = chan === 1 ? 'ch1Gain' : 'ch2Gain'; + const inp = chan === 1 ? 'ch1InputSelection' : 'ch2InputSelection'; + return { + powerDown: labelled(readField(bank, pd), POWER_DOWN_LABELS), + gain: gainField(readField(bank, gn)), + inputSelection: labelled(readField(bank, inp), INPUT_SELECTION_LABELS), + }; +} + +/** + * Decode a single 10-byte ADS1292R register bank into structured settings. + * Mirrors ShimmerObject.exgBytesGetConfigFrom (ShimmerObject.java:6894-6940) + * combined with ExGConfigBytesDetails.updateFromRegisterArray (:528-539). + * + * @throws RangeError when the bank is not exactly 10 bytes. + */ +export function decodeExgRegisters(bank: Uint8Array): DecodedExgRegisters { + if (bank.length !== EXG_BANK_LENGTH) { + throw new RangeError( + `EXG register bank must be exactly ${EXG_BANK_LENGTH} bytes, got ${bank.length}.`, + ); + } + + const respFreqValue = readField(bank, 'respirationControlFrequency'); + const phaseValue = readField(bank, 'respirationPhase'); + // Phase label depends on the detection frequency (16 options @32 kHz, 8 @64 kHz) + // — SensorEXG.java:143-146, PanelAdvancedExG.java:555-556. + const phaseLabels = + respFreqValue === 0 ? RESPIRATION_PHASE_32KHZ_LABELS : RESPIRATION_PHASE_64KHZ_LABELS; + + const comparators = onOff(readField(bank, 'leadOffComparators')); + const refElectrodeValue = bank[5] & 0x0f; + + return { + conversionMode: labelled(readField(bank, 'conversionMode'), CONVERSION_MODE_LABELS), + dataRate: labelled(readField(bank, 'dataRate'), DATA_RATE_LABELS), + referenceBuffer: onOff(readField(bank, 'referenceBuffer')), + voltageReference: labelled(readField(bank, 'voltageReference'), VOLTAGE_REFERENCE_LABELS), + oscillatorClockConnection: onOff(readField(bank, 'oscillatorClockConnection')), + testSignal: { + enabled: onOff(readField(bank, 'testSignalSelection')), + frequency: labelled(readField(bank, 'testSignalFrequency'), TEST_SIGNAL_FREQUENCY_LABELS), + }, + ch1: channelSettings(bank, 1), + ch2: channelSettings(bank, 2), + referenceElectrode: { + value: refElectrodeValue, + label: + REFERENCE_ELECTRODE_LABELS[refElectrodeValue] ?? + `Custom (0x${refElectrodeValue.toString(16)})`, + }, + leadOff: { + detectionEnabled: comparators.value === 1, + comparators, + current: labelled(readField(bank, 'leadOffCurrent'), LEAD_OFF_CURRENT_LABELS), + comparatorThreshold: labelled( + readField(bank, 'comparatorThreshold'), + COMPARATOR_THRESHOLD_LABELS, + ), + frequency: labelled(readField(bank, 'leadOffFrequency'), LEAD_OFF_FREQUENCY_LABELS), + ch1: { + posInput: onOff(readField(bank, 'ch1LeadOffDetectPosInputs')), + negInput: onOff(readField(bank, 'ch1LeadOffDetectNegInputs')), + flipCurrent: onOff(readField(bank, 'ch1FlipCurrent')), + }, + ch2: { + posInput: onOff(readField(bank, 'ch2LeadOffDetectPosInputs')), + negInput: onOff(readField(bank, 'ch2LeadOffDetectNegInputs')), + flipCurrent: onOff(readField(bank, 'ch2FlipCurrent')), + }, + }, + rld: { + bufferPower: onOff(readField(bank, 'rldBufferPower')), + leadOffSenseFunction: onOff(readField(bank, 'rldLeadOffSenseFunction')), + chopFrequency: labelled(readField(bank, 'chopFrequency'), CHOP_FREQUENCY_LABELS), + referenceSignal: labelled(readField(bank, 'rldReferenceSignal'), RLD_REFERENCE_SIGNAL_LABELS), + ch1: { + posInput: onOff(readField(bank, 'ch1RldPosInputs')), + negInput: onOff(readField(bank, 'ch1RldNegInputs')), + }, + ch2: { + posInput: onOff(readField(bank, 'ch2RldPosInputs')), + negInput: onOff(readField(bank, 'ch2RldNegInputs')), + }, + }, + respiration: { + enabled: + readField(bank, 'respirationDemodCircuitry') === 1 && + readField(bank, 'respirationModCircuitry') === 1, + demod: onOff(readField(bank, 'respirationDemodCircuitry')), + mod: onOff(readField(bank, 'respirationModCircuitry')), + phase: labelled(phaseValue, phaseLabels), + control: labelled(readField(bank, 'respirationControl'), RESPIRATION_CONTROL_LABELS), + calibration: onOff(readField(bank, 'respirationCalibration')), + frequency: labelled(respFreqValue, RESPIRATION_FREQUENCY_LABELS), + }, + status: { + clockDivider: { + value: readField(bank, 'clockDividerSelection'), + label: + readField(bank, 'clockDividerSelection') === 0 ? 'fMOD = fCLK / 4' : 'fMOD = fCLK / 16', + }, + rldLeadOff: onOff(readField(bank, 'rldLeadOffStatus')), + ch1PosElectrode: onOff(readField(bank, 'ch1PosElectrodeStatus')), + ch1NegElectrode: onOff(readField(bank, 'ch1NegElectrodeStatus')), + ch2PosElectrode: onOff(readField(bank, 'ch2PosElectrodeStatus')), + ch2NegElectrode: onOff(readField(bank, 'ch2NegElectrodeStatus')), + }, + raw: Array.from(bank), + }; +} + +// -------------------------------------------------------------------------- +// Encode +// -------------------------------------------------------------------------- + +const BIT = (n: number): number => 1 << n; + +/** + * Force the ADS1292R "must-be" bits listed in the datasheet, exactly as + * ExGConfigBytesDetails.setExgByteArrayConstants (ExGConfigBytesDetails.java:507-525). + * Mutates and returns the array. + */ +export function applyExgMustBeBits(bank: Uint8Array): Uint8Array { + // CONFIG1 (0x00): reserved bits 3-6 cleared. + bank[0] &= ~(BIT(6) | BIT(5) | BIT(4) | BIT(3)) & 0xff; + // CONFIG2 (0x80): bit7 must be 1, bit2 must be 0. + bank[1] |= BIT(7); + bank[1] &= ~BIT(2) & 0xff; + // LOFF (0x10): bit4 must be 1, bit1 must be 0. + bank[2] |= BIT(4); + bank[2] &= ~BIT(1) & 0xff; + // LOFF_SENS (0x00): bits 6-7 cleared. + bank[6] &= ~(BIT(7) | BIT(6)) & 0xff; + // LOFF_STAT (0x00): read-only status bits cleared (only bit6 clock-div kept). + bank[7] &= ~(BIT(7) | BIT(5) | BIT(4) | BIT(3) | BIT(2) | BIT(1) | BIT(0)) & 0xff; + // RESP1 (0x02): bit1 must be 1. + bank[8] |= BIT(1); + // RESP2 (0x01): reserved bits 3-6 cleared, bit0 must be 1. + bank[9] &= ~(BIT(6) | BIT(5) | BIT(4) | BIT(3)) & 0xff; + bank[9] |= BIT(0); + return bank; +} + +function writeField(bank: Uint8Array, name: FieldName, value: number): void { + const f = FIELDS[name]; + bank[f.byteIndex] |= (value & f.mask) << f.bitShift; +} + +/** + * Encode structured settings back into a 10-byte register bank, applying the + * mandatory must-be bits. Inverse of {@link decodeExgRegisters}; mirrors + * ExGConfigBytesDetails.generateExgByteArray (ExGConfigBytesDetails.java:487-502). + * + * A round-trip `encodeExgRegisters(decodeExgRegisters(bank))` reproduces + * `bank` for any bank already satisfying the must-be bits (all Java presets do). + */ +export function encodeExgRegisters(settings: DecodedExgRegisters): Uint8Array { + const bank = new Uint8Array(EXG_BANK_LENGTH); + + writeField(bank, 'conversionMode', settings.conversionMode.value); + writeField(bank, 'dataRate', settings.dataRate.value); + + writeField(bank, 'leadOffComparators', settings.leadOff.comparators.value); + writeField(bank, 'referenceBuffer', settings.referenceBuffer.value); + writeField(bank, 'voltageReference', settings.voltageReference.value); + writeField(bank, 'oscillatorClockConnection', settings.oscillatorClockConnection.value); + writeField(bank, 'testSignalSelection', settings.testSignal.enabled.value); + writeField(bank, 'testSignalFrequency', settings.testSignal.frequency.value); + + writeField(bank, 'comparatorThreshold', settings.leadOff.comparatorThreshold.value); + writeField(bank, 'leadOffCurrent', settings.leadOff.current.value); + writeField(bank, 'leadOffFrequency', settings.leadOff.frequency.value); + + writeField(bank, 'ch1PowerDown', settings.ch1.powerDown.value); + writeField(bank, 'ch1Gain', settings.ch1.gain.value); + writeField(bank, 'ch1InputSelection', settings.ch1.inputSelection.value); + + writeField(bank, 'ch2PowerDown', settings.ch2.powerDown.value); + writeField(bank, 'ch2Gain', settings.ch2.gain.value); + writeField(bank, 'ch2InputSelection', settings.ch2.inputSelection.value); + + writeField(bank, 'chopFrequency', settings.rld.chopFrequency.value); + writeField(bank, 'rldBufferPower', settings.rld.bufferPower.value); + writeField(bank, 'rldLeadOffSenseFunction', settings.rld.leadOffSenseFunction.value); + writeField(bank, 'ch2RldNegInputs', settings.rld.ch2.negInput.value); + writeField(bank, 'ch2RldPosInputs', settings.rld.ch2.posInput.value); + writeField(bank, 'ch1RldNegInputs', settings.rld.ch1.negInput.value); + writeField(bank, 'ch1RldPosInputs', settings.rld.ch1.posInput.value); + + writeField(bank, 'ch2FlipCurrent', settings.leadOff.ch2.flipCurrent.value); + writeField(bank, 'ch1FlipCurrent', settings.leadOff.ch1.flipCurrent.value); + writeField(bank, 'ch2LeadOffDetectNegInputs', settings.leadOff.ch2.negInput.value); + writeField(bank, 'ch2LeadOffDetectPosInputs', settings.leadOff.ch2.posInput.value); + writeField(bank, 'ch1LeadOffDetectNegInputs', settings.leadOff.ch1.negInput.value); + writeField(bank, 'ch1LeadOffDetectPosInputs', settings.leadOff.ch1.posInput.value); + + // REG8 status bits are read-only — cleared by must-be (except clock divider). + writeField(bank, 'clockDividerSelection', settings.status.clockDivider.value); + + writeField(bank, 'respirationDemodCircuitry', settings.respiration.demod.value); + writeField(bank, 'respirationModCircuitry', settings.respiration.mod.value); + writeField(bank, 'respirationPhase', settings.respiration.phase.value); + writeField(bank, 'respirationControl', settings.respiration.control.value); + + writeField(bank, 'respirationCalibration', settings.respiration.calibration.value); + writeField(bank, 'respirationControlFrequency', settings.respiration.frequency.value); + writeField(bank, 'rldReferenceSignal', settings.rld.referenceSignal.value); + + return applyExgMustBeBits(bank); +} diff --git a/src/devices/sdlog/header.ts b/src/devices/sdlog/header.ts index 06416e3..f0405e5 100644 --- a/src/devices/sdlog/header.ts +++ b/src/devices/sdlog/header.ts @@ -346,6 +346,14 @@ export function parseSdLog(bytes: Uint8Array): ParsedSdLog { // the Shimmer3 and Shimmer3R header layouts. const gsrRange = (bytes[11] >> 1) & 0x07; + // Bytes 56-65 / 66-75: the two ADS1292R (EXG) register banks. Same offsets + // on both the Shimmer3 and Shimmer3R modern-header branches + // (ShimmerSDLog.java:252-253 Shimmer3R, :322-323 Shimmer3 — both do + // `System.arraycopy(byteArrayInfo, 56, mEXG1RegisterArray, 0, 10)` then 66 + // for chip 2). All-zero on non-EXG devices; decode via the EXG codec. + const exg1 = bytes.slice(56, 66); + const exg2 = bytes.slice(66, 76); + // Bytes 44-51: RTC difference, signed 64-bit MSB-first. let rtc = 0n; for (let i = 44; i <= 51; i++) rtc = (rtc << 8n) | BigInt(bytes[i]); @@ -457,6 +465,8 @@ export function parseSdLog(bytes: Uint8Array): ParsedSdLog { expansionBoard, imuRanges: parseImuRanges(bytes, hardwareVersion), calibration: [], + exg1, + exg2, }; return { header, channels, syncFraming, samplesPerBlock, wallClockFreqHz }; diff --git a/src/devices/sdlog/types.ts b/src/devices/sdlog/types.ts index b242ebb..5b90f5c 100644 --- a/src/devices/sdlog/types.ts +++ b/src/devices/sdlog/types.ts @@ -97,6 +97,14 @@ export interface SdLogHeader { calibrationBytes: SdLogCalibrationBytes; /** GSR hardware range setting from the header (0-3 fixed, 4 = auto). */ gsrRange: number; + /** + * Raw 10-byte ADS1292R chip-1 (EXG1) register bank from SD header bytes + * 56-65 (ShimmerSDLog.java:252-253 Shimmer3R, :322-323 Shimmer3). Decode + * with `decodeExgRegisters` from the EXG codec. All-zero on non-EXG devices. + */ + exg1: Uint8Array; + /** Raw 10-byte ADS1292R chip-2 (EXG2) register bank from SD header bytes 66-75. */ + exg2: Uint8Array; /** Expansion-board identity, when the firmware stores it in the header. */ expansionBoard: SdLogExpansionBoard | null; /** diff --git a/src/index.ts b/src/index.ts index ee32330..f15763e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -237,6 +237,48 @@ export type { DeviceWriteDivergentRanges, } from './devices/infomem/index.js'; +// ADS1292R (EXG / ECG-EMG-respiration) register codec — phase EX1 +export { + EXG_BANK_LENGTH, + decodeExgRegisters, + encodeExgRegisters, + applyExgMustBeBits, + CONVERSION_MODE_LABELS, + DATA_RATE_LABELS, + VOLTAGE_REFERENCE_LABELS, + TEST_SIGNAL_FREQUENCY_LABELS, + COMPARATOR_THRESHOLD_LABELS, + LEAD_OFF_CURRENT_LABELS, + LEAD_OFF_FREQUENCY_LABELS, + GAIN_LABELS, + GAIN_VALUES, + POWER_DOWN_LABELS, + INPUT_SELECTION_LABELS, + CHOP_FREQUENCY_LABELS, + RESPIRATION_PHASE_32KHZ_LABELS, + RESPIRATION_PHASE_64KHZ_LABELS, + RESPIRATION_FREQUENCY_LABELS, + RLD_REFERENCE_SIGNAL_LABELS, + RESPIRATION_CONTROL_LABELS, + EXG_PRESET_ARRAYS, + exgResolutionFromSensors, + detectExgPreset, + exgPresetLabel, +} from './devices/exg/index.js'; +export type { + ExgFieldValue, + ExgGainValue, + ExgChannelSettings, + ExgLeadOffSettings, + ExgRespirationSettings, + ExgRldSettings, + ExgTestSignalSettings, + ExgStatusBits, + DecodedExgRegisters, + ExgPreset, + ExgResolution, +} from './devices/exg/index.js'; + // Inertial (accel/gyro/mag) calibration — phase P3 export { matrixInverse3x3, diff --git a/tests/exg/registers.test.ts b/tests/exg/registers.test.ts new file mode 100644 index 0000000..d2f8d7d --- /dev/null +++ b/tests/exg/registers.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect } from 'vitest'; +import { + decodeExgRegisters, + encodeExgRegisters, + applyExgMustBeBits, + detectExgPreset, + exgResolutionFromSensors, + exgPresetLabel, + EXG_PRESET_ARRAYS, + GAIN_VALUES, +} from '../../src/devices/exg/index.js'; + +// Sensor-bitmap masks (ConfigByteLayoutShimmer3.java:300-303). +const EXG1_24BIT = 0x000010; +const EXG2_24BIT = 0x000008; +const EXG1_16BIT = 0x100000; +const EXG2_16BIT = 0x080000; + +const u8 = (a: readonly number[]): Uint8Array => new Uint8Array(a); + +describe('decodeExgRegisters', () => { + it('decodes the ECG chip-1 reference bank (SensorEXG.java:1782)', () => { + const d = decodeExgRegisters(u8(EXG_PRESET_ARRAYS.ecg.exg1)); + expect(d.dataRate).toEqual({ value: 2, label: '500 Hz' }); + expect(d.conversionMode.value).toBe(0); + expect(d.referenceBuffer.value).toBe(1); // ON + expect(d.voltageReference).toEqual({ value: 0, label: '2.42 V' }); + // byte3 = 64 = 0x40 → CH1 gain setting 4 (=gain 4), input NORMAL(0) + expect(d.ch1.gain).toEqual({ value: 4, label: '4', gain: 4 }); + expect(d.ch1.inputSelection.value).toBe(0); + expect(d.ch2.gain.gain).toBe(4); + // byte5 = 45 = 0x2D → RLD buffer enabled + IN2N/IN2P/IN1P connected + expect(d.rld.bufferPower.value).toBe(1); + expect(d.rld.ch2.negInput.value).toBe(1); + expect(d.rld.ch2.posInput.value).toBe(1); + expect(d.rld.ch1.posInput.value).toBe(1); + // byte9 = 3 → RLD reference = half of supply + expect(d.rld.referenceSignal).toEqual({ value: 1, label: '(AVDD - AVSS) / 2' }); + expect(d.leadOff.detectionEnabled).toBe(false); + expect(d.respiration.enabled).toBe(false); + expect(d.raw).toEqual([...EXG_PRESET_ARRAYS.ecg.exg1]); + }); + + it('decodes the ECG chip-2 reference bank (CH2 input = RLDIN-to-neg)', () => { + const d = decodeExgRegisters(u8(EXG_PRESET_ARRAYS.ecg.exg2)); + // byte4 = 71 = 0x47 → CH2 gain 4, input 7 (RLDIN connected to neg input) + expect(d.ch2.inputSelection.value).toBe(7); + expect(d.ch2.gain.gain).toBe(4); + }); + + it('decodes the EMG reference banks (gain 12, ROUTE_CH3, power-down/shorted)', () => { + const c1 = decodeExgRegisters(u8(EXG_PRESET_ARRAYS.emg.exg1)); + expect(c1.ch1.gain.gain).toBe(12); + expect(c1.ch1.inputSelection.value).toBe(9); // ROUTE_CH3_TO_CH1 + expect(c1.ch2.gain.gain).toBe(12); + const c2 = decodeExgRegisters(u8(EXG_PRESET_ARRAYS.emg.exg2)); + expect(c2.ch1.powerDown.value).toBe(1); // power-down + expect(c2.ch1.inputSelection.value).toBe(1); // shorted + expect(c2.ch2.powerDown.value).toBe(1); + expect(c2.ch2.inputSelection.value).toBe(1); + }); + + it('decodes the test-signal reference bank (both channels = test signal)', () => { + const d = decodeExgRegisters(u8(EXG_PRESET_ARRAYS['test-signal'].exg1)); + expect(d.testSignal.enabled.value).toBe(1); + expect(d.testSignal.frequency).toEqual({ value: 1, label: '1 kHz Square Wave' }); + expect(d.ch1.inputSelection.value).toBe(5); + expect(d.ch2.inputSelection.value).toBe(5); + }); + + it('decodes the respiration chip-2 reference bank (mod+demod on, phase 112.5°@32kHz)', () => { + const d = decodeExgRegisters(u8(EXG_PRESET_ARRAYS.respiration.exg2)); + expect(d.respiration.enabled).toBe(true); + expect(d.respiration.demod.value).toBe(1); + expect(d.respiration.mod.value).toBe(1); + expect(d.respiration.frequency).toEqual({ value: 0, label: '32 kHz' }); + expect(d.respiration.phase).toEqual({ value: 10, label: '112.5°' }); + }); + + it('selects the 64 kHz phase label list when the control frequency is 64 kHz', () => { + // byte8 = phase 5 (bits2-5) + must-be bit1; byte9 bit2 = control freq 64 kHz. + const bank = [2, 160, 16, 64, 71, 0, 0, 0, (5 << 2) | 0x02, 0x05]; + const d = decodeExgRegisters(u8(bank)); + expect(d.respiration.frequency.value).toBe(1); // 64 kHz + expect(d.respiration.phase).toEqual({ value: 5, label: '112.5°' }); // 64 kHz index 5 + }); + + it('reports gain values matching convertEXGGainSettingToValue', () => { + expect([...GAIN_VALUES]).toEqual([6, 1, 2, 3, 4, 8, 12]); + }); + + it('rejects malformed bank lengths', () => { + expect(() => decodeExgRegisters(u8([1, 2, 3]))).toThrow(RangeError); + expect(() => decodeExgRegisters(new Uint8Array(11))).toThrow(RangeError); + }); +}); + +describe('encodeExgRegisters (round-trip against Java golden vectors)', () => { + const cases = [ + ['ecg', 'exg1'], + ['ecg', 'exg2'], + ['emg', 'exg1'], + ['emg', 'exg2'], + ['test-signal', 'exg1'], + ['test-signal', 'exg2'], + ['respiration', 'exg1'], + ['respiration', 'exg2'], + ['custom', 'exg1'], + ['custom', 'exg2'], + ] as const; + + it.each(cases)('round-trips decode→encode for %s.%s', (preset, chip) => { + const golden = EXG_PRESET_ARRAYS[preset][chip]; + const bank = u8(golden); + const encoded = encodeExgRegisters(decodeExgRegisters(bank)); + expect([...encoded]).toEqual([...golden]); + }); +}); + +describe('applyExgMustBeBits (ExGConfigBytesDetails.java:507-525)', () => { + it('forces every mandated constant bit from an all-zero bank', () => { + const out = applyExgMustBeBits(new Uint8Array(10)); + expect(out[1] & 0x80).toBe(0x80); // CONFIG2 bit7 = 1 + expect(out[2] & 0x10).toBe(0x10); // LOFF bit4 = 1 + expect(out[8] & 0x02).toBe(0x02); // RESP1 bit1 = 1 + expect(out[9] & 0x01).toBe(0x01); // RESP2 bit0 = 1 + }); + + it('clears the reserved / read-only bits it is required to clear', () => { + const dirty = new Uint8Array(10).fill(0xff); + const out = applyExgMustBeBits(dirty); + expect(out[0] & 0x78).toBe(0); // CONFIG1 bits 3-6 cleared + expect(out[1] & 0x04).toBe(0); // CONFIG2 bit2 cleared + expect(out[2] & 0x02).toBe(0); // LOFF bit1 cleared + expect(out[6] & 0xc0).toBe(0); // LOFF_SENS bits 6-7 cleared + expect(out[7]).toBe(0x40); // LOFF_STAT: only bit6 (clock divider) survives + expect(out[9] & 0x78).toBe(0); // RESP2 bits 3-6 cleared + }); + + it('encode always applies the must-be bits even when settings omit them', () => { + // Decode an all-zero bank (no must-be bits), then re-encode: must-be reappears. + const encoded = encodeExgRegisters(decodeExgRegisters(new Uint8Array(10))); + expect(encoded[1] & 0x80).toBe(0x80); + expect(encoded[2] & 0x10).toBe(0x10); + expect(encoded[8] & 0x02).toBe(0x02); + expect(encoded[9] & 0x01).toBe(0x01); + }); +}); + +describe('detectExgPreset (tolerant, SensorEXG.java:2680-2763)', () => { + const es = { + both24: EXG1_24BIT | EXG2_24BIT, + both16: EXG1_16BIT | EXG2_16BIT, + chip1_24: EXG1_24BIT, + chip1_16: EXG1_16BIT, + none: 0, + }; + + it('detects each preset from its Java reference arrays (no bitmap → tolerant)', () => { + expect(detectExgPreset(u8(EXG_PRESET_ARRAYS.ecg.exg1), u8(EXG_PRESET_ARRAYS.ecg.exg2))).toBe( + 'ecg', + ); + expect(detectExgPreset(u8(EXG_PRESET_ARRAYS.emg.exg1), u8(EXG_PRESET_ARRAYS.emg.exg2))).toBe( + 'emg', + ); + expect( + detectExgPreset( + u8(EXG_PRESET_ARRAYS['test-signal'].exg1), + u8(EXG_PRESET_ARRAYS['test-signal'].exg2), + ), + ).toBe('test-signal'); + expect( + detectExgPreset( + u8(EXG_PRESET_ARRAYS.respiration.exg1), + u8(EXG_PRESET_ARRAYS.respiration.exg2), + ), + ).toBe('respiration'); + expect( + detectExgPreset(u8(EXG_PRESET_ARRAYS.custom.exg1), u8(EXG_PRESET_ARRAYS.custom.exg2)), + ).toBe('custom'); + }); + + it('respects the resolution gate when enabledSensors is supplied', () => { + // ECG requires both chips at the same resolution. + expect( + detectExgPreset(u8(EXG_PRESET_ARRAYS.ecg.exg1), u8(EXG_PRESET_ARRAYS.ecg.exg2), es.both24), + ).toBe('ecg'); + // Same banks but only chip-1 enabled → ECG gate fails → falls through to custom. + expect( + detectExgPreset(u8(EXG_PRESET_ARRAYS.ecg.exg1), u8(EXG_PRESET_ARRAYS.ecg.exg2), es.chip1_24), + ).toBe('custom'); + // EMG requires chip-1 only. + expect( + detectExgPreset(u8(EXG_PRESET_ARRAYS.emg.exg1), u8(EXG_PRESET_ARRAYS.emg.exg2), es.chip1_16), + ).toBe('emg'); + }); + + it('detects respiration before ECG (shares the same input selections)', () => { + // Respiration banks have ECG input selections + resp mod/demod on. + expect( + detectExgPreset( + u8(EXG_PRESET_ARRAYS.respiration.exg1), + u8(EXG_PRESET_ARRAYS.respiration.exg2), + es.both16, + ), + ).toBe('respiration'); + }); + + it('tolerates the 16-bit hardcoded 3R preset arrays (differ in byte1/rate bits)', () => { + // 3R ECG-16 arrays from Shimmer3RClient.enableECG16Bit (payload only). + const ecg3rC1 = [0x02, 0xa8, 0x10, 0x40, 0x40, 0x2d, 0x00, 0x00, 0x02, 0x03]; + const ecg3rC2 = [0x02, 0xa0, 0x10, 0x40, 0x47, 0x00, 0x00, 0x00, 0x02, 0x01]; + expect(detectExgPreset(u8(ecg3rC1), u8(ecg3rC2), es.both16)).toBe('ecg'); + // 3R EMG-16 + const emg3rC1 = [0x02, 0xa8, 0x10, 0x69, 0x60, 0x20, 0x00, 0x00, 0x02, 0x03]; + const emg3rC2 = [0x02, 0xa0, 0x10, 0xe1, 0xe1, 0x00, 0x00, 0x00, 0x02, 0x01]; + expect(detectExgPreset(u8(emg3rC1), u8(emg3rC2), es.chip1_16)).toBe('emg'); + // 3R Test-16 + const t3rC1 = [0x02, 0xab, 0x10, 0x15, 0x15, 0x00, 0x00, 0x00, 0x02, 0x01]; + const t3rC2 = [0x02, 0xa3, 0x10, 0x15, 0x15, 0x00, 0x00, 0x00, 0x02, 0x01]; + expect(detectExgPreset(u8(t3rC1), u8(t3rC2), es.both16)).toBe('test-signal'); + }); + + it('reports off for empty banks / no enabled EXG bits', () => { + expect(detectExgPreset(new Uint8Array(10), new Uint8Array(10))).toBe('off'); + expect( + detectExgPreset(u8(EXG_PRESET_ARRAYS.ecg.exg1), u8(EXG_PRESET_ARRAYS.ecg.exg2), es.none), + ).toBe('off'); + }); + + it('rejects malformed bank lengths', () => { + expect(() => detectExgPreset(new Uint8Array(9), new Uint8Array(10))).toThrow(RangeError); + }); +}); + +describe('exgResolutionFromSensors (ShimmerObject.java:7255-7279)', () => { + it('derives 16-bit / 24-bit / null from the bitmap flags', () => { + expect(exgResolutionFromSensors(EXG1_16BIT | EXG2_16BIT)).toBe('16bit'); + expect(exgResolutionFromSensors(EXG1_24BIT | EXG2_24BIT)).toBe('24bit'); + expect(exgResolutionFromSensors(EXG1_16BIT)).toBe('16bit'); + expect(exgResolutionFromSensors(EXG1_24BIT)).toBe('24bit'); + expect(exgResolutionFromSensors(0)).toBeNull(); + // Non-EXG sensor bit set → still null. + expect(exgResolutionFromSensors(0x000080)).toBeNull(); + }); + + it('gives 16-bit precedence when both flag families are set (Java checks 16 first)', () => { + expect(exgResolutionFromSensors(EXG1_16BIT | EXG1_24BIT)).toBe('16bit'); + }); +}); + +describe('exgPresetLabel', () => { + it('maps each preset to a display label', () => { + expect(exgPresetLabel('ecg')).toBe('ECG'); + expect(exgPresetLabel('test-signal')).toBe('ExG Test Signal'); + expect(exgPresetLabel('off')).toBe('Off'); + }); +}); diff --git a/tests/sdlog/fixtures.ts b/tests/sdlog/fixtures.ts index ea78378..d7908de 100644 --- a/tests/sdlog/fixtures.ts +++ b/tests/sdlog/fixtures.ts @@ -36,6 +36,10 @@ export interface HeaderFixtureOptions { signalIds?: number[]; /** Optional per-block fill for the calibration areas (default pattern). */ calibFill?: (offset: number) => number; + /** ADS1292R chip-1 register bank → SD header bytes 56-65. */ + exg1?: number[]; + /** ADS1292R chip-2 register bank → SD header bytes 66-75. */ + exg2?: number[]; } /** Build a modern Shimmer3 (256 B) or Shimmer3R (384 B) SD-log header. */ @@ -95,6 +99,10 @@ export function buildSdLogHeader(opts: HeaderFixtureOptions = {}): Uint8Array { b[54] = Math.floor(cfgTime / 2 ** 8) % 256; b[55] = cfgTime % 256; + // EXG register banks (ShimmerSDLog.java:252-253/322-323): bytes 56-65 / 66-75. + if (opts.exg1) opts.exg1.forEach((v, i) => (b[56 + i] = v & 0xff)); + if (opts.exg2) opts.exg2.forEach((v, i) => (b[66 + i] = v & 0xff)); + // Calibration areas — recognizable default pattern. const fill = opts.calibFill ?? ((off: number) => off & 0xff); for (let off = 76; off < 182; off++) b[off] = fill(off); diff --git a/tests/sdlog/header.test.ts b/tests/sdlog/header.test.ts index bd5dfa1..080475c 100644 --- a/tests/sdlog/header.test.ts +++ b/tests/sdlog/header.test.ts @@ -5,6 +5,11 @@ import { SDLogHeaderBitmask as BM, } from '../../src/devices/sdlog/index.js'; import { getDefaultCalibration } from '../../src/devices/calibration/index.js'; +import { + decodeExgRegisters, + detectExgPreset, + EXG_PRESET_ARRAYS, +} from '../../src/devices/exg/index.js'; import { buildSdLogHeader } from './fixtures.js'; const expectCode = (fn: () => unknown, code: string): void => { @@ -399,3 +404,41 @@ describe('parseSdLogHeader — rejection paths', () => { ); }); }); + +describe('parseSdLogHeader — EXG register banks (ShimmerSDLog.java:252-253/322-323)', () => { + it('extracts exg1/exg2 from bytes 56-65 / 66-75 and they decode to a preset', () => { + const ecg1 = [...EXG_PRESET_ARRAYS.ecg.exg1]; + const ecg2 = [...EXG_PRESET_ARRAYS.ecg.exg2]; + const h = parseSdLogHeader( + buildSdLogHeader({ + enabledSensors: BM.ACCEL_LN | BM.GSR, + exg1: ecg1, + exg2: ecg2, + }), + ); + expect([...h.exg1]).toEqual(ecg1); + expect([...h.exg2]).toEqual(ecg2); + expect(detectExgPreset(h.exg1, h.exg2)).toBe('ecg'); + expect(decodeExgRegisters(h.exg1).ch1.gain.gain).toBe(4); + }); + + it('yields all-zero banks on a non-EXG header', () => { + const h = parseSdLogHeader(buildSdLogHeader({ enabledSensors: BM.ACCEL_LN | BM.GSR })); + expect([...h.exg1]).toEqual(new Array(10).fill(0)); + expect([...h.exg2]).toEqual(new Array(10).fill(0)); + }); + + it('extracts EXG banks from a Shimmer3R (384 B) header at the same offsets', () => { + const h = parseSdLogHeader( + buildSdLogHeader({ + hw: 10, + fwId: 3, + fwVersion: [0, 1, 0], + signalIds: [0x1d], + exg1: [...EXG_PRESET_ARRAYS['test-signal'].exg1], + exg2: [...EXG_PRESET_ARRAYS['test-signal'].exg2], + }), + ); + expect(detectExgPreset(h.exg1, h.exg2)).toBe('test-signal'); + }); +}); From f390f8c7c81842c9fcb0d224dd483bef041b0e24 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 01:50:29 +0000 Subject: [PATCH 02/10] feat(exg): preset application library (EX2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/devices/exg/apply.ts — the write half of EXG configuration that builds on EX1's codec/detection. applyExgPreset(input, preset, resolution) turns a chosen preset (ecg/emg/test-signal/respiration/off) + resolution (16/24-bit) into the two register banks plus the enabled-sensors bitmap, porting the Java preset setters SensorEXG.setDefault* as a whole: - resolution lives only in the sensor bitmap (setExgChannelBitsPerMode); register bytes are identical for 16- and 24-bit. EMG is chip-1-only, every other preset is both chips, respiration is NOT forced to 24-bit. - sampling-rate -> REG1 data-rate on both chips via exgRateSettingFromFreq (port of setExGRateFromFreq, <= thresholds), distinct from the live-BT getOversamplingRatioADS1292R which is not used on the docked path. - sensor-conflict clearing (exgConflictingSensors + clear from the bitmap) matching desktop's sensorMapConflictCheckandCorrect (disables, not warns). - CHIP1 oscillator-clock-joined bit set for Shimmer3R, else preserved. 'off' zeroes both banks and clears all four resolution flags. New exports: applyExgPreset, exgConflictingSensors, exgRateSettingFromFreq, EXG_CONFLICTING_SENSORS + ExgApplyInput/ExgApplyResult/ApplicableExgPreset. Golden-vector, bitmap-coherence, rate-threshold, conflict, idempotence and detect round-trip tests (50 new, 624 total). Version 0.1.10-exg.2. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012pin56yhefi5Mihjf3vTpL --- CHANGELOG.md | 1 + package.json | 2 +- src/devices/exg/apply.ts | 233 +++++++++++++++++++++++++++++++++++++++ src/devices/exg/index.ts | 9 ++ src/index.ts | 7 ++ tests/exg/apply.test.ts | 224 +++++++++++++++++++++++++++++++++++++ 6 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 src/devices/exg/apply.ts create mode 100644 tests/exg/apply.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a8f00d1..202f976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project follows [Semantic Versioning](https://semver.org/). ### Added +- **ADS1292R (EXG) preset APPLICATION library, phase EX2.** A new pure module (`src/devices/exg/apply.ts`) that turns a chosen preset + resolution into the two register banks and the enabled-sensors bitmap to write — the write half that builds on EX1's codec/detection. `applyExgPreset({ exg1, exg2, enabledSensors, samplingRateHz, hardwareVersion }, preset, resolution)` → `{ exg1, exg2, enabledSensors }` ports the Java preset setters `SensorEXG.setDefaultECGConfiguration` / `setDefaultEMGConfiguration` / `setEXGTestSignal` / `setDefaultRespirationConfiguration` (`SensorEXG.java:1781-1895`) as a whole: (a) the register bytes come from EX1's `EXG_PRESET_ARRAYS`, which are **identical for 16- and 24-bit** — the Java derives resolution purely from the sensor bitmap (`setExgChannelBitsPerMode`, `:2150-2183`), never from separate register arrays or bit-twiddling; (b) the **sampling-rate→data-rate coupling** rewrites REG1 data-rate on BOTH chips via `exgRateSettingFromFreq`, a byte-for-byte port of `setExGRateFromFreq` (`:2784-2806`, `<=` thresholds, >8 kHz→500 Hz fallback) — distinct from the live-BT `getOversamplingRatioADS1292R` (`calibration.ts:89`, strict `<`), which is NOT used on the docked InfoMem path; (c) the **resolution↔bitmap coupling** sets the chosen resolution's EXG1/EXG2 flags and clears the other's — EMG is chip-1-only, every other preset is both chips, and **respiration honours the chosen resolution (it is NOT forced to 24-bit)**; `'off'` zeroes both banks and clears all four flags; (d) **sensor-conflict clearing** — `exgConflictingSensors(enabledSensors)` reports the currently-enabled sensors that conflict with EXG (GSR, the internal ADCs, the bridge/resistance amp — the EXG `SensorDetailsRef` conflict list, `SensorEXG.java:332-344`), and `applyExgPreset` CLEARS them from the returned bitmap, matching what desktop does when a preset tile is clicked (`ShimmerDevice.sensorMapConflictCheckandCorrect`, `:2497-2512`, disables — not warns). The CHIP1 oscillator-clock-joined bit (`setDefaultExgCommon`, `:2000-2002`, `ShimmerVerObject.isSupportedExgChipClocksJoined`, `:712-723`) is set for Shimmer3R (the only case resolvable from HW id — the expansion-board rev a classic Shimmer3 would need is not in the docked context), otherwise the device's existing bit is preserved. New exports: `applyExgPreset`, `exgConflictingSensors`, `exgRateSettingFromFreq`, `EXG_CONFLICTING_SENSORS`, and the `ExgApplyInput` / `ExgApplyResult` / `ApplicableExgPreset` types. - **ADS1292R (EXG / ECG-EMG-respiration) register codec, phase EX1.** A new pure, transport-free module (`src/devices/exg/`) that decodes and encodes the 10-byte-per-chip ADS1292R register bank and recognises the whole-device preset — the read/display half of EXG configuration. Ported from the desktop Java oracle: `decodeExgRegisters(bank)` mirrors `ShimmerObject.exgBytesGetConfigFrom` (`ShimmerObject.java:6894-6940`) + `ExGConfigBytesDetails.updateFromRegisterArray`, returning every register field as `{ value, label }` (data rate, conversion mode, reference buffer/voltage, per-channel PGA gain + numeric gain value + input-selection mux + power-down, reference-electrode, lead-off detection/current/comparator-threshold + per-lead sense bits, RLD routing, respiration mod/demod/phase/frequency, and the read-only REG8 status bits) with the human-readable labels taken verbatim from `SensorEXG.java:116-149`. `encodeExgRegisters(settings)` is the inverse (`generateExgByteArray`) and always re-applies the hardware "must-be" bits (`applyExgMustBeBits`, ported from `setExgByteArrayConstants`, `ExGConfigBytesDetails.java:507-525`). `detectExgPreset(exg1, exg2, enabledSensors?)` → `'ecg' | 'emg' | 'test-signal' | 'respiration' | 'custom' | 'off'` is a **tolerant** match keyed only off the CH1/CH2 input-selection nibbles + chip-2 respiration bits (+ the resolution flags when the bitmap is supplied), exactly as `SensorEXG.isEXGUsingDefault*` (`SensorEXG.java:2680-2763`) — so the fields firmware rewrites (rate/oversampling in byte0, oscillator-clock in byte1, gain) do not affect detection, and both the 24-bit Java reference arrays and the 16-bit hardcoded 3R arrays detect correctly. **Resolution is not a register field**: `exgResolutionFromSensors(enabledSensors)` → `'16bit' | '24bit' | null` derives it from the sensor bitmap (`ShimmerObject.checkExgResolutionFromEnabledSensorsVar`, `:7255-7279`). Covers Shimmer3 and Shimmer3R (same ADS1292R). New exports: `decodeExgRegisters`, `encodeExgRegisters`, `applyExgMustBeBits`, `detectExgPreset`, `exgResolutionFromSensors`, `exgPresetLabel`, `EXG_PRESET_ARRAYS`, `EXG_BANK_LENGTH`, the option-label lists, and the `DecodedExgRegisters` / `ExgPreset` / `ExgResolution` types. - **SD-log header EXG register banks.** `SdLogHeader` now carries the raw `exg1` / `exg2` register banks, extracted from SD header bytes 56-65 / 66-75 (`ShimmerSDLog.java:252-253` Shimmer3R, `:322-323` Shimmer3 — both device families use the same offsets). Decode them with the new EXG codec. All-zero on non-EXG logs. diff --git a/package.json b/package.json index 7811fdb..49fef9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.10-exg.1", + "version": "0.1.10-exg.2", "description": "Web Bluetooth and Web Serial API for Shimmer sensor devices (Shimmer3R, Verisense IMU/Pulse+)", "type": "module", "main": "dist/shimmer-web-sdk.cjs", diff --git a/src/devices/exg/apply.ts b/src/devices/exg/apply.ts new file mode 100644 index 0000000..abbdb1b --- /dev/null +++ b/src/devices/exg/apply.ts @@ -0,0 +1,233 @@ +/** + * ADS1292R (EXG) preset APPLICATION — turn a chosen preset + resolution into the + * two register banks and the enabled-sensors bitmap to write. + * + * Pure, transport-free port of the Java oracle's preset setters. Where EX1 + * (`presets.ts`) ships the reference register arrays and preset *detection*, + * this module ships the *write* side: the resolution↔bitmap coupling, the + * sampling-rate→data-rate coupling, and the sensor-conflict clearing that + * desktop performs when a preset tile is clicked. + * + * Java oracle: + * - preset setters : SensorEXG.setDefaultECGConfiguration (:1781), + * setDefaultEMGConfiguration (:1812), setEXGTestSignal (:1844), + * setDefaultRespirationConfiguration (:1871). Each calls clearExgConfig() + * then setExgChannelBitsPerMode(sensorId) then a series of named field + * writes then setDefaultExgCommon(samplingRate). + * - resolution↔bitmap : SensorEXG.setExgChannelBitsPerMode (:2150-2183) + + * updateEnabledSensorsFromExgResolution (:2108-2148). Resolution is NOT a + * register field — it lives only in the sensor bitmap. + * - rate coupling : SensorEXG.setDefaultExgCommon (:1999-2005) calls + * setExGRateFromFreq (:2784-2806), which rewrites REG1 data-rate on BOTH + * chips (setEXGRateSetting, :2468-2471) from the device sampling rate. + * - oscillator clock : setDefaultExgCommon sets CHIP1 REG2 oscillator- + * clock-connection ON when the chip clocks are joined + * (ShimmerVerObject.isSupportedExgChipClocksJoined, :712-723). + * - conflict clearing : ShimmerDevice.sensorMapConflictCheckandCorrect + * (:2497-2512) disables every sensor in a sensor's conflict list; EXG's + * conflict list is SensorEXG.sDRefEcg etc. (:332-344). + */ + +import { EXG_BANK_LENGTH, applyExgMustBeBits } from './registers.js'; +import { EXG_PRESET_ARRAYS, type ExgResolution } from './presets.js'; + +/** HW_ID codes (ShimmerVerDetails.HW_ID) — matches infomem/layout.ts HW_ID. */ +const HW_ID_SHIMMER_3R = 10; + +// Sensor-bitmap resolution masks (ConfigByteLayoutShimmer3, == SensorBitmapShimmer3). +const MASK_EXG1_24BIT = 0x000010; +const MASK_EXG2_24BIT = 0x000008; +const MASK_EXG1_16BIT = 0x100000; +const MASK_EXG2_16BIT = 0x080000; +const MASK_ALL_EXG_RESOLUTION = + MASK_EXG1_24BIT | MASK_EXG2_24BIT | MASK_EXG1_16BIT | MASK_EXG2_16BIT; + +/** + * Sensors that conflict with any EXG preset, as the Shimmer3 streaming + * enabled-sensors bitmap masks (Configuration.Shimmer3.SensorBitmap). Ported + * from the EXG SensorDetailsRef conflict lists (SensorEXG.java:332-344 ECG, + * identical set for EMG/Test/Respiration): the internal ADC channels, GSR, the + * resistance amp, and the bridge amp. The resistance amp shares the bridge-amp + * bit on Shimmer3 (Configuration.java:643), so clearing SENSOR_BRIDGE_AMP + * covers both. + */ +export const EXG_CONFLICTING_SENSORS: ReadonlyArray<{ mask: number; label: string }> = + Object.freeze([ + { mask: 0x000004, label: 'GSR' }, // SENSOR_GSR (Configuration.java:630) + { mask: 0x000400, label: 'Internal ADC A1' }, // SENSOR_INT_A1 (:636) + { mask: 0x000200, label: 'Internal ADC A12' }, // SENSOR_INT_A12 (:637) + { mask: 0x000100, label: 'Internal ADC A13' }, // SENSOR_INT_A13 (:638) + { mask: 0x800000, label: 'Internal ADC A14' }, // SENSOR_INT_A14 (:639) + { mask: 0x008000, label: 'Bridge/Resistance Amp' }, // SENSOR_BRIDGE_AMP (:643) + ]); + +/** + * Report which currently-enabled sensors conflict with turning on EXG. Ported + * from ShimmerDevice.sensorMapConflictCheckandCorrect (ShimmerDevice.java:2497) + * reading the EXG conflict list — desktop *disables* these when an EXG tile is + * clicked, so {@link applyExgPreset} clears them from the returned bitmap and + * the UI can warn "disables: …" ahead of the write. + */ +export function exgConflictingSensors( + enabledSensors: number, +): Array<{ mask: number; label: string }> { + const es = enabledSensors >>> 0; + return EXG_CONFLICTING_SENSORS.filter((c) => (es & c.mask) !== 0); +} + +/** + * Map a device sampling rate (Hz) to the ADS1292R REG1 data-rate setting (0-6). + * Byte-for-byte port of SensorEXG.setExGRateFromFreq (SensorEXG.java:2784-2806), + * including the >8 kHz → 500 Hz fallback. NB the thresholds are `<=` here, + * distinct from the Shimmer3R live-BT oversampling helper + * `getOversamplingRatioADS1292R` (calibration.ts:89) which uses strict `<` and + * so differs at the exact boundary rates — that helper is the live-BT streaming + * path and is deliberately NOT used for the docked InfoMem write, which mirrors + * the desktop config-generation path (this function). + */ +export function exgRateSettingFromFreq(freq: number): number { + if (freq <= 125) return 0; // 125 Hz + if (freq <= 250) return 1; // 250 Hz + if (freq <= 500) return 2; // 500 Hz + if (freq <= 1000) return 3; // 1000 Hz + if (freq <= 2000) return 4; // 2000 Hz + if (freq <= 4000) return 5; // 4000 Hz + if (freq <= 8000) return 6; // 8000 Hz + return 2; // > 8 kHz → 500 Hz (SensorEXG.java:2801-2802) +} + +/** Whether the two ADS1292R chip clocks are joined for this hardware. */ +function isExgChipClocksJoined(hardwareVersion: number | undefined): boolean { + // ShimmerVerObject.isSupportedExgChipClocksJoined (:712-723): true for + // Shimmer3R with the EXG-unified board (the only EXG board on 3R), and for + // Shimmer4-SDK / Shimmer3 EXG-unified rev>=4. Only the 3R case is determinable + // from the hardware version alone (no expansion-board rev in the docked + // context), so classic Shimmer3 preserves whatever bit the device already had + // (see applyExgPreset) rather than forcing it off. + return hardwareVersion === HW_ID_SHIMMER_3R; +} + +/** Byte0 REG1 data-rate field is the low 3 bits. */ +const REG1_DATA_RATE_MASK = 0x07; +/** Byte1 REG2 oscillator-clock-connection is bit 3. */ +const REG2_OSC_CLOCK_BIT = 0x08; + +/** Inputs {@link applyExgPreset} needs from the current device configuration. */ +export interface ExgApplyInput { + /** Current EXG1 (chip-1) register bank — 10 bytes. */ + exg1: Uint8Array; + /** Current EXG2 (chip-2) register bank — 10 bytes. */ + exg2: Uint8Array; + /** Current enabled-sensors bitmap (Shimmer3 streaming bitmap). */ + enabledSensors: number; + /** + * Device sampling rate in Hz. When a sampling-rate edit is also pending in the + * same batch, pass the EDITED rate so the preset's data-rate bits match the + * rate the device will actually run at. + */ + samplingRateHz: number; + /** HW_ID (3 = Shimmer3, 10 = Shimmer3R). Drives the joined-clock bit. */ + hardwareVersion?: number; +} + +/** The banks + bitmap {@link applyExgPreset} produces, ready to fold into a config. */ +export interface ExgApplyResult { + /** New EXG1 register bank (10 bytes). */ + exg1: Uint8Array; + /** New EXG2 register bank (10 bytes). */ + exg2: Uint8Array; + /** New enabled-sensors bitmap (resolution flags set, conflicts cleared). */ + enabledSensors: number; +} + +/** A preset that can be *applied* (written). 'custom' is detect-only; 'off' clears EXG. */ +export type ApplicableExgPreset = 'ecg' | 'emg' | 'test-signal' | 'respiration' | 'off'; + +/** + * Apply an EXG preset + resolution to a device configuration, returning the two + * register banks and the updated enabled-sensors bitmap to write. Pure — does + * not mutate the input. + * + * Ports the Java preset setters (SensorEXG.setDefault*) as a whole: + * 1. Clears the four EXG resolution flags (clearExgConfig → + * setExgChannelBitsPerMode(-1) resets them, :2150-2154). + * 2. For 'off', returns zeroed banks with no EXG flags set. + * 3. Clears conflicting sensors from the bitmap + * (sensorMapConflictCheckandCorrect, ShimmerDevice.java:2497-2512). + * 4. Sets the resolution flags for the chips this preset uses — EMG is chip-1 + * only, every other preset is both chips (setExgChannelBitsPerMode, + * :2162-2182). Respiration is NOT forced to 24-bit: it honours the chosen + * resolution on both chips, exactly like ECG. + * 5. Takes the preset register arrays (identical for 16- and 24-bit — the + * resolution lives only in the bitmap, see step 4). + * 6. Rewrites REG1 data-rate on both chips from {@link ExgApplyInput.samplingRateHz} + * (setDefaultExgCommon → setExGRateFromFreq, :2003 / :2784). + * 7. Sets the CHIP1 oscillator-clock-connection bit when the hardware joins the + * chip clocks (:2000-2002); otherwise preserves the device's existing bit. + * 8. Re-applies the mandatory must-be bits. + * + * @throws RangeError when either input bank is not exactly 10 bytes. + */ +export function applyExgPreset( + input: ExgApplyInput, + preset: ApplicableExgPreset, + resolution: ExgResolution, +): ExgApplyResult { + if (input.exg1.length !== EXG_BANK_LENGTH || input.exg2.length !== EXG_BANK_LENGTH) { + throw new RangeError( + `EXG register banks must be exactly ${EXG_BANK_LENGTH} bytes each, got ${input.exg1.length}/${input.exg2.length}.`, + ); + } + + // (1) Clear all four resolution flags — clean slate for the new preset. + let enabledSensors = (input.enabledSensors >>> 0) & ~MASK_ALL_EXG_RESOLUTION; + + // (2) 'off' — no EXG chips enabled; zero the banks so the read-back summary + // reads 'off' rather than a stale preset. + if (preset === 'off') { + return { + exg1: new Uint8Array(EXG_BANK_LENGTH), + exg2: new Uint8Array(EXG_BANK_LENGTH), + enabledSensors: enabledSensors >>> 0, + }; + } + + // (3) Clear conflicting sensors (desktop disables them on tile click). + for (const c of EXG_CONFLICTING_SENSORS) enabledSensors &= ~c.mask; + + // (4) Resolution flags: EMG = chip 1 only; all others = both chips. + const bothChips = preset !== 'emg'; + if (resolution === '24bit') { + enabledSensors |= MASK_EXG1_24BIT; + if (bothChips) enabledSensors |= MASK_EXG2_24BIT; + } else { + enabledSensors |= MASK_EXG1_16BIT; + if (bothChips) enabledSensors |= MASK_EXG2_16BIT; + } + enabledSensors >>>= 0; + + // (5) Preset register arrays (same bytes for both resolutions). + const ref = EXG_PRESET_ARRAYS[preset]; + const exg1 = Uint8Array.from(ref.exg1); + const exg2 = Uint8Array.from(ref.exg2); + + // (6) Rewrite REG1 data-rate on both chips from the sampling rate. + const rate = exgRateSettingFromFreq(input.samplingRateHz); + exg1[0] = (exg1[0] & ~REG1_DATA_RATE_MASK) | rate; + exg2[0] = (exg2[0] & ~REG1_DATA_RATE_MASK) | rate; + + // (7) Oscillator-clock-connection bit (CHIP1 only). + if (isExgChipClocksJoined(input.hardwareVersion)) { + exg1[1] |= REG2_OSC_CLOCK_BIT; + } else { + // Preserve whatever the device already had (correct for a Shimmer3 whose + // EXG-unified board rev>=4 joins clocks — undetectable from HW id alone). + exg1[1] = (exg1[1] & ~REG2_OSC_CLOCK_BIT) | (input.exg1[1] & REG2_OSC_CLOCK_BIT); + } + + // (8) Mandatory must-be bits. + applyExgMustBeBits(exg1); + applyExgMustBeBits(exg2); + + return { exg1, exg2, enabledSensors: enabledSensors >>> 0 }; +} diff --git a/src/devices/exg/index.ts b/src/devices/exg/index.ts index 9e9a288..509f6df 100644 --- a/src/devices/exg/index.ts +++ b/src/devices/exg/index.ts @@ -56,3 +56,12 @@ export { } from './presets.js'; export type { ExgPreset, ExgResolution } from './presets.js'; + +export { + applyExgPreset, + exgConflictingSensors, + exgRateSettingFromFreq, + EXG_CONFLICTING_SENSORS, +} from './apply.js'; + +export type { ExgApplyInput, ExgApplyResult, ApplicableExgPreset } from './apply.js'; diff --git a/src/index.ts b/src/index.ts index f15763e..a1e5351 100644 --- a/src/index.ts +++ b/src/index.ts @@ -264,6 +264,10 @@ export { exgResolutionFromSensors, detectExgPreset, exgPresetLabel, + applyExgPreset, + exgConflictingSensors, + exgRateSettingFromFreq, + EXG_CONFLICTING_SENSORS, } from './devices/exg/index.js'; export type { ExgFieldValue, @@ -277,6 +281,9 @@ export type { DecodedExgRegisters, ExgPreset, ExgResolution, + ExgApplyInput, + ExgApplyResult, + ApplicableExgPreset, } from './devices/exg/index.js'; // Inertial (accel/gyro/mag) calibration — phase P3 diff --git a/tests/exg/apply.test.ts b/tests/exg/apply.test.ts new file mode 100644 index 0000000..e590d9b --- /dev/null +++ b/tests/exg/apply.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest'; +import { + applyExgPreset, + exgConflictingSensors, + exgRateSettingFromFreq, + EXG_CONFLICTING_SENSORS, + detectExgPreset, + exgResolutionFromSensors, + EXG_PRESET_ARRAYS, + type ApplicableExgPreset, + type ExgResolution, +} from '../../src/devices/exg/index.js'; + +// Sensor-bitmap resolution masks (ConfigByteLayoutShimmer3.java:300-303). +const EXG1_24BIT = 0x000010; +const EXG2_24BIT = 0x000008; +const EXG1_16BIT = 0x100000; +const EXG2_16BIT = 0x080000; +// Some non-EXG sensor masks for conflict tests (Configuration.java SensorBitmap). +const GSR = 0x000004; +const INT_A1 = 0x000400; +const A_ACCEL = 0x000080; +const BRIDGE_AMP = 0x008000; + +const HW3 = 3; // Shimmer3 +const HW3R = 10; // Shimmer3R + +const zeros = () => new Uint8Array(10); +const u8 = (a: readonly number[]): Uint8Array => new Uint8Array(a); + +/** A fresh (unconfigured) apply input at a given rate / hardware version. */ +const freshInput = (samplingRateHz: number, hardwareVersion?: number, enabledSensors = 0) => ({ + exg1: zeros(), + exg2: zeros(), + enabledSensors, + samplingRateHz, + hardwareVersion, +}); + +describe('exgRateSettingFromFreq (SensorEXG.setExGRateFromFreq, :2784-2806)', () => { + it('maps the sampling rate to the REG1 data-rate setting with <= thresholds', () => { + // At / below each boundary the lower index wins (Java uses freq<=boundary). + expect(exgRateSettingFromFreq(100)).toBe(0); + expect(exgRateSettingFromFreq(125)).toBe(0); + expect(exgRateSettingFromFreq(125.1)).toBe(1); + expect(exgRateSettingFromFreq(250)).toBe(1); + expect(exgRateSettingFromFreq(500)).toBe(2); + expect(exgRateSettingFromFreq(512)).toBe(3); // 512 > 500 → 1000 Hz bucket + expect(exgRateSettingFromFreq(1000)).toBe(3); + expect(exgRateSettingFromFreq(1024)).toBe(4); + expect(exgRateSettingFromFreq(2000)).toBe(4); + expect(exgRateSettingFromFreq(4000)).toBe(5); + expect(exgRateSettingFromFreq(8000)).toBe(6); + // > 8 kHz falls back to 500 Hz (index 2), matching the Java else-branch. + expect(exgRateSettingFromFreq(16000)).toBe(2); + }); +}); + +describe('applyExgPreset — golden vectors (Java preset arrays + rate/clock coupling)', () => { + it('ECG @500 Hz on Shimmer3 reproduces the Java reference arrays', () => { + const r = applyExgPreset(freshInput(500, HW3), 'ecg', '24bit'); + // byte0 rate = 2 (500 Hz) = the reference default; Shimmer3 fresh → osc bit off. + expect([...r.exg1]).toEqual([...EXG_PRESET_ARRAYS.ecg.exg1]); + expect([...r.exg2]).toEqual([...EXG_PRESET_ARRAYS.ecg.exg2]); + }); + + it('ECG @500 Hz on Shimmer3R sets the joined oscillator-clock bit on chip 1', () => { + const r = applyExgPreset(freshInput(500, HW3R), 'ecg', '24bit'); + // Matches the hardcoded 3R ECG arrays (Shimmer3RClient.ts:504-508): + // chip1 byte1 0xA0 -> 0xA8 (oscillator clock joined), chip2 unchanged. + expect([...r.exg1]).toEqual([2, 0xa8, 16, 64, 64, 45, 0, 0, 2, 3]); + expect([...r.exg2]).toEqual([2, 0xa0, 16, 64, 71, 0, 0, 0, 2, 1]); + }); + + it('rewrites REG1 data-rate on BOTH chips from the sampling rate', () => { + const r = applyExgPreset(freshInput(1024, HW3), 'ecg', '24bit'); + expect(r.exg1[0] & 0x07).toBe(4); // 1024 Hz → 2 kHz bucket (index 4) + expect(r.exg2[0] & 0x07).toBe(4); + }); + + it('EMG @500 Hz reproduces the Java reference arrays (chip-1-only preset)', () => { + const r = applyExgPreset(freshInput(500, HW3), 'emg', '16bit'); + expect([...r.exg1]).toEqual([...EXG_PRESET_ARRAYS.emg.exg1]); + expect([...r.exg2]).toEqual([...EXG_PRESET_ARRAYS.emg.exg2]); + }); + + it('Test signal @500 Hz reproduces the Java reference arrays', () => { + const r = applyExgPreset(freshInput(500, HW3), 'test-signal', '16bit'); + expect([...r.exg1]).toEqual([...EXG_PRESET_ARRAYS['test-signal'].exg1]); + expect([...r.exg2]).toEqual([...EXG_PRESET_ARRAYS['test-signal'].exg2]); + }); + + it('Respiration @500 Hz reproduces the Java reference arrays', () => { + const r = applyExgPreset(freshInput(500, HW3), 'respiration', '24bit'); + expect([...r.exg1]).toEqual([...EXG_PRESET_ARRAYS.respiration.exg1]); + expect([...r.exg2]).toEqual([...EXG_PRESET_ARRAYS.respiration.exg2]); + }); + + it('produces identical register bytes for 16-bit and 24-bit (resolution is bitmap-only)', () => { + const a = applyExgPreset(freshInput(500, HW3), 'ecg', '16bit'); + const b = applyExgPreset(freshInput(500, HW3), 'ecg', '24bit'); + expect([...a.exg1]).toEqual([...b.exg1]); + expect([...a.exg2]).toEqual([...b.exg2]); + expect(a.enabledSensors).not.toBe(b.enabledSensors); // only the bitmap differs + }); + + it('preserves a Shimmer3 device oscillator-clock bit when the HW cannot be resolved', () => { + // A Shimmer3 EXG-unified rev>=4 joins clocks (undetectable from HW id) — its + // existing bit must survive a preset apply rather than being forced off. + const input = { ...freshInput(500, HW3), exg1: u8([2, 0xa8, 16, 0, 0, 0, 0, 0, 2, 1]) }; + const r = applyExgPreset(input, 'ecg', '24bit'); + expect(r.exg1[1] & 0x08).toBe(0x08); + }); +}); + +describe('applyExgPreset — sensor-bitmap coherence', () => { + it('sets the chosen-resolution flags and clears the other resolution', () => { + const r16 = applyExgPreset(freshInput(500, HW3), 'ecg', '16bit'); + expect(r16.enabledSensors & (EXG1_16BIT | EXG2_16BIT)).toBe(EXG1_16BIT | EXG2_16BIT); + expect(r16.enabledSensors & (EXG1_24BIT | EXG2_24BIT)).toBe(0); + + const r24 = applyExgPreset(freshInput(500, HW3), 'ecg', '24bit'); + expect(r24.enabledSensors & (EXG1_24BIT | EXG2_24BIT)).toBe(EXG1_24BIT | EXG2_24BIT); + expect(r24.enabledSensors & (EXG1_16BIT | EXG2_16BIT)).toBe(0); + }); + + it('EMG enables chip 1 only', () => { + const r = applyExgPreset(freshInput(500, HW3), 'emg', '16bit'); + expect(r.enabledSensors & EXG1_16BIT).toBe(EXG1_16BIT); + expect(r.enabledSensors & EXG2_16BIT).toBe(0); + }); + + it("'off' zeroes both banks and clears all four resolution flags", () => { + const input = freshInput(500, HW3, EXG1_24BIT | EXG2_24BIT); + input.exg1 = u8([...EXG_PRESET_ARRAYS.ecg.exg1]); + input.exg2 = u8([...EXG_PRESET_ARRAYS.ecg.exg2]); + const r = applyExgPreset(input, 'off', '24bit'); + expect([...r.exg1]).toEqual(new Array(10).fill(0)); + expect([...r.exg2]).toEqual(new Array(10).fill(0)); + expect(r.enabledSensors & (EXG1_24BIT | EXG2_24BIT | EXG1_16BIT | EXG2_16BIT)).toBe(0); + }); + + it('switching resolution clears the previous resolution flags', () => { + const first = applyExgPreset(freshInput(500, HW3), 'ecg', '24bit'); + const second = applyExgPreset( + { ...freshInput(500, HW3), enabledSensors: first.enabledSensors, exg1: first.exg1, exg2: first.exg2 }, + 'ecg', + '16bit', + ); + expect(second.enabledSensors & (EXG1_24BIT | EXG2_24BIT)).toBe(0); + expect(second.enabledSensors & (EXG1_16BIT | EXG2_16BIT)).toBe(EXG1_16BIT | EXG2_16BIT); + }); +}); + +describe('applyExgPreset — sensor conflicts (ShimmerDevice.sensorMapConflictCheckandCorrect)', () => { + it('clears conflicting sensors from the returned bitmap, keeping non-conflicting ones', () => { + const enabled = A_ACCEL | GSR | INT_A1 | BRIDGE_AMP; + const r = applyExgPreset(freshInput(500, HW3, enabled), 'ecg', '24bit'); + expect(r.enabledSensors & GSR).toBe(0); + expect(r.enabledSensors & INT_A1).toBe(0); + expect(r.enabledSensors & BRIDGE_AMP).toBe(0); + expect(r.enabledSensors & A_ACCEL).toBe(A_ACCEL); // accel is not a conflict + expect(r.enabledSensors & (EXG1_24BIT | EXG2_24BIT)).toBe(EXG1_24BIT | EXG2_24BIT); + }); + + it('exgConflictingSensors reports only the currently-enabled conflicts', () => { + expect(exgConflictingSensors(A_ACCEL | GSR | INT_A1).map((c) => c.label)).toEqual([ + 'GSR', + 'Internal ADC A1', + ]); + expect(exgConflictingSensors(A_ACCEL)).toEqual([]); + expect(exgConflictingSensors(0)).toEqual([]); + }); + + it('the conflict list covers GSR, the internal ADCs and the bridge/resistance amp', () => { + const masks = EXG_CONFLICTING_SENSORS.map((c) => c.mask); + expect(masks).toContain(GSR); + expect(masks).toContain(INT_A1); + expect(masks).toContain(BRIDGE_AMP); + expect(masks).toContain(0x800000); // INT_A14 + }); +}); + +describe('applyExgPreset — idempotence + detection round-trip', () => { + const presets: ApplicableExgPreset[] = ['ecg', 'emg', 'test-signal', 'respiration']; + const resolutions: ExgResolution[] = ['16bit', '24bit']; + + for (const hw of [HW3, HW3R]) { + for (const preset of presets) { + for (const resolution of resolutions) { + it(`round-trips ${preset}/${resolution} through detectExgPreset (HW ${hw})`, () => { + const r = applyExgPreset(freshInput(500, hw), preset, resolution); + expect(detectExgPreset(r.exg1, r.exg2, r.enabledSensors)).toBe(preset); + expect(exgResolutionFromSensors(r.enabledSensors)).toBe(resolution); + }); + + it(`is idempotent for ${preset}/${resolution} (HW ${hw})`, () => { + const first = applyExgPreset(freshInput(500, hw), preset, resolution); + const second = applyExgPreset( + { exg1: first.exg1, exg2: first.exg2, enabledSensors: first.enabledSensors, samplingRateHz: 500, hardwareVersion: hw }, + preset, + resolution, + ); + expect([...second.exg1]).toEqual([...first.exg1]); + expect([...second.exg2]).toEqual([...first.exg2]); + expect(second.enabledSensors).toBe(first.enabledSensors); + }); + } + } + } + + it("'off' detects as off", () => { + const r = applyExgPreset(freshInput(500, HW3, EXG1_24BIT), 'off', '24bit'); + expect(detectExgPreset(r.exg1, r.exg2, r.enabledSensors)).toBe('off'); + }); +}); + +describe('applyExgPreset — input validation', () => { + it('rejects malformed bank lengths', () => { + expect(() => + applyExgPreset({ exg1: new Uint8Array(9), exg2: zeros(), enabledSensors: 0, samplingRateHz: 500 }, 'ecg', '24bit'), + ).toThrow(RangeError); + }); +}); From c9b60c868855b4874664825d21167fb7843bfd71 Mon Sep 17 00:00:00 2001 From: jyong15 <26561407+jyong15@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:03:25 +0000 Subject: [PATCH 03/10] style: auto-format source files --- tests/exg/apply.test.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/exg/apply.test.ts b/tests/exg/apply.test.ts index e590d9b..88030c5 100644 --- a/tests/exg/apply.test.ts +++ b/tests/exg/apply.test.ts @@ -143,7 +143,12 @@ describe('applyExgPreset — sensor-bitmap coherence', () => { it('switching resolution clears the previous resolution flags', () => { const first = applyExgPreset(freshInput(500, HW3), 'ecg', '24bit'); const second = applyExgPreset( - { ...freshInput(500, HW3), enabledSensors: first.enabledSensors, exg1: first.exg1, exg2: first.exg2 }, + { + ...freshInput(500, HW3), + enabledSensors: first.enabledSensors, + exg1: first.exg1, + exg2: first.exg2, + }, 'ecg', '16bit', ); @@ -197,7 +202,13 @@ describe('applyExgPreset — idempotence + detection round-trip', () => { it(`is idempotent for ${preset}/${resolution} (HW ${hw})`, () => { const first = applyExgPreset(freshInput(500, hw), preset, resolution); const second = applyExgPreset( - { exg1: first.exg1, exg2: first.exg2, enabledSensors: first.enabledSensors, samplingRateHz: 500, hardwareVersion: hw }, + { + exg1: first.exg1, + exg2: first.exg2, + enabledSensors: first.enabledSensors, + samplingRateHz: 500, + hardwareVersion: hw, + }, preset, resolution, ); @@ -218,7 +229,11 @@ describe('applyExgPreset — idempotence + detection round-trip', () => { describe('applyExgPreset — input validation', () => { it('rejects malformed bank lengths', () => { expect(() => - applyExgPreset({ exg1: new Uint8Array(9), exg2: zeros(), enabledSensors: 0, samplingRateHz: 500 }, 'ecg', '24bit'), + applyExgPreset( + { exg1: new Uint8Array(9), exg2: zeros(), enabledSensors: 0, samplingRateHz: 500 }, + 'ecg', + '24bit', + ), ).toThrow(RangeError); }); }); From 8c4345385ede56d4234f92f10e16ff31477a8a86 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 09:20:04 +0000 Subject: [PATCH 04/10] feat(exg): live BT/BLE GET/SET register commands with read-back-verify (EX3) Generalise the Shimmer3R hardcoded 16-bit EXG writers into a codec-driven live path, add GET read-back, and extend both to the classic Shimmer3Client. - New pure framing module src/devices/exg/live.ts: buildGetExgRegsCommand ({0x63,chip,0,10}), buildSetExgRegsCommand ({0x61,chip,0,10,reg0..reg9}), decodeExgRegsResponse (12-byte [0x62][echo][reg0..reg9]), exgBanksEqualIgnoringStatus (skips read-only REG8). Ported byte-for-byte from ShimmerBluetooth.readEXGConfigurations/writeEXGConfiguration (ShimmerBluetooth.java:4023,4220,468,1641-1652). - readExgConfig() / writeExgConfig(exg1,exg2) / applyExgPresetLive(preset, resolution) on both Shimmer3RClient and Shimmer3Client. Enabled sensors written LAST (ShimmerBluetooth.java:2732,2735; EXG regs earlier :2670). - Write-safety deviation from Java (fire-and-forget + timeout-disconnect, :4028-4034): SET -> await ACK -> GET read-back -> compare -> throw on mismatch. - 3R injects the ADS1292R oversampling ratio into REG1 (getOversampling- RatioADS1292R); classic writes banks verbatim. enable*16Bit now delegate to applyExgPresetLive (hardcoded arrays dropped). - Classic adds EXG_REGS_RESPONSE (0x62, 11 payload) to the response-length table so the unframed byte-stream parser frames it. - EXG GET/SET/apply refused while streaming (read-back needs control plane). - Tests: exg/live framing; per-client GET/SET framing, read-back decode, read-back-verify mismatch throw, applyExgPresetLive end-to-end, streaming guard (+18 tests, 642 total). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012pin56yhefi5Mihjf3vTpL --- CHANGELOG.md | 1 + src/devices/exg/index.ts | 16 ++ src/devices/exg/live.ts | 131 ++++++++++++++ src/devices/shimmer3/Shimmer3Client.ts | 142 +++++++++++++++ src/devices/shimmer3/protocol.ts | 3 + src/devices/shimmer3r/Shimmer3RClient.ts | 209 +++++++++++++++++------ src/index.ts | 13 ++ tests/exg/live.test.ts | 68 ++++++++ tests/shimmer3/exg-live.test.ts | 161 +++++++++++++++++ tests/shimmer3r/exg-live.test.ts | 197 +++++++++++++++++++++ 10 files changed, 892 insertions(+), 49 deletions(-) create mode 100644 src/devices/exg/live.ts create mode 100644 tests/exg/live.test.ts create mode 100644 tests/shimmer3/exg-live.test.ts create mode 100644 tests/shimmer3r/exg-live.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 202f976..8995f6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project follows [Semantic Versioning](https://semver.org/). ### Added +- **ADS1292R (EXG) live BT/BLE set + read commands, phase EX3.** Generalises the previous Shimmer3R hardcoded 16-bit-only EXG writers into a codec-driven live path, adds GET read-back, and extends both to the classic `Shimmer3Client`. A new pure framing module (`src/devices/exg/live.ts`) provides `buildGetExgRegsCommand(chip)` → `{0x63, chip, 0, 10}` (ported byte-for-byte from `ShimmerBluetooth.readEXGConfigurations`, `ShimmerBluetooth.java:4023`), `buildSetExgRegsCommand(chip, bank)` → `{0x61, chip, 0, 10, reg0..reg9}` (a single 14-byte write, never chunked — `ShimmerBluetooth.java:4220`), `decodeExgRegsResponse(frame)` which extracts the 10 register bytes from the 12-byte `[0x62][echo][reg0..reg9]` response (the byte after the opcode is a count/chip echo the driver ignores — the chip identity is tracked host-side from the preceding GET via `mTempChipID`, `ShimmerBluetooth.java:1087-1089,1641-1652`; declared length 11 at `:468`), and `exgBanksEqualIgnoringStatus(a, b)` for read-back comparison (skips the read-only REG8 LOFF_STAT byte). New client methods on **both** `Shimmer3RClient` and `Shimmer3Client`: `readExgConfig()` (GET ×2 chips → decode), `writeExgConfig(exg1, exg2)` (SET ×2), and `applyExgPresetLive(preset, resolution)` (reads current banks, folds EX2's `applyExgPreset`, writes registers, then updates the sensor bitmap via `setSensors` — **enabled sensors are written LAST**, matching the desktop write flow's "this should always be the last command" ordering, `ShimmerBluetooth.java:2732,2735`, with `writeEXGConfiguration()` running earlier, `:2670`). The three `enableECG16Bit` / `enableEMG16Bit` / `enableEXGTestSignal16Bit` convenience methods now delegate to `applyExgPresetLive`, dropping the hardcoded byte arrays. **Write-safety, a deliberate deviation from Java:** Java applies SET_EXG_REGS immediately with a plain ACK and relies on a timeout→disconnect failsafe (`ShimmerBluetooth.java:4028-4034`); this port instead does SET → await ACK → GET read-back → compare → throw on mismatch (read-back-verify), so a silent no-op surfaces as an error at the call site. **3R vs classic:** the Shimmer3R write injects the ADS1292R oversampling ratio into REG1 (bank byte 0) low 3 bits from the live sampling rate (`getOversamplingRatioADS1292R`, `calibration.ts:89`, reproducing the old `_writeExgPages` step); classic Shimmer3 writes the banks **verbatim** (Java `writeEXGConfiguration` does not touch reg[0]). EXG GET/SET/apply are refused while streaming (the read-back needs the control plane). Classic Shimmer3 adds `EXG_REGS_RESPONSE` (0x62, 11 payload bytes) to `SHIMMER3_RESPONSE_PAYLOAD_LENGTHS` so its unframed byte-stream parser frames the response. New exports: `SET_EXG_REGS_COMMAND`, `EXG_REGS_RESPONSE`, `GET_EXG_REGS_COMMAND`, `EXG_REGS_RESPONSE_PAYLOAD_LENGTH`, `EXG_CHIP1`, `EXG_CHIP2`, `EXG_REG8_STATUS_INDEX`, `buildGetExgRegsCommand`, `buildSetExgRegsCommand`, `decodeExgRegsResponse`, `exgBanksEqualIgnoringStatus`, and the `ExgChipIndex` type. **HARDWARE-VERIFY:** the live GET/SET round-trip and read-back-verify have not been exercised against real Shimmer3/3R firmware; the classic-Shimmer3 firmware-version gate (`(internal>=8 && code==2) || code>2`, `ShimmerBluetooth.java:4011`) is approximated by requiring a completed connect handshake rather than porting the full ShimmerVerObject version-code ladder. - **ADS1292R (EXG) preset APPLICATION library, phase EX2.** A new pure module (`src/devices/exg/apply.ts`) that turns a chosen preset + resolution into the two register banks and the enabled-sensors bitmap to write — the write half that builds on EX1's codec/detection. `applyExgPreset({ exg1, exg2, enabledSensors, samplingRateHz, hardwareVersion }, preset, resolution)` → `{ exg1, exg2, enabledSensors }` ports the Java preset setters `SensorEXG.setDefaultECGConfiguration` / `setDefaultEMGConfiguration` / `setEXGTestSignal` / `setDefaultRespirationConfiguration` (`SensorEXG.java:1781-1895`) as a whole: (a) the register bytes come from EX1's `EXG_PRESET_ARRAYS`, which are **identical for 16- and 24-bit** — the Java derives resolution purely from the sensor bitmap (`setExgChannelBitsPerMode`, `:2150-2183`), never from separate register arrays or bit-twiddling; (b) the **sampling-rate→data-rate coupling** rewrites REG1 data-rate on BOTH chips via `exgRateSettingFromFreq`, a byte-for-byte port of `setExGRateFromFreq` (`:2784-2806`, `<=` thresholds, >8 kHz→500 Hz fallback) — distinct from the live-BT `getOversamplingRatioADS1292R` (`calibration.ts:89`, strict `<`), which is NOT used on the docked InfoMem path; (c) the **resolution↔bitmap coupling** sets the chosen resolution's EXG1/EXG2 flags and clears the other's — EMG is chip-1-only, every other preset is both chips, and **respiration honours the chosen resolution (it is NOT forced to 24-bit)**; `'off'` zeroes both banks and clears all four flags; (d) **sensor-conflict clearing** — `exgConflictingSensors(enabledSensors)` reports the currently-enabled sensors that conflict with EXG (GSR, the internal ADCs, the bridge/resistance amp — the EXG `SensorDetailsRef` conflict list, `SensorEXG.java:332-344`), and `applyExgPreset` CLEARS them from the returned bitmap, matching what desktop does when a preset tile is clicked (`ShimmerDevice.sensorMapConflictCheckandCorrect`, `:2497-2512`, disables — not warns). The CHIP1 oscillator-clock-joined bit (`setDefaultExgCommon`, `:2000-2002`, `ShimmerVerObject.isSupportedExgChipClocksJoined`, `:712-723`) is set for Shimmer3R (the only case resolvable from HW id — the expansion-board rev a classic Shimmer3 would need is not in the docked context), otherwise the device's existing bit is preserved. New exports: `applyExgPreset`, `exgConflictingSensors`, `exgRateSettingFromFreq`, `EXG_CONFLICTING_SENSORS`, and the `ExgApplyInput` / `ExgApplyResult` / `ApplicableExgPreset` types. - **ADS1292R (EXG / ECG-EMG-respiration) register codec, phase EX1.** A new pure, transport-free module (`src/devices/exg/`) that decodes and encodes the 10-byte-per-chip ADS1292R register bank and recognises the whole-device preset — the read/display half of EXG configuration. Ported from the desktop Java oracle: `decodeExgRegisters(bank)` mirrors `ShimmerObject.exgBytesGetConfigFrom` (`ShimmerObject.java:6894-6940`) + `ExGConfigBytesDetails.updateFromRegisterArray`, returning every register field as `{ value, label }` (data rate, conversion mode, reference buffer/voltage, per-channel PGA gain + numeric gain value + input-selection mux + power-down, reference-electrode, lead-off detection/current/comparator-threshold + per-lead sense bits, RLD routing, respiration mod/demod/phase/frequency, and the read-only REG8 status bits) with the human-readable labels taken verbatim from `SensorEXG.java:116-149`. `encodeExgRegisters(settings)` is the inverse (`generateExgByteArray`) and always re-applies the hardware "must-be" bits (`applyExgMustBeBits`, ported from `setExgByteArrayConstants`, `ExGConfigBytesDetails.java:507-525`). `detectExgPreset(exg1, exg2, enabledSensors?)` → `'ecg' | 'emg' | 'test-signal' | 'respiration' | 'custom' | 'off'` is a **tolerant** match keyed only off the CH1/CH2 input-selection nibbles + chip-2 respiration bits (+ the resolution flags when the bitmap is supplied), exactly as `SensorEXG.isEXGUsingDefault*` (`SensorEXG.java:2680-2763`) — so the fields firmware rewrites (rate/oversampling in byte0, oscillator-clock in byte1, gain) do not affect detection, and both the 24-bit Java reference arrays and the 16-bit hardcoded 3R arrays detect correctly. **Resolution is not a register field**: `exgResolutionFromSensors(enabledSensors)` → `'16bit' | '24bit' | null` derives it from the sensor bitmap (`ShimmerObject.checkExgResolutionFromEnabledSensorsVar`, `:7255-7279`). Covers Shimmer3 and Shimmer3R (same ADS1292R). New exports: `decodeExgRegisters`, `encodeExgRegisters`, `applyExgMustBeBits`, `detectExgPreset`, `exgResolutionFromSensors`, `exgPresetLabel`, `EXG_PRESET_ARRAYS`, `EXG_BANK_LENGTH`, the option-label lists, and the `DecodedExgRegisters` / `ExgPreset` / `ExgResolution` types. - **SD-log header EXG register banks.** `SdLogHeader` now carries the raw `exg1` / `exg2` register banks, extracted from SD header bytes 56-65 / 66-75 (`ShimmerSDLog.java:252-253` Shimmer3R, `:322-323` Shimmer3 — both device families use the same offsets). Decode them with the new EXG codec. All-zero on non-EXG logs. diff --git a/src/devices/exg/index.ts b/src/devices/exg/index.ts index 509f6df..62914a3 100644 --- a/src/devices/exg/index.ts +++ b/src/devices/exg/index.ts @@ -65,3 +65,19 @@ export { } from './apply.js'; export type { ExgApplyInput, ExgApplyResult, ApplicableExgPreset } from './apply.js'; + +export { + SET_EXG_REGS_COMMAND, + EXG_REGS_RESPONSE, + GET_EXG_REGS_COMMAND, + EXG_REGS_RESPONSE_PAYLOAD_LENGTH, + EXG_CHIP1, + EXG_CHIP2, + EXG_REG8_STATUS_INDEX, + buildGetExgRegsCommand, + buildSetExgRegsCommand, + decodeExgRegsResponse, + exgBanksEqualIgnoringStatus, +} from './live.js'; + +export type { ExgChipIndex } from './live.js'; diff --git a/src/devices/exg/live.ts b/src/devices/exg/live.ts new file mode 100644 index 0000000..4e4d9dc --- /dev/null +++ b/src/devices/exg/live.ts @@ -0,0 +1,131 @@ +/** + * ADS1292R (EXG) LIVE Bluetooth/BLE framing — the GET/SET register commands and + * the read-back response decode used by the over-the-air (not docked) path. + * + * Pure, transport-free port of the Java oracle's EXG BT command flow + * (com.shimmerresearch.bluetooth.ShimmerBluetooth). Where EX1 (`registers.ts`) + * and EX2 (`apply.ts`) build the 10-byte-per-chip register banks, this module + * turns a bank into the exact LiteProtocol instruction bytes and decodes the + * response — so both `Shimmer3RClient` and `Shimmer3Client` share one framing + * definition rather than each hand-rolling the byte layout. + * + * LiteProtocol opcodes (ShimmerLiteProtocolInstructionSet.java): + * SET_EXG_REGS_COMMAND = 97 (0x61) @:479, EXG_REGS_RESPONSE = 98 (0x62) @:1604, + * GET_EXG_REGS_COMMAND = 99 (0x63) @:1058. + * + * @packageDocumentation + */ + +import { EXG_BANK_LENGTH } from './registers.js'; + +/** SET_EXG_REGS_COMMAND opcode (ShimmerLiteProtocolInstructionSet.java:479, value 97). */ +export const SET_EXG_REGS_COMMAND = 0x61; +/** EXG_REGS_RESPONSE opcode (ShimmerLiteProtocolInstructionSet.java:1604, value 98). */ +export const EXG_REGS_RESPONSE = 0x62; +/** GET_EXG_REGS_COMMAND opcode (ShimmerLiteProtocolInstructionSet.java:1058, value 99). */ +export const GET_EXG_REGS_COMMAND = 0x63; + +/** + * Number of payload bytes AFTER the {@link EXG_REGS_RESPONSE} opcode. The Java + * driver declares this response length as 11 (ShimmerBluetooth.java:468) and + * reads exactly 11 bytes (`readBytes(11, …)`, ShimmerBluetooth.java:1641). The + * 11 bytes are `[echo][reg0..reg9]`: the driver copies the 10 register bytes + * from offset 1 (`System.arraycopy(bufferAns, 1, …, 0, 10)`, + * ShimmerBluetooth.java:1645-1652), so payload byte 0 (the count/chip echo the + * firmware prepends) is ignored — the chip identity is tracked host-side from + * the preceding GET instruction, not read from the response + * (`mTempChipID = insBytes[1]`, ShimmerBluetooth.java:1087-1089). + */ +export const EXG_REGS_RESPONSE_PAYLOAD_LENGTH = 11; + +/** EXG chip index — the `EXG_CHIP_INDEX` ordinal used in the instruction header. */ +export const EXG_CHIP1 = 0; +/** EXG chip index — chip 2. */ +export const EXG_CHIP2 = 1; + +/** Chip index accepted by the framing builders. */ +export type ExgChipIndex = typeof EXG_CHIP1 | typeof EXG_CHIP2; + +/** + * Build the GET_EXG_REGS instruction for one chip: + * `{0x63, chipID, 0, 10}` — read 10 registers starting at offset 0. + * Byte-for-byte port of ShimmerBluetooth.readEXGConfigurations + * (`writeInstruction(new byte[]{GET_EXG_REGS_COMMAND,(byte)(chipID.ordinal()),0,10})`, + * ShimmerBluetooth.java:4023). + */ +export function buildGetExgRegsCommand(chip: ExgChipIndex): Uint8Array { + return new Uint8Array([GET_EXG_REGS_COMMAND, chip, 0, EXG_BANK_LENGTH]); +} + +/** + * Build the SET_EXG_REGS instruction for one chip: + * `{0x61, chipID, 0, 10, reg0..reg9}` — a single 14-byte write (the 10 register + * bytes fit one instruction, never chunked). Byte-for-byte port of + * ShimmerBluetooth.writeEXGConfiguration + * (`writeInstruction(new byte[]{SET_EXG_REGS_COMMAND,(byte)(chipID.ordinal()),0,10,reg[0]..reg[9]})`, + * ShimmerBluetooth.java:4220). + * + * @throws RangeError when `bank` is not exactly 10 bytes. + */ +export function buildSetExgRegsCommand(chip: ExgChipIndex, bank: Uint8Array): Uint8Array { + if (bank.length !== EXG_BANK_LENGTH) { + throw new RangeError( + `EXG register bank must be exactly ${EXG_BANK_LENGTH} bytes, got ${bank.length}.`, + ); + } + const out = new Uint8Array(4 + EXG_BANK_LENGTH); + out[0] = SET_EXG_REGS_COMMAND; + out[1] = chip; + out[2] = 0; + out[3] = EXG_BANK_LENGTH; + out.set(bank, 4); + return out; +} + +/** + * Extract the 10-byte register bank from an EXG_REGS_RESPONSE frame. + * + * The frame is `[0x62][echo][reg0..reg9]` (opcode + {@link EXG_REGS_RESPONSE_PAYLOAD_LENGTH} + * payload bytes). Mirrors the Java `System.arraycopy(bufferAns, 1, …, 0, 10)` + * (ShimmerBluetooth.java:1645): the byte immediately after the opcode is a + * count/chip echo that the driver ignores, and the 10 register bytes follow. + * + * @param frame the complete response including the leading 0x62 opcode. + * @throws RangeError when the frame is too short or does not start with 0x62. + */ +export function decodeExgRegsResponse(frame: Uint8Array): Uint8Array { + if (frame.length < 1 + EXG_REGS_RESPONSE_PAYLOAD_LENGTH) { + throw new RangeError( + `EXG_REGS_RESPONSE must be ${1 + EXG_REGS_RESPONSE_PAYLOAD_LENGTH} bytes (opcode + ${EXG_REGS_RESPONSE_PAYLOAD_LENGTH} payload), got ${frame.length}.`, + ); + } + if (frame[0] !== EXG_REGS_RESPONSE) { + throw new RangeError( + `EXG_REGS_RESPONSE must start with 0x${EXG_REGS_RESPONSE.toString(16)}, got 0x${frame[0].toString(16)}.`, + ); + } + // opcode at [0], echo byte at [1], 10 register bytes at [2..11]. + return frame.slice(2, 2 + EXG_BANK_LENGTH); +} + +/** + * Register index of REG8 (LOFF_STAT) within the 10-byte bank — the read-only + * lead-off status register (ExGConfigBytesDetails REG8). Its bits reflect the + * chip's live lead-off state, so the device never echoes back what was written; + * {@link exgBanksEqualIgnoringStatus} excludes it from read-back comparison. + */ +export const EXG_REG8_STATUS_INDEX = 7; + +/** + * Compare two 10-byte register banks for read-back verification, ignoring the + * read-only REG8 (LOFF_STAT) status byte at index {@link EXG_REG8_STATUS_INDEX}. + * Every other register is host-writable and must echo back exactly. + */ +export function exgBanksEqualIgnoringStatus(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== EXG_BANK_LENGTH || b.length !== EXG_BANK_LENGTH) return false; + for (let i = 0; i < EXG_BANK_LENGTH; i++) { + if (i === EXG_REG8_STATUS_INDEX) continue; + if (a[i] !== b[i]) return false; + } + return true; +} diff --git a/src/devices/shimmer3/Shimmer3Client.ts b/src/devices/shimmer3/Shimmer3Client.ts index 71f81af..b950e28 100644 --- a/src/devices/shimmer3/Shimmer3Client.ts +++ b/src/devices/shimmer3/Shimmer3Client.ts @@ -39,6 +39,20 @@ import { type Shimmer3DeviceVersion, type Shimmer3FwVersion, } from './protocol.js'; +import { + EXG_BANK_LENGTH, + EXG_CHIP1, + EXG_CHIP2, + EXG_REGS_RESPONSE, + buildGetExgRegsCommand, + buildSetExgRegsCommand, + decodeExgRegsResponse, + exgBanksEqualIgnoringStatus, + applyExgPreset, + type ExgChipIndex, + type ApplicableExgPreset, + type ExgResolution, +} from '../exg/index.js'; // --------------------------------------------------------------------------- // Constructor options @@ -497,6 +511,134 @@ export class Shimmer3Client extends BaseShimmerClient { return { expPower }; } + // --------------------------------------------------------------------------- + // ExG (ADS1292R) live configuration — GET / SET / preset apply + // + // Same LiteProtocol EXG command flow as Shimmer3R, ported from + // ShimmerBluetooth.readEXGConfigurations / writeEXGConfiguration (:4010-4227). + // Classic Shimmer3 differs from the 3R in one respect: it writes the register + // banks VERBATIM — there is NO oversampling-ratio injection into REG1 (that is + // a Shimmer3R-only step; ShimmerBluetooth.writeEXGConfiguration writes reg[0] + // unchanged, :4220). + // --------------------------------------------------------------------------- + + /** + * Whether this device's firmware supports the EXG GET/SET commands. The Java + * driver gates both on `(internal>=8 && code==2) || code>2` + * (ShimmerBluetooth.java:4011,4022,4201,4219). We do not port the full + * ShimmerVerObject firmware-version-code ladder (HARDWARE-VERIFY: the precise + * code mapping is unverified here); instead we require a completed connect + * handshake (firmwareVersion known), which every EXG-capable LogAndStream / + * BtStream firmware satisfies. The read-back-verify in {@link writeExgConfig} + * is the real failsafe against a silent no-op on unexpectedly old firmware. + */ + private _assertExgSupported(): void { + if (!this._transport) throw new Error('Not connected'); + if (this.firmwareVersion == null) { + throw new Error('EXG requires a completed connect handshake (firmware version unknown)'); + } + } + + /** + * Read both EXG chips' 10-byte register banks (GET_EXG_REGS ×2 → + * EXG_REGS_RESPONSE decode). Ported from ShimmerBluetooth.readEXGConfigurations + * (:4010-4014): CHIP1 then CHIP2. + * + * @throws Error when unsupported/not connected or while streaming. + */ + async readExgConfig( + timeoutMs = SHIMMER3_DEFAULTS.RESPONSE_TIMEOUT_MS, + ): Promise<{ exg1: Uint8Array; exg2: Uint8Array }> { + this._assertExgSupported(); + if (this._streaming) throw new Error('Cannot read EXG registers while streaming'); + const exg1 = await this._readExgChip(EXG_CHIP1, timeoutMs); + const exg2 = await this._readExgChip(EXG_CHIP2, timeoutMs); + return { exg1, exg2 }; + } + + private async _readExgChip(chip: ExgChipIndex, timeoutMs: number): Promise { + // GET is ACK-then-response; _waitForResponse tolerates the optional leading + // ACK (like inquiry()), so register it directly rather than awaiting the ACK + // separately — avoids a race if the device coalesces ACK + response. + await this._write(buildGetExgRegsCommand(chip)); + const frame = await this._waitForResponse(EXG_REGS_RESPONSE, timeoutMs); + return decodeExgRegsResponse(frame); + } + + /** + * Write both EXG chips' 10-byte register banks (SET_EXG_REGS ×2), then read + * back and verify. Classic Shimmer3 writes the banks verbatim (no oversampling + * injection — that is Shimmer3R-only). + * + * WRITE-SAFETY DEVIATION FROM JAVA: Java applies SET_EXG_REGS immediately and + * does not verify, relying on a timeout→disconnect failsafe + * (ShimmerBluetooth.java:4028-4034 doc comment). We port the safer flow: SET → + * await ACK → GET read-back → compare (ignoring the read-only REG8 status byte) + * → throw on mismatch. + * + * @throws Error when unsupported/not connected, while streaming, or on mismatch. + * @throws RangeError when either bank is not exactly 10 bytes. + */ + async writeExgConfig(exg1: Uint8Array, exg2: Uint8Array): Promise { + this._assertExgSupported(); + if (this._streaming) throw new Error('Cannot write EXG registers while streaming'); + if (exg1.length !== EXG_BANK_LENGTH || exg2.length !== EXG_BANK_LENGTH) { + throw new RangeError( + `EXG register banks must be exactly ${EXG_BANK_LENGTH} bytes each, got ${exg1.length}/${exg2.length}.`, + ); + } + + const b1 = new Uint8Array(exg1); + const b2 = new Uint8Array(exg2); + await this._writeExpectingAck( + buildSetExgRegsCommand(EXG_CHIP1, b1), + SHIMMER3_DEFAULTS.ACK_TIMEOUT_MS, + ); + await this._writeExpectingAck( + buildSetExgRegsCommand(EXG_CHIP2, b2), + SHIMMER3_DEFAULTS.ACK_TIMEOUT_MS, + ); + + const readBack = await this.readExgConfig(); + if ( + !exgBanksEqualIgnoringStatus(b1, readBack.exg1) || + !exgBanksEqualIgnoringStatus(b2, readBack.exg2) + ) { + throw new Error( + 'EXG write read-back mismatch: device registers do not match what was written', + ); + } + this._emitStatus('EXG registers written and verified.'); + } + + /** + * Apply an EXG preset live: build the banks + sensor bitmap from the current + * inquiry state via EX2's `applyExgPreset`, write the registers, then set the + * enabled sensors LAST (ShimmerBluetooth.java:2732,2735 — enabled sensors are + * always the last write; `writeEXGConfiguration()` runs earlier, :2670). + */ + async applyExgPresetLive(preset: ApplicableExgPreset, resolution: ExgResolution): Promise { + this._assertExgSupported(); + if (this._streaming) throw new Error('Cannot configure EXG while streaming'); + + const current = await this.readExgConfig(); + const result = applyExgPreset( + { + exg1: current.exg1, + exg2: current.exg2, + enabledSensors: this.enabledSensors, + samplingRateHz: this.samplingRateHz, + hardwareVersion: this.deviceVersion?.hardwareVersion, + }, + preset, + resolution, + ); + + await this.writeExgConfig(result.exg1, result.exg2); + await this.setSensors(result.enabledSensors); + this._emitStatus(`EXG preset '${preset}' (${resolution}) applied. Schema updated.`); + } + // --------------------------------------------------------------------------- // Inquiry // --------------------------------------------------------------------------- diff --git a/src/devices/shimmer3/protocol.ts b/src/devices/shimmer3/protocol.ts index 21ed942..713047c 100644 --- a/src/devices/shimmer3/protocol.ts +++ b/src/devices/shimmer3/protocol.ts @@ -352,6 +352,9 @@ export const SHIMMER3_RESPONSE_PAYLOAD_LENGTHS: Readonly> [OPCODES.DEVICE_VERSION_RESPONSE]: 1, // 0x25 [OPCODES.GSR_RANGE_RESPONSE]: 1, // 0x22 [OPCODES.INTERNAL_EXP_POWER_ENABLE_RESPONSE]: 1, // 0x5F + // EXG_REGS_RESPONSE (0x62): 11 payload bytes after the opcode = [echo][reg0..reg9] + // (ShimmerBluetooth.java:468 declares length 11; :1641 reads 11). + [OPCODES.EXG_REGS_RESPONSE]: 11, // 0x62 }); /** Sentinel: need more bytes before the message length can be determined. */ diff --git a/src/devices/shimmer3r/Shimmer3RClient.ts b/src/devices/shimmer3r/Shimmer3RClient.ts index 074fa28..6afd6c3 100644 --- a/src/devices/shimmer3r/Shimmer3RClient.ts +++ b/src/devices/shimmer3r/Shimmer3RClient.ts @@ -19,6 +19,20 @@ import { import { concatU8, u16le, u16be, u24le, u24be, sign16, sign24, hex2 } from './protocol.js'; import { WebBluetoothTransport } from '../../core/transport/WebBluetoothTransport.js'; import type { ShimmerTransport, Unsubscribe } from '../../core/transport/types.js'; +import { + EXG_BANK_LENGTH, + EXG_CHIP1, + EXG_CHIP2, + EXG_REGS_RESPONSE, + buildGetExgRegsCommand, + buildSetExgRegsCommand, + decodeExgRegsResponse, + exgBanksEqualIgnoringStatus, + applyExgPreset, + type ExgChipIndex, + type ApplicableExgPreset, + type ExgResolution, +} from '../exg/index.js'; import { applyStreamingCalibration, parseKinematicCalibBlock, @@ -28,6 +42,10 @@ import { type KinematicCalibration, } from '../calibration/index.js'; +// HW_ID for Shimmer3R (ShimmerVerDetails.HW_ID). Drives the EXG joined-clock bit +// in applyExgPreset — the 3R always joins the two ADS1292R chip clocks. +const HW_ID_SHIMMER_3R = 10; + // --------------------------------------------------------------------------- // Internal schema type // --------------------------------------------------------------------------- @@ -465,67 +483,160 @@ export class Shimmer3RClient extends BaseShimmerClient { } // --------------------------------------------------------------------------- - // ExG configuration helpers + // ExG (ADS1292R) live configuration — GET / SET / preset apply + // + // Codec-driven port of the Java EXG BT command flow + // (ShimmerBluetooth.readEXGConfigurations / writeEXGConfiguration, :4010-4227), + // replacing the previous hardcoded 16-bit-only preset byte arrays. The + // register banks now come from the shared EX1/EX2 codec (`../exg/`), and the + // live GET/SET framing from `../exg/live.ts`. // --------------------------------------------------------------------------- - /** Enable EMG (ADS1292R) in 16-bit mode on EXG1 & EXG2. */ - async enableEMG16Bit(): Promise { + /** + * Read both EXG chips' 10-byte register banks over the radio + * (GET_EXG_REGS ×2 → EXG_REGS_RESPONSE decode). Ported from + * ShimmerBluetooth.readEXGConfigurations, which issues one GET for CHIP1 then + * CHIP2 (ShimmerBluetooth.java:4010-4014). + * + * @throws Error when not connected or while streaming (the read-back needs the + * control plane, which is owned by the data plane during streaming). + */ + async readExgConfig(timeoutMs = 1500): Promise<{ exg1: Uint8Array; exg2: Uint8Array }> { if (!this._transport) throw new Error('Not connected (RX missing)'); - await this._writeExgPages( - new Uint8Array([ - 0x61, 0x00, 0x00, 0x0a, 0x02, 0xa8, 0x10, 0x69, 0x60, 0x20, 0x00, 0x00, 0x02, 0x03, - ]), - new Uint8Array([ - 0x61, 0x01, 0x00, 0x0a, 0x02, 0xa0, 0x10, 0xe1, 0xe1, 0x00, 0x00, 0x00, 0x02, 0x01, - ]), - ); - this._emitStatus('EMG 16-bit enabled on EXG1 & EXG2. Schema updated.'); + if (this._streaming) throw new Error('Cannot read EXG registers while streaming'); + const exg1 = await this._readExgChip(EXG_CHIP1, timeoutMs); + const exg2 = await this._readExgChip(EXG_CHIP2, timeoutMs); + return { exg1, exg2 }; } - /** Enable EXG test signal in 16-bit mode (useful for verifying ExG hardware). */ - async enableEXGTestSignal16Bit(): Promise { + private async _readExgChip(chip: ExgChipIndex, timeoutMs: number): Promise { + // GET is ACK-then-response like INQUIRY; the response can be piggybacked in + // the same notification as the ACK (Shimmer3R firmware coalesces them), so + // reuse the ACK-remainder path exactly as inquiry() does. + const remainder = await this._writeExpectingAck(buildGetExgRegsCommand(chip), timeoutMs); + const frame = + remainder && remainder[0] === EXG_REGS_RESPONSE + ? remainder + : await this._waitForResponse(EXG_REGS_RESPONSE, timeoutMs); + return decodeExgRegsResponse(frame); + } + + /** + * Write both EXG chips' 10-byte register banks over the radio + * (SET_EXG_REGS ×2), then read them back and verify. + * + * Ports ShimmerBluetooth.writeEXGConfiguration (:4200-4227) — per-chip, one + * 14-byte instruction each — with the Shimmer3R-specific oversampling-ratio + * injection into REG1 (bank byte 0): the ADS1292R data-rate/oversampling field + * is derived from the live sampling rate via `getOversamplingRatioADS1292R` + * (calibration.ts:89, the 3R BT path — distinct from the docked InfoMem + * `exgRateSettingFromFreq`), matching the previous `_writeExgPages` behaviour. + * + * WRITE-SAFETY DEVIATION FROM JAVA: the Java driver applies SET_EXG_REGS + * immediately on the device and does NOT verify — it relies on a + * timeout→disconnect failsafe if the write silently fails + * (ShimmerBluetooth.java:4028-4034 doc comment; the register array is stored + * driver-side only after the plain ACK, :2132). We port the safer flow: SET → + * await ACK → GET read-back → compare (ignoring the read-only REG8 status + * byte) → throw on mismatch, so a bad/no-op write surfaces as an error here + * rather than as a later disconnect. + * + * @throws Error when not connected, while streaming, or when read-back mismatches. + * @throws RangeError when either bank is not exactly 10 bytes. + */ + async writeExgConfig(exg1: Uint8Array, exg2: Uint8Array): Promise { if (!this._transport) throw new Error('Not connected (RX missing)'); - await this._writeExgPages( - new Uint8Array([ - 0x61, 0x00, 0x00, 0x0a, 0x02, 0xab, 0x10, 0x15, 0x15, 0x00, 0x00, 0x00, 0x02, 0x01, - ]), - new Uint8Array([ - 0x61, 0x01, 0x00, 0x0a, 0x02, 0xa3, 0x10, 0x15, 0x15, 0x00, 0x00, 0x00, 0x02, 0x01, - ]), - ); - this._emitStatus('EXG test signal 16-bit enabled. Schema updated.'); + if (this._streaming) throw new Error('Cannot write EXG registers while streaming'); + if (exg1.length !== EXG_BANK_LENGTH || exg2.length !== EXG_BANK_LENGTH) { + throw new RangeError( + `EXG register banks must be exactly ${EXG_BANK_LENGTH} bytes each, got ${exg1.length}/${exg2.length}.`, + ); + } + + const b1 = this._injectOversamplingRatio(exg1); + const b2 = this._injectOversamplingRatio(exg2); + + await this._writeExpectingAck(buildSetExgRegsCommand(EXG_CHIP1, b1), 1500); + await this._writeExpectingAck(buildSetExgRegsCommand(EXG_CHIP2, b2), 1500); + + // Read-back-verify (see write-safety note above). + const readBack = await this.readExgConfig(); + if ( + !exgBanksEqualIgnoringStatus(b1, readBack.exg1) || + !exgBanksEqualIgnoringStatus(b2, readBack.exg2) + ) { + throw new Error( + 'EXG write read-back mismatch: device registers do not match what was written', + ); + } + this._emitStatus('EXG registers written and verified.'); } - /** Enable ECG in 16-bit mode on EXG1 & EXG2. */ - async enableECG16Bit(): Promise { + /** + * 3R-specific: overwrite the REG1 (bank byte 0) low 3 bits with the ADS1292R + * oversampling ratio for the current sampling rate. Reproduces the previous + * `_writeExgPages` step `exg[4] = ((exg[4]>>3)<<3) | ratio` — byte 4 of the old + * 14-byte instruction was register byte 0. Classic Shimmer3 does NOT do this + * (ShimmerBluetooth.writeEXGConfiguration writes the bank verbatim). + */ + private _injectOversamplingRatio(bank: Uint8Array): Uint8Array { + const ratio = getOversamplingRatioADS1292R(this.samplingRateHz); + const out = new Uint8Array(bank); + out[0] = (((out[0] >> 3) << 3) | ratio) & 0xff; + return out; + } + + /** + * Apply an EXG preset live: build the register banks + sensor bitmap from the + * client's current inquiry state (sampling rate, enabled sensors, hardware + * version) via EX2's `applyExgPreset`, write the registers, then update the + * enabled-sensors bitmap. + * + * ORDER (ShimmerBluetooth): EXG registers are written first and the enabled + * sensors LAST — the desktop write flow marks `writeEnabledSensors(...)` with + * "this should always be the last command" (ShimmerBluetooth.java:2732,2735), + * and `writeEXGConfiguration()` runs earlier in the same flow (:2670). + * `setSensors` re-inquires, so the schema/enabledSensors reflect the new preset. + */ + async applyExgPresetLive(preset: ApplicableExgPreset, resolution: ExgResolution): Promise { if (!this._transport) throw new Error('Not connected (RX missing)'); - await this._writeExgPages( - new Uint8Array([ - 0x61, 0x00, 0x00, 0x0a, 0x02, 0xa8, 0x10, 0x40, 0x40, 0x2d, 0x00, 0x00, 0x02, 0x03, - ]), - new Uint8Array([ - 0x61, 0x01, 0x00, 0x0a, 0x02, 0xa0, 0x10, 0x40, 0x47, 0x00, 0x00, 0x00, 0x02, 0x01, - ]), + if (this._streaming) throw new Error('Cannot configure EXG while streaming'); + + // Seed the apply from the device's current banks so the oscillator-clock + // preserve path (classic rev>=4) is honoured; on 3R the banks are fully + // determined by the preset regardless. + const current = await this.readExgConfig(); + const result = applyExgPreset( + { + exg1: current.exg1, + exg2: current.exg2, + enabledSensors: this.enabledSensors, + samplingRateHz: this.samplingRateHz, + hardwareVersion: HW_ID_SHIMMER_3R, + }, + preset, + resolution, ); - this._emitStatus('ECG 16-bit enabled on EXG1 & EXG2. Schema updated.'); + + await this.writeExgConfig(result.exg1, result.exg2); + // Enabled sensors last (re-inquires to refresh the schema). + await this.setSensors(result.enabledSensors); + this._emitStatus(`EXG preset '${preset}' (${resolution}) applied. Schema updated.`); + } + + /** Enable EMG (ADS1292R) in 16-bit mode on EXG1 & EXG2. */ + async enableEMG16Bit(): Promise { + await this.applyExgPresetLive('emg', '16bit'); } - private async _writeExgPages(exg1: Uint8Array, exg2: Uint8Array): Promise { - const oversamplingRatio = getOversamplingRatioADS1292R(this.samplingRateHz); - exg1 = new Uint8Array(exg1); - exg2 = new Uint8Array(exg2); - exg1[4] = (((exg1[4] >> 3) << 3) | oversamplingRatio) & 0xff; - exg2[4] = (((exg2[4] >> 3) << 3) | oversamplingRatio) & 0xff; - - await this._write(exg1); - await new Promise((r) => setTimeout(r, 200)); - await this._write(exg2); - await new Promise((r) => setTimeout(r, 50)); - - const targetBits = - (SensorBitmapShimmer3.SENSOR_EXG1_16BIT | SensorBitmapShimmer3.SENSOR_EXG2_16BIT) >>> 0; - const newMask = ((this.enabledSensors >>> 0) | targetBits) & 0xffffff; - await this.setSensors(newMask); + /** Enable EXG test signal in 16-bit mode (useful for verifying ExG hardware). */ + async enableEXGTestSignal16Bit(): Promise { + await this.applyExgPresetLive('test-signal', '16bit'); + } + + /** Enable ECG in 16-bit mode on EXG1 & EXG2. */ + async enableECG16Bit(): Promise { + await this.applyExgPresetLive('ecg', '16bit'); } // --------------------------------------------------------------------------- diff --git a/src/index.ts b/src/index.ts index a1e5351..aa4e4e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -268,6 +268,18 @@ export { exgConflictingSensors, exgRateSettingFromFreq, EXG_CONFLICTING_SENSORS, + // Live BT/BLE GET/SET framing — phase EX3 + SET_EXG_REGS_COMMAND, + EXG_REGS_RESPONSE, + GET_EXG_REGS_COMMAND, + EXG_REGS_RESPONSE_PAYLOAD_LENGTH, + EXG_CHIP1, + EXG_CHIP2, + EXG_REG8_STATUS_INDEX, + buildGetExgRegsCommand, + buildSetExgRegsCommand, + decodeExgRegsResponse, + exgBanksEqualIgnoringStatus, } from './devices/exg/index.js'; export type { ExgFieldValue, @@ -284,6 +296,7 @@ export type { ExgApplyInput, ExgApplyResult, ApplicableExgPreset, + ExgChipIndex, } from './devices/exg/index.js'; // Inertial (accel/gyro/mag) calibration — phase P3 diff --git a/tests/exg/live.test.ts b/tests/exg/live.test.ts new file mode 100644 index 0000000..97d25d2 --- /dev/null +++ b/tests/exg/live.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { + SET_EXG_REGS_COMMAND, + EXG_REGS_RESPONSE, + GET_EXG_REGS_COMMAND, + EXG_REGS_RESPONSE_PAYLOAD_LENGTH, + EXG_CHIP1, + EXG_CHIP2, + EXG_REG8_STATUS_INDEX, + buildGetExgRegsCommand, + buildSetExgRegsCommand, + decodeExgRegsResponse, + exgBanksEqualIgnoringStatus, +} from '../../src/devices/exg/live.js'; + +// Pin the exact LiteProtocol byte layouts for the EXG GET/SET commands and the +// EXG_REGS_RESPONSE decode, byte-for-byte against ShimmerBluetooth. + +describe('EXG live framing', () => { + it('opcode values match the LiteProtocol table', () => { + expect(SET_EXG_REGS_COMMAND).toBe(0x61); + expect(EXG_REGS_RESPONSE).toBe(0x62); + expect(GET_EXG_REGS_COMMAND).toBe(0x63); + expect(EXG_REGS_RESPONSE_PAYLOAD_LENGTH).toBe(11); + }); + + it('buildGetExgRegsCommand → {0x63, chip, 0, 10} (ShimmerBluetooth.java:4023)', () => { + expect(Array.from(buildGetExgRegsCommand(EXG_CHIP1))).toEqual([0x63, 0, 0, 10]); + expect(Array.from(buildGetExgRegsCommand(EXG_CHIP2))).toEqual([0x63, 1, 0, 10]); + }); + + it('buildSetExgRegsCommand → {0x61, chip, 0, 10, reg0..reg9} (ShimmerBluetooth.java:4220)', () => { + const bank = new Uint8Array([2, 160, 16, 64, 64, 45, 0, 0, 2, 3]); + expect(Array.from(buildSetExgRegsCommand(EXG_CHIP1, bank))).toEqual([ + 0x61, 0, 0, 10, 2, 160, 16, 64, 64, 45, 0, 0, 2, 3, + ]); + expect(Array.from(buildSetExgRegsCommand(EXG_CHIP2, bank))).toEqual([ + 0x61, 1, 0, 10, 2, 160, 16, 64, 64, 45, 0, 0, 2, 3, + ]); + }); + + it('buildSetExgRegsCommand rejects a non-10-byte bank', () => { + expect(() => buildSetExgRegsCommand(EXG_CHIP1, new Uint8Array(9))).toThrow(/exactly 10 bytes/); + }); + + it('decodeExgRegsResponse extracts reg0..reg9 from [0x62][echo][10 regs]', () => { + const regs = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + // echo byte (index 1) is the count/chip echo the firmware prepends — ignored. + const frame = new Uint8Array([EXG_REGS_RESPONSE, 10, ...regs]); + expect(Array.from(decodeExgRegsResponse(frame))).toEqual(regs); + }); + + it('decodeExgRegsResponse rejects a short frame or wrong opcode', () => { + expect(() => decodeExgRegsResponse(new Uint8Array([0x62, 10, 1, 2, 3]))).toThrow(/must be 12/); + expect(() => decodeExgRegsResponse(new Uint8Array(12))).toThrow(/must start with/); + }); + + it('exgBanksEqualIgnoringStatus ignores REG8 (index 7) only', () => { + const a = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 0xaa, 9, 10]); + const b = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 0x55, 9, 10]); // differs only at REG8 + expect(EXG_REG8_STATUS_INDEX).toBe(7); + expect(exgBanksEqualIgnoringStatus(a, b)).toBe(true); + + const c = new Uint8Array(b); + c[0] = 0xff; // a writable register differs + expect(exgBanksEqualIgnoringStatus(a, c)).toBe(false); + }); +}); diff --git a/tests/shimmer3/exg-live.test.ts b/tests/shimmer3/exg-live.test.ts new file mode 100644 index 0000000..c3f0df3 --- /dev/null +++ b/tests/shimmer3/exg-live.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest'; +import { Shimmer3Client } from '../../src/devices/shimmer3/Shimmer3Client.js'; +import { OPCODES } from '../../src/devices/shimmer3r/constants.js'; +import { SensorBitmapShimmer3 } from '../../src/devices/shimmer3r/SensorBitmap.js'; +import { LoopbackTransport } from '../../src/core/transport/LoopbackTransport.js'; +import { EXG_PRESET_ARRAYS, detectExgPreset } from '../../src/devices/exg/index.js'; + +// EX3: live EXG GET/SET over the (unframed) RFCOMM byte stream for classic +// Shimmer3. Same LiteProtocol command flow as Shimmer3R, but the classic client +// writes the register banks VERBATIM (no oversampling-ratio injection). + +const ACK = OPCODES.ACK_COMMAND_PROCESSED; // 0xFF +const INQ_RSP = OPCODES.INQUIRY_RESPONSE; // 0x02 +const DEVVER = OPCODES.DEVICE_VERSION_RESPONSE; // 0x25 +const FWVER = OPCODES.FW_VERSION_RESPONSE; // 0x2F +const SET_EXG = OPCODES.SET_EXG_REGS_COMMAND; // 0x61 +const EXG_RSP = OPCODES.EXG_REGS_RESPONSE; // 0x62 +const GET_EXG = OPCODES.GET_EXG_REGS_COMMAND; // 0x63 + +// Classic-Shimmer3 inquiry layout (numChannels @7, channels @9). +const GYRO_INQ = [INQ_RSP, 0x80, 0x02, 0x00, 0x00, 0x00, 0x05, 0x03, 0x01, 0x0a, 0x0b, 0x0c]; +const EXG_INQ = [INQ_RSP, 0x80, 0x02, 0x00, 0x00, 0x00, 0x05, 0x04, 0x01, 0x23, 0x24, 0x25, 0x26]; + +interface ExgDevice { + banks: [Uint8Array, Uint8Array]; + handler: (bytes: Uint8Array, tr: LoopbackTransport) => void; + corruptChip?: 0 | 1; +} + +function exgDevice(init?: { banks?: [number[], number[]] }): ExgDevice { + const dev: ExgDevice = { + banks: [ + new Uint8Array(init?.banks?.[0] ?? new Array(10).fill(0)), + new Uint8Array(init?.banks?.[1] ?? new Array(10).fill(0)), + ], + handler: () => {}, + }; + let inquiry = GYRO_INQ; + dev.handler = (bytes, tr) => { + const op = bytes[0]; + if (op === OPCODES.GET_DEVICE_VERSION_COMMAND) setTimeout(() => tr.notify([DEVVER, 3]), 0); + else if (op === OPCODES.GET_FW_VERSION_COMMAND) + setTimeout(() => tr.notify([FWVER, 3, 0, 0, 0, 15, 0]), 0); + else if (op === GET_EXG) { + const chip = bytes[1] as 0 | 1; + let bank = dev.banks[chip]; + if (dev.corruptChip === chip) { + bank = new Uint8Array(bank); + bank[3] ^= 0x10; // flip a writable-register bit + } + // ACK then response, as one coalesced chunk (drainControl re-frames it). + setTimeout(() => tr.notify([ACK, EXG_RSP, 10, ...bank]), 0); + } else if (op === SET_EXG) { + const chip = bytes[1] as 0 | 1; + dev.banks[chip] = bytes.slice(4, 4 + 10); + setTimeout(() => tr.notify([ACK]), 0); + } else if (op === OPCODES.SET_SENSORS_COMMAND) { + inquiry = EXG_INQ; + setTimeout(() => tr.notify([ACK]), 0); + } else if (op === OPCODES.INQUIRY_COMMAND) { + setTimeout(() => tr.notify([ACK, ...inquiry]), 0); + } else if (op === OPCODES.START_STREAMING_COMMAND) { + setTimeout(() => tr.notify([ACK]), 0); + } + }; + return dev; +} + +async function connected( + dev: ExgDevice, +): Promise<{ t: LoopbackTransport; client: Shimmer3Client }> { + const t = new LoopbackTransport({ capabilities: { framed: false }, deviceName: 'Shimmer3-EXG' }); + t.setOnWrite((bytes, tr) => dev.handler(bytes, tr)); + const client = new Shimmer3Client({ debug: false, transport: t }); + await client.connect(); + return { t, client }; +} + +describe('Shimmer3Client EXG live GET/SET', () => { + it('readExgConfig sends GET {0x63,chip,0,10} for CHIP1 then CHIP2 and decodes both banks', async () => { + const dev = exgDevice({ + banks: [ + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + [11, 12, 13, 14, 15, 16, 17, 18, 19, 20], + ], + }); + const { t, client } = await connected(dev); + + const { exg1, exg2 } = await client.readExgConfig(); + expect(Array.from(exg1)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(Array.from(exg2)).toEqual([11, 12, 13, 14, 15, 16, 17, 18, 19, 20]); + + const gets = t.writes.filter((w) => w.bytes[0] === GET_EXG).map((w) => Array.from(w.bytes)); + expect(gets).toEqual([ + [0x63, 0, 0, 10], + [0x63, 1, 0, 10], + ]); + }); + + it('writeExgConfig sends SET {0x61,chip,0,10,...} VERBATIM (no oversampling injection) and read-back-verifies', async () => { + const dev = exgDevice(); + const { t, client } = await connected(dev); + + const ecg1 = Uint8Array.from(EXG_PRESET_ARRAYS.ecg.exg1); + const ecg2 = Uint8Array.from(EXG_PRESET_ARRAYS.ecg.exg2); + await client.writeExgConfig(ecg1, ecg2); + + const sets = t.writes.filter((w) => w.bytes[0] === SET_EXG).map((w) => w.bytes); + expect(sets.length).toBe(2); + // Classic writes the banks verbatim — REG1 (byte 4) is untouched. + expect(Array.from(sets[0])).toEqual([0x61, 0, 0, 10, ...EXG_PRESET_ARRAYS.ecg.exg1]); + expect(Array.from(sets[1])).toEqual([0x61, 1, 0, 10, ...EXG_PRESET_ARRAYS.ecg.exg2]); + }); + + it('writeExgConfig throws when the read-back does not match', async () => { + const dev = exgDevice(); + dev.corruptChip = 0; + const { client } = await connected(dev); + await expect( + client.writeExgConfig( + Uint8Array.from(EXG_PRESET_ARRAYS.ecg.exg1), + Uint8Array.from(EXG_PRESET_ARRAYS.ecg.exg2), + ), + ).rejects.toThrow(/read-back mismatch/i); + }); + + it('applyExgPresetLive(ecg,16bit) writes ECG banks, sets the EXG bitmap last, re-inquiry reflects EXG', async () => { + const dev = exgDevice(); + const { t, client } = await connected(dev); + await client.inquiry(); + expect(client.enabledSensors).toBe(SensorBitmapShimmer3.SENSOR_GYRO); + + await client.applyExgPresetLive('ecg', '16bit'); + + expect(detectExgPreset(dev.banks[0], dev.banks[1])).toBe('ecg'); + + const ops = t.writes.map((w) => w.bytes[0]); + expect(ops.lastIndexOf(SET_EXG)).toBeLessThan(ops.indexOf(OPCODES.SET_SENSORS_COMMAND)); + + const setSensors = t.writes.find((w) => w.bytes[0] === OPCODES.SET_SENSORS_COMMAND)!; + const mask = setSensors.bytes[1] | (setSensors.bytes[2] << 8) | (setSensors.bytes[3] << 16); + expect(mask & SensorBitmapShimmer3.SENSOR_EXG1_16BIT).toBeTruthy(); + expect(mask & SensorBitmapShimmer3.SENSOR_EXG2_16BIT).toBeTruthy(); + + expect(client.enabledSensors & SensorBitmapShimmer3.SENSOR_EXG1_16BIT).toBeTruthy(); + expect(client.enabledSensors & SensorBitmapShimmer3.SENSOR_EXG2_16BIT).toBeTruthy(); + }); + + it('rejects EXG GET/SET/apply while streaming', async () => { + const dev = exgDevice(); + const { client } = await connected(dev); + await client.inquiry(); + await client.startStreaming(); + + await expect(client.readExgConfig()).rejects.toThrow(/streaming/i); + await expect(client.writeExgConfig(new Uint8Array(10), new Uint8Array(10))).rejects.toThrow( + /streaming/i, + ); + await expect(client.applyExgPresetLive('ecg', '16bit')).rejects.toThrow(/streaming/i); + }); +}); diff --git a/tests/shimmer3r/exg-live.test.ts b/tests/shimmer3r/exg-live.test.ts new file mode 100644 index 0000000..642895f --- /dev/null +++ b/tests/shimmer3r/exg-live.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from 'vitest'; +import { Shimmer3RClient } from '../../src/devices/shimmer3r/Shimmer3RClient.js'; +import { OPCODES } from '../../src/devices/shimmer3r/constants.js'; +import { SensorBitmapShimmer3 } from '../../src/devices/shimmer3r/SensorBitmap.js'; +import { LoopbackTransport } from '../../src/core/transport/LoopbackTransport.js'; +import { getOversamplingRatioADS1292R } from '../../src/devices/shimmer3r/calibration.js'; +import { EXG_PRESET_ARRAYS, detectExgPreset } from '../../src/devices/exg/index.js'; + +// EX3: live EXG GET/SET over the radio for Shimmer3R, exercised against a +// stateful in-memory device that stores the register banks on SET and echoes +// them on GET — so the SET → read-back-verify flow can be pinned end-to-end. + +const ACK = OPCODES.ACK_COMMAND_PROCESSED; // 0xff +const INQ_RSP = OPCODES.INQUIRY_RESPONSE; // 0x02 +const SET_EXG = OPCODES.SET_EXG_REGS_COMMAND; // 0x61 +const EXG_RSP = OPCODES.EXG_REGS_RESPONSE; // 0x62 +const GET_EXG = OPCODES.GET_EXG_REGS_COMMAND; // 0x63 + +// GYRO-only inquiry (51.2 Hz), same as transport-loopback.test.ts. +const GYRO_INQ = [INQ_RSP, 0x80, 0x02, 0, 0, 0, 0, 0, 0, 0, 3, 1, 0x0a, 0x0b, 0x0c]; +// ECG 16-bit inquiry: EXG1_16BIT (0x23/0x24) + EXG2_16BIT (0x25/0x26) channels. +const EXG_INQ = [INQ_RSP, 0x80, 0x02, 0, 0, 0, 0, 0, 0, 0, 4, 1, 0x23, 0x24, 0x25, 0x26]; + +interface ExgDevice { + banks: [Uint8Array, Uint8Array]; + handler: (bytes: Uint8Array, tr: LoopbackTransport) => void; + /** When set, the read-back GET for this chip returns a mutated bank (mismatch). */ + corruptChip?: 0 | 1; +} + +function exgDevice(init?: { banks?: [number[], number[]]; piggyback?: boolean }): ExgDevice { + const dev: ExgDevice = { + banks: [ + new Uint8Array(init?.banks?.[0] ?? new Array(10).fill(0)), + new Uint8Array(init?.banks?.[1] ?? new Array(10).fill(0)), + ], + handler: () => {}, + }; + let inquiry = GYRO_INQ; + const piggyback = init?.piggyback ?? true; + dev.handler = (bytes, tr) => { + const op = bytes[0]; + if (op === GET_EXG) { + const chip = bytes[1] as 0 | 1; + let bank = dev.banks[chip]; + if (dev.corruptChip === chip) { + bank = new Uint8Array(bank); + bank[0] ^= 0x40; // flip a writable-register bit to force a mismatch + } + const frame = [EXG_RSP, 10, ...bank]; + if (piggyback) setTimeout(() => tr.notify([ACK, ...frame]), 0); + else { + setTimeout(() => tr.notify([ACK]), 0); + setTimeout(() => tr.notify(frame), 1); + } + } else if (op === SET_EXG) { + const chip = bytes[1] as 0 | 1; + dev.banks[chip] = bytes.slice(4, 4 + 10); + setTimeout(() => tr.notify([ACK]), 0); + } else if (op === OPCODES.SET_SENSORS_COMMAND) { + inquiry = EXG_INQ; // reflect the EXG enable on the next inquiry + setTimeout(() => tr.notify([ACK]), 0); + } else if (op === OPCODES.INQUIRY_COMMAND) { + setTimeout(() => tr.notify([ACK, ...inquiry]), 0); + } else if (op === OPCODES.START_STREAMING_COMMAND) { + setTimeout(() => tr.notify([ACK]), 0); + } + }; + return dev; +} + +async function connected( + dev: ExgDevice, +): Promise<{ t: LoopbackTransport; client: Shimmer3RClient }> { + const t = new LoopbackTransport({ deviceName: 'Shimmer3R-EXG' }); + t.setOnWrite((bytes, tr) => dev.handler(bytes, tr)); + const client = new Shimmer3RClient({ debug: false, transport: t }); + await client.connect(); + return { t, client }; +} + +describe('Shimmer3RClient EXG live GET/SET', () => { + it('readExgConfig sends GET {0x63,chip,0,10} for CHIP1 then CHIP2 and decodes both banks', async () => { + const dev = exgDevice({ + banks: [ + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + [11, 12, 13, 14, 15, 16, 17, 18, 19, 20], + ], + }); + const { t, client } = await connected(dev); + + const { exg1, exg2 } = await client.readExgConfig(); + expect(Array.from(exg1)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(Array.from(exg2)).toEqual([11, 12, 13, 14, 15, 16, 17, 18, 19, 20]); + + const gets = t.writes.filter((w) => w.bytes[0] === GET_EXG).map((w) => Array.from(w.bytes)); + expect(gets).toEqual([ + [0x63, 0, 0, 10], + [0x63, 1, 0, 10], + ]); + }); + + it('readExgConfig also works when ACK and response arrive in separate chunks', async () => { + const dev = exgDevice({ + banks: [ + [9, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [8, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ], + piggyback: false, + }); + const { client } = await connected(dev); + const { exg1, exg2 } = await client.readExgConfig(); + expect(exg1[0]).toBe(9); + expect(exg2[0]).toBe(8); + }); + + it('writeExgConfig sends SET {0x61,chip,0,10,...} per chip, injects the 3R oversampling ratio into REG1, and read-back-verifies', async () => { + const dev = exgDevice(); + const { t, client } = await connected(dev); + client.samplingRateHz = 1000; // getOversamplingRatioADS1292R(1000) = 4 + + const ecg1 = Uint8Array.from(EXG_PRESET_ARRAYS.ecg.exg1); + const ecg2 = Uint8Array.from(EXG_PRESET_ARRAYS.ecg.exg2); + await client.writeExgConfig(ecg1, ecg2); + + const sets = t.writes.filter((w) => w.bytes[0] === SET_EXG).map((w) => w.bytes); + expect(sets.length).toBe(2); + expect(sets[0][1]).toBe(0); // chip 0 + expect(sets[1][1]).toBe(1); // chip 1 + expect(Array.from(sets[0].slice(0, 4))).toEqual([0x61, 0, 0, 10]); + + // REG1 (instruction byte 4) low 3 bits carry the oversampling ratio. + const ratio = getOversamplingRatioADS1292R(1000); + expect(ratio).toBe(4); + expect(sets[0][4] & 0x07).toBe(4); + expect(sets[1][4] & 0x07).toBe(4); + // Upper bits of REG1 preserved from the ECG preset (0x02 → 0x00 after mask, |4). + expect(sets[0][4]).toBe(((ecg1[0] >> 3) << 3) | 4); + + // The stored device banks match what was written (read-back succeeded). + expect(dev.banks[0][4]).toBe(ecg1[4]); + }); + + it('writeExgConfig throws when the read-back does not match', async () => { + const dev = exgDevice(); + dev.corruptChip = 1; // CHIP2 read-back will differ in a writable register + const { client } = await connected(dev); + client.samplingRateHz = 512; + await expect( + client.writeExgConfig( + Uint8Array.from(EXG_PRESET_ARRAYS.ecg.exg1), + Uint8Array.from(EXG_PRESET_ARRAYS.ecg.exg2), + ), + ).rejects.toThrow(/read-back mismatch/i); + }); + + it('applyExgPresetLive(ecg,16bit) writes ECG banks, sets the EXG bitmap last, and the re-inquiry reflects EXG', async () => { + const dev = exgDevice(); + const { t, client } = await connected(dev); + await client.inquiry(); // seed samplingRateHz (51.2) + enabledSensors (GYRO) + expect(client.enabledSensors).toBe(SensorBitmapShimmer3.SENSOR_GYRO); + + await client.applyExgPresetLive('ecg', '16bit'); + + // The written banks are the ECG preset (detect confirms the input selections). + expect(detectExgPreset(dev.banks[0], dev.banks[1])).toBe('ecg'); + + // SET_EXG for both chips came BEFORE the SET_SENSORS (enabled sensors last). + const ops = t.writes.map((w) => w.bytes[0]); + const lastSetExg = ops.lastIndexOf(SET_EXG); + const setSensorsIdx = ops.indexOf(OPCODES.SET_SENSORS_COMMAND); + expect(lastSetExg).toBeLessThan(setSensorsIdx); + + // SET_SENSORS carried EXG1_16BIT | EXG2_16BIT | (kept) GYRO, conflicts cleared. + const setSensors = t.writes.find((w) => w.bytes[0] === OPCODES.SET_SENSORS_COMMAND)!; + const mask = setSensors.bytes[1] | (setSensors.bytes[2] << 8) | (setSensors.bytes[3] << 16); + expect(mask & SensorBitmapShimmer3.SENSOR_EXG1_16BIT).toBeTruthy(); + expect(mask & SensorBitmapShimmer3.SENSOR_EXG2_16BIT).toBeTruthy(); + + // The auto re-inquiry after setSensors reflects the EXG channels. + expect(client.enabledSensors & SensorBitmapShimmer3.SENSOR_EXG1_16BIT).toBeTruthy(); + expect(client.enabledSensors & SensorBitmapShimmer3.SENSOR_EXG2_16BIT).toBeTruthy(); + }); + + it('rejects EXG GET/SET/apply while streaming', async () => { + const dev = exgDevice(); + const { client } = await connected(dev); + await client.inquiry(); + await client.startStreaming(); + + await expect(client.readExgConfig()).rejects.toThrow(/streaming/i); + await expect(client.writeExgConfig(new Uint8Array(10), new Uint8Array(10))).rejects.toThrow( + /streaming/i, + ); + await expect(client.applyExgPresetLive('ecg', '16bit')).rejects.toThrow(/streaming/i); + }); +}); From 4f0fc601e840355b2f2f1b9a6a1b39735c491671 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 09:20:04 +0000 Subject: [PATCH 05/10] chore(release): 0.1.10-exg.3 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012pin56yhefi5Mihjf3vTpL --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 49fef9e..a7582d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.10-exg.2", + "version": "0.1.10-exg.3", "description": "Web Bluetooth and Web Serial API for Shimmer sensor devices (Shimmer3R, Verisense IMU/Pulse+)", "type": "module", "main": "dist/shimmer-web-sdk.cjs", From e3458cac04c13b64f125926cb26347070d5f5cec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 09:56:40 +0000 Subject: [PATCH 06/10] fix(exg): coherent live 'off' disable + fast-fail firmware gate Live applyExgPresetLive('off') previously reused the docked applyExgPreset('off'), pushing all-zero register banks through writeExgConfig; the ADS1292R forces its must-be bits on write (ExGConfigBytesDetails.java:507-525) so the zeroed banks fail read-back-verify on compliant firmware. The live path now skips the register write and clears only the EXG resolution bits via setSensors, matching Java's live-disable flow (writeEnabledSensors is the disable path, ShimmerBluetooth.java:2732,2735; readEXGConfigurations / writeEXGConfiguration run only while EXG stays enabled, :2670,4010-4014). The docked InfoMem path keeps zeroing the banks (passive storage; EX1's detectExgPreset keys 'off' off all-zero banks). New export clearExgResolutionFlags. Shimmer3Client._assertExgSupported now ports the Java firmware gate verbatim -- (internal >= 8 && code == 2) || code > 2 (ShimmerBluetooth.java:4011) -- deriving the firmware-version code from the parsed FW version + hardware id exactly as ShimmerVerObject does (ShimmerVerObject.java:266-311), and throws a clear error instead of hanging to timeout on EXG-incapable firmware. New exports deriveShimmer3FirmwareVersionCode, shimmer3SupportsExg. Tests: live 'off' on both clients issues no 0x61 traffic and clears the EXG bitmap, against a mock that enforces the must-be bits on echo (which also proves a zeroed writeExgConfig throws read-back mismatch); old BtStream (code 1) and BtStream 0.2.x below internal 8 (code 2) are rejected before any radio traffic, new firmware accepted; encode(decode) clears set LOFF_STAT bits; detectExgPreset with chip1-only sensors is not respiration (both-chips gate). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012pin56yhefi5Mihjf3vTpL --- src/devices/exg/apply.ts | 32 ++++++++ src/devices/exg/index.ts | 1 + src/devices/shimmer3/Shimmer3Client.ts | 52 ++++++++++--- src/devices/shimmer3/protocol.ts | 82 ++++++++++++++++++++ src/devices/shimmer3r/Shimmer3RClient.ts | 17 ++++ src/index.ts | 1 + tests/exg/registers.test.ts | 43 ++++++++++ tests/shimmer3/exg-live.test.ts | 99 ++++++++++++++++++++++-- tests/shimmer3r/exg-live.test.ts | 50 +++++++++++- 9 files changed, 360 insertions(+), 17 deletions(-) diff --git a/src/devices/exg/apply.ts b/src/devices/exg/apply.ts index abbdb1b..e84d58f 100644 --- a/src/devices/exg/apply.ts +++ b/src/devices/exg/apply.ts @@ -143,6 +143,24 @@ export interface ExgApplyResult { /** A preset that can be *applied* (written). 'custom' is detect-only; 'off' clears EXG. */ export type ApplicableExgPreset = 'ecg' | 'emg' | 'test-signal' | 'respiration' | 'off'; +/** + * Clear the four EXG resolution flags (the bits that enable the ADS1292R chips + * in the Shimmer3 streaming bitmap) from an enabled-sensors mask, leaving every + * non-EXG sensor untouched. This is how the Java driver DISABLES EXG live: it + * never pushes zeroed register banks to the chip — the ADS1292R forces the + * must-be bits (CONFIG2 bit7=1 etc., ExGConfigBytesDetails.java:507-525) so an + * all-zero write would fail read-back — it simply drops the EXG bits from the + * enabled-sensors bitmap and re-writes that (writeEnabledSensors is "always the + * last command", ShimmerBluetooth.java:2732,2735; readEXGConfigurations / + * writeEXGConfiguration only run while EXG stays enabled, :2670,4010-4014). The + * live `applyExgPresetLive('off')` path uses this instead of {@link applyExgPreset} + * so it never writes — nor read-back-verifies — a zeroed bank. See the docked-vs- + * live asymmetry note on {@link applyExgPreset} step (2). + */ +export function clearExgResolutionFlags(enabledSensors: number): number { + return ((enabledSensors >>> 0) & ~MASK_ALL_EXG_RESOLUTION) >>> 0; +} + /** * Apply an EXG preset + resolution to a device configuration, returning the two * register banks and the updated enabled-sensors bitmap to write. Pure — does @@ -184,6 +202,20 @@ export function applyExgPreset( // (2) 'off' — no EXG chips enabled; zero the banks so the read-back summary // reads 'off' rather than a stale preset. + // + // DOCKED vs LIVE ASYMMETRY. This function is the DOCKED (InfoMem) path: InfoMem + // is passive storage with no chip enforcement, so zeroed banks are fine and are + // in fact what EX1's detectExgPreset keys 'off' off (all-zero banks + no + // resolution flags → 'off', presets.ts). Keeping the zeroed banks here preserves + // that detection contract on the docked read-back. + // + // The LIVE (over-the-radio) path must NOT reuse this: the ADS1292R forces its + // must-be bits on write (CONFIG2 bit7=1 etc., ExGConfigBytesDetails.java:507-525), + // so a zeroed SET would read back non-zero and the read-back-verify would throw. + // Java disables EXG live by clearing only the sensor bitmap, never by writing + // zeroed registers (writeEnabledSensors is the disable path, + // ShimmerBluetooth.java:2732,2735) — so `applyExgPresetLive('off')` calls + // {@link clearExgResolutionFlags} and skips the register write entirely. if (preset === 'off') { return { exg1: new Uint8Array(EXG_BANK_LENGTH), diff --git a/src/devices/exg/index.ts b/src/devices/exg/index.ts index 62914a3..0a2aa73 100644 --- a/src/devices/exg/index.ts +++ b/src/devices/exg/index.ts @@ -59,6 +59,7 @@ export type { ExgPreset, ExgResolution } from './presets.js'; export { applyExgPreset, + clearExgResolutionFlags, exgConflictingSensors, exgRateSettingFromFreq, EXG_CONFLICTING_SENSORS, diff --git a/src/devices/shimmer3/Shimmer3Client.ts b/src/devices/shimmer3/Shimmer3Client.ts index b950e28..f9bee9d 100644 --- a/src/devices/shimmer3/Shimmer3Client.ts +++ b/src/devices/shimmer3/Shimmer3Client.ts @@ -34,6 +34,8 @@ import { parseShimmer3FwVersionResponse, shimmer3UsesThreeByteTimestamp, shimmer3ControlMessageLength, + shimmer3SupportsExg, + deriveShimmer3FirmwareVersionCode, type Shimmer3InquiryResult, type Shimmer3StreamSchema, type Shimmer3DeviceVersion, @@ -49,6 +51,7 @@ import { decodeExgRegsResponse, exgBanksEqualIgnoringStatus, applyExgPreset, + clearExgResolutionFlags, type ExgChipIndex, type ApplicableExgPreset, type ExgResolution, @@ -523,20 +526,35 @@ export class Shimmer3Client extends BaseShimmerClient { // --------------------------------------------------------------------------- /** - * Whether this device's firmware supports the EXG GET/SET commands. The Java - * driver gates both on `(internal>=8 && code==2) || code>2` - * (ShimmerBluetooth.java:4011,4022,4201,4219). We do not port the full - * ShimmerVerObject firmware-version-code ladder (HARDWARE-VERIFY: the precise - * code mapping is unverified here); instead we require a completed connect - * handshake (firmwareVersion known), which every EXG-capable LogAndStream / - * BtStream firmware satisfies. The read-back-verify in {@link writeExgConfig} - * is the real failsafe against a silent no-op on unexpectedly old firmware. + * Assert this device's firmware supports the live EXG GET/SET commands, failing + * fast rather than letting an EXG command hang to timeout on EXG-incapable + * firmware. Ports the Java gate `(getFirmwareVersionInternal() >= 8 && + * getFirmwareVersionCode() == 2) || getFirmwareVersionCode() > 2` + * (ShimmerBluetooth.java:4011,4022,4201,4219) via {@link shimmer3SupportsExg}, + * which derives the firmware-version code from the parsed FW version + hardware + * id exactly as ShimmerVerObject does (ShimmerVerObject.java:266-311). Old + * BtStream (code 1, or code 2 below internal 8) is rejected up front. + * + * @throws Error when not connected, before the connect handshake completes, or + * on firmware without the EXG command set. */ private _assertExgSupported(): void { if (!this._transport) throw new Error('Not connected'); - if (this.firmwareVersion == null) { + if (this.firmwareVersion == null || this.deviceVersion == null) { throw new Error('EXG requires a completed connect handshake (firmware version unknown)'); } + if (!shimmer3SupportsExg(this.firmwareVersion, this.deviceVersion.hardwareVersion)) { + const { major, minor, internal } = this.firmwareVersion; + const code = deriveShimmer3FirmwareVersionCode( + this.firmwareVersion, + this.deviceVersion.hardwareVersion, + ); + throw new Error( + `EXG register commands are not supported by this firmware ` + + `(v${major}.${minor}.${internal}, firmware code ${code}); ` + + `EXG requires LogAndStream (any), BtStream >= 0.2.8, or firmware code >= 3.`, + ); + } } /** @@ -621,6 +639,22 @@ export class Shimmer3Client extends BaseShimmerClient { this._assertExgSupported(); if (this._streaming) throw new Error('Cannot configure EXG while streaming'); + // 'off' — LIVE disable. Java never pushes zeroed register banks to the chip; + // the ADS1292R forces its must-be bits on write (CONFIG2 bit7=1 etc., + // ExGConfigBytesDetails.java:507-525), so a zeroed SET would fail the + // read-back-verify in writeExgConfig. The disable is done purely by dropping + // the EXG bits from the enabled-sensors bitmap (writeEnabledSensors is the + // last command, ShimmerBluetooth.java:2732,2735; readEXGConfigurations / + // writeEXGConfiguration only run while EXG stays enabled, :2670,4010-4014). + // The DOCKED path (`applyExgPreset('off')`) still zeroes the InfoMem banks — + // InfoMem is passive storage and EX1's detectExgPreset keys 'off' off them. + if (preset === 'off') { + const cleared = clearExgResolutionFlags(this.enabledSensors); + await this.setSensors(cleared); + this._emitStatus("EXG preset 'off' applied (EXG chips disabled). Schema updated."); + return; + } + const current = await this.readExgConfig(); const result = applyExgPreset( { diff --git a/src/devices/shimmer3/protocol.ts b/src/devices/shimmer3/protocol.ts index 713047c..7f330c0 100644 --- a/src/devices/shimmer3/protocol.ts +++ b/src/devices/shimmer3/protocol.ts @@ -333,6 +333,88 @@ export function shimmer3UsesThreeByteTimestamp(v: Shimmer3FwVersion): boolean { } } +/** Hardware-version codes referenced by the firmware-version-code ladder (ShimmerVerDetails.HW_ID). */ +const HW_ID = Object.freeze({ + SHIMMER_2R: 2, + SHIMMER_3: 3, + SHIMMER_3R: 10, +} as const); + +/** + * Derive the ShimmerVerObject "firmware version code" from a parsed FW version + + * hardware id, a verbatim port of the ladder in + * `ShimmerVerObject` (ShimmerVerObject.java:266-311). The code is a single + * monotonically-increasing capability number derived from the (HW id, FW id, + * major.minor.internal) tuple — the Java driver gates several protocol features + * on it rather than on the raw version. Returns `-1` when no rung matches + * (firmware older than every known threshold), exactly as Java initialises it. + * + * Only the rungs reachable by the hardware this SDK talks to (Shimmer3 / + * Shimmer3R / Shimmer2R) are ported; the desktop driver's Shimmer4-SDK / Arduino + * / SWEATCH / ShimmerGQ / StroKare rungs are out of scope for these clients and + * omitted (they would never match a Shimmer3/3R device anyway). + * + * `compareVersions` semantics (UtilShimmer.java:534-628): same HW id AND same FW + * id AND `this` version ≥ target version (major, then minor, then internal, with + * internal compared `>=`). + */ +export function deriveShimmer3FirmwareVersionCode( + fw: Shimmer3FwVersion, + hardwareVersion: number, +): number { + const { firmwareIdentifier: id, major, minor, internal } = fw; + // compareVersions(thisHwIdent, thisFwIdent, thisVer..., compHwIdent, compFwIdent, compVer...). + const ge = (tHw: number, tId: number, tMaj: number, tMin: number, tInt: number): boolean => { + if (hardwareVersion !== tHw || id !== tId) return false; + return ( + major > tMaj || (major === tMaj && (minor > tMin || (minor === tMin && internal >= tInt))) + ); + }; + const L = FW_ID.LOGANDSTREAM; + const B = FW_ID.BTSTREAM; + const S = FW_ID.SDLOG; + if (ge(HW_ID.SHIMMER_3, L, 0, 16, 6)) return 9; + if ( + ge(HW_ID.SHIMMER_3R, L, 0, 0, 1) || + ge(HW_ID.SHIMMER_3, L, 0, 13, 7) || + ge(HW_ID.SHIMMER_3, S, 0, 20, 1) + ) { + return 8; + } + if (ge(HW_ID.SHIMMER_3, L, 0, 6, 5)) return 7; + if ( + ge(HW_ID.SHIMMER_3, B, 0, 7, 3) || + ge(HW_ID.SHIMMER_3, L, 0, 5, 4) || + ge(HW_ID.SHIMMER_3, S, 0, 11, 5) + ) { + return 6; + } + if (ge(HW_ID.SHIMMER_3, B, 0, 5, 0) || ge(HW_ID.SHIMMER_3, L, 0, 3, 0)) return 5; + if (ge(HW_ID.SHIMMER_3, B, 0, 4, 0) || ge(HW_ID.SHIMMER_3, L, 0, 2, 0)) return 4; + if (ge(HW_ID.SHIMMER_3, B, 0, 3, 0) || ge(HW_ID.SHIMMER_3, L, 0, 1, 0)) return 3; + if (ge(HW_ID.SHIMMER_3, B, 0, 2, 0)) return 2; + if (ge(HW_ID.SHIMMER_2R, B, 1, 2, 0) || ge(HW_ID.SHIMMER_3, B, 0, 1, 0)) return 1; + return -1; +} + +/** + * Whether this firmware supports the live EXG GET/SET register commands. Verbatim + * port of the gate the Java driver applies before every EXG read/write — + * `(getFirmwareVersionInternal() >= 8 && getFirmwareVersionCode() == 2) || + * getFirmwareVersionCode() > 2` (ShimmerBluetooth.java:4011,4022,4201,4219). + * + * `firmwareVersionCode == 2` is exactly classic-Shimmer3 BtStream in [0.2.0, + * 0.3.0); that ancient BtStream only gained the EXG commands at internal 8, hence + * the extra `internal >= 8` guard. Every newer firmware (code > 2 — all + * LogAndStream, BtStream ≥ 0.3.0, SDLog, and all Shimmer3R) supports them + * unconditionally. BtStream 0.1.x (code 1) and anything below every rung (code + * -1) are rejected. + */ +export function shimmer3SupportsExg(fw: Shimmer3FwVersion, hardwareVersion: number): boolean { + const code = deriveShimmer3FirmwareVersionCode(fw, hardwareVersion); + return (fw.internal >= 8 && code === 2) || code > 2; +} + // --------------------------------------------------------------------------- // Unframed-stream control-message framing // --------------------------------------------------------------------------- diff --git a/src/devices/shimmer3r/Shimmer3RClient.ts b/src/devices/shimmer3r/Shimmer3RClient.ts index 6afd6c3..373d51f 100644 --- a/src/devices/shimmer3r/Shimmer3RClient.ts +++ b/src/devices/shimmer3r/Shimmer3RClient.ts @@ -29,6 +29,7 @@ import { decodeExgRegsResponse, exgBanksEqualIgnoringStatus, applyExgPreset, + clearExgResolutionFlags, type ExgChipIndex, type ApplicableExgPreset, type ExgResolution, @@ -602,6 +603,22 @@ export class Shimmer3RClient extends BaseShimmerClient { if (!this._transport) throw new Error('Not connected (RX missing)'); if (this._streaming) throw new Error('Cannot configure EXG while streaming'); + // 'off' — LIVE disable. Java never pushes zeroed register banks to the chip; + // the ADS1292R forces its must-be bits on write (CONFIG2 bit7=1 etc., + // ExGConfigBytesDetails.java:507-525), so a zeroed SET would fail the + // read-back-verify in writeExgConfig. The disable is done purely by dropping + // the EXG bits from the enabled-sensors bitmap (writeEnabledSensors is the + // last command, ShimmerBluetooth.java:2732,2735; readEXGConfigurations / + // writeEXGConfiguration only run while EXG stays enabled, :2670,4010-4014). + // The DOCKED path (`applyExgPreset('off')`) still zeroes the InfoMem banks — + // InfoMem is passive storage and EX1's detectExgPreset keys 'off' off them. + if (preset === 'off') { + const cleared = clearExgResolutionFlags(this.enabledSensors); + await this.setSensors(cleared); + this._emitStatus("EXG preset 'off' applied (EXG chips disabled). Schema updated."); + return; + } + // Seed the apply from the device's current banks so the oscillator-clock // preserve path (classic rev>=4) is honoured; on 3R the banks are fully // determined by the preset regardless. diff --git a/src/index.ts b/src/index.ts index aa4e4e3..6929696 100644 --- a/src/index.ts +++ b/src/index.ts @@ -265,6 +265,7 @@ export { detectExgPreset, exgPresetLabel, applyExgPreset, + clearExgResolutionFlags, exgConflictingSensors, exgRateSettingFromFreq, EXG_CONFLICTING_SENSORS, diff --git a/tests/exg/registers.test.ts b/tests/exg/registers.test.ts index d2f8d7d..d457ff3 100644 --- a/tests/exg/registers.test.ts +++ b/tests/exg/registers.test.ts @@ -145,6 +145,26 @@ describe('applyExgMustBeBits (ExGConfigBytesDetails.java:507-525)', () => { expect(encoded[8] & 0x02).toBe(0x02); expect(encoded[9] & 0x01).toBe(0x01); }); + + it('encode(decode(bank)) clears the read-only LOFF_STAT bits even when they were set', () => { + // REG8 (LOFF_STAT, byte 7) is the read-only lead-off status register: the + // chip reports live electrode state there, and setExgByteArrayConstants + // clears every bit except bit6 (clock divider) on generate + // (ExGConfigBytesDetails.java:507-525). Start from a valid ECG bank, set ALL + // the status bits (as a live chip would echo them), and confirm the encode + // round-trip strips them back down to just the clock-divider bit. This is why + // read-back verification excludes REG8 (exgBanksEqualIgnoringStatus). + const bank = u8(EXG_PRESET_ARRAYS.ecg.exg1); + bank[7] = 0xff; // all status bits high, incl. clock-divider bit6 + const out = encodeExgRegisters(decodeExgRegisters(bank)); + expect(out[7]).toBe(0x40); // only bit6 (clock divider) survives; status bits cleared + + // With the clock-divider bit low it must clear to 0. + const bank2 = u8(EXG_PRESET_ARRAYS.ecg.exg1); + bank2[7] = 0xbf; // every status bit high EXCEPT clock-divider (bit6) + const out2 = encodeExgRegisters(decodeExgRegisters(bank2)); + expect(out2[7]).toBe(0x00); + }); }); describe('detectExgPreset (tolerant, SensorEXG.java:2680-2763)', () => { @@ -206,6 +226,29 @@ describe('detectExgPreset (tolerant, SensorEXG.java:2680-2763)', () => { ).toBe('respiration'); }); + it('does NOT report respiration when only chip 1 is enabled (both-chips gate)', () => { + // Respiration mod/demod bits ARE set on chip 2, but respiration needs BOTH + // chips enabled (SensorEXG.isEXGUsingDefaultRespirationConfiguration checks + // both banks + the both-chips resolution flags, SensorEXG.java:2733-2734). + // With only the chip-1 flag set the respiration gate must fail — it falls + // through past ECG (also both-chips) and past the chip1-only EMG selections + // (respiration uses ECG's, not EMG's) to 'custom'. + expect( + detectExgPreset( + u8(EXG_PRESET_ARRAYS.respiration.exg1), + u8(EXG_PRESET_ARRAYS.respiration.exg2), + es.chip1_16, + ), + ).toBe('custom'); + expect( + detectExgPreset( + u8(EXG_PRESET_ARRAYS.respiration.exg1), + u8(EXG_PRESET_ARRAYS.respiration.exg2), + es.chip1_24, + ), + ).toBe('custom'); + }); + it('tolerates the 16-bit hardcoded 3R preset arrays (differ in byte1/rate bits)', () => { // 3R ECG-16 arrays from Shimmer3RClient.enableECG16Bit (payload only). const ecg3rC1 = [0x02, 0xa8, 0x10, 0x40, 0x40, 0x2d, 0x00, 0x00, 0x02, 0x03]; diff --git a/tests/shimmer3/exg-live.test.ts b/tests/shimmer3/exg-live.test.ts index c3f0df3..e8c4df4 100644 --- a/tests/shimmer3/exg-live.test.ts +++ b/tests/shimmer3/exg-live.test.ts @@ -3,7 +3,11 @@ import { Shimmer3Client } from '../../src/devices/shimmer3/Shimmer3Client.js'; import { OPCODES } from '../../src/devices/shimmer3r/constants.js'; import { SensorBitmapShimmer3 } from '../../src/devices/shimmer3r/SensorBitmap.js'; import { LoopbackTransport } from '../../src/core/transport/LoopbackTransport.js'; -import { EXG_PRESET_ARRAYS, detectExgPreset } from '../../src/devices/exg/index.js'; +import { + EXG_PRESET_ARRAYS, + detectExgPreset, + applyExgMustBeBits, +} from '../../src/devices/exg/index.js'; // EX3: live EXG GET/SET over the (unframed) RFCOMM byte stream for classic // Shimmer3. Same LiteProtocol command flow as Shimmer3R, but the classic client @@ -25,22 +29,36 @@ interface ExgDevice { banks: [Uint8Array, Uint8Array]; handler: (bytes: Uint8Array, tr: LoopbackTransport) => void; corruptChip?: 0 | 1; + /** Firmware version response payload (after the FWVER opcode). */ + fw: number[]; } -function exgDevice(init?: { banks?: [number[], number[]] }): ExgDevice { +function exgDevice(init?: { + banks?: [number[], number[]]; + /** When true, the chip forces the ADS1292R must-be bits on every SET (as real + * firmware does) instead of echoing the written bytes verbatim — so a zeroed + * write reads back non-zero and read-back-verify would reject it. */ + enforceMustBe?: boolean; + /** HW id reported by GET_DEVICE_VERSION (default 3 = Shimmer3). */ + hw?: number; + /** FW payload after the opcode (default LogAndStream 0.15.0 = EXG-capable). */ + fw?: number[]; +}): ExgDevice { + const hw = init?.hw ?? 3; const dev: ExgDevice = { banks: [ new Uint8Array(init?.banks?.[0] ?? new Array(10).fill(0)), new Uint8Array(init?.banks?.[1] ?? new Array(10).fill(0)), ], handler: () => {}, + fw: init?.fw ?? [3, 0, 0, 0, 15, 0], }; let inquiry = GYRO_INQ; dev.handler = (bytes, tr) => { const op = bytes[0]; - if (op === OPCODES.GET_DEVICE_VERSION_COMMAND) setTimeout(() => tr.notify([DEVVER, 3]), 0); + if (op === OPCODES.GET_DEVICE_VERSION_COMMAND) setTimeout(() => tr.notify([DEVVER, hw]), 0); else if (op === OPCODES.GET_FW_VERSION_COMMAND) - setTimeout(() => tr.notify([FWVER, 3, 0, 0, 0, 15, 0]), 0); + setTimeout(() => tr.notify([FWVER, ...dev.fw]), 0); else if (op === GET_EXG) { const chip = bytes[1] as 0 | 1; let bank = dev.banks[chip]; @@ -52,7 +70,8 @@ function exgDevice(init?: { banks?: [number[], number[]] }): ExgDevice { setTimeout(() => tr.notify([ACK, EXG_RSP, 10, ...bank]), 0); } else if (op === SET_EXG) { const chip = bytes[1] as 0 | 1; - dev.banks[chip] = bytes.slice(4, 4 + 10); + const written = bytes.slice(4, 4 + 10); + dev.banks[chip] = init?.enforceMustBe ? applyExgMustBeBits(new Uint8Array(written)) : written; setTimeout(() => tr.notify([ACK]), 0); } else if (op === OPCODES.SET_SENSORS_COMMAND) { inquiry = EXG_INQ; @@ -158,4 +177,74 @@ describe('Shimmer3Client EXG live GET/SET', () => { ); await expect(client.applyExgPresetLive('ecg', '16bit')).rejects.toThrow(/streaming/i); }); + + it("applyExgPresetLive('off') writes NO EXG registers and only clears the EXG bitmap", async () => { + // The chip ENFORCES the must-be bits on write, so the old off-path (writing + // zeroed banks) would fail read-back-verify. The correct disable never writes. + const dev = exgDevice({ enforceMustBe: true }); + const { t, client } = await connected(dev); + client.enabledSensors = + SensorBitmapShimmer3.SENSOR_GYRO | + SensorBitmapShimmer3.SENSOR_EXG1_16BIT | + SensorBitmapShimmer3.SENSOR_EXG2_16BIT; + + const setExgBefore = t.writes.filter((w) => w.bytes[0] === SET_EXG).length; + await expect(client.applyExgPresetLive('off', '16bit')).resolves.toBeUndefined(); + + // No SET_EXG (0x61) traffic at all during the disable. + const setExgAfter = t.writes.filter((w) => w.bytes[0] === SET_EXG).length; + expect(setExgAfter).toBe(setExgBefore); + + // SET_SENSORS carried the EXG bits cleared but GYRO retained. + const setSensors = t.writes.filter((w) => w.bytes[0] === OPCODES.SET_SENSORS_COMMAND).pop()!; + const mask = setSensors.bytes[1] | (setSensors.bytes[2] << 8) | (setSensors.bytes[3] << 16); + expect(mask & SensorBitmapShimmer3.SENSOR_EXG1_16BIT).toBe(0); + expect(mask & SensorBitmapShimmer3.SENSOR_EXG2_16BIT).toBe(0); + expect(mask & SensorBitmapShimmer3.SENSOR_EXG1_24BIT).toBe(0); + expect(mask & SensorBitmapShimmer3.SENSOR_EXG2_24BIT).toBe(0); + expect(mask & SensorBitmapShimmer3.SENSOR_GYRO).toBeTruthy(); + }); + + it('writeExgConfig of zeroed banks against a must-be-enforcing chip throws (proves the old off-path would fail)', async () => { + const dev = exgDevice({ enforceMustBe: true }); + const { client } = await connected(dev); + // What the old applyExgPreset('off') banks would have been: all zeroes. + await expect(client.writeExgConfig(new Uint8Array(10), new Uint8Array(10))).rejects.toThrow( + /read-back mismatch/i, + ); + }); + + it('rejects EXG on old BtStream firmware (code 1) with a clear error, before any radio traffic', async () => { + // BtStream (id 1) 0.1.0 → firmware code 1 → EXG commands unsupported. + const dev = exgDevice({ fw: [1, 0, 0, 0, 1, 0] }); + const { t, client } = await connected(dev); + const writesBefore = t.writes.length; + await expect(client.readExgConfig()).rejects.toThrow(/not supported by this firmware/i); + await expect(client.writeExgConfig(new Uint8Array(10), new Uint8Array(10))).rejects.toThrow( + /not supported by this firmware/i, + ); + await expect(client.applyExgPresetLive('ecg', '16bit')).rejects.toThrow( + /not supported by this firmware/i, + ); + // Failed fast — no GET/SET went out on the wire. + expect(t.writes.filter((w) => w.bytes[0] === GET_EXG || w.bytes[0] === SET_EXG).length).toBe(0); + expect(t.writes.length).toBe(writesBefore); + }); + + it('rejects EXG on old BtStream 0.2.x below internal 8 (code 2, internal<8)', async () => { + // BtStream 0.2.5 → code 2 but internal 5 < 8 → the (internal>=8 && code==2) leg fails. + const dev = exgDevice({ fw: [1, 0, 0, 0, 2, 5] }); + const { client } = await connected(dev); + await expect(client.readExgConfig()).rejects.toThrow(/not supported by this firmware/i); + }); + + it('accepts EXG on BtStream 0.2.8 (code 2, internal>=8) and on new LogAndStream (code >2)', async () => { + const btstream = exgDevice({ fw: [1, 0, 0, 0, 2, 8] }); + const { client: c1 } = await connected(btstream); + await expect(c1.readExgConfig()).resolves.toBeDefined(); + + const logAndStream = exgDevice({ fw: [3, 0, 0, 0, 15, 0] }); // LogAndStream 0.15.0 → code 8 + const { client: c2 } = await connected(logAndStream); + await expect(c2.readExgConfig()).resolves.toBeDefined(); + }); }); diff --git a/tests/shimmer3r/exg-live.test.ts b/tests/shimmer3r/exg-live.test.ts index 642895f..d54886c 100644 --- a/tests/shimmer3r/exg-live.test.ts +++ b/tests/shimmer3r/exg-live.test.ts @@ -4,7 +4,11 @@ import { OPCODES } from '../../src/devices/shimmer3r/constants.js'; import { SensorBitmapShimmer3 } from '../../src/devices/shimmer3r/SensorBitmap.js'; import { LoopbackTransport } from '../../src/core/transport/LoopbackTransport.js'; import { getOversamplingRatioADS1292R } from '../../src/devices/shimmer3r/calibration.js'; -import { EXG_PRESET_ARRAYS, detectExgPreset } from '../../src/devices/exg/index.js'; +import { + EXG_PRESET_ARRAYS, + detectExgPreset, + applyExgMustBeBits, +} from '../../src/devices/exg/index.js'; // EX3: live EXG GET/SET over the radio for Shimmer3R, exercised against a // stateful in-memory device that stores the register banks on SET and echoes @@ -28,7 +32,13 @@ interface ExgDevice { corruptChip?: 0 | 1; } -function exgDevice(init?: { banks?: [number[], number[]]; piggyback?: boolean }): ExgDevice { +function exgDevice(init?: { + banks?: [number[], number[]]; + piggyback?: boolean; + /** When true, the chip forces the ADS1292R must-be bits on every SET (as real + * firmware does) — so a zeroed write reads back non-zero. */ + enforceMustBe?: boolean; +}): ExgDevice { const dev: ExgDevice = { banks: [ new Uint8Array(init?.banks?.[0] ?? new Array(10).fill(0)), @@ -55,7 +65,8 @@ function exgDevice(init?: { banks?: [number[], number[]]; piggyback?: boolean }) } } else if (op === SET_EXG) { const chip = bytes[1] as 0 | 1; - dev.banks[chip] = bytes.slice(4, 4 + 10); + const written = bytes.slice(4, 4 + 10); + dev.banks[chip] = init?.enforceMustBe ? applyExgMustBeBits(new Uint8Array(written)) : written; setTimeout(() => tr.notify([ACK]), 0); } else if (op === OPCODES.SET_SENSORS_COMMAND) { inquiry = EXG_INQ; // reflect the EXG enable on the next inquiry @@ -194,4 +205,37 @@ describe('Shimmer3RClient EXG live GET/SET', () => { ); await expect(client.applyExgPresetLive('ecg', '16bit')).rejects.toThrow(/streaming/i); }); + + it("applyExgPresetLive('off') writes NO EXG registers and only clears the EXG bitmap", async () => { + // The chip enforces must-be bits on write, so the old off-path (writing + // zeroed banks) would fail read-back-verify. The correct disable never writes. + const dev = exgDevice({ enforceMustBe: true }); + const { t, client } = await connected(dev); + client.enabledSensors = + SensorBitmapShimmer3.SENSOR_GYRO | + SensorBitmapShimmer3.SENSOR_EXG1_16BIT | + SensorBitmapShimmer3.SENSOR_EXG2_16BIT; + + const setExgBefore = t.writes.filter((w) => w.bytes[0] === SET_EXG).length; + await expect(client.applyExgPresetLive('off', '16bit')).resolves.toBeUndefined(); + + // No SET_EXG (0x61) traffic during the disable. + expect(t.writes.filter((w) => w.bytes[0] === SET_EXG).length).toBe(setExgBefore); + + // SET_SENSORS carried the EXG bits cleared but GYRO retained. + const setSensors = t.writes.filter((w) => w.bytes[0] === OPCODES.SET_SENSORS_COMMAND).pop()!; + const mask = setSensors.bytes[1] | (setSensors.bytes[2] << 8) | (setSensors.bytes[3] << 16); + expect(mask & SensorBitmapShimmer3.SENSOR_EXG1_16BIT).toBe(0); + expect(mask & SensorBitmapShimmer3.SENSOR_EXG2_16BIT).toBe(0); + expect(mask & SensorBitmapShimmer3.SENSOR_GYRO).toBeTruthy(); + }); + + it('writeExgConfig of zeroed banks against a must-be-enforcing chip throws (proves the old off-path would fail)', async () => { + const dev = exgDevice({ enforceMustBe: true }); + const { client } = await connected(dev); + client.samplingRateHz = 512; + await expect(client.writeExgConfig(new Uint8Array(10), new Uint8Array(10))).rejects.toThrow( + /read-back mismatch/i, + ); + }); }); From f3747b84d9c7d50b6b15b5c71899081ae9670a77 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 09:56:49 +0000 Subject: [PATCH 07/10] chore(release): 0.1.10-exg.4 CHANGELOG notes for the EX post-review round: the live 'off' read-back fix, the fast-fail firmware gate, and a Changed/BREAKING-behaviour note for the EMG/test-signal preset byte changes vs the old hardcoded 3R arrays (EMG chip-2 REG4/5 0xE1->0x81; test-signal REG4/5 0x15->0x05, matching the SensorEXG.java golden arrays, SensorEXG.java:1813-1814 / :1845). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012pin56yhefi5Mihjf3vTpL --- CHANGELOG.md | 3 +++ package.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8995f6b..4d15f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ This project follows [Semantic Versioning](https://semver.org/). ### Fixed +- **Live `applyExgPresetLive('off')` no longer writes zeroed register banks that compliant firmware rejects (EX post-review).** The live over-the-radio disable path previously reused the docked `applyExgPreset('off')`, which returns all-zero register banks, then ran them through `writeExgConfig` — SET + read-back-verify. But the ADS1292R forces its must-be bits on write (CONFIG2 bit7=1, LOFF bit4=1, RESP1 bit1=1, RESP2 bit0=1 — `ExGConfigBytesDetails.java:507-525`), so a zeroed write reads back non-zero and the read-back comparison threw `EXG write read-back mismatch` for every `'off'` on real hardware. (The bug was masked in tests by a verbatim-echo loopback that did not enforce the must-be bits.) Both `Shimmer3Client` and `Shimmer3RClient` now match the Java live-disable flow: `applyExgPresetLive('off')` **skips the register write entirely** and only clears the four EXG resolution bits from the enabled-sensors bitmap via `setSensors` (Java disables EXG by re-writing the enabled-sensors map — "always the last command", `ShimmerBluetooth.java:2732,2735` — never by pushing zeroed registers; `readEXGConfigurations` / `writeEXGConfiguration` run only while EXG stays enabled, `:2670,4010-4014`). The **docked** InfoMem path (`applyExgPreset('off')`) is deliberately unchanged and still zeroes the banks: InfoMem is passive storage with no chip enforcement, and EX1's `detectExgPreset` keys `'off'` off all-zero banks — that detection contract must not break. New export `clearExgResolutionFlags(enabledSensors)`. +- **Live EXG commands to EXG-incapable classic-Shimmer3 firmware now fail fast instead of hanging to timeout (EX post-review).** `Shimmer3Client._assertExgSupported` previously only checked that the connect handshake had completed, leaving `readExgConfig` / `writeExgConfig` / `applyExgPresetLive` to hang until the response timeout on firmware without the EXG command set. It now ports the Java gate verbatim — `(getFirmwareVersionInternal() >= 8 && getFirmwareVersionCode() == 2) || getFirmwareVersionCode() > 2` (`ShimmerBluetooth.java:4011,4022,4201,4219`) — deriving the firmware-version code from the parsed FW version + hardware id exactly as `ShimmerVerObject` does (`ShimmerVerObject.java:266-311`, the full compare-versions ladder), and throws a clear `EXG register commands are not supported by this firmware (…, firmware code N); EXG requires LogAndStream (any), BtStream >= 0.2.8, or firmware code >= 3.` Old BtStream 0.1.x (code 1) and BtStream 0.2.x below internal 8 (code 2) are rejected before any radio traffic; all LogAndStream, BtStream ≥ 0.2.8, SDLog, and Shimmer3R firmware are accepted. New exports `deriveShimmer3FirmwareVersionCode`, `shimmer3SupportsExg`. - **Gen-2 GSR decoded with the wrong front-end constants when the hardware revision said otherwise** (DEV-874). `SensorADC` selected the second-generation DC front-end constants (21/150/562/1740 kΩ feedback resistors, 0.4986 V GSR reference, 1.8 V ADC reference, range-3 uncal limit 1134) only when the caller-supplied identifier string was `VERISENSE_PULSE_PLUS`; any other identifier fell back to the SR62/Shimmer3 set (40.2/287/1000/3300 kΩ, 0.5 V, 3.0 V, 683), reading ~1.91× high on an SR61-5/6. Selection now follows the production-config hardware revision (SR62 → Shimmer3 set, every other GSR-capable board → gen-2 set, mirroring the firmware's `selectFeedbackResistorsFromHwVersion`), with the identifier string kept only as the fallback before a revision is read. Verified against the DEV-793 33 k/100 k/470 k/2.7 MΩ resistor sweep on a real SR61-5. Also adds `isVerisenseGsrSupportedHardware` (mirrors the firmware's `ShimBrd_isGsrSupportedForHwVersion`: SR62 any / SR61 ≥ 5 / SR68 ≥ 5) and corrects `getVerisenseHardwareSensorSupport` to report GSR on SR68.5–8. - **LSM6DSV gyro ~12.8% low without device calibration** (DEV-874). The no-calibration fallback in `SensorLSM6DSV` scaled the gyro by `FS/32768`, but the LSM6DSV gyro does not span the 16-bit range at nominal full scale — the ST angular-rate sensitivity is 4.375 mdps/LSB at ±125 dps, doubling per range (228.571/114.286/57.143/28.571/14.286 LSB per dps). A raw 32767 at ±500 dps now reads ~573.4 dps (was 500.0), matching the device calibration seed, `calibrationDefaults`, and the Java driver fix. Gyro CSVs exported before this fix (when no device calibration was loaded) are ~12.8% low. The accel fallback (`FS/32768 · g`) is unaffected and correct. - `VerisenseBleDevice.stopStreaming()` is now best-effort and always reconciles state. Previously it awaited the STREAM_MODE-disable ACK and only then set the mode to idle / emitted `streaming:{on:false}`; at high data rates that ACK is easily lost (BLE notifications are unacknowledged and can be dropped under throughput pressure, and the in-flight stream tail is parsed by the command path rather than the CRC-gated stream scanner once streaming mode is left), so the awaited request timed out, the method threw, and the client stayed wedged in `streaming` — locking the UI even though the device had stopped. Stop now sends the disable as a **write-with-response** (link-layer delivery confirmation) and reconciles state (`idle`, assembler reset, `streaming:{on:false}`) in a `finally`, so a missing app-level ACK can no longer leave the client stuck. Mirrors the best-effort stop already used by `Shimmer3RClient`. @@ -49,6 +51,7 @@ This project follows [Semantic Versioning](https://semver.org/). ### Changed +- **BREAKING (wire behaviour): EMG and test-signal EXG preset register bytes changed vs the old hardcoded 3R arrays (EX2, restated here post-review).** The `Shimmer3RClient` previously shipped hardcoded 16-bit-only preset byte arrays whose EMG/test-signal REG4/REG5 (per-channel PGA gain + input-selection) diverged from the desktop Java oracle. The codec-driven `EXG_PRESET_ARRAYS` now use the `SensorEXG.java` golden values, so the bytes that go over the radio / into InfoMem changed: **EMG chip-2 REG4 and REG5 `0xE1 → 0x81`** (`SensorEXG.setDefaultEMGConfiguration`, `SensorEXG.java:1813-1814` — chip-2 channels are powered-down + input-shorted with PGA gain index 0, not the old index 6), and **test-signal (both chips) REG4 and REG5 `0x15 → 0x05`** (`SensorEXG.setEXGTestSignal`, `SensorEXG.java:1845` — test-signal input with PGA gain index 0, not the old index 1). Only the PGA-gain nibble of those power-down / test channels changed (the power-down and input-selection bits are unchanged); the ECG and respiration arrays were already correct. Devices configured for EMG or test signal by an older SDK build will now report a different (correct) PGA-gain field on read-back. `detectExgPreset` is unaffected — it keys only off the input-selection nibble and so still recognises both the old and new arrays. - The Verisense LSM6DSV (id 6) data block now stores its entry count as a **2-byte little-endian** value (was 1 byte), so a full FIFO drain of more than 255 samples per read decodes correctly. Requires matching firmware (the firmware writes the 2-byte count and drains the full FIFO each interrupt). - The MLX90632 skin-temperature config is now a **single "Skin Temp Sample Rate"** field (op-config byte 76 bits 3:1), shown as the medical output rate (0.25–32 Hz; = chip refresh ÷2, extended ÷3). The firmware applies the chip refresh rate from it and derives the read poll. The separate `SKIN_TEMP_SAMPLE_RATE_INDEX` (byte 77) and `SKIN_TEMP_POWER_MODE` (byte 76 bits 5:4) fields are removed from the schema and left unused in the op-config layout (continuous mode is required and set automatically). `SensorMLX90632.samplingRateHz` now derives from the refresh rate + measurement type. Requires matching firmware. - The VD6283 **"Light Continuous Mode"** option is removed from the schema (op-config byte 74 bit 0 is now unused — free to repurpose): the timer-driven poll requires the chip to run continuously, so the firmware hardcodes it. diff --git a/package.json b/package.json index a7582d4..e38a872 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.10-exg.3", + "version": "0.1.10-exg.4", "description": "Web Bluetooth and Web Serial API for Shimmer sensor devices (Shimmer3R, Verisense IMU/Pulse+)", "type": "module", "main": "dist/shimmer-web-sdk.cjs", From f8f268bdf2a13df8d561fd50393f6e557b7da521 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 10:16:10 +0000 Subject: [PATCH 08/10] fix(exg): export the firmware-gate helpers from the package root deriveShimmer3FirmwareVersionCode and shimmer3SupportsExg are used by Shimmer3Client._assertExgSupported and are useful for callers that want to check EXG support before offering the UI; surface them from the package entry point (the CHANGELOG already lists them as new exports). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012pin56yhefi5Mihjf3vTpL --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 6929696..1322086 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,6 +109,8 @@ export { parseShimmer3FwVersionResponse, shimmer3UsesThreeByteTimestamp, shimmer3ControlMessageLength, + deriveShimmer3FirmwareVersionCode, + shimmer3SupportsExg, } from './devices/shimmer3/protocol.js'; export type { Shimmer3InquiryResult, From 1bdadb84ce39ee74e853a7f01d1d523da46a4cdf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 11:07:15 +0000 Subject: [PATCH 09/10] feat(exg): per-knob edit + validation helpers (EX4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the per-knob editing layer the docked config editor stages individual EXG settings through, complementing EX2's whole-preset apply. New module `src/devices/exg/knobs.ts`: - `updateExgSetting(banks, field, value)` and batched `applyExgKnobEdits` return new banks with one field changed, the must-be bits re-enforced, and every other bit preserved. Field addressing is explicit per chip: the four gain knobs name chip+channel (exg1Ch1Gain … exg2Ch2Gain), rate and lead-off current/threshold write BOTH chips, reference electrode writes the CHIP1 REG6 nibble, respiration knobs are CHIP2-only — mirroring the SensorEXG setters behind setConfigValueUsingConfigLabel (SensorEXG.java:2314-2635). - `respirationPhaseOptions(freq)` ports the freq-dependent phase lists (16 steps @32 kHz values 0-15 / 8 @64 kHz values 0-7, SensorEXG.java:143-146). `updateExgSetting` REJECTS an illegal phase-for-frequency (ExgKnobValueError); changing the frequency auto-remaps the phase to that frequency's canonical default (112.5°/reg 10 at 32 kHz, 157.5°/reg 7 at 64 kHz), the net effect of setEXG2RespirationDetectFreq (SensorEXG.java:2618-2628). - Respiration-locked-unless-enabled: respirationFrequency/phase edits throw ExgRespirationLockedError unless chip-2 demod+mod are on, matching the desktop greying-out (PanelAdvancedExG.java:340,357-361,416-420). The respirationEnable toggle is never gated and can unlock the others within a single batch. - Lead-off detection macro ports setEXGLeadOffCurrentMode Off/DC (SensorEXG.java:2495-2545); AC current and the three-unipolar branch are descoped (EX4). - Typed errors: UnknownExgKnobError, ExgKnobValueError, ExgRespirationLockedError, RangeError for wrong bank length. Must-be bits always enforced on output. - Reuses registers.ts as the single source of truth via new exports `readExgField` / `setExgFieldPreserving` / `ExgFieldName`, plus `REFERENCE_ELECTRODE_OPTIONS` / `LEAD_OFF_DETECTION_LABELS` option lists (no enum data duplicated). Tests: tests/exg/knobs.test.ts (+39) walks every knob programmatically (set → decode → other banks untouched), the exact phase-by-freq lists, illegal phase rejection, freq-flip remap, respiration gating, chip addressing, batch ordering, and readExgKnobs↔updateExgSetting idempotence. Also drops an unused import flagged by lint in tests/dock/rtc-config-write.test.ts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012pin56yhefi5Mihjf3vTpL --- src/devices/exg/index.ts | 27 ++ src/devices/exg/knobs.ts | 519 ++++++++++++++++++++++++++++ src/devices/exg/registers.ts | 56 +++ src/index.ts | 27 ++ tests/dock/rtc-config-write.test.ts | 2 +- tests/exg/knobs.test.ts | 280 +++++++++++++++ 6 files changed, 910 insertions(+), 1 deletion(-) create mode 100644 src/devices/exg/knobs.ts create mode 100644 tests/exg/knobs.test.ts diff --git a/src/devices/exg/index.ts b/src/devices/exg/index.ts index 0a2aa73..a072b8e 100644 --- a/src/devices/exg/index.ts +++ b/src/devices/exg/index.ts @@ -16,6 +16,8 @@ export { decodeExgRegisters, encodeExgRegisters, applyExgMustBeBits, + readExgField, + setExgFieldPreserving, // option label lists (verbatim from SensorEXG.java:116-149) CONVERSION_MODE_LABELS, DATA_RATE_LABELS, @@ -34,6 +36,8 @@ export { RESPIRATION_FREQUENCY_LABELS, RLD_REFERENCE_SIGNAL_LABELS, RESPIRATION_CONTROL_LABELS, + LEAD_OFF_DETECTION_LABELS, + REFERENCE_ELECTRODE_OPTIONS, } from './registers.js'; export type { @@ -46,8 +50,31 @@ export type { ExgTestSignalSettings, ExgStatusBits, DecodedExgRegisters, + ExgFieldName, } from './registers.js'; +export { + EXG_KNOBS, + GAIN_OPTIONS, + DATA_RATE_OPTIONS, + LEAD_OFF_CURRENT_OPTIONS, + LEAD_OFF_COMPARATOR_OPTIONS, + LEAD_OFF_DETECTION_OPTIONS, + RESPIRATION_FREQUENCY_OPTIONS, + respirationPhaseOptions, + exgKnobOptions, + isExgRespirationEnabled, + updateExgSetting, + applyExgKnobEdits, + readExgKnobs, + ExgKnobError, + UnknownExgKnobError, + ExgKnobValueError, + ExgRespirationLockedError, +} from './knobs.js'; + +export type { ExgBanks, ExgKnobOption, ExgKnobField, ExgKnobEdit } from './knobs.js'; + export { EXG_PRESET_ARRAYS, exgResolutionFromSensors, diff --git a/src/devices/exg/knobs.ts b/src/devices/exg/knobs.ts new file mode 100644 index 0000000..25beb4d --- /dev/null +++ b/src/devices/exg/knobs.ts @@ -0,0 +1,519 @@ +/** + * ADS1292R (EXG) per-knob EDITING — change a single named setting on a pair of + * register banks, enforcing the hardware "must-be" bits and preserving every + * other bit, with typed validation and the desktop's respiration coupling rules. + * + * Where EX2's `apply.ts` writes a WHOLE preset, this module (EX4) is the + * per-knob layer the docked config editor stages individual edits through. It is + * pure and transport-free. + * + * Java oracle: + * - knob set + GUI labels : SensorEXG.GuiLabelConfig (SensorEXG.java:157-168), + * value lists SensorEXG.java:116-149. + * - which chip(s) each knob writes: the SensorEXG setters behind + * setConfigValueUsingConfigLabel (SensorEXG.java:2948-2998): + * · Gain — setExGGainSetting(chipID, channel, v) per chip+channel + * (SensorEXG.java:2314-2331); the GUI's single "Gain" knob sets + * all four via setExGGainSetting(v) (:2333-2338). + * · Rate — setEXGRateSetting(v) writes REG1 on BOTH chips (:2468-2471). + * · Reference electrode — setEXGReferenceElectrode(v) writes the four REG6 + * RLD-input bits on CHIP1 only (:2483-2489). + * · Lead-off detection — setEXGLeadOffCurrentMode(mode) flips comparators / + * RLD sense / per-lead detect bits across BOTH chips + * (:2495-2575); Off=0 / DC=1 ported (AC=2 descoped, GUI omits it). + * · Lead-off current — setEXGLeadOffDetectionCurrent(v) REG3 both chips + * (:2600-2604). + * · Lead-off comparator threshold — setEXGLeadOffComparatorTreshold(v) REG3 + * both chips (:2610-2614). + * · Respiration freq — setEXG2RespirationDetectFreq(v) CHIP2 only, and + * FORCES the phase to the frequency's canonical default + * (:2618-2628, see the freq-flip note below). + * · Respiration phase — setEXG2RespirationDetectPhase(v) CHIP2 only + * (:2634-2635). + * - phase-by-frequency lists : ListOfExGRespirationDetectPhase32khz (16 steps, + * values 0-15) / …64khz (8 steps, values 0-7), SensorEXG.java:143-146. + * - freq-flip behaviour : setEXG2RespirationDetectFreq re-validates the phase + * against the new list (checkWhichExgRespPhaseValuesToUse, :2883-2907, + * resets to configvalues[0] if the current phase is now illegal) and THEN + * sets a canonical phase: 112.5° (reg value 10) at 32 kHz, 157.5° (reg value + * 7) at 64 kHz (:2622-2627). We port that net effect: flipping the frequency + * auto-remaps the phase to that frequency's default. + * - respiration-locked-unless-enabled : the desktop only opens the freq/phase + * popups when the respiration sensor is enabled (PanelAdvancedExG.java:340, + * 357-361, 416-420). We reject freq/phase/control edits when chip-2 + * respiration (demod+mod) is off — see the contract note on the knob table. + */ + +import { + EXG_BANK_LENGTH, + applyExgMustBeBits, + readExgField, + setExgFieldPreserving, + decodeExgRegisters, + DATA_RATE_LABELS, + GAIN_LABELS, + LEAD_OFF_CURRENT_LABELS, + COMPARATOR_THRESHOLD_LABELS, + LEAD_OFF_DETECTION_LABELS, + REFERENCE_ELECTRODE_OPTIONS, + RESPIRATION_FREQUENCY_LABELS, + RESPIRATION_PHASE_32KHZ_LABELS, + RESPIRATION_PHASE_64KHZ_LABELS, +} from './registers.js'; +import { detectExgPreset } from './presets.js'; + +// -------------------------------------------------------------------------- +// Types +// -------------------------------------------------------------------------- + +/** The two per-chip register banks the editor stages edits against. */ +export interface ExgBanks { + /** EXG1 (chip-1) register bank — 10 bytes. */ + exg1: Uint8Array; + /** EXG2 (chip-2) register bank — 10 bytes. */ + exg2: Uint8Array; +} + +/** A selectable value for a knob: raw register/config value + human label. */ +export interface ExgKnobOption { + value: number; + label: string; +} + +/** + * Every editable EXG knob, keyed by a stable name. Chip addressing is explicit + * in the name where it matters: the four gain knobs name their chip AND channel + * ('exg1Ch1Gain' = chip-1 CH1), and the respiration knobs are chip-2 only. + */ +export type ExgKnobField = + | 'exg1Ch1Gain' + | 'exg1Ch2Gain' + | 'exg2Ch1Gain' + | 'exg2Ch2Gain' + | 'dataRate' + | 'referenceElectrode' + | 'leadOffDetection' + | 'leadOffCurrent' + | 'leadOffComparatorThreshold' + | 'respirationEnable' + | 'respirationFrequency' + | 'respirationPhase'; + +/** A single staged knob edit: which field, and the raw value to set. */ +export interface ExgKnobEdit { + field: ExgKnobField; + value: number; +} + +// -------------------------------------------------------------------------- +// Typed errors +// -------------------------------------------------------------------------- + +/** Base class for all knob-edit errors (so callers can `instanceof` one type). */ +export class ExgKnobError extends Error {} + +/** Thrown when a knob field name is not recognised. */ +export class UnknownExgKnobError extends ExgKnobError { + constructor(public readonly field: string) { + super(`Unknown EXG knob field: "${field}".`); + this.name = 'UnknownExgKnobError'; + } +} + +/** Thrown when a value is not one of the knob's legal options. */ +export class ExgKnobValueError extends ExgKnobError { + constructor( + public readonly field: ExgKnobField, + public readonly value: number, + public readonly allowed: readonly number[], + ) { + super(`Value ${value} is not valid for EXG knob "${field}"; allowed: [${allowed.join(', ')}].`); + this.name = 'ExgKnobValueError'; + } +} + +/** + * Thrown when a respiration-only knob (frequency / phase) is edited while + * chip-2 respiration is disabled (the desktop locks these — see the module + * header). Enable respiration first (the `respirationEnable` knob, or the EX2 + * Respiration preset). + */ +export class ExgRespirationLockedError extends ExgKnobError { + constructor(public readonly field: ExgKnobField) { + super( + `EXG knob "${field}" is locked until respiration is enabled ` + + `(set "respirationEnable" to 1 or apply the Respiration preset first).`, + ); + this.name = 'ExgRespirationLockedError'; + } +} + +function assertBanks(banks: ExgBanks): void { + if (banks.exg1.length !== EXG_BANK_LENGTH || banks.exg2.length !== EXG_BANK_LENGTH) { + throw new RangeError( + `EXG register banks must be exactly ${EXG_BANK_LENGTH} bytes each, got ` + + `${banks.exg1.length}/${banks.exg2.length}.`, + ); + } +} + +// -------------------------------------------------------------------------- +// Option lists +// -------------------------------------------------------------------------- + +const indexOptions = (labels: readonly string[]): ExgKnobOption[] => + labels.map((label, value) => ({ value, label })); + +/** Per-channel PGA gain options (label = GUI gain, value = REG4/5 setting 0-6). */ +export const GAIN_OPTIONS: readonly ExgKnobOption[] = Object.freeze(indexOptions(GAIN_LABELS)); +/** REG1 data-rate options (values 0-6). */ +export const DATA_RATE_OPTIONS: readonly ExgKnobOption[] = Object.freeze( + indexOptions(DATA_RATE_LABELS), +); +/** REG3 lead-off current options (values 0-3). */ +export const LEAD_OFF_CURRENT_OPTIONS: readonly ExgKnobOption[] = Object.freeze( + indexOptions(LEAD_OFF_CURRENT_LABELS), +); +/** REG3 lead-off comparator-threshold options (values 0-7). */ +export const LEAD_OFF_COMPARATOR_OPTIONS: readonly ExgKnobOption[] = Object.freeze( + indexOptions(COMPARATOR_THRESHOLD_LABELS), +); +/** Lead-off detection mode options — Off (0) / DC Current (1). */ +export const LEAD_OFF_DETECTION_OPTIONS: readonly ExgKnobOption[] = Object.freeze( + indexOptions(LEAD_OFF_DETECTION_LABELS), +); +/** Respiration detection-frequency options — 32 kHz (0) / 64 kHz (1). */ +export const RESPIRATION_FREQUENCY_OPTIONS: readonly ExgKnobOption[] = Object.freeze( + indexOptions(RESPIRATION_FREQUENCY_LABELS), +); +/** On/Off options for the respiration-enable toggle. */ +const ON_OFF_OPTIONS: readonly ExgKnobOption[] = Object.freeze([ + { value: 0, label: 'Off' }, + { value: 1, label: 'On' }, +]); + +/** + * Legal respiration-phase options for a given detection frequency. At 32 kHz + * (freq value 0) there are 16 phase steps (values 0-15); at 64 kHz (value 1) + * there are 8 (values 0-7). Verbatim from SensorEXG.java:143-146. + */ +export function respirationPhaseOptions(freq: number): ExgKnobOption[] { + return indexOptions(freq === 0 ? RESPIRATION_PHASE_32KHZ_LABELS : RESPIRATION_PHASE_64KHZ_LABELS); +} + +/** Canonical phase the desktop forces when the detection frequency changes. */ +// SensorEXG.setEXG2RespirationDetectFreq (:2622-2627): 32 kHz → PHASE_112_5 +// (REG9 value 10), 64 kHz → PHASE_157_5 (REG9 value 7). +const RESP_PHASE_DEFAULT_32KHZ = 10; +const RESP_PHASE_DEFAULT_64KHZ = 7; + +// -------------------------------------------------------------------------- +// Knob table +// -------------------------------------------------------------------------- + +interface KnobSpec { + /** Desktop GUI label (SensorEXG.GuiLabelConfig). */ + label: string; + /** Which banks this knob writes (for must-be re-application + docs). */ + banks: ReadonlyArray<'exg1' | 'exg2'>; + /** Fixed option list, or undefined when it depends on the banks (phase). */ + options?: readonly ExgKnobOption[]; + /** Resolve the option list from the current banks (respiration phase). */ + dynamicOptions?: (banks: ExgBanks) => ExgKnobOption[]; + /** True when the edit is rejected unless chip-2 respiration is enabled. */ + requiresRespiration: boolean; + /** Apply the (already-validated) value to a mutable clone of the banks. */ + apply: (banks: ExgBanks, value: number) => void; +} + +/** Set the same field on both chip banks. */ +function setBoth( + banks: ExgBanks, + field: Parameters[1], + value: number, +) { + setExgFieldPreserving(banks.exg1, field, value); + setExgFieldPreserving(banks.exg2, field, value); +} + +/** + * Port of SensorEXG.setEXGLeadOffCurrentMode for the two GUI-exposed modes + * (Off=0, DC Current=1). AC current (mode 2) and the three-unipolar branch are + * descoped (docs/handoff/13 EX4); the bipolar branch is used for DC. + */ +function applyLeadOffDetection(banks: ExgBanks, mode: number): void { + const isEmg = detectExgPreset(banks.exg1, banks.exg2) === 'emg'; + if (mode === 0) { + // OFF — SensorEXG.java:2496-2507. + setBoth(banks, 'leadOffFrequency', 0); // DC + setBoth(banks, 'leadOffComparators', 0); // OFF + setExgFieldPreserving(banks.exg1, 'rldLeadOffSenseFunction', 0); + setBoth(banks, 'ch2LeadOffDetectNegInputs', 0); + setBoth(banks, 'ch2LeadOffDetectPosInputs', 0); + setBoth(banks, 'ch1LeadOffDetectNegInputs', 0); + setBoth(banks, 'ch1LeadOffDetectPosInputs', 0); + // EMG: power down chip-2 CH2 again (only powered up for lead-off). + if (isEmg) setExgFieldPreserving(banks.exg2, 'ch2PowerDown', 1); + return; + } + // DC Current — SensorEXG.java:2508-2545 (bipolar / non-unipolar branch). + // Chip 1. + setExgFieldPreserving(banks.exg1, 'leadOffFrequency', 0); // DC + setExgFieldPreserving(banks.exg1, 'leadOffComparators', 1); // ON + setExgFieldPreserving(banks.exg1, 'rldLeadOffSenseFunction', 1); // ON + setExgFieldPreserving(banks.exg1, 'ch2LeadOffDetectNegInputs', 0); + setExgFieldPreserving(banks.exg1, 'ch2LeadOffDetectPosInputs', 1); + setExgFieldPreserving(banks.exg1, 'ch1LeadOffDetectNegInputs', 1); + setExgFieldPreserving(banks.exg1, 'ch1LeadOffDetectPosInputs', 1); + setExgFieldPreserving(banks.exg1, 'leadOffCurrent', 1); // 22 nA + setExgFieldPreserving(banks.exg1, 'comparatorThreshold', 2); // Pos90/Neg10 + // Chip 2 (always present in the docked two-bank model — isTwoChipExg true). + setExgFieldPreserving(banks.exg2, 'leadOffFrequency', 0); + setExgFieldPreserving(banks.exg2, 'leadOffComparators', 1); + setExgFieldPreserving(banks.exg2, 'ch2LeadOffDetectNegInputs', 0); + setExgFieldPreserving(banks.exg2, 'ch2LeadOffDetectPosInputs', 1); + setExgFieldPreserving(banks.exg2, 'ch1LeadOffDetectNegInputs', 0); + setExgFieldPreserving(banks.exg2, 'ch1LeadOffDetectPosInputs', 0); + setExgFieldPreserving(banks.exg2, 'leadOffCurrent', 1); + setExgFieldPreserving(banks.exg2, 'comparatorThreshold', 2); + // EMG: power up chip-2 CH2 so it can carry the lead-off measurement. + if (isEmg) setExgFieldPreserving(banks.exg2, 'ch2PowerDown', 0); +} + +/** The complete editable-knob registry. */ +export const EXG_KNOBS: Readonly> = Object.freeze({ + exg1Ch1Gain: { + label: 'Gain (EXG1 CH1)', + banks: ['exg1'], + options: GAIN_OPTIONS, + requiresRespiration: false, + apply: (b, v) => setExgFieldPreserving(b.exg1, 'ch1Gain', v), + }, + exg1Ch2Gain: { + label: 'Gain (EXG1 CH2)', + banks: ['exg1'], + options: GAIN_OPTIONS, + requiresRespiration: false, + apply: (b, v) => setExgFieldPreserving(b.exg1, 'ch2Gain', v), + }, + exg2Ch1Gain: { + label: 'Gain (EXG2 CH1)', + banks: ['exg2'], + options: GAIN_OPTIONS, + requiresRespiration: false, + apply: (b, v) => setExgFieldPreserving(b.exg2, 'ch1Gain', v), + }, + exg2Ch2Gain: { + label: 'Gain (EXG2 CH2)', + banks: ['exg2'], + options: GAIN_OPTIONS, + requiresRespiration: false, + apply: (b, v) => setExgFieldPreserving(b.exg2, 'ch2Gain', v), + }, + dataRate: { + label: 'ExG Rate', + banks: ['exg1', 'exg2'], + options: DATA_RATE_OPTIONS, + requiresRespiration: false, + apply: (b, v) => setBoth(b, 'dataRate', v), + }, + referenceElectrode: { + label: 'Reference Electrode', + banks: ['exg1'], + options: REFERENCE_ELECTRODE_OPTIONS, + requiresRespiration: false, + // setEXGReferenceElectrode (SensorEXG.java:2483-2489): the value is a REG6 + // low-nibble routing code; spread its bits over the four CHIP1 RLD-input + // fields (bit3→CH2 neg, bit2→CH2 pos, bit1→CH1 neg, bit0→CH1 pos). + apply: (b, v) => { + setExgFieldPreserving(b.exg1, 'ch2RldNegInputs', (v & 0x08) === 0x08 ? 1 : 0); + setExgFieldPreserving(b.exg1, 'ch2RldPosInputs', (v & 0x04) === 0x04 ? 1 : 0); + setExgFieldPreserving(b.exg1, 'ch1RldNegInputs', (v & 0x02) === 0x02 ? 1 : 0); + setExgFieldPreserving(b.exg1, 'ch1RldPosInputs', (v & 0x01) === 0x01 ? 1 : 0); + }, + }, + leadOffDetection: { + label: 'Lead-Off Detection', + banks: ['exg1', 'exg2'], + options: LEAD_OFF_DETECTION_OPTIONS, + requiresRespiration: false, + apply: applyLeadOffDetection, + }, + leadOffCurrent: { + label: 'Lead-Off Current', + banks: ['exg1', 'exg2'], + options: LEAD_OFF_CURRENT_OPTIONS, + requiresRespiration: false, + apply: (b, v) => setBoth(b, 'leadOffCurrent', v), + }, + leadOffComparatorThreshold: { + label: 'Lead-Off Comparator Threshold', + banks: ['exg1', 'exg2'], + options: LEAD_OFF_COMPARATOR_OPTIONS, + requiresRespiration: false, + apply: (b, v) => setBoth(b, 'comparatorThreshold', v), + }, + respirationEnable: { + label: 'Respiration', + banks: ['exg2'], + options: ON_OFF_OPTIONS, + // This IS the gate toggle — never gated on itself. + requiresRespiration: false, + apply: (b, v) => { + setExgFieldPreserving(b.exg2, 'respirationDemodCircuitry', v); + setExgFieldPreserving(b.exg2, 'respirationModCircuitry', v); + }, + }, + respirationFrequency: { + label: 'Respiration Detection Freq', + banks: ['exg2'], + options: RESPIRATION_FREQUENCY_OPTIONS, + requiresRespiration: true, + // setEXG2RespirationDetectFreq (SensorEXG.java:2618-2628): set the frequency, + // then FORCE the phase to that frequency's canonical default. + apply: (b, v) => { + setExgFieldPreserving(b.exg2, 'respirationControlFrequency', v); + setExgFieldPreserving( + b.exg2, + 'respirationPhase', + v === 0 ? RESP_PHASE_DEFAULT_32KHZ : RESP_PHASE_DEFAULT_64KHZ, + ); + }, + }, + respirationPhase: { + label: 'Respiration Detection Phase', + banks: ['exg2'], + dynamicOptions: (b) => + respirationPhaseOptions(readExgField(b.exg2, 'respirationControlFrequency')), + requiresRespiration: true, + apply: (b, v) => setExgFieldPreserving(b.exg2, 'respirationPhase', v), + }, +}); + +// -------------------------------------------------------------------------- +// Public API +// -------------------------------------------------------------------------- + +/** + * The current legal option list for a knob, given the banks (needed for the + * respiration phase list, which follows the current detection frequency). + * + * @throws UnknownExgKnobError for an unrecognised field. + */ +export function exgKnobOptions(field: ExgKnobField, banks: ExgBanks): ExgKnobOption[] { + const spec = EXG_KNOBS[field]; + if (!spec) throw new UnknownExgKnobError(field); + if (spec.dynamicOptions) return spec.dynamicOptions(banks); + return [...(spec.options ?? [])]; +} + +/** True when chip-2 respiration (both demod + mod circuits) is enabled. */ +export function isExgRespirationEnabled(banks: ExgBanks): boolean { + return ( + readExgField(banks.exg2, 'respirationDemodCircuitry') === 1 && + readExgField(banks.exg2, 'respirationModCircuitry') === 1 + ); +} + +/** + * Set ONE knob to `value`, returning NEW banks with the field changed, the + * must-be bits re-enforced, and every other bit preserved. Pure — the inputs + * are not mutated. + * + * Respiration contract: `respirationFrequency` and `respirationPhase` are + * REJECTED (throw {@link ExgRespirationLockedError}) unless chip-2 respiration + * is enabled — matching the desktop, which greys these knobs out until the + * respiration sensor is on (PanelAdvancedExG.java:340,357-361,416-420). The + * app disables the corresponding controls, so this throw is a backstop rather + * than a normal path. `respirationEnable` is the toggle itself and is never + * gated. + * + * Phase/frequency coupling: setting `respirationFrequency` also auto-remaps the + * phase to that frequency's canonical default (112.5° at 32 kHz, 157.5° at + * 64 kHz), so the stored phase is never left illegal for the new frequency + * (matches SensorEXG.setEXG2RespirationDetectFreq). Setting `respirationPhase` + * directly to a value that is illegal for the CURRENT frequency throws + * {@link ExgKnobValueError}. + * + * @throws RangeError when a bank is not 10 bytes. + * @throws UnknownExgKnobError for an unrecognised field. + * @throws ExgKnobValueError when the value is not a legal option. + * @throws ExgRespirationLockedError for a locked respiration edit. + */ +export function updateExgSetting(banks: ExgBanks, field: ExgKnobField, value: number): ExgBanks { + assertBanks(banks); + const spec = EXG_KNOBS[field]; + if (!spec) throw new UnknownExgKnobError(field); + + if (spec.requiresRespiration && !isExgRespirationEnabled(banks)) { + throw new ExgRespirationLockedError(field); + } + + const options = spec.dynamicOptions ? spec.dynamicOptions(banks) : (spec.options ?? []); + if (!options.some((o) => o.value === value)) { + throw new ExgKnobValueError( + field, + value, + options.map((o) => o.value), + ); + } + + const next: ExgBanks = { + exg1: Uint8Array.from(banks.exg1), + exg2: Uint8Array.from(banks.exg2), + }; + spec.apply(next, value); + + // Re-enforce must-be bits on every bank this knob touched. + if (spec.banks.includes('exg1')) applyExgMustBeBits(next.exg1); + if (spec.banks.includes('exg2')) applyExgMustBeBits(next.exg2); + return next; +} + +/** + * Apply a batch of knob edits in order, threading each edit's result into the + * next. Later edits win over earlier ones, and each edit sees the banks as left + * by the previous (so e.g. a `respirationEnable: 1` edit can precede — and + * unlock — a `respirationPhase` edit in the same batch). Pure. + * + * @throws the same typed errors as {@link updateExgSetting}, on the first + * offending edit. + */ +export function applyExgKnobEdits(banks: ExgBanks, edits: readonly ExgKnobEdit[]): ExgBanks { + assertBanks(banks); + let acc: ExgBanks = { + exg1: Uint8Array.from(banks.exg1), + exg2: Uint8Array.from(banks.exg2), + }; + for (const edit of edits) { + acc = updateExgSetting(acc, edit.field, edit.value); + } + return acc; +} + +/** + * Read the current value of every knob out of a pair of banks — the inverse of + * a full set. Handy for seeding an editor from the device's current config and + * for round-trip tests. The respiration-enable and lead-off-detection knobs are + * derived (they are macros over several bits), everything else reads its field. + */ +export function readExgKnobs(banks: ExgBanks): Record { + assertBanks(banks); + const d1 = decodeExgRegisters(banks.exg1); + const d2 = decodeExgRegisters(banks.exg2); + return { + exg1Ch1Gain: d1.ch1.gain.value, + exg1Ch2Gain: d1.ch2.gain.value, + exg2Ch1Gain: d2.ch1.gain.value, + exg2Ch2Gain: d2.ch2.gain.value, + dataRate: d1.dataRate.value, + referenceElectrode: banks.exg1[5] & 0x0f, + // Detection is "on" (DC=1) when the CHIP1 comparators + RLD sense are on. + leadOffDetection: d1.leadOff.detectionEnabled ? 1 : 0, + leadOffCurrent: d1.leadOff.current.value, + leadOffComparatorThreshold: d1.leadOff.comparatorThreshold.value, + respirationEnable: isExgRespirationEnabled(banks) ? 1 : 0, + respirationFrequency: d2.respiration.frequency.value, + respirationPhase: d2.respiration.phase.value, + }; +} diff --git a/src/devices/exg/registers.ts b/src/devices/exg/registers.ts index 4d46ec7..911e393 100644 --- a/src/devices/exg/registers.ts +++ b/src/devices/exg/registers.ts @@ -123,6 +123,14 @@ export const RLD_REFERENCE_SIGNAL_LABELS = ['Fed externally', '(AVDD - AVSS) / 2 /** REG9 respiration control clock (ExGConfigBytesDetails.java:342-343). */ export const RESPIRATION_CONTROL_LABELS = ['Internal clock', 'External Clock'] as const; +/** + * Lead-off detection mode (the "Lead-Off Detection" GUI knob). The GUI exposes + * only Off / DC Current (SensorEXG.ListOfExGLeadOffDetection, SensorEXG.java:132); + * the setter also supports an AC-current mode (value 2) which the GUI does not + * offer (see the knob helpers in ./knobs.ts). + */ +export const LEAD_OFF_DETECTION_LABELS = ['Off', 'DC Current'] as const; + const ON_OFF = ['Off', 'On'] as const; /** @@ -137,6 +145,23 @@ const REFERENCE_ELECTRODE_LABELS: Record = { 7: '3-Ch Single-ended', }; +/** + * Reference-electrode options as `{ value, label }`, verbatim from the Java + * "All" list ListOfExGReferenceElectrodeAll / …ConfigValuesAll + * (SensorEXG.java:123-124): Fixed Potential (0), Inverse of Ch1 (3), Inverse + * Wilson CT (13), 3-Ch Single-ended (7). The value is the REG6 low-nibble RLD + * input-routing code, NOT an index — see setEXGReferenceElectrode + * (SensorEXG.java:2483-2489). The 3-Ch single-ended entry is kept for data + * fidelity but EX4 builds no special UI for it (docs/handoff/13 EX4 descope). + */ +export const REFERENCE_ELECTRODE_OPTIONS: ReadonlyArray<{ value: number; label: string }> = + Object.freeze([ + { value: 0, label: 'Fixed Potential' }, + { value: 3, label: 'Inverse of Ch1' }, + { value: 13, label: 'Inverse Wilson CT' }, + { value: 7, label: '3-Ch Single-ended' }, + ]); + // -------------------------------------------------------------------------- // Field bit-layout table — the sole source of truth for both decode and // encode, keeping the two directions symmetric (ExGConfigBytesDetails.java:369-451). @@ -206,6 +231,37 @@ const FIELDS = { type FieldName = keyof typeof FIELDS; +/** + * The stable name of a single ADS1292R register field (the keys of the internal + * bit-layout table). Exported so the per-knob edit layer (./knobs.ts) can + * address individual fields by name and reuse this module's bit-layout as the + * single source of truth, rather than duplicating byteIndex/shift/mask. + */ +export type ExgFieldName = FieldName; + +/** + * Read one register field's raw config value out of a 10-byte bank. Companion + * to {@link setExgFieldPreserving}; both consult the internal bit-layout table. + */ +export function readExgField(bank: Uint8Array, name: ExgFieldName): number { + return readField(bank, name); +} + +/** + * Write one register field's value into a 10-byte bank IN PLACE, clearing that + * field's bits first so every OTHER bit in the byte is preserved (unlike the + * encode-time {@link writeField}, which assumes a freshly zeroed bank and only + * ORs bits in). This is the primitive the per-knob edit layer builds on: it lets + * a single knob change exactly its field and leave the rest of the populated + * bank untouched. Does NOT re-apply the must-be bits — callers do that once + * after all field writes (see ./knobs.ts). + */ +export function setExgFieldPreserving(bank: Uint8Array, name: ExgFieldName, value: number): void { + const f = FIELDS[name]; + const fieldMask = (f.mask << f.bitShift) & 0xff; + bank[f.byteIndex] = ((bank[f.byteIndex] & ~fieldMask) | ((value & f.mask) << f.bitShift)) & 0xff; +} + /** A decoded register field: raw config value plus a human-readable label. */ export interface ExgFieldValue { /** Raw config value as read from the register bits. */ diff --git a/src/index.ts b/src/index.ts index 1322086..2e82a36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -283,6 +283,28 @@ export { buildSetExgRegsCommand, decodeExgRegsResponse, exgBanksEqualIgnoringStatus, + // Per-knob editing — phase EX4 + readExgField, + setExgFieldPreserving, + LEAD_OFF_DETECTION_LABELS, + REFERENCE_ELECTRODE_OPTIONS, + EXG_KNOBS, + GAIN_OPTIONS, + DATA_RATE_OPTIONS, + LEAD_OFF_CURRENT_OPTIONS, + LEAD_OFF_COMPARATOR_OPTIONS, + LEAD_OFF_DETECTION_OPTIONS, + RESPIRATION_FREQUENCY_OPTIONS, + respirationPhaseOptions, + exgKnobOptions, + isExgRespirationEnabled, + updateExgSetting, + applyExgKnobEdits, + readExgKnobs, + ExgKnobError, + UnknownExgKnobError, + ExgKnobValueError, + ExgRespirationLockedError, } from './devices/exg/index.js'; export type { ExgFieldValue, @@ -294,12 +316,17 @@ export type { ExgTestSignalSettings, ExgStatusBits, DecodedExgRegisters, + ExgFieldName, ExgPreset, ExgResolution, ExgApplyInput, ExgApplyResult, ApplicableExgPreset, ExgChipIndex, + ExgBanks, + ExgKnobOption, + ExgKnobField, + ExgKnobEdit, } from './devices/exg/index.js'; // Inertial (accel/gyro/mag) calibration — phase P3 diff --git a/tests/dock/rtc-config-write.test.ts b/tests/dock/rtc-config-write.test.ts index 5784596..4f6a866 100644 --- a/tests/dock/rtc-config-write.test.ts +++ b/tests/dock/rtc-config-write.test.ts @@ -10,7 +10,7 @@ import { type UartRxPacket, } from '../../src/devices/dock/protocol.js'; import { UART_PACKET_CMD } from '../../src/devices/dock/constants.js'; -import { generateInfoMem, parseInfoMem, INFOMEM_SIZE } from '../../src/devices/infomem/index.js'; +import { parseInfoMem, INFOMEM_SIZE } from '../../src/devices/infomem/index.js'; import type { InfoMemContext } from '../../src/devices/infomem/index.js'; const dec = (u8: Uint8Array): string => new TextDecoder().decode(u8); diff --git a/tests/exg/knobs.test.ts b/tests/exg/knobs.test.ts new file mode 100644 index 0000000..060ed08 --- /dev/null +++ b/tests/exg/knobs.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect } from 'vitest'; +import { + EXG_KNOBS, + updateExgSetting, + applyExgKnobEdits, + exgKnobOptions, + respirationPhaseOptions, + isExgRespirationEnabled, + readExgKnobs, + decodeExgRegisters, + applyExgMustBeBits, + EXG_PRESET_ARRAYS, + UnknownExgKnobError, + ExgKnobValueError, + ExgRespirationLockedError, + type ExgBanks, + type ExgKnobField, +} from '../../src/devices/exg/index.js'; + +const u8 = (a: readonly number[]): Uint8Array => new Uint8Array(a); + +/** ECG preset banks (both chips, must-be already satisfied) — a realistic base. */ +const ecgBanks = (): ExgBanks => ({ + exg1: applyExgMustBeBits(u8([...EXG_PRESET_ARRAYS.ecg.exg1])), + exg2: applyExgMustBeBits(u8([...EXG_PRESET_ARRAYS.ecg.exg2])), +}); + +/** Respiration preset banks (chip-2 respiration on). */ +const respBanks = (): ExgBanks => ({ + exg1: applyExgMustBeBits(u8([...EXG_PRESET_ARRAYS.respiration.exg1])), + exg2: applyExgMustBeBits(u8([...EXG_PRESET_ARRAYS.respiration.exg2])), +}); + +/** All must-be bits present in a bank (so a knob edit never drops them). */ +function expectMustBe(bank: Uint8Array) { + expect(bank[1] & 0x80).toBe(0x80); // CONFIG2 bit7 + expect(bank[1] & 0x04).toBe(0); // CONFIG2 bit2 clear + expect(bank[2] & 0x10).toBe(0x10); // LOFF bit4 + expect(bank[8] & 0x02).toBe(0x02); // RESP1 bit1 + expect(bank[9] & 0x01).toBe(0x01); // RESP2 bit0 +} + +const ALL_FIELDS = Object.keys(EXG_KNOBS) as ExgKnobField[]; + +describe('EXG knob table', () => { + it('every knob exposes a label, at least one option, and a bank set', () => { + for (const field of ALL_FIELDS) { + const spec = EXG_KNOBS[field]; + expect(spec.label.length).toBeGreaterThan(0); + expect(spec.banks.length).toBeGreaterThan(0); + const opts = exgKnobOptions(field, respBanks()); + expect(opts.length).toBeGreaterThan(0); + } + }); +}); + +describe('updateExgSetting — every knob round-trips and preserves other bits', () => { + // Walk ALL knobs programmatically: for each legal value, set it and confirm + // decode reflects it, must-be bits survive, and untouched banks are untouched. + for (const field of ALL_FIELDS) { + it(`${field}: each option applies and reads back`, () => { + // Respiration freq/phase need respiration ON — use the respiration preset. + const base = EXG_KNOBS[field].requiresRespiration ? respBanks() : ecgBanks(); + const options = exgKnobOptions(field, base); + for (const opt of options) { + const next = updateExgSetting(base, field, opt.value); + // Input not mutated. + expect(next.exg1).not.toBe(base.exg1); + // Must-be bits intact on both banks. + expectMustBe(next.exg1); + expectMustBe(next.exg2); + // The knob's read-back reflects the value (respirationFrequency also + // remaps phase, so read via readExgKnobs which handles the coupling). + const knobs = readExgKnobs(next); + if (field === 'respirationFrequency') { + expect(knobs.respirationFrequency).toBe(opt.value); + } else if (field === 'leadOffDetection') { + expect(knobs.leadOffDetection).toBe(opt.value); + } else { + expect(knobs[field]).toBe(opt.value); + } + } + }); + } + + it('a chip-1-only knob (exg1Ch1Gain) never touches the chip-2 bank', () => { + const base = ecgBanks(); + const next = updateExgSetting(base, 'exg1Ch1Gain', 5); // gain "8" + expect(Array.from(next.exg2)).toEqual(Array.from(base.exg2)); + expect(decodeExgRegisters(next.exg1).ch1.gain.value).toBe(5); + // chip-1 CH2 gain unchanged. + expect(decodeExgRegisters(next.exg1).ch2.gain.value).toBe( + decodeExgRegisters(base.exg1).ch2.gain.value, + ); + }); + + it('a chip-2-only knob (respirationPhase) never touches the chip-1 bank', () => { + const base = respBanks(); + const next = updateExgSetting(base, 'respirationPhase', 3); + expect(Array.from(next.exg1)).toEqual(Array.from(base.exg1)); + expect(decodeExgRegisters(next.exg2).respiration.phase.value).toBe(3); + }); + + it('data-rate writes BOTH chips', () => { + const base = ecgBanks(); + const next = updateExgSetting(base, 'dataRate', 4); // 2 kHz + expect(decodeExgRegisters(next.exg1).dataRate.value).toBe(4); + expect(decodeExgRegisters(next.exg2).dataRate.value).toBe(4); + }); + + it('reference-electrode maps the value onto the CHIP1 REG6 nibble', () => { + const base = ecgBanks(); + // Inverse Wilson CT = 13 (0b1101): CH2 neg + CH1 neg + CH1 pos, CH2 pos off. + const next = updateExgSetting(base, 'referenceElectrode', 13); + expect(next.exg1[5] & 0x0f).toBe(13); + expect(Array.from(next.exg2)).toEqual(Array.from(base.exg2)); + }); +}); + +describe('phase-by-frequency lists (SensorEXG.java:143-146)', () => { + it('32 kHz → 16 options (values 0-15)', () => { + const opts = respirationPhaseOptions(0); + expect(opts.map((o) => o.value)).toEqual([...Array(16).keys()]); + expect(opts[0].label).toBe('0°'); + expect(opts[10].label).toBe('112.5°'); + expect(opts[15].label).toBe('168.75°'); + }); + it('64 kHz → 8 options (values 0-7)', () => { + const opts = respirationPhaseOptions(1); + expect(opts.map((o) => o.value)).toEqual([...Array(8).keys()]); + expect(opts[0].label).toBe('0°'); + expect(opts[7].label).toBe('157.5°'); + }); + it('exgKnobOptions(respirationPhase) follows the current frequency', () => { + const base = respBanks(); // respiration preset defaults to 32 kHz + expect(exgKnobOptions('respirationPhase', base).length).toBe(16); + const at64 = updateExgSetting(base, 'respirationFrequency', 1); + expect(exgKnobOptions('respirationPhase', at64).length).toBe(8); + }); +}); + +describe('respiration frequency-flip auto-remaps the phase (SensorEXG.java:2618-2628)', () => { + it('flip to 64 kHz forces phase = 157.5° (reg value 7)', () => { + const base = respBanks(); + const at64 = updateExgSetting(base, 'respirationFrequency', 1); + expect(decodeExgRegisters(at64.exg2).respiration.frequency.value).toBe(1); + expect(decodeExgRegisters(at64.exg2).respiration.phase.value).toBe(7); + }); + it('flip to 32 kHz forces phase = 112.5° (reg value 10)', () => { + // Start at 64 kHz, then flip to 32 kHz. + const at64 = updateExgSetting(respBanks(), 'respirationFrequency', 1); + const at32 = updateExgSetting(at64, 'respirationFrequency', 0); + expect(decodeExgRegisters(at32.exg2).respiration.frequency.value).toBe(0); + expect(decodeExgRegisters(at32.exg2).respiration.phase.value).toBe(10); + }); + it('a formerly-illegal phase (15 @32 kHz) is legal after the flip forces a default', () => { + // Set phase 15 at 32 kHz, then flip to 64 kHz where 15 would be illegal. + const p15 = updateExgSetting(respBanks(), 'respirationPhase', 15); + const at64 = updateExgSetting(p15, 'respirationFrequency', 1); + // Phase is now 7 (a legal 64 kHz value), not the stale 15. + expect(decodeExgRegisters(at64.exg2).respiration.phase.value).toBe(7); + }); +}); + +describe('illegal phase-for-frequency is rejected', () => { + it('phase 15 at 64 kHz throws ExgKnobValueError', () => { + const at64 = updateExgSetting(respBanks(), 'respirationFrequency', 1); + expect(() => updateExgSetting(at64, 'respirationPhase', 15)).toThrow(ExgKnobValueError); + expect(() => updateExgSetting(at64, 'respirationPhase', 8)).toThrow(ExgKnobValueError); + }); + it('phase 15 at 32 kHz is accepted', () => { + const next = updateExgSetting(respBanks(), 'respirationPhase', 15); + expect(decodeExgRegisters(next.exg2).respiration.phase.value).toBe(15); + }); +}); + +describe('respiration gating (locked unless enabled)', () => { + it('ECG banks report respiration disabled', () => { + expect(isExgRespirationEnabled(ecgBanks())).toBe(false); + }); + it('respirationFrequency edit throws when respiration is off', () => { + expect(() => updateExgSetting(ecgBanks(), 'respirationFrequency', 1)).toThrow( + ExgRespirationLockedError, + ); + }); + it('respirationPhase edit throws when respiration is off', () => { + expect(() => updateExgSetting(ecgBanks(), 'respirationPhase', 2)).toThrow( + ExgRespirationLockedError, + ); + }); + it('respirationEnable is NOT gated and unlocks the others in a batch', () => { + // Enable respiration then set a phase, in one batch, from an ECG base. + const next = applyExgKnobEdits(ecgBanks(), [ + { field: 'respirationEnable', value: 1 }, + { field: 'respirationPhase', value: 4 }, + ]); + expect(isExgRespirationEnabled(next)).toBe(true); + expect(decodeExgRegisters(next.exg2).respiration.phase.value).toBe(4); + }); + it('respirationEnable = 0 turns both demod + mod off', () => { + const off = updateExgSetting(respBanks(), 'respirationEnable', 0); + expect(isExgRespirationEnabled(off)).toBe(false); + }); +}); + +describe('lead-off detection macro (SensorEXG.setEXGLeadOffCurrentMode)', () => { + it('DC Current enables comparators + RLD sense on chip 1', () => { + const dc = updateExgSetting(ecgBanks(), 'leadOffDetection', 1); + const d1 = decodeExgRegisters(dc.exg1); + expect(d1.leadOff.detectionEnabled).toBe(true); + expect(d1.rld.leadOffSenseFunction.value).toBe(1); + expect(d1.leadOff.current.value).toBe(1); // 22 nA + expect(d1.leadOff.comparatorThreshold.value).toBe(2); // Pos90/Neg10 + }); + it('Off disables the comparators again', () => { + const dc = updateExgSetting(ecgBanks(), 'leadOffDetection', 1); + const off = updateExgSetting(dc, 'leadOffDetection', 0); + expect(decodeExgRegisters(off.exg1).leadOff.detectionEnabled).toBe(false); + expect(decodeExgRegisters(off.exg2).leadOff.detectionEnabled).toBe(false); + }); +}); + +describe('validation errors', () => { + it('unknown field name throws UnknownExgKnobError', () => { + expect(() => updateExgSetting(ecgBanks(), 'nope' as ExgKnobField, 0)).toThrow( + UnknownExgKnobError, + ); + }); + it('out-of-range value throws ExgKnobValueError', () => { + expect(() => updateExgSetting(ecgBanks(), 'dataRate', 7)).toThrow(ExgKnobValueError); // 0-6 + expect(() => updateExgSetting(ecgBanks(), 'exg1Ch1Gain', 9)).toThrow(ExgKnobValueError); // 0-6 + expect(() => updateExgSetting(ecgBanks(), 'referenceElectrode', 1)).toThrow(ExgKnobValueError); // {0,3,7,13} + }); + it('wrong bank length throws RangeError', () => { + const short: ExgBanks = { exg1: new Uint8Array(9), exg2: new Uint8Array(10) }; + expect(() => updateExgSetting(short, 'dataRate', 0)).toThrow(RangeError); + }); +}); + +describe('applyExgKnobEdits — batch, later edits win', () => { + it('applies a mixed batch in order', () => { + const next = applyExgKnobEdits(ecgBanks(), [ + { field: 'exg1Ch1Gain', value: 6 }, // gain "12" + { field: 'exg2Ch2Gain', value: 2 }, // gain "2" + { field: 'dataRate', value: 3 }, + { field: 'leadOffDetection', value: 1 }, + ]); + const k = readExgKnobs(next); + expect(k.exg1Ch1Gain).toBe(6); + expect(k.exg2Ch2Gain).toBe(2); + expect(k.dataRate).toBe(3); + expect(k.leadOffDetection).toBe(1); + }); + it('later edit of the same field wins', () => { + const next = applyExgKnobEdits(ecgBanks(), [ + { field: 'dataRate', value: 1 }, + { field: 'dataRate', value: 5 }, + ]); + expect(readExgKnobs(next).dataRate).toBe(5); + }); + it('empty batch returns an equal copy', () => { + const base = ecgBanks(); + const next = applyExgKnobEdits(base, []); + expect(Array.from(next.exg1)).toEqual(Array.from(base.exg1)); + expect(next.exg1).not.toBe(base.exg1); + }); +}); + +describe('readExgKnobs round-trips through updateExgSetting', () => { + it('reading then re-setting each knob is idempotent', () => { + const base = respBanks(); + const k = readExgKnobs(base); + for (const field of ALL_FIELDS) { + // Re-apply the current value; banks should be unchanged. + const next = updateExgSetting(base, field, k[field]); + expect(Array.from(next.exg1)).toEqual(Array.from(base.exg1)); + expect(Array.from(next.exg2)).toEqual(Array.from(base.exg2)); + } + }); +}); From d08b4ba19bc011fc36ac9930b1b3493fdecdceef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 11:08:01 +0000 Subject: [PATCH 10/10] chore(release): 0.1.10-exg.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG note for EX4 (per-knob EXG editing): updateExgSetting / applyExgKnobEdits, the freq-dependent respiration phase lists and freq-flip auto-remap (112.5°@32 kHz / 157.5°@64 kHz), the respiration-locked-unless- enabled contract, the lead-off detection macro, and the typed validation errors. Bump package.json to 0.1.10-exg.5. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012pin56yhefi5Mihjf3vTpL --- CHANGELOG.md | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d15f74..696b7f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project follows [Semantic Versioning](https://semver.org/). ### Added +- **ADS1292R (EXG) per-knob edit + validation helpers, phase EX4.** A new pure module (`src/devices/exg/knobs.ts`) — the per-knob layer the docked config editor stages individual EXG settings through, complementing EX2's whole-preset `applyExgPreset`. `updateExgSetting(banks, field, value)` (and the batched `applyExgKnobEdits(banks, edits)`) return NEW banks with exactly one field changed, the hardware must-be bits re-enforced, and every other bit preserved (via new `setExgFieldPreserving`, a clear-then-set masked write over the same `registers.ts` bit-layout table that decode/encode use — no enum data duplicated). Chip addressing is explicit: the four **gain** knobs name chip AND channel (`exg1Ch1Gain` … `exg2Ch2Gain`, `SensorEXG.setExGGainSetting(chipID,channel,v)`, `SensorEXG.java:2314-2331`); **rate** and **lead-off current / comparator threshold** write REG1/REG3 on BOTH chips (`setEXGRateSetting` `:2468`, `setEXGLeadOffDetectionCurrent` `:2600`, `setEXGLeadOffComparatorTreshold` `:2610`); **reference electrode** writes the four CHIP1 REG6 RLD-input bits from its nibble code (`setEXGReferenceElectrode`, `:2483-2489`); **lead-off detection** is a macro over comparators / RLD-sense / per-lead-detect bits across both chips (`setEXGLeadOffCurrentMode` Off=0 / DC=1, `:2495-2545`; AC current and the three-unipolar branch are descoped); and **respiration** enable / frequency / phase are CHIP2-only. **Respiration phase↔frequency coupling:** `respirationPhaseOptions(freq)` ports the exact freq-dependent lists (16 steps @32 kHz values 0-15, 8 @64 kHz values 0-7, `SensorEXG.java:143-146`); `updateExgSetting` REJECTS an illegal phase-for-frequency (`ExgKnobValueError`), and changing the frequency AUTO-REMAPS the phase to that frequency's canonical default (112.5° / reg value 10 at 32 kHz, 157.5° / reg value 7 at 64 kHz), the net effect of `setEXG2RespirationDetectFreq` (`:2618-2628`, which re-validates then force-sets the phase). **Respiration-locked-unless-enabled:** `respirationFrequency` / `respirationPhase` edits throw `ExgRespirationLockedError` unless chip-2 demod+mod circuitry is on, matching the desktop, which greys these knobs out until the respiration sensor is enabled (`PanelAdvancedExG.java:340,357-361,416-420`); the `respirationEnable` toggle is never gated and can unlock the others inside a single `applyExgKnobEdits` batch. Typed errors throughout: `UnknownExgKnobError` (unknown field), `ExgKnobValueError` (out-of-range value / illegal phase), `ExgRespirationLockedError`, and `RangeError` (bank ≠ 10 bytes). Also `exgKnobOptions(field, banks)` (resolves the phase list from the current frequency), `isExgRespirationEnabled(banks)`, and `readExgKnobs(banks)` (inverse — seed an editor / round-trip tests). New exports: `EXG_KNOBS`, `updateExgSetting`, `applyExgKnobEdits`, `respirationPhaseOptions`, `exgKnobOptions`, `isExgRespirationEnabled`, `readExgKnobs`, `readExgField`, `setExgFieldPreserving`, the `GAIN_OPTIONS` / `DATA_RATE_OPTIONS` / `LEAD_OFF_CURRENT_OPTIONS` / `LEAD_OFF_COMPARATOR_OPTIONS` / `LEAD_OFF_DETECTION_OPTIONS` / `RESPIRATION_FREQUENCY_OPTIONS` / `REFERENCE_ELECTRODE_OPTIONS` option lists, `LEAD_OFF_DETECTION_LABELS`, the error classes, and the `ExgBanks` / `ExgKnobField` / `ExgKnobOption` / `ExgKnobEdit` / `ExgFieldName` types. - **ADS1292R (EXG) live BT/BLE set + read commands, phase EX3.** Generalises the previous Shimmer3R hardcoded 16-bit-only EXG writers into a codec-driven live path, adds GET read-back, and extends both to the classic `Shimmer3Client`. A new pure framing module (`src/devices/exg/live.ts`) provides `buildGetExgRegsCommand(chip)` → `{0x63, chip, 0, 10}` (ported byte-for-byte from `ShimmerBluetooth.readEXGConfigurations`, `ShimmerBluetooth.java:4023`), `buildSetExgRegsCommand(chip, bank)` → `{0x61, chip, 0, 10, reg0..reg9}` (a single 14-byte write, never chunked — `ShimmerBluetooth.java:4220`), `decodeExgRegsResponse(frame)` which extracts the 10 register bytes from the 12-byte `[0x62][echo][reg0..reg9]` response (the byte after the opcode is a count/chip echo the driver ignores — the chip identity is tracked host-side from the preceding GET via `mTempChipID`, `ShimmerBluetooth.java:1087-1089,1641-1652`; declared length 11 at `:468`), and `exgBanksEqualIgnoringStatus(a, b)` for read-back comparison (skips the read-only REG8 LOFF_STAT byte). New client methods on **both** `Shimmer3RClient` and `Shimmer3Client`: `readExgConfig()` (GET ×2 chips → decode), `writeExgConfig(exg1, exg2)` (SET ×2), and `applyExgPresetLive(preset, resolution)` (reads current banks, folds EX2's `applyExgPreset`, writes registers, then updates the sensor bitmap via `setSensors` — **enabled sensors are written LAST**, matching the desktop write flow's "this should always be the last command" ordering, `ShimmerBluetooth.java:2732,2735`, with `writeEXGConfiguration()` running earlier, `:2670`). The three `enableECG16Bit` / `enableEMG16Bit` / `enableEXGTestSignal16Bit` convenience methods now delegate to `applyExgPresetLive`, dropping the hardcoded byte arrays. **Write-safety, a deliberate deviation from Java:** Java applies SET_EXG_REGS immediately with a plain ACK and relies on a timeout→disconnect failsafe (`ShimmerBluetooth.java:4028-4034`); this port instead does SET → await ACK → GET read-back → compare → throw on mismatch (read-back-verify), so a silent no-op surfaces as an error at the call site. **3R vs classic:** the Shimmer3R write injects the ADS1292R oversampling ratio into REG1 (bank byte 0) low 3 bits from the live sampling rate (`getOversamplingRatioADS1292R`, `calibration.ts:89`, reproducing the old `_writeExgPages` step); classic Shimmer3 writes the banks **verbatim** (Java `writeEXGConfiguration` does not touch reg[0]). EXG GET/SET/apply are refused while streaming (the read-back needs the control plane). Classic Shimmer3 adds `EXG_REGS_RESPONSE` (0x62, 11 payload bytes) to `SHIMMER3_RESPONSE_PAYLOAD_LENGTHS` so its unframed byte-stream parser frames the response. New exports: `SET_EXG_REGS_COMMAND`, `EXG_REGS_RESPONSE`, `GET_EXG_REGS_COMMAND`, `EXG_REGS_RESPONSE_PAYLOAD_LENGTH`, `EXG_CHIP1`, `EXG_CHIP2`, `EXG_REG8_STATUS_INDEX`, `buildGetExgRegsCommand`, `buildSetExgRegsCommand`, `decodeExgRegsResponse`, `exgBanksEqualIgnoringStatus`, and the `ExgChipIndex` type. **HARDWARE-VERIFY:** the live GET/SET round-trip and read-back-verify have not been exercised against real Shimmer3/3R firmware; the classic-Shimmer3 firmware-version gate (`(internal>=8 && code==2) || code>2`, `ShimmerBluetooth.java:4011`) is approximated by requiring a completed connect handshake rather than porting the full ShimmerVerObject version-code ladder. - **ADS1292R (EXG) preset APPLICATION library, phase EX2.** A new pure module (`src/devices/exg/apply.ts`) that turns a chosen preset + resolution into the two register banks and the enabled-sensors bitmap to write — the write half that builds on EX1's codec/detection. `applyExgPreset({ exg1, exg2, enabledSensors, samplingRateHz, hardwareVersion }, preset, resolution)` → `{ exg1, exg2, enabledSensors }` ports the Java preset setters `SensorEXG.setDefaultECGConfiguration` / `setDefaultEMGConfiguration` / `setEXGTestSignal` / `setDefaultRespirationConfiguration` (`SensorEXG.java:1781-1895`) as a whole: (a) the register bytes come from EX1's `EXG_PRESET_ARRAYS`, which are **identical for 16- and 24-bit** — the Java derives resolution purely from the sensor bitmap (`setExgChannelBitsPerMode`, `:2150-2183`), never from separate register arrays or bit-twiddling; (b) the **sampling-rate→data-rate coupling** rewrites REG1 data-rate on BOTH chips via `exgRateSettingFromFreq`, a byte-for-byte port of `setExGRateFromFreq` (`:2784-2806`, `<=` thresholds, >8 kHz→500 Hz fallback) — distinct from the live-BT `getOversamplingRatioADS1292R` (`calibration.ts:89`, strict `<`), which is NOT used on the docked InfoMem path; (c) the **resolution↔bitmap coupling** sets the chosen resolution's EXG1/EXG2 flags and clears the other's — EMG is chip-1-only, every other preset is both chips, and **respiration honours the chosen resolution (it is NOT forced to 24-bit)**; `'off'` zeroes both banks and clears all four flags; (d) **sensor-conflict clearing** — `exgConflictingSensors(enabledSensors)` reports the currently-enabled sensors that conflict with EXG (GSR, the internal ADCs, the bridge/resistance amp — the EXG `SensorDetailsRef` conflict list, `SensorEXG.java:332-344`), and `applyExgPreset` CLEARS them from the returned bitmap, matching what desktop does when a preset tile is clicked (`ShimmerDevice.sensorMapConflictCheckandCorrect`, `:2497-2512`, disables — not warns). The CHIP1 oscillator-clock-joined bit (`setDefaultExgCommon`, `:2000-2002`, `ShimmerVerObject.isSupportedExgChipClocksJoined`, `:712-723`) is set for Shimmer3R (the only case resolvable from HW id — the expansion-board rev a classic Shimmer3 would need is not in the docked context), otherwise the device's existing bit is preserved. New exports: `applyExgPreset`, `exgConflictingSensors`, `exgRateSettingFromFreq`, `EXG_CONFLICTING_SENSORS`, and the `ExgApplyInput` / `ExgApplyResult` / `ApplicableExgPreset` types. - **ADS1292R (EXG / ECG-EMG-respiration) register codec, phase EX1.** A new pure, transport-free module (`src/devices/exg/`) that decodes and encodes the 10-byte-per-chip ADS1292R register bank and recognises the whole-device preset — the read/display half of EXG configuration. Ported from the desktop Java oracle: `decodeExgRegisters(bank)` mirrors `ShimmerObject.exgBytesGetConfigFrom` (`ShimmerObject.java:6894-6940`) + `ExGConfigBytesDetails.updateFromRegisterArray`, returning every register field as `{ value, label }` (data rate, conversion mode, reference buffer/voltage, per-channel PGA gain + numeric gain value + input-selection mux + power-down, reference-electrode, lead-off detection/current/comparator-threshold + per-lead sense bits, RLD routing, respiration mod/demod/phase/frequency, and the read-only REG8 status bits) with the human-readable labels taken verbatim from `SensorEXG.java:116-149`. `encodeExgRegisters(settings)` is the inverse (`generateExgByteArray`) and always re-applies the hardware "must-be" bits (`applyExgMustBeBits`, ported from `setExgByteArrayConstants`, `ExGConfigBytesDetails.java:507-525`). `detectExgPreset(exg1, exg2, enabledSensors?)` → `'ecg' | 'emg' | 'test-signal' | 'respiration' | 'custom' | 'off'` is a **tolerant** match keyed only off the CH1/CH2 input-selection nibbles + chip-2 respiration bits (+ the resolution flags when the bitmap is supplied), exactly as `SensorEXG.isEXGUsingDefault*` (`SensorEXG.java:2680-2763`) — so the fields firmware rewrites (rate/oversampling in byte0, oscillator-clock in byte1, gain) do not affect detection, and both the 24-bit Java reference arrays and the 16-bit hardcoded 3R arrays detect correctly. **Resolution is not a register field**: `exgResolutionFromSensors(enabledSensors)` → `'16bit' | '24bit' | null` derives it from the sensor bitmap (`ShimmerObject.checkExgResolutionFromEnabledSensorsVar`, `:7255-7279`). Covers Shimmer3 and Shimmer3R (same ADS1292R). New exports: `decodeExgRegisters`, `encodeExgRegisters`, `applyExgMustBeBits`, `detectExgPreset`, `exgResolutionFromSensors`, `exgPresetLabel`, `EXG_PRESET_ARRAYS`, `EXG_BANK_LENGTH`, the option-label lists, and the `DecodedExgRegisters` / `ExgPreset` / `ExgResolution` types. diff --git a/package.json b/package.json index e38a872..7c66e0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.10-exg.4", + "version": "0.1.10-exg.5", "description": "Web Bluetooth and Web Serial API for Shimmer sensor devices (Shimmer3R, Verisense IMU/Pulse+)", "type": "module", "main": "dist/shimmer-web-sdk.cjs",