From 97a99efe8b3d88ab6a5ee6d8b45f950d2a017f21 Mon Sep 17 00:00:00 2001 From: Ashwin K Whitchurch Date: Wed, 17 Jun 2026 21:49:04 +0530 Subject: [PATCH] Modernize to v2.1.0: PulseExpress API, expanded examples, bootloader - Rename driver class Max32664 -> PulseExpress and types Max32664* -> PulseExpress*; old names kept via max32664.h compatibility shim. Rename sources to protocentral_pulse_express.{h,cpp}. SPDX headers throughout. - Fix earlier-firmware regression: begin() no longer hard-fails on non-40.x hubs; version check is now a soft warning exposed via firmwareSupported(). - Fix raw PPG streaming data loss: drain FIFO with a short read delay, clamp the OpenView 16-bit scaling, and make the OpenView send non-blocking so a host stall cannot back up and overflow the sensor FIFO. - Add public readStatus() hub-diagnostics accessor. - Expand examples from 3 to 11 (01.Name convention): heart rate/SpO2, BPT calibration, BPT estimation, EEPROM persistence, multi-subject calibration, HRV, device info/diagnostics. - Add MAX32664 bootloader API (pulse_express_bootloader.{h,cpp}), the FirmwareFlash example, and a host flashing script under extras/. The .msbl firmware image is non-redistributable and is gitignored. - Update README, keywords.txt, library.properties (version 2.1.0), add CHANGELOG.md and Uno R4 upload scripts. --- .gitignore | 5 + CHANGELOG.md | 45 ++ README.md | 183 +++++--- .../01.RawPPGStreamPlotter.ino | 53 ++- .../02.BPTEstimation/02.BPTEstimation.ino | 195 -------- .../02.RawPPGStreamOpenView.ino | 102 +++++ .../03.HeartRateSpO2/03.HeartRateSpO2.ino | 84 ++++ .../03.RawPPGStreamOpenView.ino | 89 ---- .../04.BPTCalibration/04.BPTCalibration.ino | 129 ++++++ .../05.BPTEstimation/05.BPTEstimation.ino | 100 +++++ .../06.BPTCalibrateAndEstimate.ino | 186 ++++++++ .../07.SaveLoadCalibrationEEPROM.ino | 193 ++++++++ .../08.MultiSubjectCalibration.ino | 111 +++++ .../09.HeartRateVariability.ino | 111 +++++ .../10.DeviceInfoAndDiagnostics.ino | 83 ++++ .../11.FirmwareFlash/11.FirmwareFlash.ino | 134 ++++++ extras/firmware/README.md | 28 ++ extras/flash_tool/flash_msbl.py | 134 ++++++ keywords.txt | 46 +- library.properties | 7 +- scripts/upload_02_openview.sh | 58 +++ scripts/upload_10_diagnostics.sh | 58 +++ src/max32664.h | 410 ++--------------- ...664.cpp => protocentral_pulse_express.cpp} | 424 +++++++++--------- src/protocentral_pulse_express.h | 416 +++++++++++++++++ src/pulse_express_bootloader.cpp | 208 +++++++++ src/pulse_express_bootloader.h | 102 +++++ 27 files changed, 2724 insertions(+), 970 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 examples/02.BPTEstimation/02.BPTEstimation.ino create mode 100644 examples/02.RawPPGStreamOpenView/02.RawPPGStreamOpenView.ino create mode 100644 examples/03.HeartRateSpO2/03.HeartRateSpO2.ino delete mode 100644 examples/03.RawPPGStreamOpenView/03.RawPPGStreamOpenView.ino create mode 100644 examples/04.BPTCalibration/04.BPTCalibration.ino create mode 100644 examples/05.BPTEstimation/05.BPTEstimation.ino create mode 100644 examples/06.BPTCalibrateAndEstimate/06.BPTCalibrateAndEstimate.ino create mode 100644 examples/07.SaveLoadCalibrationEEPROM/07.SaveLoadCalibrationEEPROM.ino create mode 100644 examples/08.MultiSubjectCalibration/08.MultiSubjectCalibration.ino create mode 100644 examples/09.HeartRateVariability/09.HeartRateVariability.ino create mode 100644 examples/10.DeviceInfoAndDiagnostics/10.DeviceInfoAndDiagnostics.ino create mode 100644 examples/11.FirmwareFlash/11.FirmwareFlash.ino create mode 100644 extras/firmware/README.md create mode 100644 extras/flash_tool/flash_msbl.py create mode 100755 scripts/upload_02_openview.sh create mode 100755 scripts/upload_10_diagnostics.sh rename src/{max32664.cpp => protocentral_pulse_express.cpp} (60%) create mode 100644 src/protocentral_pulse_express.h create mode 100644 src/pulse_express_bootloader.cpp create mode 100644 src/pulse_express_bootloader.h diff --git a/.gitignore b/.gitignore index 5996952..32cb283 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ dkms.conf .bin CLAUDE.md + +# Maxim/ADI firmware images are non-redistributable IP — never commit them. +*.msbl +*.bin + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b8e0aaf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to the ProtoCentral Pulse Express library. + +## [2.1.0] - 2026-06-17 + +### Changed +- **Primary class renamed `Max32664` -> `PulseExpress`**, and the public types + from `Max32664*` to `PulseExpress*`, to match modern ProtoCentral library + conventions. The 2.0.x names still compile: `#include "max32664.h"` provides + `using` aliases. New sketches should `#include "protocentral_pulse_express.h"`. +- Source files renamed to `protocentral_pulse_express.{h,cpp}`; `max32664.h` is + now a thin backward-compatibility shim. +- License headers switched to the SPDX format across `src/` and `examples/`. +- `library.properties`: `version=2.1.0`, added `includes=`. + +### Fixed +- **Earlier-firmware regression:** `begin()` no longer hard-fails (returned + `UnsupportedFirmware`) when the hub reports a major version other than 40. + Version checking is now a **soft warning** — `begin()` proceeds with legacy + capability defaults and exposes `firmwareSupported()` for callers to branch on. + +### Added +- `firmwareSupported()` and a public `readStatus(HubStatus&)` for hub diagnostics. +- Examples expanded from 3 to 11 (renumbered, `01.Name` convention): + `03.HeartRateSpO2`, `04.BPTCalibration`, `05.BPTEstimation`, + `06.BPTCalibrateAndEstimate`, `07.SaveLoadCalibrationEEPROM`, + `08.MultiSubjectCalibration`, `09.HeartRateVariability`, + `10.DeviceInfoAndDiagnostics`, `11.FirmwareFlash`. +- **Bootloader / firmware flashing (factory / recovery):** + `PulseExpressBootloader` class (`src/pulse_express_bootloader.{h,cpp}`), + the `11.FirmwareFlash` sketch, and a host script `extras/flash_tool/flash_msbl.py`. + Implemented from Maxim/ADI UG6806 Table 9 — validate on hardware before + production. The `.msbl` firmware image is non-redistributable and is never + committed (`*.msbl`/`*.bin` gitignored). + +### Known issues +- `Max32664Caps::sendBpMedication` / `sendRestMode` are derived from the firmware + version but not yet consumed; calibration on firmware <40.2.2 may be missing + setup steps. Tracked for a follow-up once the older opcodes are confirmed. + +## [2.0.0] + +- Clean-break rewrite from 1.0.x: runtime multi-firmware support across the + MAX32664D 40.x line, raw PPG / BPT calibration / BPT estimation modes. diff --git a/README.md b/README.md index 4ed03e7..4d2b538 100644 --- a/README.md +++ b/README.md @@ -2,82 +2,155 @@ Protocentral Pulse Express with MAX30102 and MAX32664D ================================ [![Compile Examples](https://github.com/Protocentral/protocentral-pulse-express/workflows/Compile%20Examples/badge.svg)](https://github.com/Protocentral/protocentral-pulse-express/actions?workflow=Compile+Examples) - +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Arduino Library](https://img.shields.io/badge/Arduino-Library-00979D?logo=arduino)](https://www.arduino.cc) ## Don't have one? [Buy it here](https://protocentral.com/product/pulse-express-pulse-ox-heart-rate-sensor-with-max32664/) ![](assets/pulse_exp.jpg) -Pulse Express is an efficient and versatile breakout board with integrated high-sensitivity optical sensors (MAX30102) and also a chip that does the calculations (biometric sensor hub MAX32664D). Integrating Maxim’s MAX32664 Version D makes Pulse Express unique, with an internal algorithm that works to measure different data as you start. With its built-in low power capability, the board is suitable for any wearable health for finger-based applications. +Pulse Express is a compact breakout that pairs a high-sensitivity MAX30102 optical +sensor with a MAX32664D biometric sensor hub. The MAX32664D runs Maxim/ADI's +on-chip Blood-Pressure-Trending (BPT) algorithm and reports calculated heart rate, +SpO2 and blood-pressure-trend data over I2C, so the host MCU does no signal +processing. Suitable for finger-based wearable health R&D. -**Note: This device is only meant to be used for research & development purposes and is NOT to be used as a medical device. This product is not FDA, CE or FCC approved for consumer use.** +> **Note:** This device is for research & development only and is **NOT** a medical +> device. It is not FDA, CE or FCC approved for consumer use. -## Hardware Setup +## Overview -Connection with the Arduino board is as follows: - - |Max32664 pin label| Arduino Connection |Pin Function | - |----------------- |---------------------|------------------| - | SDA | A4 | Serial Data | - | SCL | A5 | Serial Clock | - | Vin | 5V | Power | - | GND | Gnd | Gnd | - | MFIO Pin | 05 | MFIO | - | RESET Pin | 04 | Reset | +**Key capabilities (all exposed by this library):** +- Raw PPG streaming (IR + Red, 24-bit ADC counts) +- Heart rate and SpO2 with per-reading confidence +- Blood-pressure trending — calibration → estimation (systolic / diastolic) +- SpO2 coefficient calibration (per Maxim AN6845) +- Multi-subject calibration, up to 5 subjects (firmware >= 40.5.0) +- Heart-rate variability (SDNN / RMSSD) from inter-beat intervals +- Runtime firmware-version detection and capability adaptation (40.x line) +- Factory / recovery firmware flasher for the MAX32664 bootloader +## Installation -## Visualizing Output +**Arduino Library Manager (recommended):** search for *ProtoCentral Pulse Express* +and click Install. -![](assets/pulse_express_7sec.gif) +**Manual:** download this repository as a ZIP and use +*Sketch -> Include Library -> Add .ZIP Library...* in the Arduino IDE. +## Hardware Setup -## For further details, refer [the documentation on Pulse Express board](https://docs.protocentral.com/getting-started-with-PulseExpress) +| MAX32664 pin | Arduino UNO / R4 | ESP32 (example) | Function | +|--------------|------------------|-----------------|-------------------| +| SDA | A4 | GPIO21 | I2C data | +| SCL | A5 | GPIO22 | I2C clock | +| Vin | 5V | 5V / VIN | Power | +| GND | GND | GND | Ground | +| MFIO | D2 | any GPIO | Data-ready / mode | +| RESET | D4 | any GPIO | Hub reset | +The RESET and MFIO pins are passed to the constructor, so any GPIOs work. + +![](assets/pulse_express_7sec.gif) + +## Quick Start + +```cpp +#include +#include "protocentral_pulse_express.h" + +PulseExpress hub(/*RESET=*/4, /*MFIO=*/2); + +void setup() { + Serial.begin(57600); + Wire.begin(); + if (hub.begin() != PulseExpressStatus::Ok) { /* handle error */ } + hub.startEstimation(); // streams HR + SpO2 (BP needs calibration) +} + +void loop() { + PulseExpressSample s[8]; + size_t n = 0; + if (hub.readSamples(s, 8, &n) == PulseExpressStatus::Ok) + for (size_t i = 0; i < n; ++i) { + Serial.print("HR "); Serial.print(s[i].heartRate(), 1); + Serial.print(" SpO2 "); Serial.println(s[i].spo2(), 1); + } +} +``` + +## Firmware versions & capabilities + +The MAX32664D changed its protocol across the 40.x firmware line. `begin()` reads +the hub firmware version and derives a capability set (`hub.caps()`); the driver +then adapts automatically. Notable breakpoints: + +- **40.2.2+** — BP-medication / rest-mode setup steps dropped. +- **40.5.0+** — calibration vector 824 -> 512 bytes, sample 23 -> 29 bytes, date + format YYMMDD -> YYYYMMDD, and multi-point calibration (`calIndex` 0..4). + +Branch on `hub.caps().multiPointCalib` if you support both. Version checking is a +**soft warning**: `begin()` still proceeds on unexpected firmware and reports it via +`hub.firmwareSupported()`. + +**Firmware is pre-installed at the factory.** Boards ship flashed and ready; you do +not need a firmware image for normal use. The MAX32664D application image (`.msbl`) +is Maxim/ADI IP and is **not** redistributed here. Re-flashing is a factory/recovery +operation — see [`examples/11.FirmwareFlash`](examples/11.FirmwareFlash) and +[`extras/`](extras/). + +## Examples + +| # | Sketch | Shows | +|----|--------|-------| +| 01 | RawPPGStreamPlotter | Raw IR PPG to Arduino Serial Plotter | +| 02 | RawPPGStreamOpenView | Raw IR/Red to ProtoCentral OpenView GUI | +| 03 | HeartRateSpO2 | Live heart rate + SpO2 (no calibration) | +| 04 | BPTCalibration | Run BPT calibration, dump the vector | +| 05 | BPTEstimation | Estimate BP/HR/SpO2 from a saved vector | +| 06 | BPTCalibrateAndEstimate | Full calibrate -> estimate, end to end | +| 07 | SaveLoadCalibrationEEPROM | Persist the vector to EEPROM, reload on boot | +| 08 | MultiSubjectCalibration | Calibrate up to 5 subjects (FW >= 40.5.0) | +| 09 | HeartRateVariability | SDNN / RMSSD from inter-beat intervals | +| 10 | DeviceInfoAndDiagnostics | Firmware versions, caps, live hub status | +| 11 | FirmwareFlash | Factory/recovery .msbl flasher | + +## API Reference + +Construct with the reset and MFIO pins (and optionally a `TwoWire` bus): +`PulseExpress hub(resetPin, mfioPin, Wire);` + +- `begin()` — reset hub, enter application mode, read version, derive caps. +- `version()`, `algoVersion()`, `caps()`, `firmwareSupported()` — device info. +- BPT calibration: `startCalibration(...)`, `readSample(...)`, `readCalibrationVector(...)`. +- BPT estimation: `loadCalibrationVector(...)`, `startEstimation(...)`, `readSamples(...)`. +- Raw PPG: `startRaw()`, `readRaw(...)`. +- Diagnostics / teardown: `readStatus(...)`, `stop()`. + +All fallible calls return `PulseExpressStatus`; compare against +`PulseExpressStatus::Ok`. See [`src/protocentral_pulse_express.h`](src/protocentral_pulse_express.h) +for the full documented API. + +> **Migrating from 2.0.x:** the class was `Max32664` with `Max32664*` types. Those +> names still work via `#include "max32664.h"`. New code should use `PulseExpress` +> and `#include "protocentral_pulse_express.h"`. See [CHANGELOG.md](CHANGELOG.md). + +## For further details + +Refer to [the Pulse Express documentation](https://docs.protocentral.com/getting-started-with-PulseExpress). License Information =================== ![License](license_mark.svg) -This product is open source! Both, our hardware and software are open source and licensed under the following licenses: - -Hardware ---------- - -**All hardware is released under the [CERN-OHL-P v2](https://ohwr.org/cern_ohl_p_v2.txt)** license. - -Copyright CERN 2020. - -This source describes Open Hardware and is licensed under the CERN-OHL-P v2. - -You may redistribute and modify this documentation and make products -using it under the terms of the CERN-OHL-P v2 (https:/cern.ch/cern-ohl). -This documentation is distributed WITHOUT ANY EXPRESS OR IMPLIED -WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY -AND FITNESS FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-P v2 -for applicable conditions - -Software --------- - -**All software is released under the MIT License(http://opensource.org/licenses/MIT).** - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Documentation -------------- -**All documentation is released under [Creative Commons Share-alike 4.0 International](http://creativecommons.org/licenses/by-sa/4.0/).** -![CC-BY-SA-4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png) - -You are free to: +This product is open source! Both our hardware and software are open source and +licensed under the following licenses: -* Share — copy and redistribute the material in any medium or format -* Adapt — remix, transform, and build upon the material for any purpose, even commercially. -The licensor cannot revoke these freedoms as long as you follow the license terms. +**Hardware** — [CERN-OHL-P v2](https://ohwr.org/cern_ohl_p_v2.txt). Copyright CERN 2020. -Under the following terms: +**Software** — [MIT License](http://opensource.org/licenses/MIT). -* Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. -* ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original. +**Documentation** — [Creative Commons Share-alike 4.0 International](http://creativecommons.org/licenses/by-sa/4.0/). -Please check [*LICENSE.md*](LICENSE.md) for detailed license descriptions. +See [*LICENSE.md*](LICENSE.md) for the detailed license descriptions. diff --git a/examples/01.RawPPGStreamPlotter/01.RawPPGStreamPlotter.ino b/examples/01.RawPPGStreamPlotter/01.RawPPGStreamPlotter.ino index 849a90b..03f450c 100644 --- a/examples/01.RawPPGStreamPlotter/01.RawPPGStreamPlotter.ino +++ b/examples/01.RawPPGStreamPlotter/01.RawPPGStreamPlotter.ino @@ -1,36 +1,33 @@ -////////////////////////////////////////////////////////////////////////////////////////// +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics // -// Pulse Express — Raw PPG stream for the Arduino Serial Plotter +// ProtoCentral Pulse Express (MAX30102 + MAX32664D) +// Example 01 — Raw PPG stream for the Arduino Serial Plotter // -// Streams the IR PPG counter from the MAX30101 (via the MAX32664D sensor hub) -// one sample per line. Place a finger on the sensor and open Tools → Serial -// Plotter at 57600 baud to view the waveform. +// Streams the IR PPG counter from the MAX30102 (via the MAX32664D sensor hub) +// one sample per line. Place a finger on the sensor and open Tools -> Serial +// Plotter at 57600 baud to view the waveform. // -// Hardware connections (default): -// | MAX32664 pin | Arduino pin | Function | -// |--------------|-------------|----------------| -// | SDA | A4 | I2C data | -// | SCL | A5 | I2C clock | -// | Vin | 5V | Power | -// | GND | GND | | -// | MFIO | D2 | Data-ready int | -// | RESET | D4 | Hub reset | +// Original 2020 example: Joice Tm, Copyright (c) 2020 ProtoCentral. // -// Original 2020 example: Joice Tm, Copyright (c) 2020 ProtoCentral -// Modernised: Copyright (c) 2025 ProtoCentral Electronics -// -// This software is licensed under the MIT License (http://opensource.org/licenses/MIT). -// -///////////////////////////////////////////////////////////////////////////////////////// +// Hardware connections (default): +// | MAX32664 pin | Arduino pin | Function | +// |--------------|-------------|----------------| +// | SDA | A4 | I2C data | +// | SCL | A5 | I2C clock | +// | Vin | 5V | Power | +// | GND | GND | | +// | MFIO | D2 | Data-ready int | +// | RESET | D4 | Hub reset | #include -#include "max32664.h" +#include "protocentral_pulse_express.h" #define RESET_PIN 4 #define MFIO_PIN 2 #define SAMPLE_CAP 32 // max samples to drain per loop iteration -Max32664 hub(RESET_PIN, MFIO_PIN); +PulseExpress hub(RESET_PIN, MFIO_PIN); void setup() { @@ -39,8 +36,8 @@ void setup() hub.setDebug(&Serial); - Max32664Status s = hub.begin(); - if (s != Max32664Status::Ok) + PulseExpressStatus s = hub.begin(); + if (s != PulseExpressStatus::Ok) { Serial.print("hub.begin() failed: 0x"); Serial.println(uint8_t(s), HEX); @@ -52,7 +49,7 @@ void setup() Serial.println(hub.version().patch); s = hub.startRaw(); - if (s != Max32664Status::Ok) + if (s != PulseExpressStatus::Ok) { Serial.print("startRaw() failed: 0x"); Serial.println(uint8_t(s), HEX); @@ -63,10 +60,10 @@ void setup() void loop() { - Max32664RawSample buf[SAMPLE_CAP]; + PulseExpressRawSample buf[SAMPLE_CAP]; size_t n = 0; - Max32664Status s = hub.readRaw(buf, SAMPLE_CAP, &n, /*wantRed=*/false); - if (s != Max32664Status::Ok) return; + PulseExpressStatus s = hub.readRaw(buf, SAMPLE_CAP, &n, /*wantRed=*/false); + if (s != PulseExpressStatus::Ok) return; for (size_t i = 0; i < n; ++i) { diff --git a/examples/02.BPTEstimation/02.BPTEstimation.ino b/examples/02.BPTEstimation/02.BPTEstimation.ino deleted file mode 100644 index 5d31e08..0000000 --- a/examples/02.BPTEstimation/02.BPTEstimation.ino +++ /dev/null @@ -1,195 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////////////// -// -// Pulse Express — Blood Pressure Trending (BPT) calibration + estimation -// -// Walks the full flow described in UG6921: -// 1. begin() -> reset hub, read firmware version -// 2. startCalibration() -> upload reference cuff readings -// 3. readSample() polled for ~1 min while finger is on the sensor -// 4. readCalibrationVector() -> persist the user-specific vector -// 5. loadCalibrationVector() -> reload it into the hub -// 6. startEstimation() -> begin live BPT/SpO2/HR readings -// 7. readSamples() in a loop -> stream results over Serial -// -// For firmware >= 40.5.0 the hub supports a multi-point calibration with up -// to five subjects (calIndex 0..4). For brevity this example calibrates a -// single subject at calIndex 0 only; production code would loop 0..4 with a -// reference cuff measurement per subject and persist each vector in -// non-volatile storage. The driver reports the firmware-derived behaviour -// via hub.caps() — branch on caps().multiPointCalib if you support both. -// -// WARNING: the SpO2 and reference BP values below are placeholders. For -// meaningful readings you must: -// - calibrate the SpO2 polynomial (a, b, c) against a reference oximeter -// per Maxim AN6845. -// - measure each subject's actual systolic / diastolic BP with a clinically -// validated cuff at the moment of calibration. -// -// Hardware connections (default): -// | MAX32664 pin | Arduino pin | Function | -// |--------------|-------------|----------------| -// | SDA | A4 | I2C data | -// | SCL | A5 | I2C clock | -// | Vin | 5V | Power | -// | GND | GND | | -// | MFIO | D2 | Data-ready int | -// | RESET | D4 | Hub reset | -// -// Original 2020 example: Joice Tm, Copyright (c) 2020 ProtoCentral -// Modernised: Copyright (c) 2025 ProtoCentral Electronics -// -// This software is licensed under the MIT License (http://opensource.org/licenses/MIT). -// -///////////////////////////////////////////////////////////////////////////////////////// - -#include -#include "max32664.h" - -#define RESET_PIN 4 -#define MFIO_PIN 2 - -// Reference cuff measurements taken with a clinically validated device at the -// moment of calibration. REPLACE WITH YOUR OWN VALUES. -#define REF_SYSTOLIC 120 -#define REF_DIASTOLIC 80 - -// SpO2 calibration polynomial. REPLACE WITH YOUR OWN VALUES (Maxim AN6845). -#define SPO2_COEFF_A 1.5958422f -#define SPO2_COEFF_B (-34.659664f) -#define SPO2_COEFF_C 112.68987f - -// Calibration vector storage. The driver reports the size at runtime: 512 -// bytes on firmware >= 40.5.0, 824 bytes on legacy. We allocate the larger -// of the two so the same sketch fits both. On AVR boards with 2 KB SRAM this -// uses ~40% of available memory. -#define CALIB_VECTOR_MAX 824 -static uint8_t calibVector[CALIB_VECTOR_MAX]; - -Max32664 hub(RESET_PIN, MFIO_PIN); - -static void haltWithError(const char *step, Max32664Status s) -{ - while (true) - { - Serial.print(step); - Serial.print(" failed: 0x"); - Serial.println(uint8_t(s), HEX); - delay(5000); - } -} - -static Max32664Status runCalibration() -{ - Max32664Status s; - if (hub.caps().multiPointCalib) - { - Max32664CalibrationRef ref; - ref.calIndex = 0; - ref.systolic = REF_SYSTOLIC; - ref.diastolic = REF_DIASTOLIC; - s = hub.startCalibration(ref); - } - else - { - Max32664LegacyCalibrationRefs refs; - refs.systolic[0] = REF_SYSTOLIC; - refs.systolic[1] = REF_SYSTOLIC + 2; - refs.systolic[2] = REF_SYSTOLIC + 5; - refs.diastolic[0] = REF_DIASTOLIC; - refs.diastolic[1] = REF_DIASTOLIC + 1; - refs.diastolic[2] = REF_DIASTOLIC + 2; - s = hub.startCalibration(refs); - } - if (s != Max32664Status::Ok) return s; - - Serial.println("Place your finger on the sensor — hold still until 100%."); - - Max32664Sample sample; - unsigned long startMs = millis(); - while (true) - { - Max32664Status r = hub.readSample(sample); - if (r == Max32664Status::Ok) - { - Serial.print("progress: "); - Serial.print(sample.progress); - Serial.print("% status: "); - Serial.println(uint8_t(sample.bpStatus)); - - if (sample.bpStatus == Max32664BpStatus::Success && sample.progress >= 100) break; - if (sample.bpStatus == Max32664BpStatus::EstimationFailure || - sample.bpStatus == Max32664BpStatus::SubjectInitFailure || - sample.bpStatus == Max32664BpStatus::TooManyCalibrations) - { - return Max32664Status::UnknownHubError; - } - } - if (millis() - startMs > 120000UL) return Max32664Status::Timeout; - delay(40); - } - return Max32664Status::Ok; -} - -void setup() -{ - Serial.begin(57600); - while (!Serial && millis() < 3000) {} - Wire.begin(); - - Max32664Status s = hub.begin(); - if (s != Max32664Status::Ok) haltWithError("hub.begin()", s); - - Serial.print("Hub firmware "); - Serial.print(hub.version().major); Serial.print('.'); - Serial.print(hub.version().minor); Serial.print('.'); - Serial.println(hub.version().patch); - Serial.print("Calibration vector size: "); - Serial.println(hub.caps().calibVectorBytes); - - s = runCalibration(); - if (s != Max32664Status::Ok) haltWithError("calibration", s); - Serial.println("Calibration complete; reading vector."); - - size_t calibLen = 0; - s = hub.readCalibrationVector(calibVector, sizeof(calibVector), &calibLen); - if (s != Max32664Status::Ok) haltWithError("readCalibrationVector", s); - Serial.print("Stored "); Serial.print(calibLen); Serial.println(" calibration bytes."); - - s = hub.stop(); // tear down calibration mode before re-enabling for estimation - if (s != Max32664Status::Ok) haltWithError("hub.stop()", s); - - if (hub.caps().multiPointCalib) - s = hub.loadCalibrationVector(/*calIndex=*/0, calibVector, calibLen); - else - s = hub.loadCalibrationVector(calibVector, calibLen); - if (s != Max32664Status::Ok) haltWithError("loadCalibrationVector", s); - - Max32664Spo2Coeffs coeffs; - coeffs.a = SPO2_COEFF_A; - coeffs.b = SPO2_COEFF_B; - coeffs.c = SPO2_COEFF_C; - s = hub.startEstimation(coeffs); - if (s != Max32664Status::Ok) haltWithError("startEstimation", s); - - Serial.println("Estimation running."); - delay(1000); -} - -void loop() -{ - Max32664Sample samples[8]; - size_t n = 0; - Max32664Status s = hub.readSamples(samples, 8, &n); - if (s != Max32664Status::Ok) return; - - for (size_t i = 0; i < n; ++i) - { - Serial.print("sys="); Serial.print(samples[i].systolic); - Serial.print(" dia="); Serial.print(samples[i].diastolic); - Serial.print(" hr="); Serial.print(samples[i].heartRate(), 1); - Serial.print(" spo2="); Serial.print(samples[i].spo2(), 1); - Serial.print(" status="); - Serial.println(uint8_t(samples[i].bpStatus)); - } - delay(100); -} diff --git a/examples/02.RawPPGStreamOpenView/02.RawPPGStreamOpenView.ino b/examples/02.RawPPGStreamOpenView/02.RawPPGStreamOpenView.ino new file mode 100644 index 0000000..6367bc4 --- /dev/null +++ b/examples/02.RawPPGStreamOpenView/02.RawPPGStreamOpenView.ino @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// +// ProtoCentral Pulse Express (MAX30102 + MAX32664D) +// Example 02 — Raw PPG stream for the ProtoCentral OpenView desktop app +// +// Streams IR + Red PPG counters in the OpenView packet framing protocol over +// the USB serial link. +// OpenView GUI: https://github.com/Protocentral/protocentral_openview +// +// Original 2020 example: Joice Tm, Copyright (c) 2020 ProtoCentral. +// +// Hardware connections (default): +// | MAX32664 pin | Arduino pin | Function | +// |--------------|-------------|----------------| +// | SDA | A4 | I2C data | +// | SCL | A5 | I2C clock | +// | Vin | 5V | Power | +// | GND | GND | | +// | MFIO | D2 | Data-ready int | +// | RESET | D4 | Hub reset | + +#include +#include "protocentral_pulse_express.h" + +#define RESET_PIN 4 +#define MFIO_PIN 2 +#define SAMPLE_CAP 32 + +// OpenView packet framing +#define CES_CMDIF_PKT_START_1 0x0A +#define CES_CMDIF_PKT_START_2 0xFA +#define CES_CMDIF_TYPE_DATA 0x02 +#define CES_CMDIF_PKT_STOP 0x0B +#define DATA_LEN 9 + +const uint8_t DataPacketHeader[5] = {CES_CMDIF_PKT_START_1, CES_CMDIF_PKT_START_2, + DATA_LEN, 0, CES_CMDIF_TYPE_DATA}; +const uint8_t DataPacketFooter[2] = {0, CES_CMDIF_PKT_STOP}; + +PulseExpress hub(RESET_PIN, MFIO_PIN); + +// Scale a raw counter down to a signed 16-bit range without wrapping. +static int16_t scaleToInt16(uint32_t v) +{ + uint32_t s = v / 10; + if (s > 32767u) s = 32767u; + return int16_t(s); +} + +static void sendDataThroughUart(int16_t ir, int16_t red) +{ + uint8_t payload[DATA_LEN] = {0}; + payload[0] = uint8_t(ir & 0xFF); + payload[1] = uint8_t((ir >> 8) & 0xFF); + payload[2] = uint8_t(red & 0xFF); + payload[3] = uint8_t((red >> 8) & 0xFF); + + for (int i = 0; i < 5; ++i) Serial.write(DataPacketHeader[i]); + for (int i = 0; i < DATA_LEN; ++i) Serial.write(payload[i]); + for (int i = 0; i < 2; ++i) Serial.write(DataPacketFooter[i]); +} + +void setup() +{ + Serial.begin(57600); + Wire.begin(); + + if (hub.begin() != PulseExpressStatus::Ok) + { + while (true) { Serial.println("hub.begin() failed"); delay(5000); } + } + if (hub.startRaw() != PulseExpressStatus::Ok) + { + while (true) { Serial.println("startRaw() failed"); delay(5000); } + } + delay(2000); +} + +void loop() +{ + PulseExpressRawSample buf[SAMPLE_CAP]; + size_t n = 0; + if (hub.readRaw(buf, SAMPLE_CAP, &n, /*wantRed=*/true) != PulseExpressStatus::Ok) return; + + for (size_t i = 0; i < n; ++i) + { + // Non-blocking send: only emit a packet if the USB-CDC TX buffer has + // room for a whole one. If the host (OpenView) momentarily stops + // reading, Serial.write would otherwise block here and let the sensor + // FIFO back up and overflow — losing a burst of samples and desyncing. + // Dropping the odd packet during a host stall keeps the read loop fast + // and the stream in sync, so it recovers cleanly when the host resumes. + if (Serial.availableForWrite() < (5 + DATA_LEN + 2)) break; + + // OpenView expects 16-bit signed values; the hub reports up-to-24-bit + // counters. Scale down and CLAMP — without a finger the sensor can rail + // at a high count whose unclamped cast would wrap and look like a square + // wave riding on the baseline. + sendDataThroughUart(scaleToInt16(buf[i].ir), scaleToInt16(buf[i].red)); + } +} diff --git a/examples/03.HeartRateSpO2/03.HeartRateSpO2.ino b/examples/03.HeartRateSpO2/03.HeartRateSpO2.ino new file mode 100644 index 0000000..b4f6da8 --- /dev/null +++ b/examples/03.HeartRateSpO2/03.HeartRateSpO2.ino @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// +// ProtoCentral Pulse Express (MAX30102 + MAX32664D) +// Example 03 — Live heart rate + SpO2 (no blood-pressure calibration needed) +// +// The simplest "vitals" demo. The MAX32664D algorithm always reports heart +// rate, SpO2 and a per-reading confidence alongside the BPT block, so we can +// stream HR/SpO2 without ever running a BP calibration. Blood-pressure fields +// are NOT valid until you calibrate — see examples 04/05/06 for that. +// +// Place a finger on the sensor and open the Serial Monitor at 57600 baud. +// R&D use only — not a medical device. +// +// Hardware connections (default): +// | MAX32664 pin | Arduino pin | Function | +// |--------------|-------------|----------------| +// | SDA | A4 | I2C data | +// | SCL | A5 | I2C clock | +// | Vin | 5V | Power | +// | GND | GND | | +// | MFIO | D2 | Data-ready int | +// | RESET | D4 | Hub reset | + +#include +#include "protocentral_pulse_express.h" + +#define RESET_PIN 4 +#define MFIO_PIN 2 + +PulseExpress hub(RESET_PIN, MFIO_PIN); + +static void halt(const char *step, PulseExpressStatus s) +{ + while (true) + { + Serial.print(step); + Serial.print(" failed: 0x"); + Serial.println(uint8_t(s), HEX); + delay(5000); + } +} + +void setup() +{ + Serial.begin(57600); + while (!Serial && millis() < 3000) {} + Wire.begin(); + + PulseExpressStatus s = hub.begin(); + if (s != PulseExpressStatus::Ok) halt("hub.begin()", s); + + Serial.print("Hub firmware "); + Serial.print(hub.version().major); Serial.print('.'); + Serial.print(hub.version().minor); Serial.print('.'); + Serial.println(hub.version().patch); + if (!hub.firmwareSupported()) + Serial.println("WARNING: firmware outside validated 40.x line."); + + // Estimation mode streams HR/SpO2 (and BP, once calibrated). We use the + // default SpO2 coefficients here; calibrate them per AN6845 for accuracy. + s = hub.startEstimation(); + if (s != PulseExpressStatus::Ok) halt("startEstimation", s); + + Serial.println("Place finger on the sensor..."); + delay(1000); +} + +void loop() +{ + PulseExpressSample samples[8]; + size_t n = 0; + if (hub.readSamples(samples, 8, &n) != PulseExpressStatus::Ok) return; + + for (size_t i = 0; i < n; ++i) + { + const PulseExpressSample &s = samples[i]; + Serial.print("HR: "); Serial.print(s.heartRate(), 1); Serial.print(" bpm"); + Serial.print(" SpO2: "); Serial.print(s.spo2(), 1); Serial.print(" %"); + Serial.print(" conf: "); Serial.print(s.spo2Confidence); Serial.print(" %"); + Serial.print(" status: "); Serial.println(uint8_t(s.bpStatus)); + } + delay(100); +} diff --git a/examples/03.RawPPGStreamOpenView/03.RawPPGStreamOpenView.ino b/examples/03.RawPPGStreamOpenView/03.RawPPGStreamOpenView.ino deleted file mode 100644 index 2175a85..0000000 --- a/examples/03.RawPPGStreamOpenView/03.RawPPGStreamOpenView.ino +++ /dev/null @@ -1,89 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////////////// -// -// Pulse Express — Raw PPG stream for the ProtoCentral OpenView desktop app -// -// Streams IR + Red PPG counters in the OpenView packet framing protocol over -// the USB serial link. -// OpenView GUI: https://github.com/Protocentral/protocentral_openview -// -// Hardware connections (default): -// | MAX32664 pin | Arduino pin | Function | -// |--------------|-------------|----------------| -// | SDA | A4 | I2C data | -// | SCL | A5 | I2C clock | -// | Vin | 5V | Power | -// | GND | GND | | -// | MFIO | D2 | Data-ready int | -// | RESET | D4 | Hub reset | -// -// Original 2020 example: Joice Tm, Copyright (c) 2020 ProtoCentral -// Modernised: Copyright (c) 2025 ProtoCentral Electronics -// -// This software is licensed under the MIT License (http://opensource.org/licenses/MIT). -// -///////////////////////////////////////////////////////////////////////////////////////// - -#include -#include "max32664.h" - -#define RESET_PIN 4 -#define MFIO_PIN 2 -#define SAMPLE_CAP 32 - -// OpenView packet framing -#define CES_CMDIF_PKT_START_1 0x0A -#define CES_CMDIF_PKT_START_2 0xFA -#define CES_CMDIF_TYPE_DATA 0x02 -#define CES_CMDIF_PKT_STOP 0x0B -#define DATA_LEN 9 - -const uint8_t DataPacketHeader[5] = {CES_CMDIF_PKT_START_1, CES_CMDIF_PKT_START_2, - DATA_LEN, 0, CES_CMDIF_TYPE_DATA}; -const uint8_t DataPacketFooter[2] = {0, CES_CMDIF_PKT_STOP}; - -Max32664 hub(RESET_PIN, MFIO_PIN); - -static void sendDataThroughUart(int16_t ir, int16_t red) -{ - uint8_t payload[DATA_LEN] = {0}; - payload[0] = uint8_t(ir & 0xFF); - payload[1] = uint8_t((ir >> 8) & 0xFF); - payload[2] = uint8_t(red & 0xFF); - payload[3] = uint8_t((red >> 8) & 0xFF); - - for (int i = 0; i < 5; ++i) Serial.write(DataPacketHeader[i]); - for (int i = 0; i < DATA_LEN; ++i) Serial.write(payload[i]); - for (int i = 0; i < 2; ++i) Serial.write(DataPacketFooter[i]); -} - -void setup() -{ - Serial.begin(57600); - Wire.begin(); - - if (hub.begin() != Max32664Status::Ok) - { - while (true) { Serial.println("hub.begin() failed"); delay(5000); } - } - if (hub.startRaw() != Max32664Status::Ok) - { - while (true) { Serial.println("startRaw() failed"); delay(5000); } - } - delay(2000); -} - -void loop() -{ - Max32664RawSample buf[SAMPLE_CAP]; - size_t n = 0; - if (hub.readRaw(buf, SAMPLE_CAP, &n, /*wantRed=*/true) != Max32664Status::Ok) return; - - for (size_t i = 0; i < n; ++i) - { - // OpenView expects 16-bit signed values; the hub reports 24-bit - // counters that we scale down to fit the legacy packet format. - int16_t ir = int16_t(buf[i].ir / 10); - int16_t red = int16_t(buf[i].red / 10); - sendDataThroughUart(ir, red); - } -} diff --git a/examples/04.BPTCalibration/04.BPTCalibration.ino b/examples/04.BPTCalibration/04.BPTCalibration.ino new file mode 100644 index 0000000..1ae0398 --- /dev/null +++ b/examples/04.BPTCalibration/04.BPTCalibration.ino @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// +// ProtoCentral Pulse Express (MAX30102 + MAX32664D) +// Example 04 — BPT calibration only (produce a user calibration vector) +// +// Runs a single BPT calibration against a reference cuff reading, then prints +// the resulting user calibration vector as hex over Serial. Copy that vector +// into example 05 (BPTEstimation) to estimate blood pressure later WITHOUT +// recalibrating — or see example 07 to persist it to EEPROM automatically. +// +// WARNING: REF_SYSTOLIC / REF_DIASTOLIC are placeholders. Measure the subject's +// real BP with a clinically validated cuff at the moment of calibration, or the +// vector is meaningless. R&D use only — not a medical device. +// +// Hardware connections (default): +// | MAX32664 pin | Arduino pin | Function | +// |--------------|-------------|----------------| +// | SDA | A4 | I2C data | +// | SCL | A5 | I2C clock | +// | Vin | 5V | Power | +// | GND | GND | | +// | MFIO | D2 | Data-ready int | +// | RESET | D4 | Hub reset | + +#include +#include "protocentral_pulse_express.h" + +#define RESET_PIN 4 +#define MFIO_PIN 2 + +// Reference cuff measurement — REPLACE WITH YOUR OWN VALUES. +#define REF_SYSTOLIC 120 +#define REF_DIASTOLIC 80 +#define CAL_INDEX 0 // multi-point firmware: subject slot 0..4 + +// Allocate the larger of the two possible vector sizes (824 legacy / 512 new). +#define CALIB_VECTOR_MAX 824 +static uint8_t calibVector[CALIB_VECTOR_MAX]; + +PulseExpress hub(RESET_PIN, MFIO_PIN); + +static void halt(const char *step, PulseExpressStatus s) +{ + while (true) + { + Serial.print(step); Serial.print(" failed: 0x"); + Serial.println(uint8_t(s), HEX); + delay(5000); + } +} + +static PulseExpressStatus startCalibration() +{ + if (hub.caps().multiPointCalib) + { + PulseExpressCalibrationRef ref; + ref.calIndex = CAL_INDEX; + ref.systolic = REF_SYSTOLIC; + ref.diastolic = REF_DIASTOLIC; + return hub.startCalibration(ref); + } + PulseExpressLegacyCalibrationRefs refs; + refs.systolic[0] = REF_SYSTOLIC; refs.diastolic[0] = REF_DIASTOLIC; + refs.systolic[1] = REF_SYSTOLIC + 2; refs.diastolic[1] = REF_DIASTOLIC + 1; + refs.systolic[2] = REF_SYSTOLIC + 5; refs.diastolic[2] = REF_DIASTOLIC + 2; + return hub.startCalibration(refs); +} + +static void dumpHex(const uint8_t *buf, size_t len) +{ + Serial.println("---- BEGIN CALIBRATION VECTOR ----"); + for (size_t i = 0; i < len; ++i) + { + if (buf[i] < 0x10) Serial.print('0'); + Serial.print(buf[i], HEX); + if ((i & 0x1F) == 0x1F) Serial.println(); + else Serial.print(' '); + } + Serial.println(); + Serial.println("---- END CALIBRATION VECTOR ----"); +} + +void setup() +{ + Serial.begin(57600); + while (!Serial && millis() < 3000) {} + Wire.begin(); + + PulseExpressStatus s = hub.begin(); + if (s != PulseExpressStatus::Ok) halt("hub.begin()", s); + if (!hub.firmwareSupported()) + Serial.println("WARNING: firmware outside validated 40.x line."); + + s = startCalibration(); + if (s != PulseExpressStatus::Ok) halt("startCalibration", s); + + Serial.println("Place your finger on the sensor — hold still until 100%."); + PulseExpressSample sample; + unsigned long startMs = millis(); + while (true) + { + if (hub.readSample(sample) == PulseExpressStatus::Ok) + { + Serial.print("progress: "); Serial.print(sample.progress); + Serial.print("% status: "); Serial.println(uint8_t(sample.bpStatus)); + if (sample.bpStatus == PulseExpressBpStatus::Success && sample.progress >= 100) + break; + } + if (millis() - startMs > 120000UL) halt("calibration timeout", PulseExpressStatus::Timeout); + delay(40); + } + + size_t len = 0; + s = hub.readCalibrationVector(calibVector, sizeof(calibVector), &len); + if (s != PulseExpressStatus::Ok) halt("readCalibrationVector", s); + + Serial.print("Calibration complete. Vector length: "); + Serial.println(len); + dumpHex(calibVector, len); + + hub.stop(); + Serial.println("Done. Save the vector above for use with example 05."); +} + +void loop() +{ + delay(1000); +} diff --git a/examples/05.BPTEstimation/05.BPTEstimation.ino b/examples/05.BPTEstimation/05.BPTEstimation.ino new file mode 100644 index 0000000..c54a46f --- /dev/null +++ b/examples/05.BPTEstimation/05.BPTEstimation.ino @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// +// ProtoCentral Pulse Express (MAX30102 + MAX32664D) +// Example 05 — BPT estimation from a previously-saved calibration vector +// +// Loads a user calibration vector (produced by example 04) into the hub and +// streams live blood-pressure / heart-rate / SpO2 estimates — no recalibration. +// Paste the hex bytes printed by example 04 into calibVector[] below. Its +// length must match hub.caps().calibVectorBytes (512 on firmware >= 40.5.0, +// 824 on legacy); the sketch checks this and tells you if it does not. +// +// R&D use only — not a medical device. +// +// Hardware connections (default): +// | MAX32664 pin | Arduino pin | Function | +// |--------------|-------------|----------------| +// | SDA | A4 | I2C data | +// | SCL | A5 | I2C clock | +// | Vin | 5V | Power | +// | GND | GND | | +// | MFIO | D2 | Data-ready int | +// | RESET | D4 | Hub reset | + +#include +#include "protocentral_pulse_express.h" + +#define RESET_PIN 4 +#define MFIO_PIN 2 +#define CAL_INDEX 0 // multi-point firmware: subject slot this vector belongs to + +// >>> PASTE the calibration vector from example 04 here (whitespace-separated +// hex bytes are fine after reformatting to 0x.. literals). The placeholder +// below is intentionally too short so the sketch halts until you replace it. +static const uint8_t calibVector[] = { + 0x00, 0x00, 0x00, 0x00 // <-- REPLACE with the full vector from example 04 +}; + +PulseExpress hub(RESET_PIN, MFIO_PIN); + +static void halt(const char *msg, PulseExpressStatus s) +{ + while (true) + { + Serial.print(msg); Serial.print(" (0x"); Serial.print(uint8_t(s), HEX); + Serial.println(')'); + delay(5000); + } +} + +void setup() +{ + Serial.begin(57600); + while (!Serial && millis() < 3000) {} + Wire.begin(); + + PulseExpressStatus s = hub.begin(); + if (s != PulseExpressStatus::Ok) halt("hub.begin()", s); + if (!hub.firmwareSupported()) + Serial.println("WARNING: firmware outside validated 40.x line."); + + if (sizeof(calibVector) != hub.caps().calibVectorBytes) + { + Serial.print("Calibration vector is "); + Serial.print(sizeof(calibVector)); + Serial.print(" bytes but this firmware expects "); + Serial.println(hub.caps().calibVectorBytes); + halt("Paste a full vector from example 04", PulseExpressStatus::InvalidArgument); + } + + if (hub.caps().multiPointCalib) + s = hub.loadCalibrationVector(CAL_INDEX, calibVector, sizeof(calibVector)); + else + s = hub.loadCalibrationVector(calibVector, sizeof(calibVector)); + if (s != PulseExpressStatus::Ok) halt("loadCalibrationVector", s); + + s = hub.startEstimation(); + if (s != PulseExpressStatus::Ok) halt("startEstimation", s); + + Serial.println("Estimation running. Place finger on the sensor."); + delay(1000); +} + +void loop() +{ + PulseExpressSample samples[8]; + size_t n = 0; + if (hub.readSamples(samples, 8, &n) != PulseExpressStatus::Ok) return; + + for (size_t i = 0; i < n; ++i) + { + const PulseExpressSample &s = samples[i]; + Serial.print("sys="); Serial.print(s.systolic); + Serial.print(" dia="); Serial.print(s.diastolic); + Serial.print(" hr="); Serial.print(s.heartRate(), 1); + Serial.print(" spo2="); Serial.print(s.spo2(), 1); + Serial.print(" status="); Serial.println(uint8_t(s.bpStatus)); + } + delay(100); +} diff --git a/examples/06.BPTCalibrateAndEstimate/06.BPTCalibrateAndEstimate.ino b/examples/06.BPTCalibrateAndEstimate/06.BPTCalibrateAndEstimate.ino new file mode 100644 index 0000000..541a831 --- /dev/null +++ b/examples/06.BPTCalibrateAndEstimate/06.BPTCalibrateAndEstimate.ino @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// +// ProtoCentral Pulse Express (MAX30102 + MAX32664D) +// Example 06 — Blood Pressure Trending (BPT): calibrate AND estimate, end-to-end +// +// Walks the full flow described in UG6921 in a single sketch: +// 1. begin() -> reset hub, read firmware version +// 2. startCalibration() -> upload reference cuff readings +// 3. readSample() polled until calibration progress reaches 100% +// 4. readCalibrationVector() -> obtain the user-specific vector +// 5. loadCalibrationVector() -> reload it into the hub +// 6. startEstimation() -> begin live BPT/SpO2/HR readings +// 7. readSamples() in a loop -> stream results over Serial +// +// See examples 04 and 05 to split calibration and estimation into separate +// sketches with the vector persisted in between (07 shows EEPROM persistence). +// +// WARNING: the SpO2 and reference BP values below are PLACEHOLDERS. For +// meaningful readings calibrate the SpO2 polynomial (a, b, c) per Maxim AN6845 +// and measure each subject's real systolic/diastolic BP with a validated cuff +// at the moment of calibration. R&D use only — not a medical device. +// +// Hardware connections (default): +// | MAX32664 pin | Arduino pin | Function | +// |--------------|-------------|----------------| +// | SDA | A4 | I2C data | +// | SCL | A5 | I2C clock | +// | Vin | 5V | Power | +// | GND | GND | | +// | MFIO | D2 | Data-ready int | +// | RESET | D4 | Hub reset | +// +// Original 2020 example: Joice Tm, Copyright (c) 2020 ProtoCentral. + +#include +#include "protocentral_pulse_express.h" + +#define RESET_PIN 4 +#define MFIO_PIN 2 + +// Reference cuff measurements taken with a clinically validated device at the +// moment of calibration. REPLACE WITH YOUR OWN VALUES. +#define REF_SYSTOLIC 120 +#define REF_DIASTOLIC 80 + +// SpO2 calibration polynomial. REPLACE WITH YOUR OWN VALUES (Maxim AN6845). +#define SPO2_COEFF_A 1.5958422f +#define SPO2_COEFF_B (-34.659664f) +#define SPO2_COEFF_C 112.68987f + +// Calibration vector storage. The driver reports the size at runtime: 512 +// bytes on firmware >= 40.5.0, 824 bytes on legacy. We allocate the larger +// of the two so the same sketch fits both. On AVR boards with 2 KB SRAM this +// uses ~40% of available memory. +#define CALIB_VECTOR_MAX 824 +static uint8_t calibVector[CALIB_VECTOR_MAX]; + +PulseExpress hub(RESET_PIN, MFIO_PIN); + +static void haltWithError(const char *step, PulseExpressStatus s) +{ + while (true) + { + Serial.print(step); + Serial.print(" failed: 0x"); + Serial.println(uint8_t(s), HEX); + delay(5000); + } +} + +static PulseExpressStatus runCalibration() +{ + PulseExpressStatus s; + if (hub.caps().multiPointCalib) + { + PulseExpressCalibrationRef ref; + ref.calIndex = 0; + ref.systolic = REF_SYSTOLIC; + ref.diastolic = REF_DIASTOLIC; + s = hub.startCalibration(ref); + } + else + { + PulseExpressLegacyCalibrationRefs refs; + refs.systolic[0] = REF_SYSTOLIC; + refs.systolic[1] = REF_SYSTOLIC + 2; + refs.systolic[2] = REF_SYSTOLIC + 5; + refs.diastolic[0] = REF_DIASTOLIC; + refs.diastolic[1] = REF_DIASTOLIC + 1; + refs.diastolic[2] = REF_DIASTOLIC + 2; + s = hub.startCalibration(refs); + } + if (s != PulseExpressStatus::Ok) return s; + + Serial.println("Place your finger on the sensor — hold still until 100%."); + + PulseExpressSample sample; + unsigned long startMs = millis(); + while (true) + { + PulseExpressStatus r = hub.readSample(sample); + if (r == PulseExpressStatus::Ok) + { + Serial.print("progress: "); + Serial.print(sample.progress); + Serial.print("% status: "); + Serial.println(uint8_t(sample.bpStatus)); + + if (sample.bpStatus == PulseExpressBpStatus::Success && sample.progress >= 100) break; + if (sample.bpStatus == PulseExpressBpStatus::EstimationFailure || + sample.bpStatus == PulseExpressBpStatus::SubjectInitFailure || + sample.bpStatus == PulseExpressBpStatus::TooManyCalibrations) + { + return PulseExpressStatus::UnknownHubError; + } + } + if (millis() - startMs > 120000UL) return PulseExpressStatus::Timeout; + delay(40); + } + return PulseExpressStatus::Ok; +} + +void setup() +{ + Serial.begin(57600); + while (!Serial && millis() < 3000) {} + Wire.begin(); + + PulseExpressStatus s = hub.begin(); + if (s != PulseExpressStatus::Ok) haltWithError("hub.begin()", s); + + Serial.print("Hub firmware "); + Serial.print(hub.version().major); Serial.print('.'); + Serial.print(hub.version().minor); Serial.print('.'); + Serial.println(hub.version().patch); + Serial.print("Calibration vector size: "); + Serial.println(hub.caps().calibVectorBytes); + + s = runCalibration(); + if (s != PulseExpressStatus::Ok) haltWithError("calibration", s); + Serial.println("Calibration complete; reading vector."); + + size_t calibLen = 0; + s = hub.readCalibrationVector(calibVector, sizeof(calibVector), &calibLen); + if (s != PulseExpressStatus::Ok) haltWithError("readCalibrationVector", s); + Serial.print("Stored "); Serial.print(calibLen); Serial.println(" calibration bytes."); + + s = hub.stop(); // tear down calibration mode before re-enabling for estimation + if (s != PulseExpressStatus::Ok) haltWithError("hub.stop()", s); + + if (hub.caps().multiPointCalib) + s = hub.loadCalibrationVector(/*calIndex=*/0, calibVector, calibLen); + else + s = hub.loadCalibrationVector(calibVector, calibLen); + if (s != PulseExpressStatus::Ok) haltWithError("loadCalibrationVector", s); + + PulseExpressSpo2Coeffs coeffs; + coeffs.a = SPO2_COEFF_A; + coeffs.b = SPO2_COEFF_B; + coeffs.c = SPO2_COEFF_C; + s = hub.startEstimation(coeffs); + if (s != PulseExpressStatus::Ok) haltWithError("startEstimation", s); + + Serial.println("Estimation running."); + delay(1000); +} + +void loop() +{ + PulseExpressSample samples[8]; + size_t n = 0; + PulseExpressStatus s = hub.readSamples(samples, 8, &n); + if (s != PulseExpressStatus::Ok) return; + + for (size_t i = 0; i < n; ++i) + { + Serial.print("sys="); Serial.print(samples[i].systolic); + Serial.print(" dia="); Serial.print(samples[i].diastolic); + Serial.print(" hr="); Serial.print(samples[i].heartRate(), 1); + Serial.print(" spo2="); Serial.print(samples[i].spo2(), 1); + Serial.print(" status="); + Serial.println(uint8_t(samples[i].bpStatus)); + } + delay(100); +} diff --git a/examples/07.SaveLoadCalibrationEEPROM/07.SaveLoadCalibrationEEPROM.ino b/examples/07.SaveLoadCalibrationEEPROM/07.SaveLoadCalibrationEEPROM.ino new file mode 100644 index 0000000..a84d853 --- /dev/null +++ b/examples/07.SaveLoadCalibrationEEPROM/07.SaveLoadCalibrationEEPROM.ino @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// +// ProtoCentral Pulse Express (MAX30102 + MAX32664D) +// Example 07 — Persist the BPT calibration vector to EEPROM, reload on boot +// +// First run: no valid vector in EEPROM, so the sketch calibrates and stores the +// resulting vector (plus a small header) to EEPROM. Every subsequent boot it +// finds the stored vector, loads it into the hub, and goes straight to +// estimation — no recalibration needed. +// +// NOTE: the calibration vector is up to 824 bytes. Classic ATmega328 boards +// have 1 KB of EEPROM, so it just fits; ESP32/ESP8266 use emulated EEPROM and +// must call EEPROM.begin()/commit() (handled below). Boards without an EEPROM +// library are reported at build time. +// +// R&D use only — not a medical device. + +#include +#include "protocentral_pulse_express.h" + +// EEPROM is present on most cores but not all (Arduino mbed and SAM/Due cores +// lack it). Guard on architecture so the full CI board matrix still compiles; +// unsupported boards build a tiny stub instead. A plain #include (not +// __has_include) is required so arduino-cli adds the EEPROM library path. +#if defined(__AVR__) || defined(ARDUINO_ARCH_MEGAAVR) || defined(ARDUINO_ARCH_RENESAS) || \ + defined(ESP32) || defined(ESP8266) || defined(ARDUINO_ARCH_RP2040) || \ + defined(ARDUINO_ARCH_STM32) || defined(ARDUINO_ARCH_APOLLO3) + +#include + +#define RESET_PIN 4 +#define MFIO_PIN 2 +#define CAL_INDEX 0 + +#define REF_SYSTOLIC 120 // REPLACE with a real cuff reading at calibration +#define REF_DIASTOLIC 80 + +#define EE_MAGIC 0x42505445UL // "BPTE" +#define EE_ADDR 0 // base EEPROM address +#define CALIB_VECTOR_MAX 824 + +struct EeHeader { uint32_t magic; uint16_t len; }; + +static uint8_t calibVector[CALIB_VECTOR_MAX]; +PulseExpress hub(RESET_PIN, MFIO_PIN); + +static void halt(const char *msg, PulseExpressStatus s) +{ + while (true) + { + Serial.print(msg); Serial.print(" (0x"); Serial.print(uint8_t(s), HEX); + Serial.println(')'); delay(5000); + } +} + +static void eeBegin(size_t bytes) +{ +#if defined(ESP32) || defined(ESP8266) + EEPROM.begin(bytes); +#else + (void)bytes; +#endif +} + +static void eeCommit() +{ +#if defined(ESP32) || defined(ESP8266) + EEPROM.commit(); +#endif +} + +static bool loadFromEeprom(size_t expectedLen) +{ + EeHeader h; + EEPROM.get(EE_ADDR, h); + if (h.magic != EE_MAGIC || h.len != expectedLen) return false; + for (size_t i = 0; i < h.len; ++i) + calibVector[i] = EEPROM.read(EE_ADDR + sizeof(EeHeader) + i); + return true; +} + +static void saveToEeprom(size_t len) +{ + EeHeader h = { EE_MAGIC, uint16_t(len) }; + EEPROM.put(EE_ADDR, h); + for (size_t i = 0; i < len; ++i) + EEPROM.write(EE_ADDR + sizeof(EeHeader) + i, calibVector[i]); + eeCommit(); +} + +static PulseExpressStatus calibrate(size_t &lenOut) +{ + PulseExpressStatus s; + if (hub.caps().multiPointCalib) + { + PulseExpressCalibrationRef ref; + ref.calIndex = CAL_INDEX; ref.systolic = REF_SYSTOLIC; ref.diastolic = REF_DIASTOLIC; + s = hub.startCalibration(ref); + } + else + { + PulseExpressLegacyCalibrationRefs refs; + refs.systolic[0]=REF_SYSTOLIC; refs.systolic[1]=REF_SYSTOLIC+2; refs.systolic[2]=REF_SYSTOLIC+5; + refs.diastolic[0]=REF_DIASTOLIC; refs.diastolic[1]=REF_DIASTOLIC+1; refs.diastolic[2]=REF_DIASTOLIC+2; + s = hub.startCalibration(refs); + } + if (s != PulseExpressStatus::Ok) return s; + + Serial.println("Calibrating — finger on sensor, hold still until 100%."); + PulseExpressSample sample; + unsigned long startMs = millis(); + while (true) + { + if (hub.readSample(sample) == PulseExpressStatus::Ok) + { + Serial.print("progress: "); Serial.print(sample.progress); Serial.println('%'); + if (sample.bpStatus == PulseExpressBpStatus::Success && sample.progress >= 100) break; + } + if (millis() - startMs > 120000UL) return PulseExpressStatus::Timeout; + delay(40); + } + return hub.readCalibrationVector(calibVector, sizeof(calibVector), &lenOut); +} + +void setup() +{ + Serial.begin(57600); + while (!Serial && millis() < 3000) {} + Wire.begin(); + + PulseExpressStatus s = hub.begin(); + if (s != PulseExpressStatus::Ok) halt("hub.begin()", s); + if (!hub.firmwareSupported()) + Serial.println("WARNING: firmware outside validated 40.x line."); + + const size_t vecLen = hub.caps().calibVectorBytes; + eeBegin(sizeof(EeHeader) + vecLen); + + if (loadFromEeprom(vecLen)) + { + Serial.println("Loaded calibration vector from EEPROM."); + } + else + { + Serial.println("No stored vector — running calibration once."); + size_t len = 0; + s = calibrate(len); + if (s != PulseExpressStatus::Ok) halt("calibration", s); + saveToEeprom(len); + Serial.println("Calibration saved to EEPROM."); + hub.stop(); + } + + if (hub.caps().multiPointCalib) + s = hub.loadCalibrationVector(CAL_INDEX, calibVector, vecLen); + else + s = hub.loadCalibrationVector(calibVector, vecLen); + if (s != PulseExpressStatus::Ok) halt("loadCalibrationVector", s); + + s = hub.startEstimation(); + if (s != PulseExpressStatus::Ok) halt("startEstimation", s); + Serial.println("Estimation running."); + delay(1000); +} + +void loop() +{ + PulseExpressSample samples[8]; + size_t n = 0; + if (hub.readSamples(samples, 8, &n) != PulseExpressStatus::Ok) return; + for (size_t i = 0; i < n; ++i) + { + Serial.print("sys="); Serial.print(samples[i].systolic); + Serial.print(" dia="); Serial.print(samples[i].diastolic); + Serial.print(" hr="); Serial.print(samples[i].heartRate(), 1); + Serial.print(" spo2="); Serial.println(samples[i].spo2(), 1); + } + delay(100); +} + +#else // no EEPROM library on this board + +void setup() +{ + Serial.begin(57600); + while (!Serial && millis() < 3000) {} + Serial.println("EEPROM not available on this board — see example 05 to load"); + Serial.println("a calibration vector from a code array instead."); +} +void loop() {} + +#endif // EEPROM-capable architecture diff --git a/examples/08.MultiSubjectCalibration/08.MultiSubjectCalibration.ino b/examples/08.MultiSubjectCalibration/08.MultiSubjectCalibration.ino new file mode 100644 index 0000000..2d9dce4 --- /dev/null +++ b/examples/08.MultiSubjectCalibration/08.MultiSubjectCalibration.ino @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// +// ProtoCentral Pulse Express (MAX30102 + MAX32664D) +// Example 08 — Multi-subject BPT calibration (firmware >= 40.5.0) +// +// Firmware 40.5.0+ stores up to five independent calibrations (calIndex 0..4), +// one per subject. This sketch walks through calibrating several subjects in +// turn, printing each subject's vector so you can persist it (see example 07). +// To keep RAM small enough for AVR boards it calibrates one subject at a time +// and reuses a single vector buffer rather than holding all five at once. +// +// Requires multi-point firmware; on older hubs it reports that and stops. +// Replace the placeholder cuff readings with real per-subject measurements. +// R&D use only — not a medical device. + +#include +#include "protocentral_pulse_express.h" + +#define RESET_PIN 4 +#define MFIO_PIN 2 +#define NUM_SUBJECTS 2 // up to 5 (calIndex 0..4) +#define CALIB_VECTOR_MAX 824 + +// Per-subject reference cuff readings — REPLACE with real measurements. +static const uint8_t kRefSys[5] = {120, 118, 130, 125, 122}; +static const uint8_t kRefDia[5] = { 80, 79, 85, 82, 81}; + +static uint8_t calibVector[CALIB_VECTOR_MAX]; +PulseExpress hub(RESET_PIN, MFIO_PIN); + +static void halt(const char *msg, PulseExpressStatus s) +{ + while (true) + { + Serial.print(msg); Serial.print(" (0x"); Serial.print(uint8_t(s), HEX); + Serial.println(')'); delay(5000); + } +} + +static void waitForKey(uint8_t subject) +{ + Serial.print("Subject "); Serial.print(subject); + Serial.println(": place finger on sensor, then send any character to start."); + while (Serial.available()) Serial.read(); + while (!Serial.available()) delay(20); + while (Serial.available()) Serial.read(); +} + +static PulseExpressStatus calibrateSubject(uint8_t calIndex, size_t &lenOut) +{ + PulseExpressCalibrationRef ref; + ref.calIndex = calIndex; + ref.systolic = kRefSys[calIndex]; + ref.diastolic = kRefDia[calIndex]; + + PulseExpressStatus s = hub.startCalibration(ref); + if (s != PulseExpressStatus::Ok) return s; + + PulseExpressSample sample; + unsigned long startMs = millis(); + while (true) + { + if (hub.readSample(sample) == PulseExpressStatus::Ok) + { + Serial.print(" progress: "); Serial.print(sample.progress); Serial.println('%'); + if (sample.bpStatus == PulseExpressBpStatus::Success && sample.progress >= 100) break; + } + if (millis() - startMs > 120000UL) return PulseExpressStatus::Timeout; + delay(40); + } + s = hub.readCalibrationVector(calibVector, sizeof(calibVector), &lenOut); + if (s != PulseExpressStatus::Ok) return s; + hub.stop(); + return PulseExpressStatus::Ok; +} + +void setup() +{ + Serial.begin(57600); + while (!Serial && millis() < 3000) {} + Wire.begin(); + + PulseExpressStatus s = hub.begin(); + if (s != PulseExpressStatus::Ok) halt("hub.begin()", s); + + if (!hub.caps().multiPointCalib) + { + Serial.println("This hub firmware does not support multi-point calibration."); + Serial.println("Multi-subject calibration requires firmware >= 40.5.0."); + halt("unsupported firmware", PulseExpressStatus::UnsupportedFirmware); + } + + for (uint8_t i = 0; i < NUM_SUBJECTS; ++i) + { + waitForKey(i); + size_t len = 0; + s = calibrateSubject(i, len); + if (s != PulseExpressStatus::Ok) halt("calibrateSubject", s); + Serial.print("Subject "); Serial.print(i); + Serial.print(" calibrated. Vector length: "); Serial.println(len); + Serial.println(" (persist calibVector[] now — see example 07)"); + } + + Serial.println("All subjects calibrated."); +} + +void loop() +{ + delay(1000); +} diff --git a/examples/09.HeartRateVariability/09.HeartRateVariability.ino b/examples/09.HeartRateVariability/09.HeartRateVariability.ino new file mode 100644 index 0000000..089226e --- /dev/null +++ b/examples/09.HeartRateVariability/09.HeartRateVariability.ino @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// +// ProtoCentral Pulse Express (MAX30102 + MAX32664D) +// Example 09 — Heart-rate variability (HRV) from inter-beat intervals +// +// Firmware 40.5.0+ reports an inter-beat interval (IBI, in ms) per sample. This +// sketch collects a rolling window of IBI values and computes two common HRV +// metrics: SDNN (standard deviation of NN intervals) and RMSSD (root mean +// square of successive differences). Both are reported in milliseconds. +// +// IBI is only populated on firmware >= 40.5.0; on older hubs the sketch says so. +// R&D use only — not a medical device. +// +// Hardware connections (default): SDA=A4, SCL=A5, MFIO=D2, RESET=D4, Vin=5V. + +#include +#include "protocentral_pulse_express.h" + +#define RESET_PIN 4 +#define MFIO_PIN 2 +#define WINDOW 32 // number of IBI samples per HRV computation + +PulseExpress hub(RESET_PIN, MFIO_PIN); + +static uint16_t ibis[WINDOW]; +static uint8_t count = 0; +static uint16_t lastIbi = 0; + +static void halt(const char *msg, PulseExpressStatus s) +{ + while (true) + { + Serial.print(msg); Serial.print(" (0x"); Serial.print(uint8_t(s), HEX); + Serial.println(')'); delay(5000); + } +} + +static void computeHrv() +{ + // Mean + float mean = 0; + for (uint8_t i = 0; i < count; ++i) mean += ibis[i]; + mean /= count; + + // SDNN + float var = 0; + for (uint8_t i = 0; i < count; ++i) + { + float d = ibis[i] - mean; + var += d * d; + } + float sdnn = sqrt(var / count); + + // RMSSD + float ss = 0; + for (uint8_t i = 1; i < count; ++i) + { + float d = float(ibis[i]) - float(ibis[i - 1]); + ss += d * d; + } + float rmssd = (count > 1) ? sqrt(ss / (count - 1)) : 0; + + Serial.print("HRV over "); Serial.print(count); Serial.print(" beats "); + Serial.print("mean IBI="); Serial.print(mean, 1); Serial.print(" ms "); + Serial.print("SDNN="); Serial.print(sdnn, 1); Serial.print(" ms "); + Serial.print("RMSSD="); Serial.print(rmssd, 1); Serial.println(" ms"); +} + +void setup() +{ + Serial.begin(57600); + while (!Serial && millis() < 3000) {} + Wire.begin(); + + PulseExpressStatus s = hub.begin(); + if (s != PulseExpressStatus::Ok) halt("hub.begin()", s); + + if (hub.caps().sampleBytes < 25) + Serial.println("NOTE: this firmware does not report IBI (needs >= 40.5.0)."); + + s = hub.startEstimation(); + if (s != PulseExpressStatus::Ok) halt("startEstimation", s); + + Serial.println("Collecting beats — keep finger still on the sensor."); + delay(1000); +} + +void loop() +{ + PulseExpressSample samples[8]; + size_t n = 0; + if (hub.readSamples(samples, 8, &n) != PulseExpressStatus::Ok) return; + + for (size_t i = 0; i < n; ++i) + { + uint16_t ibi = samples[i].ibiMs; + // Accept only fresh, plausible intervals (250..2000 ms ~ 30..240 bpm). + if (ibi >= 250 && ibi <= 2000 && ibi != lastIbi) + { + lastIbi = ibi; + ibis[count++] = ibi; + if (count >= WINDOW) + { + computeHrv(); + count = 0; + } + } + } + delay(50); +} diff --git a/examples/10.DeviceInfoAndDiagnostics/10.DeviceInfoAndDiagnostics.ino b/examples/10.DeviceInfoAndDiagnostics/10.DeviceInfoAndDiagnostics.ino new file mode 100644 index 0000000..8b301fa --- /dev/null +++ b/examples/10.DeviceInfoAndDiagnostics/10.DeviceInfoAndDiagnostics.ino @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// +// ProtoCentral Pulse Express (MAX30102 + MAX32664D) +// Example 10 — Device info and live hub diagnostics +// +// Prints the sensor-hub and algorithm firmware versions and the capability set +// the driver derived from them (vector size, sample size, date format, +// multi-point support). Then it starts raw acquisition and polls the hub status +// byte so you can watch the data-ready / FIFO-overflow / sensor-comm flags in +// real time — handy when bringing up new hardware. +// +// Hardware connections (default): SDA=A4, SCL=A5, MFIO=D2, RESET=D4, Vin=5V. + +#include +#include "protocentral_pulse_express.h" + +#define RESET_PIN 4 +#define MFIO_PIN 2 + +PulseExpress hub(RESET_PIN, MFIO_PIN); + +static void printVersion(const char *label, PulseExpressVersion v) +{ + Serial.print(label); + Serial.print(v.major); Serial.print('.'); + Serial.print(v.minor); Serial.print('.'); + Serial.println(v.patch); +} + +void setup() +{ + Serial.begin(57600); + while (!Serial && millis() < 3000) {} + Wire.begin(); + + PulseExpressStatus s = hub.begin(); + if (s != PulseExpressStatus::Ok) + { + while (true) { Serial.print("hub.begin() failed: 0x"); + Serial.println(uint8_t(s), HEX); delay(5000); } + } + + Serial.println("=== ProtoCentral Pulse Express — device info ==="); + printVersion("Hub firmware: ", hub.version()); + printVersion("Algorithm firmware: ", hub.algoVersion()); + Serial.print("Firmware supported: "); Serial.println(hub.firmwareSupported() ? "yes" : "no (legacy defaults)"); + Serial.print("MFIO pin: "); Serial.println(hub.mfioPin()); + + PulseExpressCaps c = hub.caps(); + Serial.println("--- derived capabilities ---"); + Serial.print("Calib vector bytes: "); Serial.println(c.calibVectorBytes); + Serial.print("Sample bytes: "); Serial.println(c.sampleBytes); + Serial.print("Date format: "); Serial.println(c.dateYYYYMMDD ? "YYYYMMDD" : "YYMMDD"); + Serial.print("Multi-point calib: "); Serial.println(c.multiPointCalib ? "yes (40.5.0+)" : "no (legacy)"); + Serial.println("================================================="); + + s = hub.startRaw(); + if (s != PulseExpressStatus::Ok) + Serial.println("startRaw() failed; status polling may stay idle."); + delay(500); +} + +void loop() +{ + PulseExpress::HubStatus st; + if (hub.readStatus(st) == PulseExpressStatus::Ok) + { + Serial.print("dataReady="); Serial.print(st.dataReady); + Serial.print(" fifoOutOvr="); Serial.print(st.fifoOutOverflow); + Serial.print(" fifoInOvr="); Serial.print(st.fifoInOverflow); + Serial.print(" sensorCommErr="); Serial.print(st.sensorCommError); + Serial.print(" busy="); Serial.println(st.deviceBusy); + } + + // Drain the FIFO so overflow flags reflect current state rather than a + // backlog we never read. + PulseExpressRawSample buf[16]; + size_t n = 0; + hub.readRaw(buf, 16, &n, /*wantRed=*/false); + + delay(500); +} diff --git a/examples/11.FirmwareFlash/11.FirmwareFlash.ino b/examples/11.FirmwareFlash/11.FirmwareFlash.ino new file mode 100644 index 0000000..63eff2f --- /dev/null +++ b/examples/11.FirmwareFlash/11.FirmwareFlash.ino @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// +// ProtoCentral Pulse Express (MAX30102 + MAX32664D) +// Example 11 — Firmware flasher (FACTORY / RECOVERY tool) +// +// Pulse Express boards ship PRE-FLASHED. You normally never need this. It exists +// for factory programming and recovery, and drives the MAX32664 bootloader to +// write a Maxim/ADI application image (.msbl) over I2C. +// +// The .msbl is Maxim/ADI IP and is NOT distributed with this library — supply +// your own. Because a page payload is 8208 bytes, this sketch needs a host +// board with ample RAM (ESP32, UNO R4, RP2040, STM32, Portenta, ...). On small +// AVR boards it builds a stub. +// +// HOW IT WORKS: this sketch puts the hub in bootloader mode, then takes commands +// over USB-Serial from the host script extras/flash_tool/flash_msbl.py, which +// parses the .msbl and streams the pages. Run: +// python3 extras/flash_tool/flash_msbl.py --port firmware.msbl +// +// WARNING: implemented from UG6806 Table 9; VALIDATE ON HARDWARE before +// production. A failed/interrupted flash leaves the hub in bootloader mode — +// just re-run the flash to recover. + +#include +#include "pulse_express_bootloader.h" + +#define RESET_PIN 4 +#define MFIO_PIN 2 + +#if defined(__AVR__) + +void setup() +{ + Serial.begin(115200); + while (!Serial && millis() < 3000) {} + Serial.println("11.FirmwareFlash needs a board with >= ~16 KB RAM for the"); + Serial.println("8208-byte page buffer (ESP32, UNO R4, RP2040, STM32, ...)."); + Serial.println("This AVR board is too small. Build for a larger host."); +} +void loop() {} + +#else + +using BL = PulseExpressBootloader; +static BL bl(RESET_PIN, MFIO_PIN); +static uint8_t page[MSBL_PAGE_PAYLOAD_BYTES]; + +// Read exactly n bytes from Serial with a timeout; returns false on timeout. +static bool readExact(uint8_t *buf, size_t n, unsigned long timeoutMs = 5000) +{ + size_t got = 0; + unsigned long start = millis(); + while (got < n) + { + int c = Serial.read(); + if (c < 0) { if (millis() - start > timeoutMs) return false; continue; } + buf[got++] = uint8_t(c); + start = millis(); + } + return true; +} + +static void ack(BL::Status s) { Serial.write(uint8_t(s)); Serial.flush(); } + +void setup() +{ + Serial.begin(115200); + while (!Serial) {} + Wire.begin(); +#if defined(WIRE_BUFFER_SIZE) || defined(ESP32) + Wire.setClock(400000); +#endif + + bl.setDebug(nullptr); // keep the serial channel clean for the binary protocol + BL::Status s = bl.enterBootloader(); + if (s != BL::Status::Ok) + { + Serial.print("ERR enterBootloader 0x"); Serial.println(uint8_t(s), HEX); + while (true) delay(1000); + } + + uint8_t ver[3] = {0, 0, 0}; + uint16_t pageSize = 0; + bl.readBootloaderVersion(ver); + bl.readPageSize(pageSize); + Serial.print("BL "); Serial.print(ver[0]); Serial.print('.'); + Serial.print(ver[1]); Serial.print('.'); Serial.print(ver[2]); + Serial.print(" pageSize="); Serial.println(pageSize); + Serial.println("READY"); // host script waits for this line +} + +void loop() +{ + int cmd = Serial.read(); + if (cmd < 0) return; + + switch (cmd) + { + case 'N': { // set number of pages (2 bytes BE) + uint8_t b[2]; + if (!readExact(b, 2)) { ack(BL::Status::Timeout); break; } + ack(bl.setNumPages(uint16_t((b[0] << 8) | b[1]))); + break; + } + case 'I': { // init vector (11 bytes) + uint8_t iv[MSBL_INIT_VECTOR_BYTES]; + if (!readExact(iv, sizeof(iv))) { ack(BL::Status::Timeout); break; } + ack(bl.setInitVector(iv)); + break; + } + case 'A': { // auth (16 bytes) + uint8_t au[MSBL_AUTH_BYTES]; + if (!readExact(au, sizeof(au))) { ack(BL::Status::Timeout); break; } + ack(bl.setAuth(au)); + break; + } + case 'E': // erase application + ack(bl.eraseApplication()); + break; + case 'P': { // one page (8208 bytes) + if (!readExact(page, sizeof(page), 15000)) { ack(BL::Status::Timeout); break; } + ack(bl.writePage(page, sizeof(page))); + break; + } + case 'X': // exit to application + ack(bl.exitToApplication()); + break; + default: + break; // ignore stray bytes / newlines + } +} + +#endif // RAM guard diff --git a/extras/firmware/README.md b/extras/firmware/README.md new file mode 100644 index 0000000..fc48c05 --- /dev/null +++ b/extras/firmware/README.md @@ -0,0 +1,28 @@ +# Firmware images (.msbl) — not included + +Pulse Express boards ship **pre-flashed at the ProtoCentral factory**. You do not +need a firmware image for normal use. + +The MAX32664D application firmware (`.msbl`) is **Maxim/Analog Devices intellectual +property and is not redistributed with this library.** No `.msbl` is, or should ever +be, committed to this repository — `*.msbl` and `*.bin` are gitignored. + +## For factory programming / recovery only + +If you are re-flashing a board (factory or recovery), place your licensed image in +this directory, e.g.: + +``` +extras/firmware/MAX32664D_BPT_.msbl <-- gitignored, never committed +``` + +Then flash it with the host tool, which drives the +[`11.FirmwareFlash`](../../examples/11.FirmwareFlash) sketch over USB-Serial: + +``` +python3 ../flash_tool/flash_msbl.py --port MAX32664D_BPT_.msbl +``` + +The flashing code is implemented from Maxim/ADI User Guide 6806 (Table 9) and +**must be validated on hardware before production use**. A failed or interrupted +flash leaves the hub in bootloader mode; simply re-run the flash to recover. diff --git a/extras/flash_tool/flash_msbl.py b/extras/flash_tool/flash_msbl.py new file mode 100644 index 0000000..63aaca3 --- /dev/null +++ b/extras/flash_tool/flash_msbl.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +# +# Host-side flasher for the ProtoCentral Pulse Express (MAX32664D). +# +# Parses a Maxim/ADI application image (.msbl) and streams it to the Arduino +# sketch examples/11.FirmwareFlash over USB-Serial, which relays the bootloader +# I2C commands to the hub. Field offsets follow UG6806 (Figures 9-12): +# +# number of pages : byte 0x44 +# init vector : bytes 0x28..0x32 (11 bytes) +# auth bytes : bytes 0x34..0x43 (16 bytes) +# page data : from 0x4C, 8208 bytes per page (8192 flash + 16 CRC) +# +# FACTORY / RECOVERY tool. The .msbl is Maxim/ADI IP and is NOT distributed with +# this library — supply your own. Implemented from spec; validate on hardware. +# +# Usage: +# python3 flash_msbl.py --port /dev/ttyACM0 firmware.msbl +# python3 flash_msbl.py --port COM5 --dry-run firmware.msbl # parse only +# +# Requires: pyserial (pip install pyserial) + +import argparse +import sys +import time + +MSBL_OFF_NUM_PAGES = 0x44 +MSBL_OFF_INIT_VEC = 0x28 +MSBL_INIT_VEC_LEN = 11 +MSBL_OFF_AUTH = 0x34 +MSBL_AUTH_LEN = 16 +MSBL_OFF_PAGE_DATA = 0x4C +MSBL_PAGE_BYTES = 8208 # 8192 + 16 CRC + +# Status byte returned by the sketch (mirrors PulseExpressBootloader::Status). +STATUS_OK = 0x00 + + +def parse_msbl(path): + with open(path, "rb") as f: + data = f.read() + if len(data) < MSBL_OFF_PAGE_DATA + MSBL_PAGE_BYTES: + raise ValueError("File too small to be a valid .msbl") + + num_pages = data[MSBL_OFF_NUM_PAGES] + init_vec = data[MSBL_OFF_INIT_VEC:MSBL_OFF_INIT_VEC + MSBL_INIT_VEC_LEN] + auth = data[MSBL_OFF_AUTH:MSBL_OFF_AUTH + MSBL_AUTH_LEN] + + expected = MSBL_OFF_PAGE_DATA + num_pages * MSBL_PAGE_BYTES + if len(data) < expected: + raise ValueError( + f"Header says {num_pages} pages ({expected} bytes) but file is {len(data)} bytes" + ) + + pages = [] + for i in range(num_pages): + start = MSBL_OFF_PAGE_DATA + i * MSBL_PAGE_BYTES + pages.append(data[start:start + MSBL_PAGE_BYTES]) + return num_pages, init_vec, auth, pages + + +def expect_ack(ser, what): + b = ser.read(1) + if len(b) != 1: + raise RuntimeError(f"{what}: no ack (timeout)") + if b[0] != STATUS_OK: + raise RuntimeError(f"{what}: hub returned status 0x{b[0]:02X}") + + +def main(): + ap = argparse.ArgumentParser(description="Flash a .msbl to Pulse Express (MAX32664D)") + ap.add_argument("msbl", help="path to the .msbl firmware image") + ap.add_argument("--port", help="serial port (e.g. /dev/ttyACM0 or COM5)") + ap.add_argument("--baud", type=int, default=115200) + ap.add_argument("--dry-run", action="store_true", help="parse the .msbl only; no serial I/O") + args = ap.parse_args() + + num_pages, init_vec, auth, pages = parse_msbl(args.msbl) + print(f"Parsed {args.msbl}:") + print(f" pages : {num_pages}") + print(f" init vector: {init_vec.hex(' ')}") + print(f" auth : {auth.hex(' ')}") + print(f" page bytes : {MSBL_PAGE_BYTES} each, {num_pages * MSBL_PAGE_BYTES} total") + + if args.dry_run: + print("Dry run — not flashing.") + return 0 + if not args.port: + print("error: --port is required unless --dry-run", file=sys.stderr) + return 2 + + import serial # imported here so --dry-run works without pyserial + + ser = serial.Serial(args.port, args.baud, timeout=20) + time.sleep(2.0) # allow the board to reset and enter bootloader + + # Wait for the sketch's READY line. + deadline = time.time() + 15 + while time.time() < deadline: + line = ser.readline().decode(errors="replace").strip() + if line: + print(f" [board] {line}") + if line == "READY": + break + else: + raise RuntimeError("Board did not report READY") + + ser.write(b"N" + bytes([(num_pages >> 8) & 0xFF, num_pages & 0xFF])) + expect_ack(ser, "set num pages") + ser.write(b"I" + bytes(init_vec)) + expect_ack(ser, "set init vector") + ser.write(b"A" + bytes(auth)) + expect_ack(ser, "set auth") + + print("Erasing application...") + ser.write(b"E") + expect_ack(ser, "erase") + + for i, pg in enumerate(pages): + ser.write(b"P" + pg) + expect_ack(ser, f"page {i + 1}/{num_pages}") + print(f"\r flashed page {i + 1}/{num_pages}", end="", flush=True) + print() + + ser.write(b"X") + expect_ack(ser, "exit to application") + print("Done. Hub restarted into application mode.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/keywords.txt b/keywords.txt index a7d0e5b..8ab57b8 100644 --- a/keywords.txt +++ b/keywords.txt @@ -3,20 +3,26 @@ ####################################### ####################################### -# Class (KEYWORD1) +# Datatypes / Classes (KEYWORD1) ####################################### +PulseExpress KEYWORD1 +PulseExpressStatus KEYWORD1 +PulseExpressVersion KEYWORD1 +PulseExpressCaps KEYWORD1 +PulseExpressDateTime KEYWORD1 +PulseExpressSpo2Coeffs KEYWORD1 +PulseExpressCalibrationRef KEYWORD1 +PulseExpressLegacyCalibrationRefs KEYWORD1 +PulseExpressBpStatus KEYWORD1 +PulseExpressSample KEYWORD1 +PulseExpressRawSample KEYWORD1 +PulseExpressBootloader KEYWORD1 +HubStatus KEYWORD1 + +# Deprecated 2.0.x aliases (still valid via max32664.h) Max32664 KEYWORD1 Max32664Status KEYWORD1 -Max32664Version KEYWORD1 -Max32664Caps KEYWORD1 -Max32664DateTime KEYWORD1 -Max32664Spo2Coeffs KEYWORD1 -Max32664CalibrationRef KEYWORD1 -Max32664LegacyCalibrationRefs KEYWORD1 -Max32664BpStatus KEYWORD1 -Max32664Sample KEYWORD1 -Max32664RawSample KEYWORD1 ####################################### # Methods and Functions (KEYWORD2) @@ -30,6 +36,7 @@ version KEYWORD2 algoVersion KEYWORD2 caps KEYWORD2 mfioPin KEYWORD2 +firmwareSupported KEYWORD2 # Calibration startCalibration KEYWORD2 @@ -45,7 +52,8 @@ readSamples KEYWORD2 startRaw KEYWORD2 readRaw KEYWORD2 -# Teardown +# Diagnostics / teardown +readStatus KEYWORD2 stop KEYWORD2 # Sample helpers @@ -54,6 +62,17 @@ spo2 KEYWORD2 rValue KEYWORD2 atLeast KEYWORD2 +# Bootloader (factory / recovery) +enterBootloader KEYWORD2 +readBootloaderVersion KEYWORD2 +readPageSize KEYWORD2 +setNumPages KEYWORD2 +setInitVector KEYWORD2 +setAuth KEYWORD2 +eraseApplication KEYWORD2 +writePage KEYWORD2 +exitToApplication KEYWORD2 + ####################################### # Constants (LITERAL1) ####################################### @@ -64,3 +83,8 @@ MAX32664_STATUS_DATA_READY LITERAL1 MAX32664_STATUS_FIFO_OUT_OVR LITERAL1 MAX32664_STATUS_FIFO_IN_OVR LITERAL1 MAX32664_STATUS_DEVICE_BUSY LITERAL1 +MSBL_OFFSET_NUM_PAGES LITERAL1 +MSBL_OFFSET_INIT_VECTOR LITERAL1 +MSBL_OFFSET_AUTH LITERAL1 +MSBL_OFFSET_PAGE_DATA LITERAL1 +MSBL_PAGE_PAYLOAD_BYTES LITERAL1 diff --git a/library.properties b/library.properties index 4adf028..ac630ba 100644 --- a/library.properties +++ b/library.properties @@ -1,10 +1,11 @@ name=ProtoCentral Pulse Express SpO2 Heartrate and BPT sensor -version=2.0.0 -author=Protocentral Electronics +version=2.1.0 +author=Ashwin Whitchurch, Protocentral Electronics maintainer=Protocentral Electronics sentence=Library for the Protocentral Pulse Express board (MAX30102 + MAX32664D). -paragraph=Driver for the ProtoCentral Pulse Express breakout — MAX30102 optical pulse-oximeter coupled to a MAX32664D biometric sensor hub running Maxim's blood-pressure-trending (BPT) algorithm. Selects the correct command set at runtime based on the hub firmware version, so it supports MAX32664D firmware revisions across the 40.x line including the 40.5.0+ multi-point calibration / 512-byte vector / YYYYMMDD changes. Exposes raw PPG, BPT calibration, and BPT estimation modes with explicit calibration-vector persistence. Version 2.0.0 is a clean break from 1.0.x — see README for migration notes. +paragraph=Driver for the ProtoCentral Pulse Express breakout — MAX30102 optical pulse-oximeter coupled to a MAX32664D biometric sensor hub running Maxim's blood-pressure-trending (BPT) algorithm. Selects the correct command set at runtime based on the hub firmware version, so it supports MAX32664D firmware revisions across the 40.x line including the 40.5.0+ multi-point calibration / 512-byte vector / YYYYMMDD changes. Exposes raw PPG, heart rate, SpO2, BPT calibration and estimation, HRV, and a factory firmware-flashing helper. The main class is PulseExpress (the 2.0.x Max32664 name still works via a compatibility header). Not a medical device — for research and development use only. category=Sensors url=https://github.com/Protocentral/protocentral-pulse-express architectures=* +includes=protocentral_pulse_express.h depends= diff --git a/scripts/upload_02_openview.sh b/scripts/upload_02_openview.sh new file mode 100755 index 0000000..c8fe27a --- /dev/null +++ b/scripts/upload_02_openview.sh @@ -0,0 +1,58 @@ +#!/bin/bash +######################################################################################### +# +# Compile + upload example 02 (RawPPGStreamOpenView) to a connected Arduino Uno R4. +# +# Usage: +# ./scripts/upload_02_openview.sh # auto-detect port, Uno R4 Minima +# ./scripts/upload_02_openview.sh --wifi # Uno R4 WiFi target +# PORT=/dev/cu.usbmodemXXXX ./scripts/upload_02_openview.sh +# FQBN=arduino:renesas_uno:unor4wifi ./scripts/upload_02_openview.sh +# +# Copyright (c) 2025 ProtoCentral Electronics — MIT License. +# +######################################################################################### + +set -euo pipefail + +SKETCH="examples/02.RawPPGStreamOpenView" +LIB_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FQBN="${FQBN:-arduino:renesas_uno:minima}" + +for arg in "$@"; do + case "$arg" in + --wifi) FQBN="arduino:renesas_uno:unor4wifi" ;; + -h|--help) + sed -n '4,14p' "$0"; exit 0 ;; + *) echo "Unknown option: $arg" >&2; exit 1 ;; + esac +done + +if ! command -v arduino-cli >/dev/null 2>&1; then + echo "ERROR: arduino-cli not found. Install: https://arduino.github.io/arduino-cli/" >&2 + exit 1 +fi + +# Auto-detect the Uno R4 (Renesas) port unless PORT is provided. +if [[ -z "${PORT:-}" ]]; then + PORT="$(arduino-cli board list 2>/dev/null | awk '/renesas_uno/ {print $1; exit}')" +fi +if [[ -z "${PORT:-}" ]]; then + echo "ERROR: could not auto-detect a Uno R4 (no 'renesas_uno' board in 'arduino-cli board list')." >&2 + echo "Connected boards:" >&2 + arduino-cli board list >&2 + echo "Re-run with the port explicitly, e.g.: PORT=/dev/cu.usbmodemXXXX $0" >&2 + exit 1 +fi + +echo "[INFO] Sketch: $SKETCH" +echo "[INFO] FQBN: $FQBN" +echo "[INFO] Port: $PORT" + +echo "[INFO] Compiling..." +arduino-cli compile --fqbn "$FQBN" --library "$LIB_ROOT" "$LIB_ROOT/$SKETCH" + +echo "[INFO] Uploading..." +arduino-cli upload --fqbn "$FQBN" --port "$PORT" "$LIB_ROOT/$SKETCH" + +echo "[OK] Uploaded $SKETCH to $PORT" diff --git a/scripts/upload_10_diagnostics.sh b/scripts/upload_10_diagnostics.sh new file mode 100755 index 0000000..73843df --- /dev/null +++ b/scripts/upload_10_diagnostics.sh @@ -0,0 +1,58 @@ +#!/bin/bash +######################################################################################### +# +# Compile + upload example 10 (DeviceInfoAndDiagnostics) to a connected Arduino Uno R4. +# +# Usage: +# ./scripts/upload_10_diagnostics.sh # auto-detect port, Uno R4 Minima +# ./scripts/upload_10_diagnostics.sh --wifi # Uno R4 WiFi target +# PORT=/dev/cu.usbmodemXXXX ./scripts/upload_10_diagnostics.sh +# FQBN=arduino:renesas_uno:unor4wifi ./scripts/upload_10_diagnostics.sh +# +# Copyright (c) 2025 ProtoCentral Electronics — MIT License. +# +######################################################################################### + +set -euo pipefail + +SKETCH="examples/10.DeviceInfoAndDiagnostics" +LIB_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FQBN="${FQBN:-arduino:renesas_uno:minima}" + +for arg in "$@"; do + case "$arg" in + --wifi) FQBN="arduino:renesas_uno:unor4wifi" ;; + -h|--help) + sed -n '4,14p' "$0"; exit 0 ;; + *) echo "Unknown option: $arg" >&2; exit 1 ;; + esac +done + +if ! command -v arduino-cli >/dev/null 2>&1; then + echo "ERROR: arduino-cli not found. Install: https://arduino.github.io/arduino-cli/" >&2 + exit 1 +fi + +# Auto-detect the Uno R4 (Renesas) port unless PORT is provided. +if [[ -z "${PORT:-}" ]]; then + PORT="$(arduino-cli board list 2>/dev/null | awk '/renesas_uno/ {print $1; exit}')" +fi +if [[ -z "${PORT:-}" ]]; then + echo "ERROR: could not auto-detect a Uno R4 (no 'renesas_uno' board in 'arduino-cli board list')." >&2 + echo "Connected boards:" >&2 + arduino-cli board list >&2 + echo "Re-run with the port explicitly, e.g.: PORT=/dev/cu.usbmodemXXXX $0" >&2 + exit 1 +fi + +echo "[INFO] Sketch: $SKETCH" +echo "[INFO] FQBN: $FQBN" +echo "[INFO] Port: $PORT" + +echo "[INFO] Compiling..." +arduino-cli compile --fqbn "$FQBN" --library "$LIB_ROOT" "$LIB_ROOT/$SKETCH" + +echo "[INFO] Uploading..." +arduino-cli upload --fqbn "$FQBN" --port "$PORT" "$LIB_ROOT/$SKETCH" + +echo "[OK] Uploaded $SKETCH to $PORT (open Serial Monitor at 57600 baud)" diff --git a/src/max32664.h b/src/max32664.h index ff20dc9..258a72e 100644 --- a/src/max32664.h +++ b/src/max32664.h @@ -1,393 +1,33 @@ -////////////////////////////////////////////////////////////////////////////////////////// -// -// Arduino library for the ProtoCentral Pulse Express breakout board -// (MAX30102 optical sensor + MAX32664D biometric sensor hub). -// -// Implements the host-side procedure documented in: -// Maxim/ADI UG6921 Rev 2 (11/20) — "Measuring Blood Pressure, Heart Rate, -// and SpO2 Using MAX32664D — A Quick Start Guide for Programmers". -// -// Supports MAX32664D firmware revisions across the 40.x.y line, including -// the 40.5.0+ multi-point calibration / 512-byte vector / YYYYMMDD date -// changes. The exact behavioural set is selected at runtime via begin() -// once the hub firmware version has been read back. -// -// Original 2020 driver: Joice Tm, Copyright (c) 2020 ProtoCentral -// Modernised rewrite: Copyright (c) 2025 ProtoCentral Electronics -// -// This software is licensed under the MIT License (http://opensource.org/licenses/MIT). -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -// -// For information on how to use, visit https://github.com/Protocentral/protocentral-pulse-express -// -///////////////////////////////////////////////////////////////////////////////////////// +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics -#ifndef _MAX32664_H_ -#define _MAX32664_H_ - -#include -#include -#include -#include - -///////////////////////////////////////////////////////////////////////////////////////// -// I2C Address -///////////////////////////////////////////////////////////////////////////////////////// - -#define MAX32664_I2C_ADDR 0x55 // 7-bit form (0xAA/0xAB in 8-bit form) - -///////////////////////////////////////////////////////////////////////////////////////// -// Hub status byte bits (Family 0x00 / Index 0x00, see UG6921 step 2.1) -///////////////////////////////////////////////////////////////////////////////////////// - -#define MAX32664_STATUS_SENSOR_COMM 0x01 -#define MAX32664_STATUS_DATA_READY 0x08 -#define MAX32664_STATUS_FIFO_OUT_OVR 0x10 -#define MAX32664_STATUS_FIFO_IN_OVR 0x20 -#define MAX32664_STATUS_DEVICE_BUSY 0x40 - -/** - * @brief Driver return codes. - * - * Values 0x00..0xFF mirror the sensor-hub status byte (UG6921 Table 1) so a - * raw hub error can be returned to the caller without remapping. Values - * starting at 0xE0 are synthesised by the host driver to surface conditions - * the hub itself does not report (transport failure, programmer error). - */ -enum class Max32664Status : uint8_t -{ - Ok = 0x00, - IllegalIndex = 0x01, - IllegalByteCount = 0x02, - IllegalConfig = 0x03, - NotInAppMode = 0x05, - DeviceBusy = 0xFE, - UnknownHubError = 0xFF, - - HostCommError = 0xE0, - UnsupportedFirmware = 0xE1, - Timeout = 0xE2, - InvalidArgument = 0xE3, - BufferTooSmall = 0xE4, - NoDataAvailable = 0xE5, - NotConfigured = 0xE6, -}; - -/** - * @brief Hub or algorithm firmware version triplet (e.g. 40.6.0). - */ -struct Max32664Version -{ - uint8_t major; - uint8_t minor; - uint8_t patch; - - /// True if (major, minor, patch) >= (M, m, p) lexicographically. - bool atLeast(uint8_t M, uint8_t m, uint8_t p) const - { - if (major != M) return major > M; - if (minor != m) return minor > m; - return patch >= p; - } -}; - -/** - * @brief Capability flags derived from the hub firmware version. - * - * Populated by Max32664::begin() and exposed via Max32664::caps() so user - * code can size buffers correctly and branch on what the hub will accept. - * - * Breakpoints from UG6921 Tables 2 & 3: - * - 40.2.2+ : BP-medication and Rest-mode setup steps deprecated. - * - 40.5.0+ : Calibration vector size 824 -> 512 bytes; - * sample size 23 -> 29 bytes; - * date format YYMMDD -> YYYYMMDD; - * multi-point user calibration (cal_index 0..4) replaces the - * legacy 3-systolic / 3-diastolic single-shot procedure. - */ -struct Max32664Caps -{ - uint16_t calibVectorBytes = 824; // 824 (legacy) | 512 (>=40.5.0) - uint8_t sampleBytes = 23; // 23 (legacy) | 29 (>=40.5.0) - bool dateYYYYMMDD = false; // false: YYMMDD legacy | true: >=40.5.0 - bool multiPointCalib = false; // false: 0x50/04/01+02 | true: 0x50/04/07+08 - bool sendBpMedication = true; // dropped in 40.2.2+ - bool sendRestMode = true; // dropped in 40.2.2+ -}; - -/** - * @brief Wall-clock time supplied to the hub during configureCalibration() / - * configureEstimation(). - * - * The driver re-encodes this as YYMMDD or YYYYMMDD on the wire depending on - * Max32664Caps::dateYYYYMMDD; user code passes the same DateTime regardless - * of firmware version. - */ -struct Max32664DateTime -{ - uint16_t year = 2025; // full 4-digit year, e.g. 2025 - uint8_t month = 1; // 1..12 - uint8_t day = 1; // 1..31 - uint8_t hour = 0; // 0..23 - uint8_t minute = 0; - uint8_t second = 0; -}; - -/** - * @brief SpO2 calibration coefficients (per Maxim AN6845). - * - * Each coefficient is converted to round(10^5 * value) and uploaded as a - * 32-bit signed integer. Defaults are the example values from UG6921 §2.1. - */ -struct Max32664Spo2Coeffs -{ - float a = 1.5958422f; - float b = -34.659664f; - float c = 112.68987f; -}; - -/** - * @brief Multi-point reference cuff measurement (firmware 40.5.0+). - * - * One CalibrationRef is supplied per subject; calIndex 0..4. The firmware - * stores up to five subject-specific calibrations in parallel. - */ -struct Max32664CalibrationRef -{ - uint8_t calIndex = 0; // 0..4 - uint8_t systolic = 120; // mmHg - uint8_t diastolic = 80; // mmHg -}; - -/** - * @brief Legacy single-shot reference cuff ramp (firmware <40.5.0). - * - * Three systolic and three diastolic reference values, taken at three points - * around the user's resting BP, uploaded via the deprecated 0x50/04/01 and - * 0x50/04/02 commands. - */ -struct Max32664LegacyCalibrationRefs -{ - uint8_t systolic[3] = {120, 122, 125}; - uint8_t diastolic[3] = {80, 81, 82}; -}; - -/** - * @brief BP status code from sample byte 12 (UG6921 Table 4). - */ -enum class Max32664BpStatus : uint8_t -{ - NoSignal = 0, - InProgress = 1, - Success = 2, - WeakSignal = 3, - Motion = 4, - EstimationFailure = 5, - CalibrationPartial = 6, - SubjectInitFailure = 7, - InitCompleted = 8, - RefBpTrendingError = 9, - RefInconsistency1 = 10, - RefInconsistency2 = 11, - RefInconsistency3 = 12, - RefCountMismatch = 13, - RefOutOfLimits = 14, - TooManyCalibrations = 15, - PulsePressureOutRange = 16, - HrOutOfRange = 17, - HrAboveResting = 18, - PerfusionOutOfRange = 19, - EstimationRetry = 20, - EstimateOutOfRefRange = 21, - EstimateOutOfMaxLimit = 22, - NoContact = 23, - NoFinger = 24, -}; - -/** - * @brief One algorithm-mode FIFO sample (UG6921 Table 4). +/* + * Backward-compatibility shim for ProtoCentral Pulse Express library 2.0.x. * - * Heart rate, SpO2 and R are reported by the hub as fixed-point integers; the - * helper accessors below return the equivalent floats. Fields beyond pulseFlag - * are zero on firmware <40.5.0. - */ -struct Max32664Sample -{ - Max32664BpStatus bpStatus = Max32664BpStatus::NoSignal; - uint8_t progress = 0; // % calibration progress - uint16_t heartRate10x = 0; // 10x bpm - uint8_t systolic = 0; // mmHg - uint8_t diastolic = 0; // mmHg - uint16_t spo210x = 0; // 10x SpO2 percent - uint16_t rValue1000x = 0; // 1000x R (used by SpO2 calibration) - uint8_t pulseFlag = 0; // 1 = R-peak detected - uint16_t ibiMs = 0; // inter-beat interval, ms (40.5.0+) - uint8_t spo2Confidence = 0; // % (40.5.0+) - uint8_t bptReportFlag = 0; // 1 = BPT estimation updated (40.5.0+) - uint8_t spo2ReportFlag = 0; // 1 = SpO2 estimation updated (40.5.0+) - - float heartRate() const { return heartRate10x / 10.0f; } - float spo2() const { return spo210x / 10.0f; } - float rValue() const { return rValue1000x / 1000.0f; } -}; - -/** - * @brief One raw-mode FIFO sample (24-bit IR + Red ADC counters). - */ -struct Max32664RawSample -{ - uint32_t ir = 0; - uint32_t red = 0; -}; - -///////////////////////////////////////////////////////////////////////////////////////// -// Driver Class -///////////////////////////////////////////////////////////////////////////////////////// - -/** - * @brief Driver for the MAX32664D biometric sensor hub. + * The 2.0.x API named the driver class `Max32664` and prefixed its types + * `Max32664*`. As of 2.1.0 the canonical names are `PulseExpress` / + * `PulseExpress*` (see protocentral_pulse_express.h). This header keeps the old + * spelling compiling via aliases. New sketches should include + * "protocentral_pulse_express.h" directly. * - * Lifecycle: - * 1. Construct with the RSTN and MFIO host pin numbers (and optional TwoWire - * bus reference for boards with multiple I2C peripherals). - * 2. From setup(): call Wire.begin(), then begin(). begin() resets the hub, - * reads the firmware version, and selects the matching capability set. - * 3. Enter one of the three modes: - * - startCalibration() + readSample() loop -> readCalibrationVector() - * - loadCalibrationVector() + startEstimation() + readSamples() - * - startRaw() + readRaw() - * 4. stop() to disable AFE / algorithm / AGC. + * DEPRECATED: prefer #include "protocentral_pulse_express.h". */ -class Max32664 -{ -public: - Max32664(uint8_t resetPin, uint8_t mfioPin, TwoWire &bus = Wire); - - /// Optional debug stream (e.g. &Serial). nullptr silences trace output. - void setDebug(Print *dbg) { _dbg = dbg; } - - /// Cache the wall-clock time the hub will be initialised with on the next - /// startCalibration()/startEstimation() call. Returns false on out-of-range. - bool setDateTime(const Max32664DateTime &dt); - - /// Reset the hub, enter application mode, read firmware version, derive caps. - Max32664Status begin(); - - Max32664Version version() const { return _hubVer; } - Max32664Version algoVersion() const { return _algoVer; } - Max32664Caps caps() const { return _caps; } - uint8_t mfioPin() const { return _mfioPin; } - - // ---------------- BPT calibration mode ---------------------------------- - - /// Multi-point flow (firmware 40.5.0+). Repeat for calIndex 0..4. - Max32664Status startCalibration(const Max32664CalibrationRef &ref); - - /// Legacy single-shot flow (firmware <40.5.0). - Max32664Status startCalibration(const Max32664LegacyCalibrationRefs &refs); - - /// Read one sample if DataRdyInt is set. Returns NoDataAvailable if FIFO empty. - Max32664Status readSample(Max32664Sample &out); - - /// Read the user calibration vector after BP status==Success / progress==100. - /// `cap` must be at least caps().calibVectorBytes; `written` returns the - /// actual byte count (always equal to caps().calibVectorBytes on success). - Max32664Status readCalibrationVector(uint8_t *out, size_t cap, size_t *written); - - // ---------------- BPT estimation mode ----------------------------------- - - /// Multi-point: load the previously-saved vector for one calIndex (0..4). - Max32664Status loadCalibrationVector(uint8_t calIndex, const uint8_t *vec, size_t len); - /// Legacy: load the single previously-saved vector. - Max32664Status loadCalibrationVector(const uint8_t *vec, size_t len); - - Max32664Status startEstimation(const Max32664Spo2Coeffs &coeffs = Max32664Spo2Coeffs{}); - - /// Read up to `cap` samples; `*count` returns the number actually written. - Max32664Status readSamples(Max32664Sample *out, size_t cap, size_t *count); - - // ---------------- Raw PPG mode ------------------------------------------ - - Max32664Status startRaw(); - Max32664Status readRaw(Max32664RawSample *out, size_t cap, size_t *count, bool wantRed = true); - - // ---------------- Teardown ---------------------------------------------- - - /// Disable AFE, BPT algorithm, and AGC (UG6921 Tables 5 & 6 §3). - Max32664Status stop(); - -private: - struct HubStatus - { - bool sensorCommError; - bool dataReady; - bool fifoOutOverflow; - bool fifoInOverflow; - bool deviceBusy; - }; - - // I2C primitives. Each thin wrapper builds a small command frame on the - // stack and routes it through writeImpl()/readImpl(), which apply the - // CMD_DELAY, validate the hub status byte, and retry on 0xFE. - Max32664Status writeCmd(uint8_t fam, uint8_t idx, uint16_t cmdDelayMs = 0); - Max32664Status writeCmd(uint8_t fam, uint8_t idx, uint8_t v0, uint16_t cmdDelayMs = 0); - Max32664Status writeCmd(uint8_t fam, uint8_t idx, uint8_t v0, uint8_t v1, uint16_t cmdDelayMs = 0); - Max32664Status writeCmd3(uint8_t fam, uint8_t idx, uint8_t v0, uint8_t v1, uint8_t v2, uint16_t cmdDelayMs = 0); - Max32664Status writeCmd(uint8_t fam, uint8_t idx, const uint8_t *payload, size_t len, uint16_t cmdDelayMs); - Max32664Status writeCmd(uint8_t fam, uint8_t idx, uint8_t sub, const uint8_t *payload, size_t len, uint16_t cmdDelayMs); - - Max32664Status readBytes(uint8_t fam, uint8_t idx, uint8_t *out, size_t len, uint16_t cmdDelayMs = 0); - Max32664Status readBytes(uint8_t fam, uint8_t idx, uint8_t sub, uint8_t *out, size_t len, uint16_t cmdDelayMs = 0); - - // Underlying send-then-status helpers with retry-on-0xFE. `frame` holds - // the command bytes (fam, idx, optional sub, optional payload) — the - // caller must build it on the stack before invoking these. - Max32664Status writeImpl(const uint8_t *frame, size_t frameLen, uint16_t cmdDelayMs); - Max32664Status readImpl(const uint8_t *frame, size_t frameLen, - uint8_t *out, size_t outLen, uint16_t cmdDelayMs); - - // Procedural pieces from UG6921 Tables 2/5/6. - Max32664Status hardReset(); - Max32664Status enterAppMode(); - Max32664Status readFirmwareVersion(uint8_t indexByte, Max32664Version &out); - Max32664Caps capsFor(Max32664Version v) const; - - Max32664Status sendDateTime(); - Max32664Status sendSpo2Coeffs(const Max32664Spo2Coeffs &c); - Max32664Status sendCalibrationRef(const Max32664CalibrationRef &r); - Max32664Status sendLegacyCalibrationRefs(const Max32664LegacyCalibrationRefs &r); - Max32664Status setCalIndex(uint8_t idx); - Max32664Status sendCalibrationVectorChunked(const uint8_t *vec, size_t len); - Max32664Status setOutputMode(uint8_t mode); - Max32664Status setFifoIntrThreshold(uint8_t th); - Max32664Status enableAfe(bool on); - Max32664Status enableAgc(bool on); - Max32664Status enableBpt(uint8_t mode); // 0=disabled, 1=calib, 2=estimation - - Max32664Status readHubStatus(HubStatus &out); - Max32664Status readNumFifoSamples(uint8_t &nn); - Max32664Status readFifoSample(uint8_t *out, size_t len); - void parseSample(const uint8_t *buf, Max32664Sample &s) const; - - void trace(const char *msg) const; - void tracef(const char *fmt, ...) const; +#ifndef _MAX32664_H_ +#define _MAX32664_H_ - TwoWire &_bus; - Print *_dbg = nullptr; - uint8_t _resetPin; - uint8_t _mfioPin; - Max32664Version _hubVer = {0, 0, 0}; - Max32664Version _algoVer = {0, 0, 0}; - Max32664Caps _caps; - Max32664DateTime _now; - bool _began = false; -}; +#include "protocentral_pulse_express.h" + +using Max32664 = PulseExpress; +using Max32664Status = PulseExpressStatus; +using Max32664Version = PulseExpressVersion; +using Max32664Caps = PulseExpressCaps; +using Max32664DateTime = PulseExpressDateTime; +using Max32664Spo2Coeffs = PulseExpressSpo2Coeffs; +using Max32664CalibrationRef = PulseExpressCalibrationRef; +using Max32664LegacyCalibrationRefs = PulseExpressLegacyCalibrationRefs; +using Max32664BpStatus = PulseExpressBpStatus; +using Max32664Sample = PulseExpressSample; +using Max32664RawSample = PulseExpressRawSample; #endif // _MAX32664_H_ diff --git a/src/max32664.cpp b/src/protocentral_pulse_express.cpp similarity index 60% rename from src/max32664.cpp rename to src/protocentral_pulse_express.cpp index 54b758e..b6f9457 100644 --- a/src/max32664.cpp +++ b/src/protocentral_pulse_express.cpp @@ -1,28 +1,21 @@ -////////////////////////////////////////////////////////////////////////////////////////// -// -// Arduino library for the ProtoCentral Pulse Express breakout board -// (MAX30102 optical sensor + MAX32664D biometric sensor hub). -// -// See max32664.h for the public API and UG6921 Rev 2 (Maxim/ADI) for the -// underlying I2C command set. -// -// Original 2020 driver: Joice Tm, Copyright (c) 2020 ProtoCentral -// Modernised rewrite: Copyright (c) 2025 ProtoCentral Electronics -// -// This software is licensed under the MIT License (http://opensource.org/licenses/MIT). -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -// -// For information on how to use, visit https://github.com/Protocentral/protocentral-pulse-express -// -///////////////////////////////////////////////////////////////////////////////////////// - -#include "max32664.h" +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// SPDX-FileCopyrightText: Copyright (c) 2020 Maxim Integrated / Analog Devices (protocol) + +/* + * ProtoCentral Pulse Express — MAX30102 + MAX32664D biometric sensor hub driver. + * + * See protocentral_pulse_express.h for the public API and UG6921 Rev 2 + * (Maxim/ADI) for the underlying I2C command set. + * + * Original 2020 driver: Joice Tm, Copyright (c) 2020 ProtoCentral. + * Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics. + * + * This software is licensed under the MIT License. See LICENSE.md for the full + * text. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. + */ + +#include "protocentral_pulse_express.h" #include #include @@ -79,6 +72,14 @@ constexpr uint8_t kFifoIntrThreshold = 0x0F; constexpr uint8_t kOutputModeAlgo = 0x03; constexpr uint8_t kOutputModeSensorOnly = 0x01; +// FIFO/status reads just return already-buffered data, so they do not need the +// long sensor-proxy CMD_DELAY. Keeping this short is critical: the FIFO must be +// drained faster than the hub fills it or samples are lost. (The hub only +// returns valid FIFO data in the first I2C read transaction after the command, +// so samples must be read one-per-command, not batched — the throughput win is +// purely from this shorter delay vs the 10 ms default.) +constexpr uint16_t kFifoReadDelayMs = 2; + // LED current registers on the MAX30101: 0x0C (LED1/red) and 0x0D (LED2/IR). // Half-scale (0x7F) per UG6921 Table 6 §1.8/1.9. constexpr uint8_t kMax30101Led1RegAddr = 0x0C; @@ -104,12 +105,12 @@ inline void packU32BE(uint32_t v, uint8_t out[4]) // Construction & lifecycle ///////////////////////////////////////////////////////////////////////////////////////// -Max32664::Max32664(uint8_t resetPin, uint8_t mfioPin, TwoWire &bus) +PulseExpress::PulseExpress(uint8_t resetPin, uint8_t mfioPin, TwoWire &bus) : _bus(bus), _resetPin(resetPin), _mfioPin(mfioPin) { } -bool Max32664::setDateTime(const Max32664DateTime &dt) +bool PulseExpress::setDateTime(const PulseExpressDateTime &dt) { if (dt.year < 2000 || dt.year > 2099) return false; if (dt.month == 0 || dt.month > 12) return false; @@ -119,16 +120,16 @@ bool Max32664::setDateTime(const Max32664DateTime &dt) return true; } -Max32664Status Max32664::begin() +PulseExpressStatus PulseExpress::begin() { - Max32664Status s = hardReset(); - if (s != Max32664Status::Ok) return s; + PulseExpressStatus s = hardReset(); + if (s != PulseExpressStatus::Ok) return s; s = enterAppMode(); - if (s != Max32664Status::Ok) return s; + if (s != PulseExpressStatus::Ok) return s; s = readFirmwareVersion(0x03, _hubVer); - if (s != Max32664Status::Ok) return s; + if (s != PulseExpressStatus::Ok) return s; // Algorithm version is informational; ignore failure. readFirmwareVersion(0x07, _algoVer); @@ -140,15 +141,23 @@ Max32664Status Max32664::begin() _caps.dateYYYYMMDD ? "YYYYMMDD " : "YYMMDD ", _caps.multiPointCalib ? "multi-point" : "single-point"); - // Anything outside the 40.x line is one of the A/B/C variants or a future - // product — refuse rather than misdrive it. - if (_hubVer.major != 40) return Max32664Status::UnsupportedFirmware; + // Soft version check: the 40.x line is what this driver is validated + // against. Earlier/other firmware (A/B/C variants, older BPT builds) may + // not be fully supported, but we no longer hard-fail on it — begin() + // proceeds with legacy capability defaults and flags the condition via + // firmwareSupported() so callers can warn. (Was a hard UnsupportedFirmware + // return prior to this; that blocked earlier-firmware boards outright.) + _fwSupported = (_hubVer.major == 40); + if (!_fwSupported) + tracef("WARNING: hub firmware %u.%u.%u is outside the validated 40.x " + "line; proceeding with legacy capability defaults", + _hubVer.major, _hubVer.minor, _hubVer.patch); _began = true; - return Max32664Status::Ok; + return PulseExpressStatus::Ok; } -Max32664Status Max32664::hardReset() +PulseExpressStatus PulseExpress::hardReset() { pinMode(_mfioPin, OUTPUT); pinMode(_resetPin, OUTPUT); @@ -158,37 +167,37 @@ Max32664Status Max32664::hardReset() digitalWrite(_resetPin, HIGH); delay(kResetSettleMs); pinMode(_mfioPin, INPUT_PULLUP); // hub now drives MFIO as data-ready intr - return Max32664Status::Ok; + return PulseExpressStatus::Ok; } -Max32664Status Max32664::enterAppMode() +PulseExpressStatus PulseExpress::enterAppMode() { // 0x01/0x00/0x00 — switch operating mode to application. - Max32664Status s = writeCmd(0x01, 0x00, uint8_t(0x00), kEnterAppModeDelayMs); - if (s != Max32664Status::Ok) return s; + PulseExpressStatus s = writeCmd(0x01, 0x00, uint8_t(0x00), kEnterAppModeDelayMs); + if (s != PulseExpressStatus::Ok) return s; // 0x02/0x00 — read operating mode; expect 0x00 (application). uint8_t mode = 0xFF; s = readBytes(0x02, 0x00, &mode, 1); - if (s != Max32664Status::Ok) return s; - if (mode != 0x00) return Max32664Status::NotInAppMode; - return Max32664Status::Ok; + if (s != PulseExpressStatus::Ok) return s; + if (mode != 0x00) return PulseExpressStatus::NotInAppMode; + return PulseExpressStatus::Ok; } -Max32664Status Max32664::readFirmwareVersion(uint8_t indexByte, Max32664Version &out) +PulseExpressStatus PulseExpress::readFirmwareVersion(uint8_t indexByte, PulseExpressVersion &out) { uint8_t buf[3] = {0, 0, 0}; - Max32664Status s = readBytes(0xFF, indexByte, buf, sizeof(buf)); - if (s != Max32664Status::Ok) return s; + PulseExpressStatus s = readBytes(0xFF, indexByte, buf, sizeof(buf)); + if (s != PulseExpressStatus::Ok) return s; out.major = buf[0]; out.minor = buf[1]; out.patch = buf[2]; - return Max32664Status::Ok; + return PulseExpressStatus::Ok; } -Max32664Caps Max32664::capsFor(Max32664Version v) const +PulseExpressCaps PulseExpress::capsFor(PulseExpressVersion v) const { - Max32664Caps c; // defaults are legacy values + PulseExpressCaps c; // defaults are legacy values if (v.atLeast(40, 2, 2)) { c.sendBpMedication = false; @@ -208,168 +217,173 @@ Max32664Caps Max32664::capsFor(Max32664Version v) const // BPT calibration mode ///////////////////////////////////////////////////////////////////////////////////////// -Max32664Status Max32664::startCalibration(const Max32664CalibrationRef &ref) +PulseExpressStatus PulseExpress::startCalibration(const PulseExpressCalibrationRef &ref) { - if (!_began) return Max32664Status::NotConfigured; - if (!_caps.multiPointCalib) return Max32664Status::UnsupportedFirmware; - if (ref.calIndex > 4) return Max32664Status::InvalidArgument; + if (!_began) return PulseExpressStatus::NotConfigured; + if (!_caps.multiPointCalib) return PulseExpressStatus::UnsupportedFirmware; + if (ref.calIndex > 4) return PulseExpressStatus::InvalidArgument; - Max32664Status s = sendDateTime(); - if (s != Max32664Status::Ok) return s; + PulseExpressStatus s = sendDateTime(); + if (s != PulseExpressStatus::Ok) return s; s = sendCalibrationRef(ref); - if (s != Max32664Status::Ok) return s; + if (s != PulseExpressStatus::Ok) return s; - s = setOutputMode(kOutputModeAlgo); if (s != Max32664Status::Ok) return s; - s = setFifoIntrThreshold(kFifoIntrThreshold); if (s != Max32664Status::Ok) return s; - s = enableAgc(true); if (s != Max32664Status::Ok) return s; - s = enableAfe(true); if (s != Max32664Status::Ok) return s; - s = enableBpt(0x01); if (s != Max32664Status::Ok) return s; + s = setOutputMode(kOutputModeAlgo); if (s != PulseExpressStatus::Ok) return s; + s = setFifoIntrThreshold(kFifoIntrThreshold); if (s != PulseExpressStatus::Ok) return s; + s = enableAgc(true); if (s != PulseExpressStatus::Ok) return s; + s = enableAfe(true); if (s != PulseExpressStatus::Ok) return s; + s = enableBpt(0x01); if (s != PulseExpressStatus::Ok) return s; delay(kPostEnableSettleMs); - return Max32664Status::Ok; + return PulseExpressStatus::Ok; } -Max32664Status Max32664::startCalibration(const Max32664LegacyCalibrationRefs &refs) +PulseExpressStatus PulseExpress::startCalibration(const PulseExpressLegacyCalibrationRefs &refs) { - if (!_began) return Max32664Status::NotConfigured; + if (!_began) return PulseExpressStatus::NotConfigured; // Multi-point firmware rejects the deprecated 0x50/04/01 + 0x50/04/02 path. - if (_caps.multiPointCalib) return Max32664Status::UnsupportedFirmware; + if (_caps.multiPointCalib) return PulseExpressStatus::UnsupportedFirmware; - Max32664Status s = sendDateTime(); - if (s != Max32664Status::Ok) return s; + PulseExpressStatus s = sendDateTime(); + if (s != PulseExpressStatus::Ok) return s; s = sendLegacyCalibrationRefs(refs); - if (s != Max32664Status::Ok) return s; + if (s != PulseExpressStatus::Ok) return s; - s = setOutputMode(kOutputModeAlgo); if (s != Max32664Status::Ok) return s; - s = setFifoIntrThreshold(kFifoIntrThreshold); if (s != Max32664Status::Ok) return s; - s = enableAgc(true); if (s != Max32664Status::Ok) return s; - s = enableAfe(true); if (s != Max32664Status::Ok) return s; - s = enableBpt(0x01); if (s != Max32664Status::Ok) return s; + s = setOutputMode(kOutputModeAlgo); if (s != PulseExpressStatus::Ok) return s; + s = setFifoIntrThreshold(kFifoIntrThreshold); if (s != PulseExpressStatus::Ok) return s; + s = enableAgc(true); if (s != PulseExpressStatus::Ok) return s; + s = enableAfe(true); if (s != PulseExpressStatus::Ok) return s; + s = enableBpt(0x01); if (s != PulseExpressStatus::Ok) return s; delay(kPostEnableSettleMs); - return Max32664Status::Ok; + return PulseExpressStatus::Ok; } -Max32664Status Max32664::readSample(Max32664Sample &out) +PulseExpressStatus PulseExpress::readSample(PulseExpressSample &out) { HubStatus st; - Max32664Status s = readHubStatus(st); - if (s != Max32664Status::Ok) return s; - if (!st.dataReady) return Max32664Status::NoDataAvailable; + PulseExpressStatus s = readHubStatus(st); + if (s != PulseExpressStatus::Ok) return s; + if (!st.dataReady) return PulseExpressStatus::NoDataAvailable; uint8_t nn = 0; s = readNumFifoSamples(nn); - if (s != Max32664Status::Ok) return s; - if (nn == 0) return Max32664Status::NoDataAvailable; + if (s != PulseExpressStatus::Ok) return s; + if (nn == 0) return PulseExpressStatus::NoDataAvailable; uint8_t buf[32]; - if (_caps.sampleBytes > sizeof(buf)) return Max32664Status::BufferTooSmall; + if (_caps.sampleBytes > sizeof(buf)) return PulseExpressStatus::BufferTooSmall; s = readFifoSample(buf, _caps.sampleBytes); - if (s != Max32664Status::Ok) return s; + if (s != PulseExpressStatus::Ok) return s; parseSample(buf, out); - return Max32664Status::Ok; + return PulseExpressStatus::Ok; } -Max32664Status Max32664::readCalibrationVector(uint8_t *out, size_t cap, size_t *written) +PulseExpressStatus PulseExpress::readCalibrationVector(uint8_t *out, size_t cap, size_t *written) { - if (!_began) return Max32664Status::NotConfigured; - if (!out) return Max32664Status::InvalidArgument; - if (cap < _caps.calibVectorBytes) return Max32664Status::BufferTooSmall; + if (!_began) return PulseExpressStatus::NotConfigured; + if (!out) return PulseExpressStatus::InvalidArgument; + if (cap < _caps.calibVectorBytes) return PulseExpressStatus::BufferTooSmall; // 0x51/0x04/0x03 — read user calibration vector. - Max32664Status s = readBytes(0x51, 0x04, 0x03, out, _caps.calibVectorBytes); - if (s != Max32664Status::Ok) return s; + PulseExpressStatus s = readBytes(0x51, 0x04, 0x03, out, _caps.calibVectorBytes); + if (s != PulseExpressStatus::Ok) return s; if (written) *written = _caps.calibVectorBytes; - return Max32664Status::Ok; + return PulseExpressStatus::Ok; } ///////////////////////////////////////////////////////////////////////////////////////// // BPT estimation mode ///////////////////////////////////////////////////////////////////////////////////////// -Max32664Status Max32664::loadCalibrationVector(uint8_t calIndex, const uint8_t *vec, size_t len) +PulseExpressStatus PulseExpress::loadCalibrationVector(uint8_t calIndex, const uint8_t *vec, size_t len) { - if (!_began) return Max32664Status::NotConfigured; - if (!_caps.multiPointCalib) return Max32664Status::UnsupportedFirmware; + if (!_began) return PulseExpressStatus::NotConfigured; + if (!_caps.multiPointCalib) return PulseExpressStatus::UnsupportedFirmware; if (calIndex > 4 || vec == nullptr || len != _caps.calibVectorBytes) - return Max32664Status::InvalidArgument; + return PulseExpressStatus::InvalidArgument; - Max32664Status s = setCalIndex(calIndex); - if (s != Max32664Status::Ok) return s; + PulseExpressStatus s = setCalIndex(calIndex); + if (s != PulseExpressStatus::Ok) return s; return sendCalibrationVectorChunked(vec, len); } -Max32664Status Max32664::loadCalibrationVector(const uint8_t *vec, size_t len) +PulseExpressStatus PulseExpress::loadCalibrationVector(const uint8_t *vec, size_t len) { - if (!_began) return Max32664Status::NotConfigured; - if (_caps.multiPointCalib) return Max32664Status::UnsupportedFirmware; + if (!_began) return PulseExpressStatus::NotConfigured; + if (_caps.multiPointCalib) return PulseExpressStatus::UnsupportedFirmware; if (vec == nullptr || len != _caps.calibVectorBytes) - return Max32664Status::InvalidArgument; + return PulseExpressStatus::InvalidArgument; return sendCalibrationVectorChunked(vec, len); } -Max32664Status Max32664::startEstimation(const Max32664Spo2Coeffs &coeffs) +PulseExpressStatus PulseExpress::startEstimation(const PulseExpressSpo2Coeffs &coeffs) { - if (!_began) return Max32664Status::NotConfigured; + if (!_began) return PulseExpressStatus::NotConfigured; - Max32664Status s = sendDateTime(); - if (s != Max32664Status::Ok) return s; + PulseExpressStatus s = sendDateTime(); + if (s != PulseExpressStatus::Ok) return s; s = sendSpo2Coeffs(coeffs); - if (s != Max32664Status::Ok) return s; + if (s != PulseExpressStatus::Ok) return s; - s = setOutputMode(kOutputModeAlgo); if (s != Max32664Status::Ok) return s; - s = setFifoIntrThreshold(kFifoIntrThreshold); if (s != Max32664Status::Ok) return s; - s = enableAgc(true); if (s != Max32664Status::Ok) return s; - s = enableAfe(true); if (s != Max32664Status::Ok) return s; - s = enableBpt(0x02); if (s != Max32664Status::Ok) return s; + s = setOutputMode(kOutputModeAlgo); if (s != PulseExpressStatus::Ok) return s; + s = setFifoIntrThreshold(kFifoIntrThreshold); if (s != PulseExpressStatus::Ok) return s; + s = enableAgc(true); if (s != PulseExpressStatus::Ok) return s; + s = enableAfe(true); if (s != PulseExpressStatus::Ok) return s; + s = enableBpt(0x02); if (s != PulseExpressStatus::Ok) return s; delay(kPostEnableSettleMs); - return Max32664Status::Ok; + return PulseExpressStatus::Ok; } -Max32664Status Max32664::readSamples(Max32664Sample *out, size_t cap, size_t *count) +PulseExpressStatus PulseExpress::readSamples(PulseExpressSample *out, size_t cap, size_t *count) { if (count) *count = 0; - if (!_began || out == nullptr) return Max32664Status::InvalidArgument; + if (!_began || out == nullptr) return PulseExpressStatus::InvalidArgument; HubStatus st; - Max32664Status s = readHubStatus(st); - if (s != Max32664Status::Ok) return s; - if (!st.dataReady) return Max32664Status::Ok; // empty FIFO is not an error + PulseExpressStatus s = readHubStatus(st); + if (s != PulseExpressStatus::Ok) return s; + if (!st.dataReady) return PulseExpressStatus::Ok; // empty FIFO is not an error uint8_t nn = 0; s = readNumFifoSamples(nn); - if (s != Max32664Status::Ok) return s; - if (nn == 0) return Max32664Status::Ok; + if (s != PulseExpressStatus::Ok) return s; + if (nn == 0) return PulseExpressStatus::Ok; if (nn > cap) nn = uint8_t(cap); + // One sample per 0x12 0x01 command: each read is a single I2C transaction + // (<= 30 bytes), which the hub returns reliably. The throughput win over the + // original comes from the short kFifoReadDelayMs (see readFifoSample), NOT + // from batching — the hub only returns valid FIFO data in the first read + // transaction after the command, so a multi-chunk burst read corrupts. uint8_t buf[32]; - if (_caps.sampleBytes > sizeof(buf)) return Max32664Status::BufferTooSmall; + if (_caps.sampleBytes > sizeof(buf)) return PulseExpressStatus::BufferTooSmall; for (uint8_t i = 0; i < nn; ++i) { s = readFifoSample(buf, _caps.sampleBytes); - if (s != Max32664Status::Ok) return s; + if (s != PulseExpressStatus::Ok) return s; parseSample(buf, out[i]); } if (count) *count = nn; - return Max32664Status::Ok; + return PulseExpressStatus::Ok; } ///////////////////////////////////////////////////////////////////////////////////////// // Raw PPG mode ///////////////////////////////////////////////////////////////////////////////////////// -Max32664Status Max32664::startRaw() +PulseExpressStatus PulseExpress::startRaw() { - if (!_began) return Max32664Status::NotConfigured; + if (!_began) return PulseExpressStatus::NotConfigured; // Per UG6921 Table 6: output mode = sensor-only, then enable AFE, then // enable BPT in estimation mode (the algorithm runs but does not affect // PPG), then disable AGC so LED currents stay where the user puts them. - Max32664Status s = setOutputMode(kOutputModeSensorOnly); - if (s != Max32664Status::Ok) return s; - s = setFifoIntrThreshold(kFifoIntrThreshold); if (s != Max32664Status::Ok) return s; - s = enableAfe(true); if (s != Max32664Status::Ok) return s; - s = enableBpt(0x02); if (s != Max32664Status::Ok) return s; - s = enableAgc(false); if (s != Max32664Status::Ok) return s; + PulseExpressStatus s = setOutputMode(kOutputModeSensorOnly); + if (s != PulseExpressStatus::Ok) return s; + s = setFifoIntrThreshold(kFifoIntrThreshold); if (s != PulseExpressStatus::Ok) return s; + s = enableAfe(true); if (s != PulseExpressStatus::Ok) return s; + s = enableBpt(0x02); if (s != PulseExpressStatus::Ok) return s; + s = enableAgc(false); if (s != PulseExpressStatus::Ok) return s; delay(kRawModeSettleMs); // LED1 (red) and LED2 (IR) currents to half-scale. Must come AFTER the @@ -378,53 +392,55 @@ Max32664Status Max32664::startRaw() // more headroom than the trivial-command default. s = writeCmd3(0x40, 0x03, kMax30101Led1RegAddr, kMax30101LedHalfScale, 0, kLedCurrentDelayMs); - if (s != Max32664Status::Ok) return s; + if (s != PulseExpressStatus::Ok) return s; return writeCmd3(0x40, 0x03, kMax30101Led2RegAddr, kMax30101LedHalfScale, 0, kLedCurrentDelayMs); } -Max32664Status Max32664::readRaw(Max32664RawSample *out, size_t cap, size_t *count, bool wantRed) +PulseExpressStatus PulseExpress::readRaw(PulseExpressRawSample *out, size_t cap, size_t *count, bool wantRed) { if (count) *count = 0; - if (!_began || out == nullptr) return Max32664Status::InvalidArgument; + if (!_began || out == nullptr) return PulseExpressStatus::InvalidArgument; HubStatus st; - Max32664Status s = readHubStatus(st); - if (s != Max32664Status::Ok) return s; - if (!st.dataReady) return Max32664Status::Ok; + PulseExpressStatus s = readHubStatus(st); + if (s != PulseExpressStatus::Ok) return s; + if (!st.dataReady) return PulseExpressStatus::Ok; uint8_t nn = 0; s = readNumFifoSamples(nn); - if (s != Max32664Status::Ok) return s; - if (nn == 0) return Max32664Status::Ok; + if (s != PulseExpressStatus::Ok) return s; + if (nn == 0) return PulseExpressStatus::Ok; if (nn > cap) nn = uint8_t(cap); // Per UG6921 Table 4 a sensor-only sample is 12 bytes (4 LEDs * 3 bytes). // Only LED1 (IR, bytes 0-2) and LED2 (Red, bytes 3-5) are wired up on the - // MAX30101 in this product; LED3/4 are reported as zero. + // MAX30101 in this product; LED3/4 are reported as zero. One sample per + // 0x12 0x01 command (single I2C transaction); the speed-up over the original + // is the short kFifoReadDelayMs, not batching. uint8_t buf[16]; for (uint8_t i = 0; i < nn; ++i) { s = readFifoSample(buf, 12); - if (s != Max32664Status::Ok) return s; + if (s != PulseExpressStatus::Ok) return s; out[i].ir = (uint32_t(buf[0]) << 16) | (uint32_t(buf[1]) << 8) | uint32_t(buf[2]); out[i].red = wantRed ? ((uint32_t(buf[3]) << 16) | (uint32_t(buf[4]) << 8) | uint32_t(buf[5])) : 0u; } if (count) *count = nn; - return Max32664Status::Ok; + return PulseExpressStatus::Ok; } -Max32664Status Max32664::stop() +PulseExpressStatus PulseExpress::stop() { - if (!_began) return Max32664Status::Ok; + if (!_began) return PulseExpressStatus::Ok; // Disable order from UG6921 Tables 5 & 6 §3. - Max32664Status s1 = enableAfe(false); - Max32664Status s2 = enableBpt(0x00); - Max32664Status s3 = enableAgc(false); - if (s1 != Max32664Status::Ok) return s1; - if (s2 != Max32664Status::Ok) return s2; + PulseExpressStatus s1 = enableAfe(false); + PulseExpressStatus s2 = enableBpt(0x00); + PulseExpressStatus s3 = enableAgc(false); + if (s1 != PulseExpressStatus::Ok) return s1; + if (s2 != PulseExpressStatus::Ok) return s2; return s3; } @@ -432,7 +448,7 @@ Max32664Status Max32664::stop() // Sub-procedures (private) ///////////////////////////////////////////////////////////////////////////////////////// -Max32664Status Max32664::sendDateTime() +PulseExpressStatus PulseExpress::sendDateTime() { // Two 32-bit values: date then time. UG6921 Table 3 0x04 describes the // wire format as little-endian, and the worked example for the default @@ -472,7 +488,7 @@ Max32664Status Max32664::sendDateTime() return writeCmd(0x50, 0x04, 0x04, payload, sizeof(payload), kSetDateTimeDelayMs); } -Max32664Status Max32664::sendSpo2Coeffs(const Max32664Spo2Coeffs &c) +PulseExpressStatus PulseExpress::sendSpo2Coeffs(const PulseExpressSpo2Coeffs &c) { // Each coefficient is round(10^5 * value) packed as a 32-bit signed // integer in MSB-first byte order (per the UG6921 worked example for A, @@ -494,7 +510,7 @@ Max32664Status Max32664::sendSpo2Coeffs(const Max32664Spo2Coeffs &c) return writeCmd(0x50, 0x04, 0x06, payload, sizeof(payload), kSetSpo2CoeffsDelayMs); } -Max32664Status Max32664::sendCalibrationRef(const Max32664CalibrationRef &r) +PulseExpressStatus PulseExpress::sendCalibrationRef(const PulseExpressCalibrationRef &r) { // 0x50/0x04/0x07 — set cal_index + reference systolic + reference // diastolic (introduced in 40.5.0+). @@ -502,26 +518,26 @@ Max32664Status Max32664::sendCalibrationRef(const Max32664CalibrationRef &r) return writeCmd(0x50, 0x04, 0x07, payload, sizeof(payload), kSetCalIndexDelayMs); } -Max32664Status Max32664::sendLegacyCalibrationRefs(const Max32664LegacyCalibrationRefs &r) +PulseExpressStatus PulseExpress::sendLegacyCalibrationRefs(const PulseExpressLegacyCalibrationRefs &r) { // 0x50/0x04/0x01 — three systolic refs. - Max32664Status s = writeCmd(0x50, 0x04, 0x01, r.systolic, 3, kDefaultCmdDelayMs); - if (s != Max32664Status::Ok) return s; + PulseExpressStatus s = writeCmd(0x50, 0x04, 0x01, r.systolic, 3, kDefaultCmdDelayMs); + if (s != PulseExpressStatus::Ok) return s; // 0x50/0x04/0x02 — three diastolic refs. return writeCmd(0x50, 0x04, 0x02, r.diastolic, 3, kDefaultCmdDelayMs); } -Max32664Status Max32664::setCalIndex(uint8_t idx) +PulseExpressStatus PulseExpress::setCalIndex(uint8_t idx) { - if (idx > 4) return Max32664Status::InvalidArgument; + if (idx > 4) return PulseExpressStatus::InvalidArgument; // 0x50/0x04/0x08 — select cal_index for subsequent vector load (40.5.0+). uint8_t payload[1] = {idx}; return writeCmd(0x50, 0x04, 0x08, payload, 1, kSetCalIndexDelayMs); } -Max32664Status Max32664::sendCalibrationVectorChunked(const uint8_t *vec, size_t len) +PulseExpressStatus PulseExpress::sendCalibrationVectorChunked(const uint8_t *vec, size_t len) { - if (vec == nullptr || len == 0) return Max32664Status::InvalidArgument; + if (vec == nullptr || len == 0) return PulseExpressStatus::InvalidArgument; // The cal-vector upload is 0x50/0x04/0x03 followed by `len` raw bytes. // On most Arduino platforms the I2C buffer is too small to hold the whole @@ -550,44 +566,44 @@ Max32664Status Max32664::sendCalibrationVectorChunked(const uint8_t *vec, size_t _bus.write(vec[cursor++]); ++inFrame; } - if (_bus.endTransmission() != 0) return Max32664Status::HostCommError; + if (_bus.endTransmission() != 0) return PulseExpressStatus::HostCommError; delay(kCalibVectorChunkDelayMs); } // Final status read confirms the upload was accepted by the hub. if (_bus.requestFrom(uint8_t(MAX32664_I2C_ADDR), uint8_t(1)) != 1) - return Max32664Status::HostCommError; + return PulseExpressStatus::HostCommError; uint8_t status = uint8_t(_bus.read()); - if (status != 0x00) return Max32664Status(status); - return Max32664Status::Ok; + if (status != 0x00) return PulseExpressStatus(status); + return PulseExpressStatus::Ok; } -Max32664Status Max32664::setOutputMode(uint8_t mode) +PulseExpressStatus PulseExpress::setOutputMode(uint8_t mode) { return writeCmd(0x10, 0x00, mode, kDefaultCmdDelayMs); } -Max32664Status Max32664::setFifoIntrThreshold(uint8_t th) +PulseExpressStatus PulseExpress::setFifoIntrThreshold(uint8_t th) { return writeCmd(0x10, 0x01, th, kDefaultCmdDelayMs); } -Max32664Status Max32664::enableAfe(bool on) +PulseExpressStatus PulseExpress::enableAfe(bool on) { return writeCmd(0x44, 0x03, uint8_t(on ? 0x01 : 0x00), on ? kEnableAfeDelayMs : kDisableAfeDelayMs); } -Max32664Status Max32664::enableAgc(bool on) +PulseExpressStatus PulseExpress::enableAgc(bool on) { return writeCmd(0x52, 0x00, uint8_t(on ? 0x01 : 0x00), on ? kEnableAgcDelayMs : kDisableAgcDelayMs); } -Max32664Status Max32664::enableBpt(uint8_t mode) +PulseExpressStatus PulseExpress::enableBpt(uint8_t mode) { // mode: 0=disabled, 1=calibration, 2=estimation. - if (mode > 2) return Max32664Status::InvalidArgument; + if (mode > 2) return PulseExpressStatus::InvalidArgument; return writeCmd(0x52, 0x04, mode, mode == 0 ? kDisableBptDelayMs : kEnableBptDelayMs); } @@ -596,34 +612,34 @@ Max32664Status Max32664::enableBpt(uint8_t mode) // FIFO / sample I/O ///////////////////////////////////////////////////////////////////////////////////////// -Max32664Status Max32664::readHubStatus(HubStatus &out) +PulseExpressStatus PulseExpress::readHubStatus(HubStatus &out) { uint8_t bits = 0; - Max32664Status s = readBytes(0x00, 0x00, &bits, 1); - if (s != Max32664Status::Ok) return s; + PulseExpressStatus s = readBytes(0x00, 0x00, &bits, 1, kFifoReadDelayMs); + if (s != PulseExpressStatus::Ok) return s; out.sensorCommError = (bits & MAX32664_STATUS_SENSOR_COMM) != 0; out.dataReady = (bits & MAX32664_STATUS_DATA_READY) != 0; out.fifoOutOverflow = (bits & MAX32664_STATUS_FIFO_OUT_OVR) != 0; out.fifoInOverflow = (bits & MAX32664_STATUS_FIFO_IN_OVR) != 0; out.deviceBusy = (bits & MAX32664_STATUS_DEVICE_BUSY) != 0; - return Max32664Status::Ok; + return PulseExpressStatus::Ok; } -Max32664Status Max32664::readNumFifoSamples(uint8_t &nn) +PulseExpressStatus PulseExpress::readNumFifoSamples(uint8_t &nn) { - return readBytes(0x12, 0x00, &nn, 1); + return readBytes(0x12, 0x00, &nn, 1, kFifoReadDelayMs); } -Max32664Status Max32664::readFifoSample(uint8_t *out, size_t len) +PulseExpressStatus PulseExpress::readFifoSample(uint8_t *out, size_t len) { - return readBytes(0x12, 0x01, out, len); + return readBytes(0x12, 0x01, out, len, kFifoReadDelayMs); } -void Max32664::parseSample(const uint8_t *buf, Max32664Sample &s) const +void PulseExpress::parseSample(const uint8_t *buf, PulseExpressSample &s) const { // Layout per UG6921 Table 4. Bytes 0..11 are MAX30101 PPG counters in // algorithm mode; byte 12 onward is the BPT block. - s.bpStatus = Max32664BpStatus(buf[12]); + s.bpStatus = PulseExpressBpStatus(buf[12]); s.progress = buf[13]; s.heartRate10x = pack16BE(&buf[14]); s.systolic = buf[16]; @@ -652,7 +668,7 @@ void Max32664::parseSample(const uint8_t *buf, Max32664Sample &s) const // each attempt up to kBusyMaxDelayMs. // Per UG6921 §1.1: "0xFE: Device is busy. Try again. Increase the CMD_DELAY." -Max32664Status Max32664::writeImpl(const uint8_t *frame, size_t frameLen, uint16_t cmdDelayMs) +PulseExpressStatus PulseExpress::writeImpl(const uint8_t *frame, size_t frameLen, uint16_t cmdDelayMs) { uint16_t actualDelay = cmdDelayMs ? cmdDelayMs : kDefaultCmdDelayMs; @@ -664,34 +680,34 @@ Max32664Status Max32664::writeImpl(const uint8_t *frame, size_t frameLen, uint16 { tracef("write fam=0x%02X idx=0x%02X i2c-nack", frameLen > 0 ? frame[0] : 0, frameLen > 1 ? frame[1] : 0); - return Max32664Status::HostCommError; + return PulseExpressStatus::HostCommError; } delay(actualDelay); if (_bus.requestFrom(uint8_t(MAX32664_I2C_ADDR), uint8_t(1)) != 1) - return Max32664Status::HostCommError; + return PulseExpressStatus::HostCommError; uint8_t st = uint8_t(_bus.read()); - if (st == 0x00) return Max32664Status::Ok; + if (st == 0x00) return PulseExpressStatus::Ok; if (st != 0xFE) { tracef("write fam=0x%02X idx=0x%02X status=0x%02X", frameLen > 0 ? frame[0] : 0, frameLen > 1 ? frame[1] : 0, st); - return Max32664Status(st); + return PulseExpressStatus(st); } if (attempt == kBusyRetryMax) { tracef("write fam=0x%02X idx=0x%02X 0xFE busy after %u retries", frameLen > 0 ? frame[0] : 0, frameLen > 1 ? frame[1] : 0, attempt); - return Max32664Status::DeviceBusy; + return PulseExpressStatus::DeviceBusy; } actualDelay = (actualDelay < kBusyMaxDelayMs / 2) ? uint16_t(actualDelay * 2u) : kBusyMaxDelayMs; } - return Max32664Status::DeviceBusy; + return PulseExpressStatus::DeviceBusy; } -Max32664Status Max32664::readImpl(const uint8_t *frame, size_t frameLen, +PulseExpressStatus PulseExpress::readImpl(const uint8_t *frame, size_t frameLen, uint8_t *out, size_t outLen, uint16_t cmdDelayMs) { uint16_t actualDelay = cmdDelayMs ? cmdDelayMs : kDefaultCmdDelayMs; @@ -704,7 +720,7 @@ Max32664Status Max32664::readImpl(const uint8_t *frame, size_t frameLen, { tracef("read fam=0x%02X idx=0x%02X i2c-nack", frameLen > 0 ? frame[0] : 0, frameLen > 1 ? frame[1] : 0); - return Max32664Status::HostCommError; + return PulseExpressStatus::HostCommError; } delay(actualDelay); @@ -722,7 +738,7 @@ Max32664Status Max32664::readImpl(const uint8_t *frame, size_t frameLen, uint16_t want = uint16_t((firstChunk ? 1u : 0u) + remaining); if (want > kReadChunkBytes) want = kReadChunkBytes; uint8_t got = _bus.requestFrom(uint8_t(MAX32664_I2C_ADDR), uint8_t(want)); - if (got != want) return Max32664Status::HostCommError; + if (got != want) return PulseExpressStatus::HostCommError; if (firstChunk) { @@ -744,46 +760,46 @@ Max32664Status Max32664::readImpl(const uint8_t *frame, size_t frameLen, if (remaining == 0) break; } - if (status == 0x00) return Max32664Status::Ok; + if (status == 0x00) return PulseExpressStatus::Ok; if (!transient) { tracef("read fam=0x%02X idx=0x%02X status=0x%02X", frameLen > 0 ? frame[0] : 0, frameLen > 1 ? frame[1] : 0, status); - return Max32664Status(status); + return PulseExpressStatus(status); } if (attempt == kBusyRetryMax) { tracef("read fam=0x%02X idx=0x%02X 0xFE busy after %u retries", frameLen > 0 ? frame[0] : 0, frameLen > 1 ? frame[1] : 0, attempt); - return Max32664Status::DeviceBusy; + return PulseExpressStatus::DeviceBusy; } actualDelay = (actualDelay < kBusyMaxDelayMs / 2) ? uint16_t(actualDelay * 2u) : kBusyMaxDelayMs; } - return Max32664Status::DeviceBusy; + return PulseExpressStatus::DeviceBusy; } -Max32664Status Max32664::writeCmd(uint8_t fam, uint8_t idx, uint16_t cmdDelayMs) +PulseExpressStatus PulseExpress::writeCmd(uint8_t fam, uint8_t idx, uint16_t cmdDelayMs) { uint8_t frame[2] = {fam, idx}; return writeImpl(frame, 2, cmdDelayMs); } -Max32664Status Max32664::writeCmd(uint8_t fam, uint8_t idx, uint8_t v0, uint16_t cmdDelayMs) +PulseExpressStatus PulseExpress::writeCmd(uint8_t fam, uint8_t idx, uint8_t v0, uint16_t cmdDelayMs) { uint8_t frame[3] = {fam, idx, v0}; return writeImpl(frame, 3, cmdDelayMs); } -Max32664Status Max32664::writeCmd(uint8_t fam, uint8_t idx, uint8_t v0, uint8_t v1, +PulseExpressStatus PulseExpress::writeCmd(uint8_t fam, uint8_t idx, uint8_t v0, uint8_t v1, uint16_t cmdDelayMs) { uint8_t frame[4] = {fam, idx, v0, v1}; return writeImpl(frame, 4, cmdDelayMs); } -Max32664Status Max32664::writeCmd3(uint8_t fam, uint8_t idx, +PulseExpressStatus PulseExpress::writeCmd3(uint8_t fam, uint8_t idx, uint8_t v0, uint8_t v1, uint8_t v2, uint16_t cmdDelayMs) { @@ -791,25 +807,25 @@ Max32664Status Max32664::writeCmd3(uint8_t fam, uint8_t idx, return writeImpl(frame, 5, cmdDelayMs); } -Max32664Status Max32664::writeCmd(uint8_t fam, uint8_t idx, +PulseExpressStatus PulseExpress::writeCmd(uint8_t fam, uint8_t idx, const uint8_t *payload, size_t len, uint16_t cmdDelayMs) { // Inline assemble: max payload across callers is 12 bytes (SpO2 coeffs). uint8_t frame[16]; - if (2 + len > sizeof(frame)) return Max32664Status::BufferTooSmall; + if (2 + len > sizeof(frame)) return PulseExpressStatus::BufferTooSmall; frame[0] = fam; frame[1] = idx; for (size_t i = 0; i < len; ++i) frame[2 + i] = payload[i]; return writeImpl(frame, 2 + len, cmdDelayMs); } -Max32664Status Max32664::writeCmd(uint8_t fam, uint8_t idx, uint8_t sub, +PulseExpressStatus PulseExpress::writeCmd(uint8_t fam, uint8_t idx, uint8_t sub, const uint8_t *payload, size_t len, uint16_t cmdDelayMs) { uint8_t frame[16]; - if (3 + len > sizeof(frame)) return Max32664Status::BufferTooSmall; + if (3 + len > sizeof(frame)) return PulseExpressStatus::BufferTooSmall; frame[0] = fam; frame[1] = idx; frame[2] = sub; @@ -817,14 +833,14 @@ Max32664Status Max32664::writeCmd(uint8_t fam, uint8_t idx, uint8_t sub, return writeImpl(frame, 3 + len, cmdDelayMs); } -Max32664Status Max32664::readBytes(uint8_t fam, uint8_t idx, +PulseExpressStatus PulseExpress::readBytes(uint8_t fam, uint8_t idx, uint8_t *out, size_t len, uint16_t cmdDelayMs) { uint8_t frame[2] = {fam, idx}; return readImpl(frame, 2, out, len, cmdDelayMs); } -Max32664Status Max32664::readBytes(uint8_t fam, uint8_t idx, uint8_t sub, +PulseExpressStatus PulseExpress::readBytes(uint8_t fam, uint8_t idx, uint8_t sub, uint8_t *out, size_t len, uint16_t cmdDelayMs) { uint8_t frame[3] = {fam, idx, sub}; @@ -835,12 +851,12 @@ Max32664Status Max32664::readBytes(uint8_t fam, uint8_t idx, uint8_t sub, // Tracing ///////////////////////////////////////////////////////////////////////////////////////// -void Max32664::trace(const char *msg) const +void PulseExpress::trace(const char *msg) const { if (_dbg) _dbg->println(msg); } -void Max32664::tracef(const char *fmt, ...) const +void PulseExpress::tracef(const char *fmt, ...) const { if (!_dbg) return; char buf[128]; diff --git a/src/protocentral_pulse_express.h b/src/protocentral_pulse_express.h new file mode 100644 index 0000000..645ae0f --- /dev/null +++ b/src/protocentral_pulse_express.h @@ -0,0 +1,416 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// SPDX-FileCopyrightText: Copyright (c) 2020 Maxim Integrated / Analog Devices (protocol) + +/* + * ProtoCentral Pulse Express — MAX30102 optical sensor + MAX32664D biometric + * sensor hub. Public API for the PulseExpress driver. + * + * Implements the host-side procedure documented in Maxim/ADI UG6921 Rev 2 + * (11/20) — "Measuring Blood Pressure, Heart Rate, and SpO2 Using MAX32664D". + * Supports MAX32664D firmware across the 40.x.y line, including the 40.5.0+ + * multi-point calibration / 512-byte vector / YYYYMMDD changes; the behaviour + * set is selected at runtime by begin() once the hub firmware version is read. + * + * Original 2020 driver: Joice Tm, Copyright (c) 2020 ProtoCentral. + * + * Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics + * Email: support@protocentral.com + * + * This software is licensed under the MIT License. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef _PROTOCENTRAL_PULSE_EXPRESS_H_ +#define _PROTOCENTRAL_PULSE_EXPRESS_H_ + +#include +#include +#include +#include + +///////////////////////////////////////////////////////////////////////////////////////// +// I2C Address +///////////////////////////////////////////////////////////////////////////////////////// + +#define MAX32664_I2C_ADDR 0x55 // 7-bit form (0xAA/0xAB in 8-bit form) + +///////////////////////////////////////////////////////////////////////////////////////// +// Hub status byte bits (Family 0x00 / Index 0x00, see UG6921 step 2.1) +///////////////////////////////////////////////////////////////////////////////////////// + +#define MAX32664_STATUS_SENSOR_COMM 0x01 +#define MAX32664_STATUS_DATA_READY 0x08 +#define MAX32664_STATUS_FIFO_OUT_OVR 0x10 +#define MAX32664_STATUS_FIFO_IN_OVR 0x20 +#define MAX32664_STATUS_DEVICE_BUSY 0x40 + +/** + * @brief Driver return codes. + * + * Values 0x00..0xFF mirror the sensor-hub status byte (UG6921 Table 1) so a + * raw hub error can be returned to the caller without remapping. Values + * starting at 0xE0 are synthesised by the host driver to surface conditions + * the hub itself does not report (transport failure, programmer error). + */ +enum class PulseExpressStatus : uint8_t +{ + Ok = 0x00, + IllegalIndex = 0x01, + IllegalByteCount = 0x02, + IllegalConfig = 0x03, + NotInAppMode = 0x05, + DeviceBusy = 0xFE, + UnknownHubError = 0xFF, + + HostCommError = 0xE0, + UnsupportedFirmware = 0xE1, + Timeout = 0xE2, + InvalidArgument = 0xE3, + BufferTooSmall = 0xE4, + NoDataAvailable = 0xE5, + NotConfigured = 0xE6, +}; + +/** + * @brief Hub or algorithm firmware version triplet (e.g. 40.6.0). + */ +struct PulseExpressVersion +{ + uint8_t major; + uint8_t minor; + uint8_t patch; + + /// True if (major, minor, patch) >= (M, m, p) lexicographically. + bool atLeast(uint8_t M, uint8_t m, uint8_t p) const + { + if (major != M) return major > M; + if (minor != m) return minor > m; + return patch >= p; + } +}; + +/** + * @brief Capability flags derived from the hub firmware version. + * + * Populated by PulseExpress::begin() and exposed via PulseExpress::caps() so user + * code can size buffers correctly and branch on what the hub will accept. + * + * Breakpoints from UG6921 Tables 2 & 3: + * - 40.2.2+ : BP-medication and Rest-mode setup steps deprecated. + * - 40.5.0+ : Calibration vector size 824 -> 512 bytes; + * sample size 23 -> 29 bytes; + * date format YYMMDD -> YYYYMMDD; + * multi-point user calibration (cal_index 0..4) replaces the + * legacy 3-systolic / 3-diastolic single-shot procedure. + */ +struct PulseExpressCaps +{ + uint16_t calibVectorBytes = 824; // 824 (legacy) | 512 (>=40.5.0) + uint8_t sampleBytes = 23; // 23 (legacy) | 29 (>=40.5.0) + bool dateYYYYMMDD = false; // false: YYMMDD legacy | true: >=40.5.0 + bool multiPointCalib = false; // false: 0x50/04/01+02 | true: 0x50/04/07+08 + bool sendBpMedication = true; // dropped in 40.2.2+ + bool sendRestMode = true; // dropped in 40.2.2+ +}; + +/** + * @brief Wall-clock time supplied to the hub during configureCalibration() / + * configureEstimation(). + * + * The driver re-encodes this as YYMMDD or YYYYMMDD on the wire depending on + * PulseExpressCaps::dateYYYYMMDD; user code passes the same DateTime regardless + * of firmware version. + */ +struct PulseExpressDateTime +{ + uint16_t year = 2025; // full 4-digit year, e.g. 2025 + uint8_t month = 1; // 1..12 + uint8_t day = 1; // 1..31 + uint8_t hour = 0; // 0..23 + uint8_t minute = 0; + uint8_t second = 0; +}; + +/** + * @brief SpO2 calibration coefficients (per Maxim AN6845). + * + * Each coefficient is converted to round(10^5 * value) and uploaded as a + * 32-bit signed integer. Defaults are the example values from UG6921 §2.1. + */ +struct PulseExpressSpo2Coeffs +{ + float a = 1.5958422f; + float b = -34.659664f; + float c = 112.68987f; +}; + +/** + * @brief Multi-point reference cuff measurement (firmware 40.5.0+). + * + * One CalibrationRef is supplied per subject; calIndex 0..4. The firmware + * stores up to five subject-specific calibrations in parallel. + */ +struct PulseExpressCalibrationRef +{ + uint8_t calIndex = 0; // 0..4 + uint8_t systolic = 120; // mmHg + uint8_t diastolic = 80; // mmHg +}; + +/** + * @brief Legacy single-shot reference cuff ramp (firmware <40.5.0). + * + * Three systolic and three diastolic reference values, taken at three points + * around the user's resting BP, uploaded via the deprecated 0x50/04/01 and + * 0x50/04/02 commands. + */ +struct PulseExpressLegacyCalibrationRefs +{ + uint8_t systolic[3] = {120, 122, 125}; + uint8_t diastolic[3] = {80, 81, 82}; +}; + +/** + * @brief BP status code from sample byte 12 (UG6921 Table 4). + */ +enum class PulseExpressBpStatus : uint8_t +{ + NoSignal = 0, + InProgress = 1, + Success = 2, + WeakSignal = 3, + Motion = 4, + EstimationFailure = 5, + CalibrationPartial = 6, + SubjectInitFailure = 7, + InitCompleted = 8, + RefBpTrendingError = 9, + RefInconsistency1 = 10, + RefInconsistency2 = 11, + RefInconsistency3 = 12, + RefCountMismatch = 13, + RefOutOfLimits = 14, + TooManyCalibrations = 15, + PulsePressureOutRange = 16, + HrOutOfRange = 17, + HrAboveResting = 18, + PerfusionOutOfRange = 19, + EstimationRetry = 20, + EstimateOutOfRefRange = 21, + EstimateOutOfMaxLimit = 22, + NoContact = 23, + NoFinger = 24, +}; + +/** + * @brief One algorithm-mode FIFO sample (UG6921 Table 4). + * + * Heart rate, SpO2 and R are reported by the hub as fixed-point integers; the + * helper accessors below return the equivalent floats. Fields beyond pulseFlag + * are zero on firmware <40.5.0. + */ +struct PulseExpressSample +{ + PulseExpressBpStatus bpStatus = PulseExpressBpStatus::NoSignal; + uint8_t progress = 0; // % calibration progress + uint16_t heartRate10x = 0; // 10x bpm + uint8_t systolic = 0; // mmHg + uint8_t diastolic = 0; // mmHg + uint16_t spo210x = 0; // 10x SpO2 percent + uint16_t rValue1000x = 0; // 1000x R (used by SpO2 calibration) + uint8_t pulseFlag = 0; // 1 = R-peak detected + uint16_t ibiMs = 0; // inter-beat interval, ms (40.5.0+) + uint8_t spo2Confidence = 0; // % (40.5.0+) + uint8_t bptReportFlag = 0; // 1 = BPT estimation updated (40.5.0+) + uint8_t spo2ReportFlag = 0; // 1 = SpO2 estimation updated (40.5.0+) + + float heartRate() const { return heartRate10x / 10.0f; } + float spo2() const { return spo210x / 10.0f; } + float rValue() const { return rValue1000x / 1000.0f; } +}; + +/** + * @brief One raw-mode FIFO sample (24-bit IR + Red ADC counters). + */ +struct PulseExpressRawSample +{ + uint32_t ir = 0; + uint32_t red = 0; +}; + +///////////////////////////////////////////////////////////////////////////////////////// +// Driver Class +///////////////////////////////////////////////////////////////////////////////////////// + +/** + * @brief Driver for the MAX32664D biometric sensor hub. + * + * Lifecycle: + * 1. Construct with the RSTN and MFIO host pin numbers (and optional TwoWire + * bus reference for boards with multiple I2C peripherals). + * 2. From setup(): call Wire.begin(), then begin(). begin() resets the hub, + * reads the firmware version, and selects the matching capability set. + * 3. Enter one of the three modes: + * - startCalibration() + readSample() loop -> readCalibrationVector() + * - loadCalibrationVector() + startEstimation() + readSamples() + * - startRaw() + readRaw() + * 4. stop() to disable AFE / algorithm / AGC. + */ +class PulseExpress +{ +public: + PulseExpress(uint8_t resetPin, uint8_t mfioPin, TwoWire &bus = Wire); + + /// Optional debug stream (e.g. &Serial). nullptr silences trace output. + void setDebug(Print *dbg) { _dbg = dbg; } + + /// Cache the wall-clock time the hub will be initialised with on the next + /// startCalibration()/startEstimation() call. Returns false on out-of-range. + bool setDateTime(const PulseExpressDateTime &dt); + + /// Reset the hub, enter application mode, read firmware version, derive caps. + PulseExpressStatus begin(); + + PulseExpressVersion version() const { return _hubVer; } + PulseExpressVersion algoVersion() const { return _algoVer; } + PulseExpressCaps caps() const { return _caps; } + uint8_t mfioPin() const { return _mfioPin; } + + /// True if the hub firmware is on the validated 40.x line. begin() now + /// proceeds even when this is false (soft warning), driving the hub with + /// legacy capability defaults; user code can branch on this to warn. + bool firmwareSupported() const { return _fwSupported; } + + // ---------------- BPT calibration mode ---------------------------------- + + /// Multi-point flow (firmware 40.5.0+). Repeat for calIndex 0..4. + PulseExpressStatus startCalibration(const PulseExpressCalibrationRef &ref); + + /// Legacy single-shot flow (firmware <40.5.0). + PulseExpressStatus startCalibration(const PulseExpressLegacyCalibrationRefs &refs); + + /// Read one sample if DataRdyInt is set. Returns NoDataAvailable if FIFO empty. + PulseExpressStatus readSample(PulseExpressSample &out); + + /// Read the user calibration vector after BP status==Success / progress==100. + /// `cap` must be at least caps().calibVectorBytes; `written` returns the + /// actual byte count (always equal to caps().calibVectorBytes on success). + PulseExpressStatus readCalibrationVector(uint8_t *out, size_t cap, size_t *written); + + // ---------------- BPT estimation mode ----------------------------------- + + /// Multi-point: load the previously-saved vector for one calIndex (0..4). + PulseExpressStatus loadCalibrationVector(uint8_t calIndex, const uint8_t *vec, size_t len); + + /// Legacy: load the single previously-saved vector. + PulseExpressStatus loadCalibrationVector(const uint8_t *vec, size_t len); + + PulseExpressStatus startEstimation(const PulseExpressSpo2Coeffs &coeffs = PulseExpressSpo2Coeffs{}); + + /// Read up to `cap` samples; `*count` returns the number actually written. + PulseExpressStatus readSamples(PulseExpressSample *out, size_t cap, size_t *count); + + // ---------------- Raw PPG mode ------------------------------------------ + + PulseExpressStatus startRaw(); + PulseExpressStatus readRaw(PulseExpressRawSample *out, size_t cap, size_t *count, bool wantRed = true); + + // ---------------- Diagnostics ------------------------------------------- + + /// Decoded sensor-hub status byte (UG6921 Table 1). Surfaced so user code + /// can detect FIFO overflow / sensor-comm errors during streaming. + struct HubStatus + { + bool sensorCommError; + bool dataReady; + bool fifoOutOverflow; + bool fifoInOverflow; + bool deviceBusy; + }; + + /// Read and decode the hub status byte (Family 0x00 / Index 0x00). + PulseExpressStatus readStatus(HubStatus &out) { return readHubStatus(out); } + + // ---------------- Teardown ---------------------------------------------- + + /// Disable AFE, BPT algorithm, and AGC (UG6921 Tables 5 & 6 §3). + PulseExpressStatus stop(); + +private: + // I2C primitives. Each thin wrapper builds a small command frame on the + // stack and routes it through writeImpl()/readImpl(), which apply the + // CMD_DELAY, validate the hub status byte, and retry on 0xFE. + PulseExpressStatus writeCmd(uint8_t fam, uint8_t idx, uint16_t cmdDelayMs = 0); + PulseExpressStatus writeCmd(uint8_t fam, uint8_t idx, uint8_t v0, uint16_t cmdDelayMs = 0); + PulseExpressStatus writeCmd(uint8_t fam, uint8_t idx, uint8_t v0, uint8_t v1, uint16_t cmdDelayMs = 0); + PulseExpressStatus writeCmd3(uint8_t fam, uint8_t idx, uint8_t v0, uint8_t v1, uint8_t v2, uint16_t cmdDelayMs = 0); + PulseExpressStatus writeCmd(uint8_t fam, uint8_t idx, const uint8_t *payload, size_t len, uint16_t cmdDelayMs); + PulseExpressStatus writeCmd(uint8_t fam, uint8_t idx, uint8_t sub, const uint8_t *payload, size_t len, uint16_t cmdDelayMs); + + PulseExpressStatus readBytes(uint8_t fam, uint8_t idx, uint8_t *out, size_t len, uint16_t cmdDelayMs = 0); + PulseExpressStatus readBytes(uint8_t fam, uint8_t idx, uint8_t sub, uint8_t *out, size_t len, uint16_t cmdDelayMs = 0); + + // Underlying send-then-status helpers with retry-on-0xFE. `frame` holds + // the command bytes (fam, idx, optional sub, optional payload) — the + // caller must build it on the stack before invoking these. + PulseExpressStatus writeImpl(const uint8_t *frame, size_t frameLen, uint16_t cmdDelayMs); + PulseExpressStatus readImpl(const uint8_t *frame, size_t frameLen, + uint8_t *out, size_t outLen, uint16_t cmdDelayMs); + + // Procedural pieces from UG6921 Tables 2/5/6. + PulseExpressStatus hardReset(); + PulseExpressStatus enterAppMode(); + PulseExpressStatus readFirmwareVersion(uint8_t indexByte, PulseExpressVersion &out); + PulseExpressCaps capsFor(PulseExpressVersion v) const; + + PulseExpressStatus sendDateTime(); + PulseExpressStatus sendSpo2Coeffs(const PulseExpressSpo2Coeffs &c); + PulseExpressStatus sendCalibrationRef(const PulseExpressCalibrationRef &r); + PulseExpressStatus sendLegacyCalibrationRefs(const PulseExpressLegacyCalibrationRefs &r); + PulseExpressStatus setCalIndex(uint8_t idx); + PulseExpressStatus sendCalibrationVectorChunked(const uint8_t *vec, size_t len); + PulseExpressStatus setOutputMode(uint8_t mode); + PulseExpressStatus setFifoIntrThreshold(uint8_t th); + PulseExpressStatus enableAfe(bool on); + PulseExpressStatus enableAgc(bool on); + PulseExpressStatus enableBpt(uint8_t mode); // 0=disabled, 1=calib, 2=estimation + + PulseExpressStatus readHubStatus(HubStatus &out); + PulseExpressStatus readNumFifoSamples(uint8_t &nn); + PulseExpressStatus readFifoSample(uint8_t *out, size_t len); + void parseSample(const uint8_t *buf, PulseExpressSample &s) const; + + void trace(const char *msg) const; + void tracef(const char *fmt, ...) const; + + TwoWire &_bus; + Print *_dbg = nullptr; + uint8_t _resetPin; + uint8_t _mfioPin; + PulseExpressVersion _hubVer = {0, 0, 0}; + PulseExpressVersion _algoVer = {0, 0, 0}; + PulseExpressCaps _caps; + PulseExpressDateTime _now; + bool _began = false; + bool _fwSupported = false; +}; + +#endif // _PROTOCENTRAL_PULSE_EXPRESS_H_ diff --git a/src/pulse_express_bootloader.cpp b/src/pulse_express_bootloader.cpp new file mode 100644 index 0000000..7d985ab --- /dev/null +++ b/src/pulse_express_bootloader.cpp @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// SPDX-FileCopyrightText: Copyright (c) 2020 Maxim Integrated / Analog Devices (protocol) + +/* + * ProtoCentral Pulse Express — MAX32664 bootloader-mode API. + * See pulse_express_bootloader.h. Command sequence from UG6806 Table 9. + * + * Implemented from spec; validate on hardware before production use. + */ + +#include "pulse_express_bootloader.h" + +#include +#include + +namespace +{ +constexpr uint8_t kI2cAddr = 0x55; // 7-bit (0xAA/0xAB 8-bit) +constexpr uint16_t kDefaultDelayMs = 5; +constexpr uint16_t kEnterBlDelayMs = 20; +constexpr uint16_t kSetCfgDelayMs = 10; // num pages / IV / auth +constexpr uint16_t kEraseDelayMs = 1500; // application erase is slow +constexpr uint16_t kPageWriteDelayMs = 700; // per-page erase+write +constexpr uint16_t kResetSettleMs = 50; // UG6806: 50 ms to reach BL mode +constexpr uint8_t kBusyRetryMax = 5; // 0x80 = ERR_BTLDR_TRY_AGAIN +constexpr uint8_t kI2cChunkBytes = 30; // fits AVR 32-byte Wire buffer +} + +PulseExpressBootloader::PulseExpressBootloader(uint8_t resetPin, uint8_t mfioPin, TwoWire &bus) + : _bus(bus), _resetPin(resetPin), _mfioPin(mfioPin) +{ +} + +PulseExpressBootloader::Status PulseExpressBootloader::enterBootloader() +{ + // UG6806: hold MFIO LOW while releasing RSTN to select bootloader mode. + pinMode(_resetPin, OUTPUT); + pinMode(_mfioPin, OUTPUT); + digitalWrite(_mfioPin, LOW); + digitalWrite(_resetPin, LOW); + delay(10); + digitalWrite(_resetPin, HIGH); + delay(kResetSettleMs); // >= 50 ms to reach bootloader + // MFIO may now be released; the enter-bootloader command must follow within + // ~780 ms or a valid application would start instead. + pinMode(_mfioPin, INPUT_PULLUP); + + Status s = setMode(0x08); // 0x01 0x00 0x08 — enter bootloader + if (s != Status::Ok) return s; + + uint8_t mode = 0xFF; + s = readMode(mode); // 0x02 0x00 — expect 0x08 + if (s != Status::Ok) return s; + if (mode != 0x08) { trace("not in bootloader (mode=0x%02X)", mode); return Status::NotBootloader; } + return Status::Ok; +} + +PulseExpressBootloader::Status PulseExpressBootloader::setMode(uint8_t mode) +{ + uint8_t f[3] = {0x01, 0x00, mode}; + return writeFrame(f, 3, kEnterBlDelayMs); +} + +PulseExpressBootloader::Status PulseExpressBootloader::readMode(uint8_t &modeOut) +{ + uint8_t f[2] = {0x02, 0x00}; + return readFrame(f, 2, &modeOut, 1, kDefaultDelayMs); +} + +PulseExpressBootloader::Status PulseExpressBootloader::readBootloaderVersion(uint8_t out[3]) +{ + uint8_t f[2] = {0x81, 0x00}; + return readFrame(f, 2, out, 3, kDefaultDelayMs); +} + +PulseExpressBootloader::Status PulseExpressBootloader::readPageSize(uint16_t &sizeOut) +{ + uint8_t f[2] = {0x81, 0x01}; + uint8_t buf[2] = {0, 0}; + Status s = readFrame(f, 2, buf, 2, kDefaultDelayMs); + if (s != Status::Ok) return s; + sizeOut = uint16_t((uint16_t(buf[0]) << 8) | buf[1]); // big-endian + return Status::Ok; +} + +PulseExpressBootloader::Status PulseExpressBootloader::setNumPages(uint16_t numPages) +{ + uint8_t f[4] = {0x80, 0x02, uint8_t(numPages >> 8), uint8_t(numPages & 0xFF)}; + return writeFrame(f, 4, kSetCfgDelayMs); +} + +PulseExpressBootloader::Status PulseExpressBootloader::setInitVector(const uint8_t iv[MSBL_INIT_VECTOR_BYTES]) +{ + uint8_t f[2 + MSBL_INIT_VECTOR_BYTES] = {0x80, 0x00}; + for (uint8_t i = 0; i < MSBL_INIT_VECTOR_BYTES; ++i) f[2 + i] = iv[i]; + return writeFrame(f, sizeof(f), kSetCfgDelayMs); +} + +PulseExpressBootloader::Status PulseExpressBootloader::setAuth(const uint8_t auth[MSBL_AUTH_BYTES]) +{ + uint8_t f[2 + MSBL_AUTH_BYTES] = {0x80, 0x01}; + for (uint8_t i = 0; i < MSBL_AUTH_BYTES; ++i) f[2 + i] = auth[i]; + return writeFrame(f, sizeof(f), kSetCfgDelayMs); +} + +PulseExpressBootloader::Status PulseExpressBootloader::eraseApplication() +{ + uint8_t f[2] = {0x80, 0x03}; + return writeFrame(f, 2, kEraseDelayMs); +} + +PulseExpressBootloader::Status PulseExpressBootloader::writePage(const uint8_t *page, size_t len) +{ + if (page == nullptr || len != MSBL_PAGE_PAYLOAD_BYTES) return Status::InvalidArg; + + // 0x80 0x04 followed by the page payload. The payload is far larger than any + // Arduino Wire buffer, so it is split across I2C frames: the first frame + // carries the 2-byte command header, the rest are raw continuations the hub + // accumulates. A single status read terminates the transaction. Retried as a + // whole on 0x80 (ERR_BTLDR_TRY_AGAIN). + for (uint8_t attempt = 0; attempt <= kBusyRetryMax; ++attempt) + { + size_t cursor = 0; + bool first = true; + bool ioOk = true; + while (cursor < len) + { + _bus.beginTransmission(kI2cAddr); + uint8_t inFrame = 0; + if (first) { _bus.write(uint8_t(0x80)); _bus.write(uint8_t(0x04)); inFrame = 2; first = false; } + while (inFrame < kI2cChunkBytes && cursor < len) { _bus.write(page[cursor++]); ++inFrame; } + if (_bus.endTransmission() != 0) { ioOk = false; break; } + } + if (!ioOk) { trace("writePage i2c-nack"); return Status::CommError; } + + delay(kPageWriteDelayMs); + if (_bus.requestFrom(kI2cAddr, uint8_t(1)) != 1) return Status::CommError; + uint8_t st = uint8_t(_bus.read()); + if (st == 0x00) return Status::Ok; + if (st != 0x80) { trace("writePage status=0x%02X", st); return Status(st); } + if (attempt == kBusyRetryMax) return Status::TryAgain; + delay(kPageWriteDelayMs); // busy: wait and resend the whole page + } + return Status::TryAgain; +} + +PulseExpressBootloader::Status PulseExpressBootloader::exitToApplication() +{ + Status s = setMode(0x00); // 0x01 0x00 0x00 — exit bootloader + // Hard reset back into application mode (MFIO high during reset). + pinMode(_mfioPin, OUTPUT); + pinMode(_resetPin, OUTPUT); + digitalWrite(_mfioPin, HIGH); + digitalWrite(_resetPin, LOW); + delay(10); + digitalWrite(_resetPin, HIGH); + delay(1000); + pinMode(_mfioPin, INPUT_PULLUP); + return s; +} + +PulseExpressBootloader::Status PulseExpressBootloader::writeFrame(const uint8_t *frame, size_t len, + uint16_t cmdDelayMs) +{ + for (uint8_t attempt = 0; attempt <= kBusyRetryMax; ++attempt) + { + _bus.beginTransmission(kI2cAddr); + for (size_t i = 0; i < len; ++i) _bus.write(frame[i]); + if (_bus.endTransmission() != 0) { trace("write i2c-nack fam=0x%02X", frame[0]); return Status::CommError; } + delay(cmdDelayMs ? cmdDelayMs : kDefaultDelayMs); + if (_bus.requestFrom(kI2cAddr, uint8_t(1)) != 1) return Status::CommError; + uint8_t st = uint8_t(_bus.read()); + if (st == 0x00) return Status::Ok; + if (st != 0x80) { trace("write status=0x%02X (fam=0x%02X)", st, frame[0]); return Status(st); } + if (attempt == kBusyRetryMax) return Status::TryAgain; + delay(cmdDelayMs ? cmdDelayMs : kDefaultDelayMs); + } + return Status::TryAgain; +} + +PulseExpressBootloader::Status PulseExpressBootloader::readFrame(const uint8_t *frame, size_t frameLen, + uint8_t *out, size_t outLen, + uint16_t cmdDelayMs) +{ + _bus.beginTransmission(kI2cAddr); + for (size_t i = 0; i < frameLen; ++i) _bus.write(frame[i]); + if (_bus.endTransmission() != 0) return Status::CommError; + delay(cmdDelayMs ? cmdDelayMs : kDefaultDelayMs); + + uint8_t want = uint8_t(1 + outLen); + if (_bus.requestFrom(kI2cAddr, want) != want) return Status::CommError; + uint8_t st = uint8_t(_bus.read()); + for (size_t i = 0; i < outLen; ++i) out[i] = uint8_t(_bus.read()); + if (st == 0x00) return Status::Ok; + return Status(st); +} + +void PulseExpressBootloader::trace(const char *fmt, ...) const +{ + if (!_dbg) return; + char buf[80]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + _dbg->println(buf); +} diff --git a/src/pulse_express_bootloader.h b/src/pulse_express_bootloader.h new file mode 100644 index 0000000..f10c5a5 --- /dev/null +++ b/src/pulse_express_bootloader.h @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2025 Ashwin Whitchurch, Protocentral Electronics +// SPDX-FileCopyrightText: Copyright (c) 2020 Maxim Integrated / Analog Devices (protocol) + +/* + * ProtoCentral Pulse Express — MAX32664 bootloader-mode API (firmware flashing). + * + * This is a SEPARATE, FACTORY / RECOVERY tool, distinct from the application- + * mode driver in protocentral_pulse_express.h. It implements the in-application + * programming sequence from Maxim/ADI User Guide 6806, Table 9 ("Annotated I2C + * Trace for Flashing the Application"): + * + * 1. enterBootloader() - pin-sequence MFIO low at reset, set mode 0x08 + * 2. setNumPages() - .msbl byte 0x44 + * 3. setInitVector() - .msbl bytes 0x28..0x32 (11 bytes) + * 4. setAuth() - .msbl bytes 0x34..0x43 (16 bytes) + * 5. eraseApplication() + * 6. writePage() x N - .msbl page data from 0x4C, 8208 bytes/page + * 7. exitToApplication() + * + * IMPORTANT: Pulse Express boards ship pre-flashed at the factory. The firmware + * image (.msbl) is Maxim/ADI IP and is NOT redistributed with this library — + * supply your own. This code is implemented from the UG6806 spec and MUST be + * validated on hardware before any production use; a failed flash leaves the + * hub in bootloader mode (recoverable by re-running the flash). + */ + +#ifndef _PULSE_EXPRESS_BOOTLOADER_H_ +#define _PULSE_EXPRESS_BOOTLOADER_H_ + +#include +#include +#include +#include + +/// MAX32664 .msbl header field locations (UG6806, Figures 9-12). The values +/// change between firmware revisions but their offsets do not. +#define MSBL_OFFSET_NUM_PAGES 0x44 +#define MSBL_OFFSET_INIT_VECTOR 0x28 // 11 bytes: 0x28..0x32 +#define MSBL_OFFSET_AUTH 0x34 // 16 bytes: 0x34..0x43 +#define MSBL_OFFSET_PAGE_DATA 0x4C // first page; 8208 bytes each thereafter +#define MSBL_INIT_VECTOR_BYTES 11 +#define MSBL_AUTH_BYTES 16 +#define MSBL_PAGE_PAYLOAD_BYTES 8208 // 8192 flash + 16 CRC bytes per page + +class PulseExpressBootloader +{ +public: + enum class Status : uint8_t + { + Ok = 0x00, + // 0x80..0x83 mirror the hub bootloader error byte (UG6806 status table). + TryAgain = 0x80, + ChecksumError = 0x81, + AuthError = 0x82, + InvalidApp = 0x83, + // Host-synthesised conditions. + CommError = 0xE0, + NotBootloader = 0xE1, + Timeout = 0xE2, + InvalidArg = 0xE3, + }; + + PulseExpressBootloader(uint8_t resetPin, uint8_t mfioPin, TwoWire &bus = Wire); + + void setDebug(Print *dbg) { _dbg = dbg; } + + /// Sequence RSTN/MFIO to power up in bootloader mode, then confirm via the + /// operating-mode read (expects 0x08). UG6806 "MAX32664 Bootloader Mode". + Status enterBootloader(); + + Status readBootloaderVersion(uint8_t out[3]); // 0x81 0x00 + Status readPageSize(uint16_t &sizeOut); // 0x81 0x01 + + Status setNumPages(uint16_t numPages); // 0x80 0x02 + Status setInitVector(const uint8_t iv[MSBL_INIT_VECTOR_BYTES]); // 0x80 0x00 + Status setAuth(const uint8_t auth[MSBL_AUTH_BYTES]); // 0x80 0x01 + Status eraseApplication(); // 0x80 0x03 + + /// Write one page payload (must be MSBL_PAGE_PAYLOAD_BYTES). 0x80 0x04 + + /// page bytes, chunked across I2C frames to fit small Wire buffers. + Status writePage(const uint8_t *page, size_t len); + + /// Leave bootloader and start the (newly flashed) application. 0x01 0x00 0x00, + /// then a reset with MFIO held high to select application mode. + Status exitToApplication(); + +private: + Status setMode(uint8_t mode); + Status readMode(uint8_t &modeOut); + Status writeFrame(const uint8_t *frame, size_t len, uint16_t cmdDelayMs); + Status readFrame(const uint8_t *frame, size_t frameLen, + uint8_t *out, size_t outLen, uint16_t cmdDelayMs); + void trace(const char *fmt, ...) const; + + TwoWire &_bus; + Print *_dbg = nullptr; + uint8_t _resetPin; + uint8_t _mfioPin; +}; + +#endif // _PULSE_EXPRESS_BOOTLOADER_H_