From bc31aeb33f670e373ef2a82708bf7ecd9afbb4b4 Mon Sep 17 00:00:00 2001 From: Geoff McIntyre Date: Thu, 24 Sep 2026 15:24:32 -0600 Subject: [PATCH] Initial commit --- .gitattributes | 2 + .github/workflows/add_issue_to_project.yml | 18 + .github/workflows/build-deploy-ghpages.yml | 64 + .github/workflows/cross-compilation.yml | 122 + .gitignore | 19 + .gitmodules | 3 + LICENSE.md | 43 + README.md | 298 +++ docs/doxygen/doxygen-config | 60 + docs/doxygen/doxygen-custom/custom.css | 21 + docs/doxygen/doxygen-custom/header.html | 123 + docs/images/gh-banner-bmi330.png | Bin 0 -> 158637 bytes docs/images/sfe_flame.png | Bin 0 -> 3832 bytes .../Example01_BasicReadingsI2C.ino | 105 + .../Example02_BasicReadingsSPI.ino | 100 + .../Example03_Configuration.ino | 179 ++ .../Example04_DataReadyInterrupt.ino | 146 ++ .../Example05_FIFOBuffer.ino | 188 ++ .../Example06_WakeOnMotion.ino | 195 ++ .../Example07_TapDetection.ino | 126 + .../Example08_StepCounter.ino | 151 ++ .../Example09_TiltOrientationFlat.ino | 161 ++ .../Example10_SelfTest/Example10_SelfTest.ino | 148 ++ .../Example11_SelfCalibration.ino | 173 ++ .../Example12_AxisRemap.ino | 178 ++ extras/TapDiagnostic/TapDiagnostic.ino | 410 ++++ keywords.txt | 260 +++ library.properties | 11 + src/SparkFun_BMI330.h | 251 ++ src/sfTk/sfDevBMI330.cpp | 1804 +++++++++++++++ src/sfTk/sfDevBMI330.h | 2060 +++++++++++++++++ 31 files changed, 7419 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/add_issue_to_project.yml create mode 100644 .github/workflows/build-deploy-ghpages.yml create mode 100644 .github/workflows/cross-compilation.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 docs/doxygen/doxygen-config create mode 100644 docs/doxygen/doxygen-custom/custom.css create mode 100644 docs/doxygen/doxygen-custom/header.html create mode 100644 docs/images/gh-banner-bmi330.png create mode 100644 docs/images/sfe_flame.png create mode 100644 examples/Example01_BasicReadingsI2C/Example01_BasicReadingsI2C.ino create mode 100644 examples/Example02_BasicReadingsSPI/Example02_BasicReadingsSPI.ino create mode 100644 examples/Example03_Configuration/Example03_Configuration.ino create mode 100644 examples/Example04_DataReadyInterrupt/Example04_DataReadyInterrupt.ino create mode 100644 examples/Example05_FIFOBuffer/Example05_FIFOBuffer.ino create mode 100644 examples/Example06_WakeOnMotion/Example06_WakeOnMotion.ino create mode 100644 examples/Example07_TapDetection/Example07_TapDetection.ino create mode 100644 examples/Example08_StepCounter/Example08_StepCounter.ino create mode 100644 examples/Example09_TiltOrientationFlat/Example09_TiltOrientationFlat.ino create mode 100644 examples/Example10_SelfTest/Example10_SelfTest.ino create mode 100644 examples/Example11_SelfCalibration/Example11_SelfCalibration.ino create mode 100644 examples/Example12_AxisRemap/Example12_AxisRemap.ino create mode 100644 extras/TapDiagnostic/TapDiagnostic.ino create mode 100644 keywords.txt create mode 100644 library.properties create mode 100644 src/SparkFun_BMI330.h create mode 100644 src/sfTk/sfDevBMI330.cpp create mode 100644 src/sfTk/sfDevBMI330.h diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/workflows/add_issue_to_project.yml b/.github/workflows/add_issue_to_project.yml new file mode 100644 index 0000000..1a6fb42 --- /dev/null +++ b/.github/workflows/add_issue_to_project.yml @@ -0,0 +1,18 @@ +# This action will add your issue to our Product Defect Project +name: Add new issue to main project + +on: + issues: + types: + - opened + +jobs: + add-to-project: + name: Add issue to project + runs-on: ubuntu-latest + steps: + - uses: actions/add-to-project@main + with: + project-url: https://github.com/orgs/sparkfun/projects/19 + github-token: ${{ secrets.DEFECT_ADD_TO_PROJECT }} + diff --git a/.github/workflows/build-deploy-ghpages.yml b/.github/workflows/build-deploy-ghpages.yml new file mode 100644 index 0000000..b69a484 --- /dev/null +++ b/.github/workflows/build-deploy-ghpages.yml @@ -0,0 +1,64 @@ +name: Build Documentation and Deploy + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + id-token: write + pages: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + # Checkout the repository + - name: Checkout repository + uses: actions/checkout@v2 + with: + fetch-depth: 0 + submodules: "true" + + - name: Set Version + run: echo "PROJECT_NUMBER = `git describe --tags`" >> ./docs/doxygen/doxygen-config + + - name: Build Documentation + uses: mattnotmitt/doxygen-action@v1.9.5 + with: + doxyfile-path: "./docs/doxygen/doxygen-config" + + # Upload the documentation as an artifact + - name: Upload documentation + uses: actions/upload-pages-artifact@v3.0.1 + with: + path: ./docs/html + + # Deploy job + deploy: + # Add a dependency to the build job + needs: build + + # Grant GITHUB_TOKEN the permissions required to make a Pages deployment + permissions: + pages: write # to deploy to Pages + id-token: write # to verify the deployment originates from an appropriate source + + # Deploy to the github-pages environment + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + # Specify runner + deployment step + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 # or specific "vX.X.X" version tag for this action diff --git a/.github/workflows/cross-compilation.yml b/.github/workflows/cross-compilation.yml new file mode 100644 index 0000000..a2ef678 --- /dev/null +++ b/.github/workflows/cross-compilation.yml @@ -0,0 +1,122 @@ +name: Cross-compilation Library Test + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + + +jobs: + compile-sketch: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + + matrix: + board: + # Uno (ATmega328P) - the 2 kB SRAM / 32 kB flash floor the README promises + - fqbn: arduino:avr:uno + name: arduino:avr + source-url: https://downloads.arduino.cc/packages/package_index.json + + # Mega 2560 + # https://github.com/arduino/ArduinoCore-avr/blob/master/boards.txt + - fqbn: arduino:avr:mega + name: arduino:avr + source-url: https://downloads.arduino.cc/packages/package_index.json + + # ESP32 + # https://github.com/espressif/arduino-esp32/blob/master/boards.txt + - fqbn: esp32:esp32:esp32 + name: esp32:esp32 + source-url: https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json + + # ESP32-S2 + - fqbn: esp32:esp32:esp32s2 + name: esp32:esp32 + source-url: https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json + + # ESP32-C3 + - fqbn: esp32:esp32:esp32c3 + name: esp32:esp32 + source-url: https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json + + # ESP8266 + # https://github.com/esp8266/Arduino/blob/master/boards.txt + - fqbn: esp8266:esp8266:thingdev + name: esp8266:esp8266 + source-url: https://arduino.esp8266.com/stable/package_esp8266com_index.json + + # SAMD21 + # https://github.com/arduino/ArduinoCore-samd/blob/master/boards.txt + - fqbn: arduino:samd:mkr1000 + name: arduino:samd + source-url: https://downloads.arduino.cc/packages/package_index.json + + # Nano BLE 33 / nRF52840 + # https://github.com/arduino/ArduinoCore-mbed/blob/master/boards.txt + - fqbn: arduino:mbed:nano33ble + name: arduino:mbed + source-url: https://downloads.arduino.cc/packages/package_index.json + + # RP2040 + # https://github.com/earlephilhower/arduino-pico/blob/master/boards.txt + - fqbn: rp2040:rp2040:sparkfun_promicrorp2040 + name: rp2040:rp2040 + source-url: https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json + + # RP2350 + - fqbn: rp2040:rp2040:sparkfun_promicrorp2350 + name: rp2040:rp2040 + source-url: https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json + + # STM32 + # https://github.com/stm32duino/Arduino_Core_STM32/blob/main/boards.txt + - fqbn: STMicroelectronics:stm32:GenF4 + name: STMicroelectronics:stm32 + source-url: https://github.com/stm32duino/BoardManagerFiles/raw/main/package_stmicroelectronics_index.json + + # not using the arduino action to compile everything - it fails to load libraries at a given ref/branch + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Branch name + run: echo running on branch ${GITHUB_REF##*/} + + - name: Arduino - Install and setup the Arduino CLI + uses: arduino/setup-arduino-cli@v2 + + - name: Arduino - Start config file + run: arduino-cli config init --additional-urls ${{ matrix.board.source-url}} + + - name: Arduino - Update index + run: arduino-cli core update-index + + - name: Arduino - Install platform + run: arduino-cli core install ${{ matrix.board.name}} + + - name: Arduino - Install libraries + run: | + arduino-cli config set library.enable_unsafe_install true + arduino-cli version + arduino-cli lib install "SparkFun Toolkit"@1.2.0 + + # Compile every example, not just the first one. With a dozen sketches exercising + # different corners of the driver - FIFO, feature engine, self-test - a single-example + # check would miss most of the library. + - name: Compile all examples + run: | + status=0 + for example in examples/*/ ; do + echo "::group::${example}" + if ! arduino-cli compile --fqbn ${{ matrix.board.fqbn }} "${example}" --library . ; then + echo "::error::${example} failed to compile for ${{ matrix.board.fqbn }}" + status=1 + fi + echo "::endgroup::" + done + exit $status diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..caacc63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Doxygen build output (generated by .github/workflows/build-deploy-ghpages.yml) +docs/html/ +docs/latex/ + +# Arduino / build artifacts +*.o +*.a +*.elf +*.hex +*.bin +build/ + +# One-time repo bootstrap files (bundle + setup script); safe to delete once pushed +_repo-setup/ + +# Editor / OS cruft +.vscode/ +.DS_Store +Thumbs.db diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a8d0052 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "docs/doxygen/doxygen-awesome-css"] + path = docs/doxygen/doxygen-awesome-css + url = https://github.com/jothepro/doxygen-awesome-css.git diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..6829dff --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,43 @@ +SparkFun License Information +============================ + +SparkFun uses two different licenses for our files — one for hardware and one for code. + +Hardware +--------- + +**SparkFun hardware is released under [Creative Commons Share-alike 4.0 International](http://creativecommons.org/licenses/by-sa/4.0/).** + +Note: This is a human-readable summary of (and not a substitute for) the [license](http://creativecommons.org/licenses/by-sa/4.0/legalcode). + +You are free to: + +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. +Under the following terms: + +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. +No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits. + +Notices: + +You do not have to comply with the license for elements of the material in the public domain or where your use is permitted by an applicable exception or limitation. +No warranties are given. The license may not give you all of the permissions necessary for your intended use. For example, other rights such as publicity, privacy, or moral rights may limit how you use the material. + + +Code +-------- + +**SparkFun code, firmware, and software are released under the [MIT License](http://opensource.org/licenses/MIT).** + +The MIT License (MIT) + +Copyright (c) 2026 SparkFun Electronics + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3661227 --- /dev/null +++ b/README.md @@ -0,0 +1,298 @@ + +![SparkFun BMI330 Arduino Library](docs/images/gh-banner-bmi330.png "SparkFun BMI330 Arduino Library") + +# SparkFun BMI330 Arduino Library + +Arduino library for the SparkFun Qwiic BMI330 breakout, built around the Bosch BMI330 6-DoF IMU. + +![GitHub License](https://img.shields.io/github/license/sparkfun/SparkFun_BMI330_Arduino_Library) +![Release](https://img.shields.io/github/v/release/sparkfun/SparkFun_BMI330_Arduino_Library) +![Release Date](https://img.shields.io/github/release-date/sparkfun/SparkFun_BMI330_Arduino_Library) +![Documentation - build](https://img.shields.io/github/actions/workflow/status/sparkfun/SparkFun_BMI330_Arduino_Library/build-deploy-ghpages.yml?label=doc%20build) +[![Compile Test](https://github.com/sparkfun/SparkFun_BMI330_Arduino_Library/actions/workflows/cross-compilation.yml/badge.svg)](https://github.com/sparkfun/SparkFun_BMI330_Arduino_Library/actions/workflows/cross-compilation.yml) +![GitHub issues](https://img.shields.io/github/issues/sparkfun/SparkFun_BMI330_Arduino_Library) + +The BMI330 pairs a 16-bit triaxial accelerometer with a 16-bit triaxial gyroscope, a temperature +sensor, a 2 kB FIFO, two interrupt pins, and an on-chip feature engine that detects motion events +without the host doing any work. It is Bosch's extended-temperature successor to the BMI270, and it +is considerably easier to live with: every feature lives in ROM, so there is no configuration blob +to upload and no boot wait before the first sample. + +This library allows you to: + +* Read acceleration, angular rate, die temperature and a hardware timestamp in one transaction +* Talk to the board over **I2C or SPI** with identical code above the bus layer +* Set output data rate, full-scale range, filter bandwidth, hardware averaging and power mode +* Use **both interrupt pins**, with any source routed to either one independently +* Buffer samples in the **2 kB FIFO** with watermark and full interrupts, and decode frames +* Run the **feature engine** - any-motion, no-motion, significant motion, tilt, orientation, flat, + single/double/triple tap, step detector and step counter +* Remap axes inside the device, so the fix applies to the features as well as the data +* Run **self-test** and **gyroscope self-calibration**, and set user offsets + +## Quick Start + +```cpp +#include + +SfeBMI330ArdI2C myIMU; + +void setup() +{ + Serial.begin(115200); + Wire.begin(); + + while (myIMU.begin() == false) + { + Serial.println("BMI330 not found - check your wiring!"); + delay(1000); + } + + // Both sensors power up disabled. Turn them on. + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_ACCEL_RANGE_4G); + myIMU.setGyroConfig(BMI330_GYRO_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_GYRO_RANGE_500DPS); +} + +void loop() +{ + sfe_bmi330_sensor_data_t data; + myIMU.getSensorData(data); + + Serial.print(data.accel.x, 3); + Serial.print(" g, "); + Serial.print(data.gyro.z, 1); + Serial.println(" dps"); + + delay(100); +} +``` + +### Five things worth knowing up front + +**Both sensors power up disabled.** `begin()` leaves them that way, matching the device's own +power-on state. A sketch that calls `begin()` and immediately reads gets zeros. This is deliberate: +the right power mode is an application decision, and defaulting to high-performance would cost +current on a battery design that wanted low-power mode. + +**No configuration file.** The BMI270 will not report a single sample until the host uploads an +8 kB firmware blob and waits for it to boot. Every BMI330 feature lives in ROM. `begin()` is a chip +ID check and a feature-engine enable, and this library carries no vendor binary. + +**The default I2C address is 0x68.** Close the board's ADR jumper for `0x69`. `SfeBMI330ArdI2C::findDevice()` +will check both and confirm the chip ID, so a sketch does not have to know how the jumper is set. + +**Temperature is `raw / 256 + 23`, not `raw / 512 + 23`.** The BMI323 uses 512. If you are porting +a BMI323 driver, this is the line that silently reports half the true excursion from 23 °C. The +library handles it in `rawToCelsius()`. + +**Enabling a feature takes two writes.** Setting a bit in `FEATURE_IO0` does nothing until the host +writes the commit register. `enableFeature()` does both. A hand-rolled driver that skips the commit +reads the enable bits back correctly and the feature stays off - a quiet failure worth knowing about. + +## Hardware + +### I2C (default) + +Plug a Qwiic cable in. That is the whole procedure. + +| Breakout | Host | +| -------- | ----------- | +| Qwiic | Qwiic | +| INT1 | any input pin (optional) | +| INT2 | any input pin (optional) | + +### SPI + +The SPI pins are broken out as well, and the same driver handles them. + +| Breakout | Host | +| -------- | ------------ | +| SDI | COPI (MOSI) | +| SDO | CIPO (MISO) | +| SCK | SCK | +| CS | any output pin | +| 3V3 | 3V3 | +| GND | GND | + +```cpp +SfeBMI330ArdSPI myIMU; +myIMU.begin(10); // chip select on D10 +``` + +SPI is worth the extra wires in two situations. The first is throughput: at 6400 Hz with both +sensors running, each sample is 16 bytes of payload, which is over 100 kB/s and eats most of a +400 kHz I2C bus. A 4 MHz SPI link moves it in a few percent of its capacity. The second is +addressing - the board has one ADR jumper, so I2C tops out at two BMI330s per bus, where SPI needs +only another chip select. + +One BMI330 quirk the library handles for you: the device powers up undecided about its interface +and commits to SPI on the first CS edge it sees, so the first transfer returns garbage *by design*. +The library performs and discards that read inside `begin()` and after every `softReset()`. A +hand-rolled driver without it usually fails to read the chip ID on the first try. + +### Interrupts + +Both pins are brought out, and every source is routed independently: + +```cpp +myIMU.mapInterrupt(BMI330_INT_SRC_ACCEL_DRDY, BMI330_INT1); // data ready on INT1 +myIMU.mapInterrupt(BMI330_INT_SRC_ANY_MOTION, BMI330_INT2); // motion on INT2 +``` + +Splitting sources across the two pins lets an ISR tell events apart without a bus transaction, +which is the difference between a clean wake and a read that has to happen before the sketch knows +whether it needed to. + +Interrupt status registers are **clear-on-read**: one call to `getInterruptStatus()` consumes every +pending flag for that pin. Read once and test the whole returned word against the +`sfe_bmi330_int_status_t` masks. + +The board fits no interrupt pull-up, so supply one externally if you configure the pins as +open-drain. + +## Examples + +| Example | What it covers | +| ------- | -------------- | +| [Example01_BasicReadingsI2C](examples/Example01_BasicReadingsI2C) | Acceleration, rate and temperature over Qwiic | +| [Example02_BasicReadingsSPI](examples/Example02_BasicReadingsSPI) | The same sketch over SPI - compare the two | +| [Example03_Configuration](examples/Example03_Configuration) | Range, ODR, bandwidth and averaging, with measured noise at each; the ERR_REG configuration-error flag | +| [Example04_DataReadyInterrupt](examples/Example04_DataReadyInterrupt) | One read per sample, paced by INT1 | +| [Example05_FIFOBuffer](examples/Example05_FIFOBuffer) | Watermark interrupts and headerless frame decoding | +| [Example06_WakeOnMotion](examples/Example06_WakeOnMotion) | Any-motion wakes to full rate, no-motion drops to low power - the battery-design pattern | +| [Example07_TapDetection](examples/Example07_TapDetection) | Single, double and triple tap | +| [Example08_StepCounter](examples/Example08_StepCounter) | Step detector and step counter with a watermark | +| [Example09_TiltOrientationFlat](examples/Example09_TiltOrientationFlat) | Three posture features on one pin | +| [Example10_SelfTest](examples/Example10_SelfTest) | Per-axis health check of both sensors | +| [Example11_SelfCalibration](examples/Example11_SelfCalibration) | Measuring and correcting gyroscope offset | +| [Example12_AxisRemap](examples/Example12_AxisRemap) | Correcting a mounting orientation inside the device | + +Significant motion (`setSigMotionConfig()`, `BMI330_FEAT_SIG_MOTION`) follows exactly the same +configure / map / enable pattern as Example06 and has no example of its own. + +[extras/TapDiagnostic](extras/TapDiagnostic) is a bench utility rather than an example: it dumps +the feature-engine state, measures which axis your taps land on, and sweeps the tap detector across +X, Y and Z. + +## Key Specifications + +| | | +| --- | --- | +| Accelerometer range | ±2 / ±4 / ±8 / ±16 g (16384 / 8192 / 4096 / 2048 LSB/g) | +| Gyroscope range | ±125 / ±250 / ±500 / ±1000 / ±2000 dps (262.144 … 16.384 LSB/dps) | +| Output data rate | 0.78 Hz to 6400 Hz | +| FIFO | 2048 bytes (1024 words) | +| Sensor time resolution | 39.0625 µs per LSB | +| Temperature | 256 LSB/K, zero at 23 °C, −41 °C to +87 °C | +| Supply | VDD 1.71–3.63 V, VDDIO 1.08–3.63 V | +| Current | ~15 µA suspend, ~45 µA low-power accelerometer, ~790 µA full IMU | +| Interfaces | I2C (0x68 / 0x69), SPI up to 10 MHz, I3C | +| Chip ID | 0x48 (the BMI323 reads 0x43) | + +## API Notes + +### Units are the hardware's, not SI + +The feature-engine configuration structures carry the device's own units, because converting them +would hide the quantization. The ones that matter: + +| Field | Unit | +| ----- | ---- | +| Slope thresholds, hysteresis | 1.953 mg per LSB (1/512 g) | +| Durations, wait times, hold times | 20 ms per LSB (a 50 Hz internal tick) | +| Step counter watermark | 20 steps per LSB | +| FIFO watermark | 16-bit **words**, not bytes and not frames | +| Tilt minimum angle | 256 × cos(angle) - a *larger* number is a *smaller* angle | + +`frameLengthWords()` converts a FIFO source set into words per frame, so a watermark can be +expressed in frames without doing the arithmetic by hand. + +### The FIFO is headerless + +Frames carry no tag saying what is in them. The order is fixed by hardware — accelerometer +(3 words), gyroscope (3 words), temperature (1 word), sensor time (1 word) — with disabled sources +simply absent. That is why `readFifoData()` takes the source set as an argument: pass the wrong one +and the data decodes into plausible nonsense rather than failing. + +`readFifoData()` reads only whole frames. A partial frame is left in the buffer and arrives +complete on the next call. + +### Tap watches exactly one axis + +`tap.axis` selects the single axis the detector runs on, and it ignores the other two. Z is the +board's face, which is right for tapping the top of a board lying flat — but a board standing in a +carrier, mounted on edge, or tapped through the side of an enclosure puts the impulse on X or Y, +and a Z-axis detector will never see it however low the threshold goes. Bosch's own reference +example uses Y. + +If tap is not firing, check the axis before touching the threshold. +The [TapDiagnostic](extras/TapDiagnostic) utility arms each axis in turn with a low +threshold and counts what lands, and it polls rather than using the interrupt pin, so a wiring +fault cannot be mistaken for a detector fault. + +That polling point generalizes: a source mapped with `mapInterrupt()` appears in that pin's +`INT_STATUS` register whether or not anything is wired to the pin. You can develop and debug any +feature over a bare Qwiic cable and add the interrupt wire later purely for latency. + +### Configuration errors do not fail the write + +An ODR the selected power mode cannot support (6400 Hz in low-power mode, say) is not rejected by +the register write. It sets `acc_conf_err` or `gyr_conf_err` in `ERR_REG`. Anything unexpected +about a sensor's data rate is worth one call: + +```cpp +sfe_bmi330_error_status_t errors; +myIMU.getErrorStatus(errors); +if (errors.accelConfError) { /* the device refused that combination */ } +``` + +### Axis remapping briefly suspends the accelerometer + +The device only accepts an axis-map update while the accelerometer is suspended. `setAxisRemap()` +handles that — it saves the current power mode, disables the sensor, commits the map, waits for the +feature engine to acknowledge, clears the interrupt flag the update leaves behind, and restores the +mode. The cost is that sampling stops for a few tens of milliseconds, so this is a setup-time call, +not something to do inside a data-ready handler. A remap also does not survive `softReset()`. + +### Self-test and self-calibration are disruptive + +Both routines reconfigure the accelerometer, take hundreds of milliseconds, and require the board +to be **still**. The library saves and restores your configuration, but sampling in flight is +interrupted regardless. + +Self-calibration has one failure mode with no error flag: a run on a moving device reports success +and leaves an offset *worse* than it started with, because the routine assumes any rate it sees is +offset. Gyroscope offsets are also volatile — they are lost on reset or power-down, so either +re-run the calibration at boot or read them out with `getGyroOffset()` and restore them yourself. + +## Repository Contents + +* **/examples** - Example sketches for the library (.ino). Run these from the Arduino IDE. +* **/extras** - Bench utilities that are not library examples (TapDiagnostic). Open them in the Arduino IDE like any sketch. +* **/src** - Source files for the library (.cpp, .h). +* **/docs** - Configuration files for the documentation. +* **keywords.txt** - Keywords from this library that will be highlighted in the Arduino IDE. +* **library.properties** - General library properties for the Arduino package manager. + +The library is in two layers. `src/sfTk/sfDevBMI330.{h,cpp}` is a comms-agnostic driver that knows +the register map and nothing about Arduino; `src/SparkFun_BMI330.h` supplies the Arduino I2C and SPI +wrappers. A sketch only ever needs the latter. + +## Documentation + +* **[Library Documentation](https://docs.sparkfun.com/SparkFun_BMI330_Arduino_Library/)** - Arduino Library Documentation for this repository. +* **[Hookup Guide](https://docs.sparkfun.com)** - Hookup guide for the SparkFun Qwiic BMI330 breakout. +* **[Installing an Arduino Library Guide](https://learn.sparkfun.com/tutorials/installing-an-arduino-library)** - Basic information on how to install an Arduino library. +* **[SparkFun Toolkit](https://github.com/sparkfun/SparkFun_Toolkit)** - The bus abstraction this library is built on. Required. +* **[BMI330 Datasheet](https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bmi330-ds000.pdf)** - Bosch Sensortec. +* **[Hardware GitHub Repository](TODO)** - Main repository (including hardware files) for the product that uses this library. + +## Products That Use This Library + +* [[TODO: SKU]](https://www.sparkfun.com/) - SparkFun Qwiic 6DoF IMU Breakout - BMI330 + +## License Information + +This product is ***open source***! + +This product is licensed using the [MIT Open Source License](https://opensource.org/license/mit) diff --git a/docs/doxygen/doxygen-config b/docs/doxygen/doxygen-config new file mode 100644 index 0000000..5cde37c --- /dev/null +++ b/docs/doxygen/doxygen-config @@ -0,0 +1,60 @@ +# Doxygen configuration for the SparkFun BMI330 Arduino Library +# The CI workflow appends a PROJECT_NUMBER line (from git describe) before running. + +DOXYFILE_ENCODING = UTF-8 +PROJECT_NAME = "SparkFun BMI330 Arduino Library" +PROJECT_BRIEF = "Arduino Library for the SparkFun Qwiic BMI330 6-DoF IMU (Bosch BMI330)" +PROJECT_LOGO = ./docs/images/sfe_flame.png +OUTPUT_DIRECTORY = ./docs +CREATE_SUBDIRS = NO +ALLOW_UNICODE_NAMES = NO +OPTIMIZE_OUTPUT_FOR_C = NO +MARKDOWN_SUPPORT = YES +AUTOLINK_SUPPORT = YES + +# Extraction +EXTRACT_ALL = YES +EXTRACT_PRIVATE = NO +EXTRACT_STATIC = YES +HIDE_UNDOC_MEMBERS = NO +JAVADOC_AUTOBRIEF = YES + +# Input +INPUT = src \ + README.md \ + docs +INPUT_ENCODING = UTF-8 +FILE_PATTERNS = *.h *.cpp *.md +RECURSIVE = YES +IMAGE_PATH = docs/images +USE_MDFILE_AS_MAINPAGE = README.md + +# HTML output +GENERATE_HTML = YES +HTML_OUTPUT = html +GENERATE_TREEVIEW = YES +DISABLE_INDEX = NO +FULL_SIDEBAR = NO + +# SparkFun doxygen-awesome theme (header, stylesheets, color scheme) +HTML_HEADER = ./docs/doxygen/doxygen-custom/header.html +HTML_EXTRA_STYLESHEET = docs/doxygen/doxygen-awesome-css/doxygen-awesome.css \ + docs/doxygen/doxygen-awesome-css/doxygen-awesome-sidebar-only.css \ + docs/doxygen/doxygen-custom/custom.css +HTML_COLORSTYLE = AUTO_LIGHT +HTML_COLORSTYLE_HUE = 209 +HTML_COLORSTYLE_SAT = 255 +HTML_COLORSTYLE_GAMMA = 113 + +# Disable other generators +GENERATE_LATEX = NO +GENERATE_XML = NO +GENERATE_RTF = NO +GENERATE_MAN = NO + +# Diagrams +HAVE_DOT = NO + +# Warnings +QUIET = YES +WARN_IF_UNDOCUMENTED = NO diff --git a/docs/doxygen/doxygen-custom/custom.css b/docs/doxygen/doxygen-custom/custom.css new file mode 100644 index 0000000..3ca06b8 --- /dev/null +++ b/docs/doxygen/doxygen-custom/custom.css @@ -0,0 +1,21 @@ +:root { + --side-nav-fixed-width: 300px; +} + +.github-corner svg { + fill: var(--primary-light-color); + color: var(--page-background-color); + width: 72px; + height: 72px; +} + +#projectnumber { + margin-right: 22px; +} + +@media screen and (max-width: 767px) { + .github-corner svg { + width: 55px; + height: 55px; + } +} \ No newline at end of file diff --git a/docs/doxygen/doxygen-custom/header.html b/docs/doxygen/doxygen-custom/header.html new file mode 100644 index 0000000..0e42507 --- /dev/null +++ b/docs/doxygen/doxygen-custom/header.html @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + $projectname: $title + + $title + + + + + $treeview + $search + $mathjax + + $extrastylesheet + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
+
$projectname +  $projectnumber +
+ +
$projectbrief
+
+
$projectbrief
+
$searchbox
+
+ + \ No newline at end of file diff --git a/docs/images/gh-banner-bmi330.png b/docs/images/gh-banner-bmi330.png new file mode 100644 index 0000000000000000000000000000000000000000..b8f6ec4b3f14349b8cc7e21f7ad54cc062a9e78f GIT binary patch literal 158637 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 FzX4VB6Da@y literal 0 HcmV?d00001 diff --git a/examples/Example01_BasicReadingsI2C/Example01_BasicReadingsI2C.ino b/examples/Example01_BasicReadingsI2C/Example01_BasicReadingsI2C.ino new file mode 100644 index 0000000..9c6e0de --- /dev/null +++ b/examples/Example01_BasicReadingsI2C/Example01_BasicReadingsI2C.ino @@ -0,0 +1,105 @@ +/* + Example 01 - Basic Readings (I2C) + + Reads acceleration, angular rate and die temperature from a SparkFun Qwiic BMI330 breakout + and prints them once every 100 ms. + + Two things to know about a fresh BMI330: + + 1. Both sensors power up DISABLED. begin() leaves them that way on purpose, because the + right power mode is an application decision. Nothing is read until enableAccel() and + enableGyro() have run. + + 2. The default ranges are +/-2 g and +/-2000 dps. Picking the smallest range your + application can survive is free resolution - the output is always 16 bits. + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + QWIIC --> QWIIC + + The board answers at 0x68 as shipped. Close the ADR jumper for 0x69. + + Open the Serial Monitor at 115200 baud. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +SfeBMI330ArdI2C myIMU; + +void setup() +{ + Serial.begin(115200); + Serial.println("SparkFun BMI330 Example 1 - Basic Readings (I2C)"); + + Wire.begin(); + + // Use SFE_BMI330_ALT_ADDRESS here if the ADR jumper is closed. + while (myIMU.begin() == false) + { + Serial.println("BMI330 not found - check your wiring and the ADR jumper!"); + delay(1000); + } + + uint8_t chipId = 0, revId = 0; + myIMU.getChipID(chipId, revId); + + Serial.print("Connected to a BMI330 at 0x"); + Serial.print(myIMU.getAddress(), HEX); + Serial.print(" (chip ID 0x"); + Serial.print(chipId, HEX); + Serial.print(", rev "); + Serial.print(revId); + Serial.println(")"); + + // Turn both sensors on at 100 Hz. +/-4 g and +/-500 dps suit general handheld motion: fast + // enough to see a shake, fine enough to resolve a slow tilt. + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_ACCEL_RANGE_4G); + myIMU.setGyroConfig(BMI330_GYRO_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_GYRO_RANGE_500DPS); + + // An ODR the selected power mode cannot support does not fail the write - it shows up here. + sfe_bmi330_error_status_t errors; + myIMU.getErrorStatus(errors); + + if (errors.accelConfError || errors.gyroConfError) + Serial.println("WARNING: the device rejected a sensor configuration."); + + Serial.println(); + Serial.println("AccX(g)\tAccY(g)\tAccZ(g)\tGyrX(dps)\tGyrY(dps)\tGyrZ(dps)\tTemp(C)"); +} + +void loop() +{ + sfe_bmi330_sensor_data_t data; + + // One burst read, so every field below describes the same instant. + if (myIMU.getSensorData(data) != ksfTkErrOk) + { + Serial.println("Read failed!"); + delay(1000); + return; + } + + Serial.print(data.accel.x, 3); + Serial.print("\t"); + Serial.print(data.accel.y, 3); + Serial.print("\t"); + Serial.print(data.accel.z, 3); + Serial.print("\t"); + Serial.print(data.gyro.x, 2); + Serial.print("\t\t"); + Serial.print(data.gyro.y, 2); + Serial.print("\t\t"); + Serial.print(data.gyro.z, 2); + Serial.print("\t\t"); + Serial.println(data.temperature, 1); + + delay(100); +} diff --git a/examples/Example02_BasicReadingsSPI/Example02_BasicReadingsSPI.ino b/examples/Example02_BasicReadingsSPI/Example02_BasicReadingsSPI.ino new file mode 100644 index 0000000..71f4cd4 --- /dev/null +++ b/examples/Example02_BasicReadingsSPI/Example02_BasicReadingsSPI.ino @@ -0,0 +1,100 @@ +/* + Example 02 - Basic Readings (SPI) + + The same readings as Example 01, over SPI instead of I2C. Compare the two sketches: below + setup(), they are identical. Everything above the bus - configuration, data, interrupts, + FIFO, the feature engine - is the same code either way. + + Why bother with four more wires? Two reasons: + + 1. Throughput. At 6400 Hz with both sensors running, each sample is 16 bytes of payload. + That is over 100 kB/s, which eats most of a 400 kHz I2C bus. A 4 MHz SPI link moves it + in a few percent of its capacity and leaves room for everything else. + + 2. Addressing. The board has one ADR jumper, so I2C tops out at two BMI330s per bus. SPI + needs only another chip select line. + + NOTE: the BMI330 powers up undecided about its interface and commits to SPI on the first CS + edge it sees. That first transfer returns garbage by design. The library performs and + discards it inside begin(), so nothing here has to care - but it is why a hand-rolled driver + often fails to read the chip ID on the first try. + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + CIPO (MISO) --> SDO + COPI (MOSI) --> SDI + SCK --> SCK + D10 --> CS + 3V3 --> 3V3 + GND --> GND + + Open the Serial Monitor at 115200 baud. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +// Chip select pin. Any free digital pin works. +const uint8_t kCSPin = 10; + +SfeBMI330ArdSPI myIMU; + +void setup() +{ + Serial.begin(115200); + Serial.println("SparkFun BMI330 Example 2 - Basic Readings (SPI)"); + + SPI.begin(); + + // The default clock is 4 MHz. The part is specified to 10 MHz - raise it once the wiring is + // short, lower it if long jumper wires are producing read errors. + while (myIMU.begin(kCSPin) == false) + { + Serial.println("BMI330 not found - check your wiring and chip select pin!"); + delay(1000); + } + + Serial.print("Connected to a BMI330 on CS pin "); + Serial.println(myIMU.getCSPin()); + + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_ACCEL_RANGE_4G); + myIMU.setGyroConfig(BMI330_GYRO_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_GYRO_RANGE_500DPS); + + Serial.println(); + Serial.println("AccX(g)\tAccY(g)\tAccZ(g)\tGyrX(dps)\tGyrY(dps)\tGyrZ(dps)\tTemp(C)"); +} + +void loop() +{ + sfe_bmi330_sensor_data_t data; + + if (myIMU.getSensorData(data) != ksfTkErrOk) + { + Serial.println("Read failed!"); + delay(1000); + return; + } + + Serial.print(data.accel.x, 3); + Serial.print("\t"); + Serial.print(data.accel.y, 3); + Serial.print("\t"); + Serial.print(data.accel.z, 3); + Serial.print("\t"); + Serial.print(data.gyro.x, 2); + Serial.print("\t\t"); + Serial.print(data.gyro.y, 2); + Serial.print("\t\t"); + Serial.print(data.gyro.z, 2); + Serial.print("\t\t"); + Serial.println(data.temperature, 1); + + delay(100); +} diff --git a/examples/Example03_Configuration/Example03_Configuration.ino b/examples/Example03_Configuration/Example03_Configuration.ino new file mode 100644 index 0000000..a1eeb1e --- /dev/null +++ b/examples/Example03_Configuration/Example03_Configuration.ino @@ -0,0 +1,179 @@ +/* + Example 03 - Configuration and Filtering + + Walks through the BMI330's configuration knobs and shows what each one costs and buys. The + sketch cycles through several settings, printing the noise it measures at each, so you can + watch the trade-offs on a bench rather than take them on faith. + + The knobs, and what they do: + + Range 16-bit output always spans the selected range, so sensitivity halves each time + the range doubles: 16384 LSB/g at +/-2 g, 2048 LSB/g at +/-16 g. Narrower is + better resolution - until something clips, at which point it is much worse. + + ODR Output data rate. Higher rates cost current and admit more noise per sample. + + Bandwidth Low-pass cutoff, expressed as ODR/2 or ODR/4. ODR/4 is quieter and adds group + delay, which matters if you are closing a control loop around the output. + + Averaging Averages N samples in hardware. Noise falls as sqrt(N) - 64x averaging is 8x + quieter - and the output data rate does NOT change, so it costs the host + nothing. It does raise the sensor's internal sample rate, and with it current. + + An illegal ODR-and-power-mode pairing (say 6400 Hz in low-power mode) does not make the + register write fail. It sets acc_conf_err or gyr_conf_err in ERR_REG, which this sketch + checks after every change. That check is worth keeping in your own code. + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + QWIIC --> QWIIC + + Leave the board STILL on a table while this runs - it is measuring noise, and a nudge will + swamp the numbers it is trying to show you. + + Open the Serial Monitor at 115200 baud. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +SfeBMI330ArdI2C myIMU; + +// Measure the standard deviation of the accelerometer Z axis, in milli-g, over a batch of +// samples. This is the number that averaging and bandwidth actually move. +float measureNoise_mg(uint16_t samples) +{ + float sum = 0.0f; + float sumSq = 0.0f; + + for (uint16_t i = 0; i < samples; i++) + { + sfe_bmi330_data_t accel; + + if (myIMU.getAccelData(accel) != ksfTkErrOk) + return -1.0f; + + sum += accel.z; + sumSq += accel.z * accel.z; + delay(5); + } + + float mean = sum / samples; + float variance = (sumSq / samples) - (mean * mean); + + if (variance < 0.0f) + variance = 0.0f; + + return sqrt(variance) * 1000.0f; +} + +void reportConfig(const char *label, sfe_bmi330_accel_range_t range, sfe_bmi330_odr_t odr, + sfe_bmi330_bandwidth_t bandwidth, sfe_bmi330_averaging_t averaging) +{ + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, odr, range, bandwidth, averaging); + + // Let the filter settle before measuring - the first samples after a configuration change + // still carry the old settings' history. + delay(200); + + sfe_bmi330_error_status_t errors; + myIMU.getErrorStatus(errors); + + Serial.print(label); + Serial.print("\t"); + + if (errors.accelConfError) + { + Serial.println("REJECTED by the device (illegal ODR / power mode pairing)"); + return; + } + + Serial.print(measureNoise_mg(100), 2); + Serial.println(" mg RMS"); +} + +void setup() +{ + Serial.begin(115200); + Serial.println("SparkFun BMI330 Example 3 - Configuration and Filtering"); + + Wire.begin(); + + while (myIMU.begin() == false) + { + Serial.println("BMI330 not found - check your wiring!"); + delay(1000); + } + + Serial.println(); + Serial.println("Measuring accelerometer Z-axis noise. Keep the board still."); + Serial.println(); + Serial.println("Configuration\t\t\tNoise"); + Serial.println("-------------------------------------------"); + + // Range: wider range, coarser LSB, more quantization noise. + reportConfig("+/-2 g, 100 Hz, no avg ", BMI330_ACCEL_RANGE_2G, BMI330_ODR_100_HZ, BMI330_BW_ODR_HALF, + BMI330_AVG_1); + reportConfig("+/-16 g, 100 Hz, no avg ", BMI330_ACCEL_RANGE_16G, BMI330_ODR_100_HZ, BMI330_BW_ODR_HALF, + BMI330_AVG_1); + + // ODR: higher rate, wider noise bandwidth per sample. + reportConfig("+/-2 g, 800 Hz, no avg ", BMI330_ACCEL_RANGE_2G, BMI330_ODR_800_HZ, BMI330_BW_ODR_HALF, + BMI330_AVG_1); + + // Bandwidth: ODR/4 rolls off earlier than ODR/2. + reportConfig("+/-2 g, 800 Hz, BW ODR/4", BMI330_ACCEL_RANGE_2G, BMI330_ODR_800_HZ, + BMI330_BW_ODR_QUARTER, BMI330_AVG_1); + + // Averaging: the cheapest noise reduction available, if you can spare the current. + reportConfig("+/-2 g, 800 Hz, avg 8 ", BMI330_ACCEL_RANGE_2G, BMI330_ODR_800_HZ, BMI330_BW_ODR_HALF, + BMI330_AVG_8); + reportConfig("+/-2 g, 800 Hz, avg 64 ", BMI330_ACCEL_RANGE_2G, BMI330_ODR_800_HZ, BMI330_BW_ODR_HALF, + BMI330_AVG_64); + + // And an illegal pairing, to show what the error flags are for: 6400 Hz is a + // high-performance-only rate, so asking for it in low-power mode is refused. + Serial.println(); + Serial.println("Deliberately asking for 6400 Hz in low-power mode:"); + myIMU.setAccelConfig(BMI330_ACCEL_MODE_LOW_POWER, BMI330_ODR_6400_HZ, BMI330_ACCEL_RANGE_2G); + delay(50); + + sfe_bmi330_error_status_t errors; + myIMU.getErrorStatus(errors); + Serial.print(" accel configuration error flag: "); + Serial.println(errors.accelConfError ? "SET, as expected" : "clear"); + + Serial.println(); + Serial.println("Done. Reading the current configuration back from the device:"); + + sfe_bmi330_accel_mode_t mode; + sfe_bmi330_odr_t odr; + sfe_bmi330_accel_range_t range; + sfe_bmi330_bandwidth_t bandwidth; + sfe_bmi330_averaging_t averaging; + + myIMU.getAccelConfig(mode, odr, range, bandwidth, averaging); + + Serial.print(" mode 0x"); + Serial.print(mode, HEX); + Serial.print(", odr 0x"); + Serial.print(odr, HEX); + Serial.print(", range 0x"); + Serial.print(range, HEX); + Serial.print(", bandwidth "); + Serial.print(bandwidth); + Serial.print(", averaging "); + Serial.println(averaging); +} + +void loop() +{ + delay(1000); +} diff --git a/examples/Example04_DataReadyInterrupt/Example04_DataReadyInterrupt.ino b/examples/Example04_DataReadyInterrupt/Example04_DataReadyInterrupt.ino new file mode 100644 index 0000000..ba6b37b --- /dev/null +++ b/examples/Example04_DataReadyInterrupt/Example04_DataReadyInterrupt.ino @@ -0,0 +1,146 @@ +/* + Example 04 - Data Ready Interrupt + + Instead of polling, let the BMI330 tell the host when a sample is ready. The breakout brings + out both interrupt pins, and this sketch uses INT1. + + Polling a 100 Hz sensor from loop() either reads the same sample twice or misses one, because + the host's loop rate and the sensor's sample rate are unrelated. An interrupt ties them + together: exactly one read per sample, no duplicates, no gaps. + + Three settings matter here: + + Pin behavior Push-pull, active high, and enabled. Open-drain is for sharing one host line + between several devices - supply a pull-up if you use it, because the board + does not fit one. + + Latch mode Pulsed (the default) asserts the pin briefly per event. Latched holds it until + the status register is read. Use latched if your host might miss a short + pulse; the cost is that the status read becomes mandatory. + + Routing Every source is routed independently, so data-ready can sit on INT1 while + motion events go to INT2 - see Example 06. + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + QWIIC --> QWIIC + D2 --> INT1 + + Pick a pin that supports attachInterrupt() on your board. + + Open the Serial Monitor at 115200 baud. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +SfeBMI330ArdI2C myIMU; + +const uint8_t kInterruptPin = 2; + +// Set in the ISR, cleared in loop(). volatile because the compiler cannot see the ISR writing it. +volatile bool interruptFired = false; + +uint32_t samplesRead = 0; +uint32_t lastReport = 0; + +void bmi330ISR() +{ + // Keep the ISR to a flag. Reading the sensor here would mean I2C traffic from interrupt + // context, which is a good way to hang a Wire library that was not designed for it. + interruptFired = true; +} + +void setup() +{ + Serial.begin(115200); + Serial.println("SparkFun BMI330 Example 4 - Data Ready Interrupt"); + + Wire.begin(); + + while (myIMU.begin() == false) + { + Serial.println("BMI330 not found - check your wiring!"); + delay(1000); + } + + // 100 Hz on both sensors. + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_ACCEL_RANGE_4G); + myIMU.setGyroConfig(BMI330_GYRO_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_GYRO_RANGE_500DPS); + + // Push-pull, active high, enabled. + sfe_bmi330_int_pin_config_t pinConfig; + pinConfig.enabled = true; + pinConfig.activeHigh = true; + pinConfig.openDrain = false; + myIMU.setInterruptPinConfig(BMI330_INT1, pinConfig); + + // Pulsed. The sketch reads the status register every time anyway, so latching would work + // equally well here - try it by passing true. + myIMU.setInterruptLatch(false); + + // Route accelerometer data-ready to INT1. The gyroscope runs at the same rate, so one + // source is enough to pace the reads; routing both would just double the interrupts. + myIMU.mapInterrupt(BMI330_INT_SRC_ACCEL_DRDY, BMI330_INT1); + + pinMode(kInterruptPin, INPUT); + attachInterrupt(digitalPinToInterrupt(kInterruptPin), bmi330ISR, RISING); + + Serial.println("Waiting for interrupts on INT1..."); + Serial.println(); + + lastReport = millis(); +} + +void loop() +{ + if (interruptFired) + { + interruptFired = false; + + // Clear-on-read: this one read consumes every pending flag for INT1, so test the whole + // word rather than reading again per bit. + uint16_t status = 0; + myIMU.getInterruptStatus(BMI330_INT1, status); + + if (status & BMI330_INT_STATUS_ACCEL_DRDY) + { + sfe_bmi330_sensor_data_t data; + myIMU.getSensorData(data); + samplesRead++; + + // Print sparingly - at 100 Hz, printing every sample would make the serial port the + // bottleneck and cost you interrupts. + if (samplesRead % 100 == 0) + { + Serial.print("Accel: "); + Serial.print(data.accel.x, 3); + Serial.print(", "); + Serial.print(data.accel.y, 3); + Serial.print(", "); + Serial.print(data.accel.z, 3); + Serial.print(" g Sensor time: "); + Serial.println(data.sensorTime); + } + } + } + + // Once a second, report the measured rate. It should sit very close to the configured ODR; + // if it is low, the host is missing interrupts. + if (millis() - lastReport >= 1000) + { + Serial.print(">>> "); + Serial.print(samplesRead); + Serial.println(" samples in the last second (expect ~100)"); + + samplesRead = 0; + lastReport = millis(); + } +} diff --git a/examples/Example05_FIFOBuffer/Example05_FIFOBuffer.ino b/examples/Example05_FIFOBuffer/Example05_FIFOBuffer.ino new file mode 100644 index 0000000..8da7dba --- /dev/null +++ b/examples/Example05_FIFOBuffer/Example05_FIFOBuffer.ino @@ -0,0 +1,188 @@ +/* + Example 05 - FIFO Buffer + + The BMI330 has a 2 kB FIFO. Instead of one interrupt per sample, it can collect samples and + interrupt once when it has enough of them - which is how you sample at 400 Hz without the + host servicing 400 interrupts a second, and how the host can sleep between bursts. + + This sketch configures the FIFO for accelerometer, gyroscope and sensor time, sets a + watermark, routes the watermark interrupt to INT2, and decodes frames when it fires. + + THE FIFO IS HEADERLESS. Frames carry no tag saying what is in them. The order is fixed by + hardware - accelerometer (3 words), gyroscope (3 words), temperature (1 word), sensor time + (1 word) - with disabled sources simply absent. That means the parser has to be told the same + enable set that was programmed, which is why readFifoData() takes it as an argument. Pass the + wrong one and the data decodes into plausible nonsense rather than failing. + + WATERMARK IS IN WORDS, NOT FRAMES. A watermark of 80 words with accelerometer plus gyroscope + plus time enabled (7 words per frame) is about 11 frames. frameLengthWords() does the + arithmetic so you do not have to. + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + QWIIC --> QWIIC + D3 --> INT2 + + Open the Serial Monitor at 115200 baud. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +SfeBMI330ArdI2C myIMU; + +const uint8_t kInterruptPin = 3; + +// What the FIFO records. Temperature is left out here - it changes far too slowly to be worth a +// word in every frame. +const uint16_t kFifoSources = BMI330_FIFO_ACCEL | BMI330_FIFO_GYRO | BMI330_FIFO_TIME; + +// Collect roughly this many frames before interrupting. +const uint8_t kFramesPerBurst = 20; + +volatile bool interruptFired = false; + +// Decoded frames land here. Sized to the burst, with headroom in case the FIFO ran slightly +// ahead of the host. +sfe_bmi330_fifo_frame_t frames[32]; + +void bmi330ISR() +{ + interruptFired = true; +} + +void setup() +{ + Serial.begin(115200); + Serial.println("SparkFun BMI330 Example 5 - FIFO Buffer"); + + Wire.begin(); + + while (myIMU.begin() == false) + { + Serial.println("BMI330 not found - check your wiring!"); + delay(1000); + } + + // 100 Hz keeps the printing readable. The FIFO earns its keep at much higher rates. + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_ACCEL_RANGE_4G); + myIMU.setGyroConfig(BMI330_GYRO_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_GYRO_RANGE_500DPS); + + // Overwrite the oldest data when full rather than stopping. For streaming, losing the + // stale end of the buffer is better than losing the live end. + myIMU.setFifoConfig(kFifoSources, false); + + uint8_t wordsPerFrame = sfDevBMI330::frameLengthWords(kFifoSources); + uint16_t watermarkWords = (uint16_t)wordsPerFrame * kFramesPerBurst; + myIMU.setFifoWatermark(watermarkWords); + + Serial.print("Frame length: "); + Serial.print(wordsPerFrame); + Serial.print(" words. Watermark: "); + Serial.print(watermarkWords); + Serial.print(" words ("); + Serial.print(kFramesPerBurst); + Serial.println(" frames)."); + + sfe_bmi330_int_pin_config_t pinConfig; + myIMU.setInterruptPinConfig(BMI330_INT2, pinConfig); + myIMU.mapInterrupt(BMI330_INT_SRC_FIFO_WATERMARK, BMI330_INT2); + + pinMode(kInterruptPin, INPUT); + attachInterrupt(digitalPinToInterrupt(kInterruptPin), bmi330ISR, RISING); + + // Start from an empty buffer - anything captured during configuration is not worth keeping. + myIMU.flushFifo(); + + Serial.println(); + Serial.println("Waiting for the FIFO to fill..."); +} + +void loop() +{ + if (!interruptFired) + return; + + interruptFired = false; + + uint16_t status = 0; + myIMU.getInterruptStatus(BMI330_INT2, status); + + if (!(status & BMI330_INT_STATUS_FIFO_WATERMARK)) + return; + + uint16_t fillLevel = 0; + myIMU.getFifoFillLevel(fillLevel); + + uint8_t numFrames = 0; + + // Only whole frames come out. A partial frame at the end of the buffer is left in place and + // arrives complete on the next read. + if (myIMU.readFifoData(frames, sizeof(frames) / sizeof(frames[0]), numFrames, kFifoSources) != + ksfTkErrOk) + { + Serial.println("FIFO read failed!"); + return; + } + + Serial.print("--- Watermark hit. Fill level "); + Serial.print(fillLevel); + Serial.print(" words, decoded "); + Serial.print(numFrames); + Serial.println(" frames ---"); + + // Print the first and last frame of the burst. Printing all twenty at 100 Hz would put the + // serial port permanently behind the sensor. + for (uint8_t i = 0; i < numFrames; i++) + { + if (i != 0 && i != numFrames - 1) + continue; + + Serial.print(" frame "); + Serial.print(i); + Serial.print(": "); + + if (frames[i].hasAccel) + { + Serial.print("accel "); + Serial.print(myIMU.accelRawToG(frames[i].accel.x), 3); + Serial.print(", "); + Serial.print(myIMU.accelRawToG(frames[i].accel.y), 3); + Serial.print(", "); + Serial.print(myIMU.accelRawToG(frames[i].accel.z), 3); + Serial.print(" g "); + } + else + { + // The accelerometer was idle when this frame was captured, so the device wrote a + // sentinel rather than a sample. + Serial.print("accel (no data) "); + } + + if (frames[i].hasGyro) + { + Serial.print("gyro "); + Serial.print(myIMU.gyroRawToDps(frames[i].gyro.x), 1); + Serial.print(", "); + Serial.print(myIMU.gyroRawToDps(frames[i].gyro.y), 1); + Serial.print(", "); + Serial.print(myIMU.gyroRawToDps(frames[i].gyro.z), 1); + Serial.print(" dps "); + } + + if (frames[i].hasTime) + { + Serial.print("t="); + Serial.print(frames[i].sensorTime); + } + + Serial.println(); + } +} diff --git a/examples/Example06_WakeOnMotion/Example06_WakeOnMotion.ino b/examples/Example06_WakeOnMotion/Example06_WakeOnMotion.ino new file mode 100644 index 0000000..a9eb818 --- /dev/null +++ b/examples/Example06_WakeOnMotion/Example06_WakeOnMotion.ino @@ -0,0 +1,195 @@ +/* + Example 06 - Wake on Motion (Any-Motion, No-Motion and Low Power) + + The first of the feature-engine examples, and the pattern most battery-powered designs end up + wanting: watch for motion in hardware at a few tens of microamps, run at full rate only while + something is happening, and drop back to low power when it stops. + + Any-motion fires when the board starts moving. Routed to INT1 - it wakes the design. + No-motion fires when it has been still a while. Routed to INT2 - it puts it back to sleep. + + Separate pins let an ISR tell the two apart without a bus transaction. + + THE UNITS ARE THE HARDWARE'S, NOT SI: + + slopeThreshold 1.953 mg per LSB (1/512 g). A threshold of 100 is about 195 mg. + hysteresis Same 1.953 mg per LSB. + duration 20 ms per LSB. A duration of 10 is 200 ms. + waitTime 20 ms per LSB, 0 to 7. + + These detectors watch the SLOPE of acceleration, not its magnitude. A device sitting still at + any angle reads 1 g of gravity but zero slope - which is why no-motion works in any + orientation. Both are enabled per axis: pass BMI330_FEAT_ANY_MOTION_Z alone, say, to ignore + motion you do not care about. + + THE POWER MODES, ROUGHLY: + + Low-power accel Accelerometer duty-cycled, gyroscope off. About 45 uA, and enough to run + the feature engine. + High-perf IMU Both sensors continuous. Around 790 uA. + + GYRO SUSPEND VERSUS DISABLE. DISABLE (used here) turns the drive circuit off; waking costs the + gyroscope's full start-up time, tens of milliseconds. SUSPEND keeps the drive running and only + the ADC off, so waking is quick at the cost of standing current. + + Low-power mode is limited to 400 Hz and below. A rate the mode cannot support does not fail the + write - it sets a flag in ERR_REG (see Example 03). + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + QWIIC --> QWIIC + D2 --> INT1 (any-motion) + D3 --> INT2 (no-motion) + + Open the Serial Monitor at 115200 baud. Pick the board up to wake it, put it down to let it + sleep again. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +SfeBMI330ArdI2C myIMU; + +const uint8_t kAnyMotionPin = 2; +const uint8_t kNoMotionPin = 3; + +volatile bool anyMotionFired = false; +volatile bool noMotionFired = false; + +bool awake = false; +uint32_t lastPrint = 0; + +void anyMotionISR() +{ + anyMotionFired = true; +} + +void noMotionISR() +{ + noMotionFired = true; +} + +// Low-power watch: accelerometer duty-cycled at 50 Hz, gyroscope fully off. The feature engine +// keeps running, so both detectors stay armed. +void enterLowPower() +{ + myIMU.setAccelConfig(BMI330_ACCEL_MODE_LOW_POWER, BMI330_ODR_50_HZ, BMI330_ACCEL_RANGE_4G, + BMI330_BW_ODR_HALF, BMI330_AVG_4); + myIMU.setGyroMode(BMI330_GYRO_MODE_DISABLE); + awake = false; + Serial.println("[INT2] Still - dropping to low power (accel duty-cycled, gyro off)"); +} + +// Full rate: both sensors in high-performance mode. +void enterActive() +{ + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_200_HZ, BMI330_ACCEL_RANGE_4G); + myIMU.setGyroConfig(BMI330_GYRO_MODE_HIGH_PERF, BMI330_ODR_200_HZ, BMI330_GYRO_RANGE_500DPS); + + // The gyroscope needs a moment after its drive circuit starts before the data means anything. + // This is the cost that BMI330_GYRO_MODE_SUSPEND buys out. + delay(50); + awake = true; + Serial.println("[INT1] Motion - waking to full rate"); +} + +void setup() +{ + Serial.begin(115200); + Serial.println("SparkFun BMI330 Example 6 - Wake on Motion"); + + Wire.begin(); + + while (myIMU.begin() == false) + { + Serial.println("BMI330 not found - check your wiring!"); + delay(1000); + } + + // Any-motion: about 195 mg of slope, sustained for 200 ms. Low enough to catch a deliberate + // pick-up, high enough to ignore a desk being leaned on. + sfe_bmi330_motion_config_t anyMotion; + anyMotion.slopeThreshold = 100; // 100 x 1.953 mg = 195 mg + anyMotion.duration = 10; // 10 x 20 ms = 200 ms + myIMU.setAnyMotionConfig(anyMotion); + + // No-motion: under about 39 mg of slope for two full seconds before calling it still. The long + // duration is deliberate - a short one fires between footsteps. + sfe_bmi330_motion_config_t noMotion; + noMotion.slopeThreshold = 20; // 20 x 1.953 mg = 39 mg + noMotion.duration = 100; // 100 x 20 ms = 2000 ms + myIMU.setNoMotionConfig(noMotion); + + sfe_bmi330_int_pin_config_t pinConfig; // Push-pull, active high + myIMU.setInterruptPinConfig(BMI330_INT1, pinConfig); + myIMU.setInterruptPinConfig(BMI330_INT2, pinConfig); + + myIMU.mapInterrupt(BMI330_INT_SRC_ANY_MOTION, BMI330_INT1); + myIMU.mapInterrupt(BMI330_INT_SRC_NO_MOTION, BMI330_INT2); + + // All three axes for both detectors. + myIMU.enableFeature(BMI330_FEAT_ANY_MOTION | BMI330_FEAT_NO_MOTION, true); + + pinMode(kAnyMotionPin, INPUT); + pinMode(kNoMotionPin, INPUT); + attachInterrupt(digitalPinToInterrupt(kAnyMotionPin), anyMotionISR, RISING); + attachInterrupt(digitalPinToInterrupt(kNoMotionPin), noMotionISR, RISING); + + enterLowPower(); +} + +void loop() +{ + if (anyMotionFired) + { + anyMotionFired = false; + + uint16_t status = 0; + myIMU.getInterruptStatus(BMI330_INT1, status); + + if ((status & BMI330_INT_STATUS_ANY_MOTION) && !awake) + enterActive(); + } + + if (noMotionFired) + { + noMotionFired = false; + + uint16_t status = 0; + myIMU.getInterruptStatus(BMI330_INT2, status); + + if ((status & BMI330_INT_STATUS_NO_MOTION) && awake) + enterLowPower(); + } + + // While awake, do the real work. A real design would also sleep the host while !awake and let + // INT1 wake it. + if (awake && millis() - lastPrint >= 250) + { + lastPrint = millis(); + + sfe_bmi330_sensor_data_t data; + if (myIMU.getSensorData(data) == ksfTkErrOk) + { + Serial.print(" Accel (g): "); + Serial.print(data.accel.x, 2); + Serial.print(", "); + Serial.print(data.accel.y, 2); + Serial.print(", "); + Serial.print(data.accel.z, 2); + Serial.print(" Gyro (dps): "); + Serial.print(data.gyro.x, 1); + Serial.print(", "); + Serial.print(data.gyro.y, 1); + Serial.print(", "); + Serial.println(data.gyro.z, 1); + } + } +} diff --git a/examples/Example07_TapDetection/Example07_TapDetection.ino b/examples/Example07_TapDetection/Example07_TapDetection.ino new file mode 100644 index 0000000..d7ca832 --- /dev/null +++ b/examples/Example07_TapDetection/Example07_TapDetection.ino @@ -0,0 +1,126 @@ +/* + Example 07 - Tap Detection + + Single, double and triple tap, detected entirely on the BMI330. The host is told a gesture + happened; it does not have to watch the accelerometer to find one. + + ONE INTERRUPT, THREE GESTURES. The device raises a single tap interrupt and reports which + gesture it was in a separate register. The sequence is always: see the flag, then call + getTapEvent(). + + TWO SETTINGS DECIDE WHETHER TAP WORKS AT ALL: + + THE AXIS. tap.axis is the axis the detector watches, and it watches ONLY that one. Z is the + board's face - right if you tap the top of a board lying flat. If the board is mounted on + edge, or you tap the enclosure from the side, the impulse lands on X or Y and a Z-axis + detector never sees it. If nothing registers, change kTapAxis first. + + THE THRESHOLD. tap.peakThreshold too low and footsteps near the bench register; too high and + nothing does. The right value depends on what the board is mounted in - a tap through 3 mm + of acrylic is a very different signal from a tap on a bare PCB. + + The values below are Bosch's reference configuration for this part. The duration fields are in + 20 ms ticks. waitForTimeout = true waits out the gesture window and reports the CONFIRMED + gesture; false reports as soon as something is seen, so a triple tap arrives as a single first. + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + QWIIC --> QWIIC + D2 --> INT1 + + Open the Serial Monitor at 115200 baud, then tap the board once, twice or three times. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +SfeBMI330ArdI2C myIMU; + +const uint8_t kInterruptPin = 2; + +// Which axis to watch. Change this first if nothing registers. +const sfe_bmi330_tap_axis_t kTapAxis = BMI330_TAP_AXIS_Z; + +volatile bool interruptFired = false; + +void bmi330ISR() +{ + interruptFired = true; +} + +void setup() +{ + Serial.begin(115200); + Serial.println("SparkFun BMI330 Example 7 - Tap Detection"); + + Wire.begin(); + + while (myIMU.begin() == false) + { + Serial.println("BMI330 not found - check your wiring!"); + delay(1000); + } + + // Tap needs a fast accelerometer - the impulse is short, and at 50 Hz it falls between + // samples. +/-8 g keeps a firm tap inside the range; at +/-2 g the peak would clip. + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_400_HZ, BMI330_ACCEL_RANGE_8G); + + // Bosch's reference tap configuration for this part. + sfe_bmi330_tap_config_t tap; + tap.axis = kTapAxis; + tap.mode = 1; // 0 sensitive, 1 normal, 2 robust. + tap.peakThreshold = 0x2C; // The knob to tune. Raise it if stray taps appear. + tap.maxPeaksForTap = 5; + tap.maxGestureDuration = 0x11; // 340 ms for the whole gesture. + tap.maxDurationBetweenPeaks = 5; + tap.shockSettlingDuration = 5; + tap.minQuietBetweenTaps = 7; // 140 ms of quiet required between taps. + tap.quietTimeAfterGesture = 5; + tap.waitForTimeout = true; // Report the confirmed gesture, not the first thing seen. + myIMU.setTapConfig(tap); + + sfe_bmi330_int_pin_config_t pinConfig; // Push-pull, active high + myIMU.setInterruptPinConfig(BMI330_INT1, pinConfig); + myIMU.mapInterrupt(BMI330_INT_SRC_TAP, BMI330_INT1); + + // Enabling only single tap makes detection quicker, because the device stops waiting to see + // whether a second tap follows. + myIMU.enableFeature(BMI330_FEAT_SINGLE_TAP | BMI330_FEAT_DOUBLE_TAP | BMI330_FEAT_TRIPLE_TAP, true); + + pinMode(kInterruptPin, INPUT); + attachInterrupt(digitalPinToInterrupt(kInterruptPin), bmi330ISR, RISING); + + Serial.println("Tap the board. Nothing registering? Change kTapAxis, or lower tap.peakThreshold."); +} + +void loop() +{ + if (!interruptFired) + return; + + interruptFired = false; + + // Clear-on-read: one read consumes every pending INT1 flag. + uint16_t status = 0; + myIMU.getInterruptStatus(BMI330_INT1, status); + + if (!(status & BMI330_INT_STATUS_TAP)) + return; + + sfe_bmi330_tap_event_t event; + myIMU.getTapEvent(event); + + if (event.tripleTap) + Serial.println("Triple tap!"); + else if (event.doubleTap) + Serial.println("Double tap!"); + else if (event.singleTap) + Serial.println("Single tap!"); +} diff --git a/examples/Example08_StepCounter/Example08_StepCounter.ino b/examples/Example08_StepCounter/Example08_StepCounter.ino new file mode 100644 index 0000000..7093777 --- /dev/null +++ b/examples/Example08_StepCounter/Example08_StepCounter.ino @@ -0,0 +1,151 @@ +/* + Example 08 - Step Counter and Step Detector + + The BMI330 counts steps by itself. The host can poll the count, or be interrupted every N + steps, or be interrupted on every single step - three different power and latency trade-offs + for the same feature. + + Step counter Maintains a 32-bit count in the device. Interrupts at a watermark, in units + of 20 steps. Watermark 0 means "count, but never interrupt" - the right + setting when the host polls at its own pace. + + Step detector Interrupts on every step. Costs an interrupt per step, so it is for + something that must react immediately, not for counting. + + This sketch enables both: the detector so you can see each step land, and the counter with a + 20-step watermark so you can see the milestone interrupt arrive. + + THE COUNT IS 32 BITS ACROSS TWO REGISTERS. getStepCount() reads them in one burst, so the + halves cannot disagree. Reading them separately could straddle a carry and report a count + that never existed - unlikely, but it is a real class of bug and it costs nothing to avoid. + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + QWIIC --> QWIIC + D2 --> INT1 (step detector - every step) + D3 --> INT2 (step counter - every 20 steps) + + Open the Serial Monitor at 115200 baud and carry the board around while you walk. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +SfeBMI330ArdI2C myIMU; + +const uint8_t kStepDetectorPin = 2; +const uint8_t kStepCounterPin = 3; + +volatile bool stepDetected = false; +volatile bool watermarkHit = false; + +uint32_t lastPoll = 0; + +void stepDetectorISR() +{ + stepDetected = true; +} + +void stepCounterISR() +{ + watermarkHit = true; +} + +void setup() +{ + Serial.begin(115200); + Serial.println("SparkFun BMI330 Example 8 - Step Counter"); + + Wire.begin(); + + while (myIMU.begin() == false) + { + Serial.println("BMI330 not found - check your wiring!"); + delay(1000); + } + + // The step algorithm is tuned for a 50 Hz accelerometer. Normal mode keeps the current + // down, which is the whole point of counting steps in hardware. + myIMU.setAccelConfig(BMI330_ACCEL_MODE_NORMAL, BMI330_ODR_50_HZ, BMI330_ACCEL_RANGE_8G); + + // Watermark is in units of 20 steps: 1 means interrupt every 20 steps. Set 0 to count + // without ever interrupting. + myIMU.setStepCounterWatermark(1); + + sfe_bmi330_int_pin_config_t pinConfig; + myIMU.setInterruptPinConfig(BMI330_INT1, pinConfig); + myIMU.setInterruptPinConfig(BMI330_INT2, pinConfig); + + myIMU.mapInterrupt(BMI330_INT_SRC_STEP_DETECTOR, BMI330_INT1); + myIMU.mapInterrupt(BMI330_INT_SRC_STEP_COUNTER, BMI330_INT2); + + myIMU.enableFeature(BMI330_FEAT_STEP_DETECTOR | BMI330_FEAT_STEP_COUNTER, true); + + // Start from zero so the numbers below mean something. + myIMU.resetStepCount(); + + pinMode(kStepDetectorPin, INPUT); + pinMode(kStepCounterPin, INPUT); + attachInterrupt(digitalPinToInterrupt(kStepDetectorPin), stepDetectorISR, RISING); + attachInterrupt(digitalPinToInterrupt(kStepCounterPin), stepCounterISR, RISING); + + Serial.println(); + Serial.println("Ready. Carry the board and walk."); + Serial.println("The algorithm wants a few consistent steps before it starts counting -"); + Serial.println("a couple of paces of warm-up is normal, not a fault."); + Serial.println(); + + lastPoll = millis(); +} + +void loop() +{ + if (stepDetected) + { + stepDetected = false; + + uint16_t status = 0; + myIMU.getInterruptStatus(BMI330_INT1, status); + + if (status & BMI330_INT_STATUS_STEP_DETECTOR) + Serial.println(" step"); + } + + if (watermarkHit) + { + watermarkHit = false; + + uint16_t status = 0; + myIMU.getInterruptStatus(BMI330_INT2, status); + + if (status & BMI330_INT_STATUS_STEP_COUNTER) + { + uint32_t steps = 0; + myIMU.getStepCount(steps); + + Serial.print(">>> Watermark: "); + Serial.print(steps); + Serial.println(" steps"); + } + } + + // Polling the count directly - no interrupt needed. This is all a pedometer really has to + // do, and with watermark 0 it costs one register read whenever the host feels like it. + if (millis() - lastPoll >= 5000) + { + uint32_t steps = 0; + myIMU.getStepCount(steps); + + Serial.print("[poll] total steps: "); + Serial.println(steps); + + lastPoll = millis(); + } +} diff --git a/examples/Example09_TiltOrientationFlat/Example09_TiltOrientationFlat.ino b/examples/Example09_TiltOrientationFlat/Example09_TiltOrientationFlat.ino new file mode 100644 index 0000000..79670e2 --- /dev/null +++ b/examples/Example09_TiltOrientationFlat/Example09_TiltOrientationFlat.ino @@ -0,0 +1,161 @@ +/* + Example 09 - Tilt, Orientation and Flat + + Three posture features that all answer "which way is the board pointing?", from different + angles: + + Tilt Fires when the gravity vector has moved more than a set angle away from a + slowly-updated reference. It detects the CHANGE, not the absolute pose, which + is what you want for "has this been knocked over?" + + Orientation Classifies the board as portrait or landscape - and optionally face up or + face down - and interrupts on a change. This is the phone-screen-rotation + feature. It reports an absolute state you can read at any time. + + Flat Fires when the board has been lying within a set angle of horizontal for a + while. Useful for "is it on the table?" + + All three run off the accelerometer alone, which is why they cost so little: the gyroscope + can stay off entirely. + + BLOCKING is worth understanding. Each of these has a blocking setting that suppresses + detection while the board is moving, because gravity cannot be measured reliably during + acceleration. Higher blocking means fewer false reports and slower response. The defaults are + a reasonable middle. + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + QWIIC --> QWIIC + D2 --> INT1 (all three features) + + Open the Serial Monitor at 115200 baud, then rotate the board, stand it on edge, and lay it + flat again. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +SfeBMI330ArdI2C myIMU; + +const uint8_t kInterruptPin = 2; + +volatile bool interruptFired = false; + +void bmi330ISR() +{ + interruptFired = true; +} + +const char *orientationName(sfe_bmi330_orientation_t orientation) +{ + switch (orientation) + { + case BMI330_ORIENT_PORTRAIT_UPRIGHT: + return "portrait, upright"; + case BMI330_ORIENT_LANDSCAPE_LEFT: + return "landscape, left"; + case BMI330_ORIENT_PORTRAIT_UPSIDE_DOWN: + return "portrait, upside down"; + case BMI330_ORIENT_LANDSCAPE_RIGHT: + return "landscape, right"; + default: + return "unknown"; + } +} + +void setup() +{ + Serial.begin(115200); + Serial.println("SparkFun BMI330 Example 9 - Tilt, Orientation and Flat"); + + Wire.begin(); + + while (myIMU.begin() == false) + { + Serial.println("BMI330 not found - check your wiring!"); + delay(1000); + } + + // Posture features only need the accelerometer, and only slowly. Leaving the gyroscope off + // saves the large majority of the part's current. + myIMU.setAccelConfig(BMI330_ACCEL_MODE_NORMAL, BMI330_ODR_50_HZ, BMI330_ACCEL_RANGE_4G); + + // Tilt: report a departure of roughly 35 degrees from the reference vector. minTiltAngle is + // stored as 256 x cos(angle), so 210 is about 35 degrees and a LARGER number is a SMALLER + // angle - easy to get backwards. + sfe_bmi330_tilt_config_t tilt; + tilt.segmentSize = 100; // 2 s of averaging into the reference vector. + tilt.minTiltAngle = 210; // ~35 degrees. + myIMU.setTiltConfig(tilt); + + // Orientation, with face up / face down classification turned on. + sfe_bmi330_orientation_config_t orientation; + orientation.faceUpDownEnabled = true; + orientation.mode = 0; // Symmetrical. + orientation.blocking = 3; // Most aggressive - ignore everything while moving. + orientation.theta = 40; + orientation.holdTime = 5; // 100 ms of stability before reporting. + myIMU.setOrientationConfig(orientation); + + // Flat: within about 8 units of horizontal, held for 640 ms. + sfe_bmi330_flat_config_t flat; + flat.theta = 8; + flat.blocking = 2; + flat.holdTime = 32; + myIMU.setFlatConfig(flat); + + sfe_bmi330_int_pin_config_t pinConfig; + myIMU.setInterruptPinConfig(BMI330_INT1, pinConfig); + + // All three share INT1. The status word says which one fired, so one pin is enough. + myIMU.mapInterrupt(BMI330_INT_SRC_TILT, BMI330_INT1); + myIMU.mapInterrupt(BMI330_INT_SRC_ORIENTATION, BMI330_INT1); + myIMU.mapInterrupt(BMI330_INT_SRC_FLAT, BMI330_INT1); + + myIMU.enableFeature(BMI330_FEAT_TILT | BMI330_FEAT_ORIENTATION | BMI330_FEAT_FLAT, true); + + pinMode(kInterruptPin, INPUT); + attachInterrupt(digitalPinToInterrupt(kInterruptPin), bmi330ISR, RISING); + + Serial.println(); + Serial.println("Ready. Rotate the board, stand it on edge, lay it flat."); + Serial.println(); +} + +void loop() +{ + if (!interruptFired) + return; + + interruptFired = false; + + // One read, several possible causes - clear-on-read means testing the returned word rather + // than reading again per feature. + uint16_t status = 0; + myIMU.getInterruptStatus(BMI330_INT1, status); + + if (status & BMI330_INT_STATUS_TILT) + Serial.println("Tilt detected - the board has moved away from its reference attitude."); + + if (status & BMI330_INT_STATUS_ORIENTATION) + { + sfe_bmi330_orientation_t orientation; + bool faceDown = false; + myIMU.getOrientation(orientation, faceDown); + + Serial.print("Orientation changed: "); + Serial.print(orientationName(orientation)); + Serial.print(", "); + Serial.println(faceDown ? "face down" : "face up"); + } + + if (status & BMI330_INT_STATUS_FLAT) + Serial.println("Flat - the board is lying level."); +} diff --git a/examples/Example10_SelfTest/Example10_SelfTest.ino b/examples/Example10_SelfTest/Example10_SelfTest.ino new file mode 100644 index 0000000..5824770 --- /dev/null +++ b/examples/Example10_SelfTest/Example10_SelfTest.ino @@ -0,0 +1,148 @@ +/* + Example 10 - Self-Test + + The BMI330 can check its own sensors. Self-test drives a known electrostatic stimulus into + the accelerometer and gyroscope and compares the response against factory limits, reporting a + pass or fail per axis. It is the right thing to run at power-up on anything where a silently + dead axis would matter. + + What it tells you, and what it does not: a pass means the MEMS structures move as they + should and the signal chain is intact. It says nothing about calibration or mounting - a + perfectly healthy sensor glued on crooked passes every time. For offset and sensitivity, see + Example 11. + + THREE THINGS TO KNOW BEFORE RUNNING IT: + + 1. Keep the board STILL. The test measures a small deliberate deflection; external motion + lands on top of it and produces failures on a good part. + + 2. It is disruptive. The routine forces the accelerometer into high-performance mode at + 100 Hz and takes up to 350 ms. This library saves your accelerometer configuration and + restores it afterwards, but any sampling in flight is interrupted regardless. + + 3. The BMI330 needs its own gyroscope filter coefficients seeded into feature memory first, + and they are NOT the same as the BMI323's. The library handles this inside + performSelfTest(); a driver ported from the BMI323 without that step reports failures on + healthy parts. + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + QWIIC --> QWIIC + + Leave the board on a table and do not touch it while the test runs. + + Open the Serial Monitor at 115200 baud. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +SfeBMI330ArdI2C myIMU; + +void printResult(const char *label, bool ok) +{ + Serial.print(" "); + Serial.print(label); + Serial.println(ok ? "PASS" : "FAIL"); +} + +void runSelfTest(const char *what, sfe_bmi330_self_test_t selection) +{ + Serial.print("Running self-test: "); + Serial.println(what); + + sfe_bmi330_self_test_result_t result; + sfTkError_t rc = myIMU.performSelfTest(selection, result); + + if (rc != ksfTkErrOk || !result.completed) + { + // "Did not finish" is a different problem from "finished and failed" - most often a + // precondition was not met, which shows up as error status 0x0C. + Serial.print(" The test did not complete. Feature engine error status: 0x"); + Serial.println(result.errorStatus, HEX); + Serial.println(" 0x0C means a precondition failed. 0x05 means no error."); + Serial.println(); + return; + } + + Serial.print(" Overall: "); + Serial.println(result.passed ? "PASS" : "FAIL"); + + if (selection != BMI330_SELF_TEST_GYRO) + { + printResult("Accel X: ", result.accelXOk); + printResult("Accel Y: ", result.accelYOk); + printResult("Accel Z: ", result.accelZOk); + } + + if (selection != BMI330_SELF_TEST_ACCEL) + { + printResult("Gyro X: ", result.gyroXOk); + printResult("Gyro Y: ", result.gyroYOk); + printResult("Gyro Z: ", result.gyroZOk); + printResult("Gyro drive: ", result.gyroDriveOk); + } + + Serial.print(" Feature engine error status: 0x"); + Serial.print(result.errorStatus, HEX); + Serial.println(" (0x05 = no error)"); + Serial.println(); +} + +void setup() +{ + Serial.begin(115200); + Serial.println("SparkFun BMI330 Example 10 - Self-Test"); + + Wire.begin(); + + while (myIMU.begin() == false) + { + Serial.println("BMI330 not found - check your wiring!"); + delay(1000); + } + + // The self-test sets its own preconditions, but the sensors have to be running first. + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_ACCEL_RANGE_8G); + myIMU.setGyroConfig(BMI330_GYRO_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_GYRO_RANGE_2000DPS); + + Serial.println(); + Serial.println("Keep the board completely still. Starting in 3 seconds..."); + delay(3000); + Serial.println(); + + // BMI330_SELF_TEST_ACCEL or BMI330_SELF_TEST_GYRO test one sensor on its own. + runSelfTest("accelerometer and gyroscope", BMI330_SELF_TEST_BOTH); + + Serial.println("Self-test complete. Resuming normal operation."); + Serial.println(); + + // performSelfTest() restores the accelerometer configuration, but set it explicitly here so + // the sketch does not depend on that. + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_ACCEL_RANGE_4G); + + Serial.println("AccX(g)\tAccY(g)\tAccZ(g)"); +} + +void loop() +{ + sfe_bmi330_data_t accel; + + if (myIMU.getAccelData(accel) == ksfTkErrOk) + { + Serial.print(accel.x, 3); + Serial.print("\t"); + Serial.print(accel.y, 3); + Serial.print("\t"); + Serial.println(accel.z, 3); + } + + delay(500); +} diff --git a/examples/Example11_SelfCalibration/Example11_SelfCalibration.ino b/examples/Example11_SelfCalibration/Example11_SelfCalibration.ino new file mode 100644 index 0000000..ac90778 --- /dev/null +++ b/examples/Example11_SelfCalibration/Example11_SelfCalibration.ino @@ -0,0 +1,173 @@ +/* + Example 11 - Gyroscope Self-Calibration + + Every MEMS gyroscope has a zero-rate offset: it reports a small non-zero rate while + perfectly still. That offset is what makes an integrated heading drift, and it is the single + biggest thing between a raw gyroscope and a usable one. The BMI330 can measure and correct + its own. + + This sketch prints the resting gyroscope output before and after calibration, so you can see + the offset collapse. + + THE PRECONDITIONS ARE NOT OPTIONAL: + + 1. The device must be MOTIONLESS. This is the important one. The routine assumes any rate + it sees is offset, so a calibration run on a moving device reports success and leaves an + offset WORSE than the one it started with. There is no error flag for "you moved it". + + 2. The accelerometer must be in high-performance mode at 100 Hz and +/-8 g. The library + sets that up and restores your configuration afterwards. + + 3. It takes up to 430 ms. + + WHAT GETS CORRECTED, AND WHERE IT LIVES: pass BMI330_SELF_CALIB_OFFSET, _SENSITIVITY or + _BOTH. With applyCorrection true, the device applies the result to its own output + immediately. The correction lives in volatile registers - it does NOT survive a power cycle + or a soft reset, so either re-run this at boot or read the offsets out with getGyroOffset() + and store them yourself. + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + QWIIC --> QWIIC + + Put the board on a solid surface and do not touch it while this runs. + + Open the Serial Monitor at 115200 baud. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +SfeBMI330ArdI2C myIMU; + +// Average the gyroscope over a second of standing still. Whatever this reads is offset. +void measureRestingOffset(const char *label) +{ + const uint16_t kSamples = 100; + + float sumX = 0.0f, sumY = 0.0f, sumZ = 0.0f; + + for (uint16_t i = 0; i < kSamples; i++) + { + sfe_bmi330_data_t gyro; + + if (myIMU.getGyroData(gyro) == ksfTkErrOk) + { + sumX += gyro.x; + sumY += gyro.y; + sumZ += gyro.z; + } + + delay(10); + } + + Serial.print(label); + Serial.print(sumX / kSamples, 3); + Serial.print(", "); + Serial.print(sumY / kSamples, 3); + Serial.print(", "); + Serial.print(sumZ / kSamples, 3); + Serial.println(" dps"); +} + +void setup() +{ + Serial.begin(115200); + Serial.println("SparkFun BMI330 Example 11 - Gyroscope Self-Calibration"); + + Wire.begin(); + + while (myIMU.begin() == false) + { + Serial.println("BMI330 not found - check your wiring!"); + delay(1000); + } + + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_ACCEL_RANGE_8G); + + // The narrowest gyroscope range gives the finest resolution, which makes a small offset + // easy to see. + myIMU.setGyroConfig(BMI330_GYRO_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_GYRO_RANGE_125DPS); + + Serial.println(); + Serial.println("Place the board on a solid surface. Do not touch it."); + Serial.println("Starting in 3 seconds..."); + delay(3000); + + Serial.println(); + measureRestingOffset("Resting rate BEFORE calibration: "); + + int16_t ox = 0, oy = 0, oz = 0; + myIMU.getGyroOffset(ox, oy, oz); + Serial.print("Stored offsets before: "); + Serial.print(ox); + Serial.print(", "); + Serial.print(oy); + Serial.print(", "); + Serial.println(oz); + + Serial.println(); + Serial.println("Calibrating - keep still..."); + + sfe_bmi330_self_calib_result_t result; + sfTkError_t rc = myIMU.performGyroSelfCalibration(BMI330_SELF_CALIB_BOTH, true, result); + + if (rc != ksfTkErrOk || !result.completed) + { + Serial.print("Calibration did not complete. Feature engine error status: 0x"); + Serial.println(result.errorStatus, HEX); + Serial.println("0x0C means a precondition failed - check the accelerometer is enabled."); + return; + } + + Serial.print("Calibration finished: "); + Serial.println(result.passed ? "PASS" : "FAIL"); + Serial.print("Feature engine error status: 0x"); + Serial.print(result.errorStatus, HEX); + Serial.println(" (0x05 = no error)"); + + // Restore the range the sketch wants - the routine reconfigured the accelerometer, and + // this puts the gyroscope back on the setting we are measuring with. + myIMU.setGyroConfig(BMI330_GYRO_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_GYRO_RANGE_125DPS); + delay(100); + + Serial.println(); + measureRestingOffset("Resting rate AFTER calibration: "); + + myIMU.getGyroOffset(ox, oy, oz); + Serial.print("Stored offsets after: "); + Serial.print(ox); + Serial.print(", "); + Serial.print(oy); + Serial.print(", "); + Serial.println(oz); + + Serial.println(); + Serial.println("These offsets are volatile - they are lost on reset or power-down."); + Serial.println("Save them and restore with setGyroOffset(), or re-run this at every boot."); + Serial.println(); + Serial.println("GyrX(dps)\tGyrY(dps)\tGyrZ(dps)"); +} + +void loop() +{ + sfe_bmi330_data_t gyro; + + if (myIMU.getGyroData(gyro) == ksfTkErrOk) + { + Serial.print(gyro.x, 3); + Serial.print("\t\t"); + Serial.print(gyro.y, 3); + Serial.print("\t\t"); + Serial.println(gyro.z, 3); + } + + delay(500); +} diff --git a/examples/Example12_AxisRemap/Example12_AxisRemap.ino b/examples/Example12_AxisRemap/Example12_AxisRemap.ino new file mode 100644 index 0000000..ca661c5 --- /dev/null +++ b/examples/Example12_AxisRemap/Example12_AxisRemap.ino @@ -0,0 +1,178 @@ +/* + Example 12 - Axis Remapping + + A sensor is rarely mounted the way the datasheet drew it. Axis remapping tells the BMI330 how + the board actually sits, and it applies the correction INSIDE the device - so the + accelerometer, the gyroscope, the FIFO and every feature-engine feature all agree. + + That last part is why this is worth doing in the device rather than in your sketch. Fixing + the axes in software corrects the numbers you print and nothing else: tap detection still + watches the physical Z axis, tilt still references the physical gravity direction, and the + step counter still expects up to be up. One setAxisRemap() call fixes all of them at once. + + A remap is a permutation plus three sign flips. Read the permutation as "reported X, Y, Z + come from physical ...": BMI330_AXIS_MAP_YXZ means reported X is physical Y, reported Y is + physical X, and Z is unchanged. + + KEEP THE FRAME RIGHT-HANDED. A physical rotation always swaps an even number of signs and axes + together. Swapping two axes on their own, or flipping a single sign, describes a MIRROR image: + the accelerometer still looks plausible, but every gyroscope rate comes out with the wrong + handedness. A 90 degree turn in the board's plane is a swap of X and Y PLUS one inversion. + + TWO THINGS THAT CATCH PEOPLE OUT: + + 1. THE COMMIT SUSPENDS THE ACCELEROMETER. The device only accepts an axis-map update while + the accelerometer is suspended, so setAxisRemap() saves the current power mode, disables + the sensor, commits, and restores it. Sampling stops for a few tens of milliseconds. Do + not call this from inside a data-ready interrupt handler, and do not expect a continuous + sample stream across it. + + 2. A remap does not survive a soft reset. softReset() returns everything to defaults, so + re-apply it afterwards. + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + QWIIC --> QWIIC + + Lay the board flat, face up, on a table. Z should read about +1 g. The sketch then flips the + board's frame upside down in software and Z should read about -1 g without you touching it. + + Open the Serial Monitor at 115200 baud. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +SfeBMI330ArdI2C myIMU; + +void printAccel(const char *label) +{ + // Average a few samples so the comparison is not dominated by noise. + const uint8_t kSamples = 20; + float sx = 0.0f, sy = 0.0f, sz = 0.0f; + + for (uint8_t i = 0; i < kSamples; i++) + { + sfe_bmi330_data_t accel; + + if (myIMU.getAccelData(accel) == ksfTkErrOk) + { + sx += accel.x; + sy += accel.y; + sz += accel.z; + } + + delay(20); + } + + Serial.print(label); + Serial.print("X "); + Serial.print(sx / kSamples, 3); + Serial.print(" g, Y "); + Serial.print(sy / kSamples, 3); + Serial.print(" g, Z "); + Serial.print(sz / kSamples, 3); + Serial.println(" g"); +} + +void applyRemap(const char *description, sfe_bmi330_axis_map_t map, bool invX, bool invY, bool invZ) +{ + sfe_bmi330_axis_remap_t remap; + remap.map = map; + remap.invertX = invX; + remap.invertY = invY; + remap.invertZ = invZ; + + Serial.println(); + Serial.print("Applying: "); + Serial.println(description); + + if (myIMU.setAxisRemap(remap) != ksfTkErrOk) + { + Serial.println(" Remap FAILED - the feature engine did not acknowledge the update."); + Serial.println(" Check getFeatureEngineStatus(); the engine must be running."); + return; + } + + // Read it back - the device is the authority on what was actually stored. + sfe_bmi330_axis_remap_t readBack; + myIMU.getAxisRemap(readBack); + + Serial.print(" Stored: map "); + Serial.print(readBack.map); + Serial.print(", invert X/Y/Z = "); + Serial.print(readBack.invertX); + Serial.print("/"); + Serial.print(readBack.invertY); + Serial.print("/"); + Serial.println(readBack.invertZ); + + delay(100); + printAccel(" Reading: "); +} + +void setup() +{ + Serial.begin(115200); + Serial.println("SparkFun BMI330 Example 12 - Axis Remapping"); + + Wire.begin(); + + while (myIMU.begin() == false) + { + Serial.println("BMI330 not found - check your wiring!"); + delay(1000); + } + + // Turn the accelerometer on so there is something to read. setAxisRemap() will suspend and + // restore it around each commit. + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_ACCEL_RANGE_4G); + delay(100); + + Serial.println(); + Serial.println("Lay the board flat and face up. Z should read about +1 g."); + Serial.println("Starting in 3 seconds - do not move the board during the test."); + delay(3000); + + Serial.println(); + printAccel("Default (no remap): "); + + // Board rotated 90 degrees in its own plane: reported X is physical Y, reported Y is + // physical -X. The swap needs the inversion to stay a rotation rather than a mirror. + applyRemap("rotated 90 degrees (X = Y, Y = -X)", BMI330_AXIS_MAP_YXZ, false, true, false); + + // Invert Y and Z: the board is mounted upside down. Z should now read about -1 g even + // though nothing physically moved. + applyRemap("mounted upside down (invert Y and Z)", BMI330_AXIS_MAP_XYZ, false, true, true); + + // Back to the factory frame. + applyRemap("back to default", BMI330_AXIS_MAP_XYZ, false, false, false); + + Serial.println(); + Serial.println("Done. Remember: a remap is lost on soft reset, so re-apply it afterwards."); + Serial.println(); + Serial.println("AccX(g)\tAccY(g)\tAccZ(g)"); +} + +void loop() +{ + sfe_bmi330_data_t accel; + + if (myIMU.getAccelData(accel) == ksfTkErrOk) + { + Serial.print(accel.x, 3); + Serial.print("\t"); + Serial.print(accel.y, 3); + Serial.print("\t"); + Serial.println(accel.z, 3); + } + + delay(500); +} diff --git a/extras/TapDiagnostic/TapDiagnostic.ino b/extras/TapDiagnostic/TapDiagnostic.ino new file mode 100644 index 0000000..c917f5b --- /dev/null +++ b/extras/TapDiagnostic/TapDiagnostic.ino @@ -0,0 +1,410 @@ +/* + Tap Diagnostic (bench utility, not a library example) + + A bench tool for when tap detection is not firing. Tap is the fussiest feature on the BMI330, + and when it fails it fails silently - there is no error code for "your taps are landing on an + axis I am not watching" or "your threshold is above your knuckles". This sketch answers those + questions directly. + + It does three things: + + 1. DUMPS THE DEVICE STATE. Feature engine status, the feature-engine error nibble, the + enable register, the interrupt map, and the accelerometer configuration read back from + the hardware rather than from what the sketch thinks it wrote. If the engine is not + running or the enable did not stick, everything else is moot and you will see it here. + + 2. MEASURES YOUR TAPS. It samples the accelerometer flat out and reports the peak excursion + on each axis, so you can see how hard your taps actually are and - more importantly - + WHICH AXIS they land on. The tap detector watches exactly one axis. A board standing in + a carrier, or tapped on the enclosure side, puts the impulse on X or Y, and a Z-axis + detector will never see it no matter how low the threshold goes. + + 3. SWEEPS THE AXES. It arms the detector on X, then Y, then Z in turn, each with a + deliberately low threshold, and counts gestures on each. Whichever axis counts, that is + the axis to put in your sketch. + + RUN IT, THEN TAP STEADILY throughout - about one tap per second, in the way you intend the + finished product to be tapped. The whole sweep takes about half a minute. + + WHAT TO DO WITH THE RESULT: + + One axis counts gestures -> use that axis in Example07, raise the threshold until the + false positives stop. + No axis counts, but the peak -> the threshold is too high for your mounting. Lower + excursions are large kThresholdLow further, or tap harder. + No axis counts, peaks tiny -> the impulse is not reaching the sensor. Usually a board + floating on a cable rather than fixed to what you are + tapping. Mount it down and try again. + Feature engine not running -> nothing to do with tap. Check power and the bus. + + SparkFun Electronics + Date: 2026 + SparkFun code, firmware, and software is released under the MIT License. + Please see LICENSE.md for further details. + + Hardware Connections: + IoT RedBoard --> BMI330 Breakout + QWIIC --> QWIIC + + No interrupt wiring needed. This sketch polls, which is exactly the point - it takes the + interrupt pin out of the equation so a wiring fault cannot be mistaken for a detector fault. + + Open the Serial Monitor at 115200 baud. + + Feel like supporting our work? Buy a board from SparkFun! + https://www.sparkfun.com/ +*/ + +#include + +SfeBMI330ArdI2C myIMU; + +// Deliberately low, so the sweep errs toward false positives rather than missing a real tap. +// Tune upward in your own sketch once you know the axis. +const uint16_t kThresholdLow = 0x10; + +// Seconds to listen on each axis. +const uint8_t kSecondsPerAxis = 8; + +// Resting acceleration, measured at startup and subtracted so gravity does not count as a peak. +float baselineX = 0.0f, baselineY = 0.0f, baselineZ = 0.0f; + +// Peak excursion seen on each axis since the last report. +float peakX = 0.0f, peakY = 0.0f, peakZ = 0.0f; + +void dumpState(void) +{ + Serial.println(F("--- Device state -------------------------------------------")); + + uint8_t chipId = 0, revId = 0; + myIMU.getChipID(chipId, revId); + Serial.print(F("Chip ID : 0x")); + Serial.print(chipId, HEX); + Serial.print(F(" (expect 0x48) revision ")); + Serial.println(revId); + + Serial.print(F("Feature engine : ")); + Serial.println(myIMU.getFeatureEngineStatus() ? F("running") : F("*** NOT RUNNING ***")); + + uint16_t featureIO1 = 0; + myIMU.getFeatureEngineError(featureIO1); + Serial.print(F("FEATURE_IO1 : 0x")); + Serial.print(featureIO1, HEX); + Serial.print(F(" error nibble 0x")); + Serial.print(featureIO1 & 0x000F, HEX); + Serial.println(F(" (0x5 = no error)")); + + // Bit 7 is the feature engine's complaint that the accelerometer is running too slowly for + // the features that are enabled. If this is set, fix the ODR before touching anything else. + if (featureIO1 & 0x0080) + Serial.println(F(" *** sample rate error: the accelerometer ODR is too low for an enabled feature")); + + uint16_t enabled = 0; + myIMU.getEnabledFeatures(enabled); + Serial.print(F("FEATURE_IO0 : 0x")); + Serial.print(enabled, HEX); + Serial.print(F(" tap bits ")); + Serial.print((enabled & 0x1000) ? F("S") : F("-")); + Serial.print((enabled & 0x2000) ? F("D") : F("-")); + Serial.println((enabled & 0x4000) ? F("T") : F("-")); + + // Read the accelerometer configuration back from the hardware. Worth doing on any sketch + // that is behaving oddly: a reading that looks scaled wrong usually means the range in the + // device and the range the conversion is using have diverged. + sfe_bmi330_accel_mode_t mode; + sfe_bmi330_odr_t odr; + sfe_bmi330_accel_range_t range; + sfe_bmi330_bandwidth_t bandwidth; + sfe_bmi330_averaging_t averaging; + myIMU.getAccelConfig(mode, odr, range, bandwidth, averaging); + + const char *rangeName = "?"; + float fullScale = 0.0f; + switch (range) + { + case BMI330_ACCEL_RANGE_2G: + rangeName = "+/-2 g"; + fullScale = 2.0f; + break; + case BMI330_ACCEL_RANGE_4G: + rangeName = "+/-4 g"; + fullScale = 4.0f; + break; + case BMI330_ACCEL_RANGE_8G: + rangeName = "+/-8 g"; + fullScale = 8.0f; + break; + case BMI330_ACCEL_RANGE_16G: + rangeName = "+/-16 g"; + fullScale = 16.0f; + break; + } + + Serial.print(F("Accel range : ")); + Serial.print(rangeName); + Serial.print(F(" (readings above ")); + Serial.print(fullScale, 1); + Serial.println(F(" g are impossible and mean something is wrong)")); + + Serial.print(F("Accel mode / ODR : 0x")); + Serial.print(mode, HEX); + Serial.print(F(" / 0x")); + Serial.println(odr, HEX); + + uint16_t intMap2 = 0; + myIMU.readRegister(0x3B, intMap2); + Serial.print(F("INT_MAP2 : 0x")); + Serial.print(intMap2, HEX); + Serial.print(F(" tap routed to ")); + uint8_t tapRoute = intMap2 & 0x0003; + Serial.println(tapRoute == 0 ? F("NOWHERE - tap will not appear in any status register") + : (tapRoute == 1 ? F("INT1") : F("INT2"))); + + // The raw tap configuration words, straight out of feature memory. + uint16_t tapWords[3] = {0}; + myIMU.readFeatureWords(BMI330_FEAT_ADDR_TAP, tapWords, 3); + Serial.print(F("Tap config words : 0x")); + Serial.print(tapWords[0], HEX); + Serial.print(F(" 0x")); + Serial.print(tapWords[1], HEX); + Serial.print(F(" 0x")); + Serial.println(tapWords[2], HEX); + + Serial.println(F("------------------------------------------------------------")); + Serial.println(); +} + +void measureBaseline(void) +{ + const uint8_t kSamples = 50; + float sx = 0.0f, sy = 0.0f, sz = 0.0f; + + for (uint8_t i = 0; i < kSamples; i++) + { + sfe_bmi330_data_t accel; + + if (myIMU.getAccelData(accel) == ksfTkErrOk) + { + sx += accel.x; + sy += accel.y; + sz += accel.z; + } + + delay(10); + } + + baselineX = sx / kSamples; + baselineY = sy / kSamples; + baselineZ = sz / kSamples; + + Serial.print(F("Resting orientation: X ")); + Serial.print(baselineX, 3); + Serial.print(F(" g, Y ")); + Serial.print(baselineY, 3); + Serial.print(F(" g, Z ")); + Serial.print(baselineZ, 3); + Serial.println(F(" g")); + Serial.println(); +} + +// Arm the tap detector on one axis and listen. Returns the number of gestures counted, and +// leaves the per-axis peak excursions in peakX / peakY / peakZ. +uint16_t listenOnAxis(sfe_bmi330_tap_axis_t axis, const char *axisName) +{ + sfe_bmi330_tap_config_t tap; + tap.axis = axis; + tap.mode = 0; // Most sensitive mode for the sweep. + tap.peakThreshold = kThresholdLow; + tap.maxPeaksForTap = 6; + tap.maxGestureDuration = 0x11; + tap.maxDurationBetweenPeaks = 5; + tap.shockSettlingDuration = 5; + tap.minQuietBetweenTaps = 7; + tap.quietTimeAfterGesture = 5; + tap.waitForTimeout = false; // Report as soon as anything is seen - we are counting, not classifying. + myIMU.setTapConfig(tap); + + // Re-arm. Disabling and re-enabling makes the engine pick up the new axis cleanly. + myIMU.enableFeature(BMI330_FEAT_SINGLE_TAP | BMI330_FEAT_DOUBLE_TAP | BMI330_FEAT_TRIPLE_TAP, false); + myIMU.enableFeature(BMI330_FEAT_SINGLE_TAP | BMI330_FEAT_DOUBLE_TAP | BMI330_FEAT_TRIPLE_TAP, true); + + // Throw away anything pending from the previous axis. + uint16_t discard = 0; + myIMU.getInterruptStatus(BMI330_INT1, discard); + + peakX = 0.0f; + peakY = 0.0f; + peakZ = 0.0f; + + Serial.print(F("Listening on ")); + Serial.print(axisName); + Serial.print(F(" for ")); + Serial.print(kSecondsPerAxis); + Serial.println(F(" seconds - tap now, about once a second...")); + + uint16_t gestures = 0; + uint32_t samples = 0; + uint32_t started = millis(); + + while (millis() - started < (uint32_t)kSecondsPerAxis * 1000UL) + { + sfe_bmi330_data_t accel; + + if (myIMU.getAccelData(accel) == ksfTkErrOk) + { + samples++; + + float dx = fabs(accel.x - baselineX); + float dy = fabs(accel.y - baselineY); + float dz = fabs(accel.z - baselineZ); + + if (dx > peakX) + peakX = dx; + if (dy > peakY) + peakY = dy; + if (dz > peakZ) + peakZ = dz; + } + + uint16_t status = 0; + myIMU.getInterruptStatus(BMI330_INT1, status); + + if (status & BMI330_INT_STATUS_TAP) + { + gestures++; + Serial.print(F(" tap! (")); + Serial.print(gestures); + Serial.println(F(")")); + } + } + + Serial.print(F(" ")); + Serial.print(axisName); + Serial.print(F(": ")); + Serial.print(gestures); + Serial.print(F(" gesture(s) from ")); + Serial.print(samples); + Serial.print(F(" samples. Peak excursion X ")); + Serial.print(peakX, 2); + Serial.print(F(" g, Y ")); + Serial.print(peakY, 2); + Serial.print(F(" g, Z ")); + Serial.print(peakZ, 2); + Serial.println(F(" g")); + Serial.println(); + + return gestures; +} + +void setup() +{ + Serial.begin(115200); + while (!Serial) + delay(10); + + Serial.println(F("SparkFun BMI330 Tap Diagnostic")); + Serial.println(); + + Wire.begin(); + Wire.setClock(400000); + + while (myIMU.begin() == false) + { + Serial.println(F("BMI330 not found - check your wiring!")); + delay(1000); + } + + // Fast and wide: 400 Hz so the impulse is not missed between samples, +/-8 g so a firm tap + // does not clip. A clipped peak loses the shape the detector is looking for. + myIMU.setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_400_HZ, BMI330_ACCEL_RANGE_8G); + + // Route tap into INT_STATUS_INT1. This is required even when polling - an unmapped source + // does not appear in either status register. + myIMU.mapInterrupt(BMI330_INT_SRC_TAP, BMI330_INT1); + + myIMU.enableFeature(BMI330_FEAT_SINGLE_TAP | BMI330_FEAT_DOUBLE_TAP | BMI330_FEAT_TRIPLE_TAP, true); + + delay(100); + dumpState(); + + Serial.println(F("Hold still for a moment - measuring the resting orientation.")); + measureBaseline(); + + Serial.println(F("Now tap steadily, about once a second, the way you intend to use it.")); + Serial.println(); + delay(1000); + + uint16_t onX = listenOnAxis(BMI330_TAP_AXIS_X, "X"); + uint16_t onY = listenOnAxis(BMI330_TAP_AXIS_Y, "Y"); + uint16_t onZ = listenOnAxis(BMI330_TAP_AXIS_Z, "Z"); + + Serial.println(F("=== Result =================================================")); + Serial.print(F("Gestures counted: X ")); + Serial.print(onX); + Serial.print(F(", Y ")); + Serial.print(onY); + Serial.print(F(", Z ")); + Serial.println(onZ); + + uint16_t best = onX; + const char *bestName = "X"; + sfe_bmi330_tap_axis_t bestAxis = BMI330_TAP_AXIS_X; + + if (onY > best) + { + best = onY; + bestName = "Y"; + bestAxis = BMI330_TAP_AXIS_Y; + } + + if (onZ > best) + { + best = onZ; + bestName = "Z"; + bestAxis = BMI330_TAP_AXIS_Z; + } + + if (best == 0) + { + Serial.println(F("No gestures on any axis.")); + Serial.println(F("If the peak excursions above were well under 1 g, the impulse is not")); + Serial.println(F("reaching the sensor - fix the board to whatever you are tapping and retry.")); + Serial.println(F("If they were large, lower kThresholdLow and run again.")); + } + else + { + Serial.print(F("Use BMI330_TAP_AXIS_")); + Serial.print(bestName); + Serial.println(F(" in Example07, then raise peakThreshold until false taps stop.")); + + // Leave the detector armed on the winning axis so loop() is immediately useful. + sfe_bmi330_tap_config_t tap; + tap.axis = bestAxis; + tap.peakThreshold = kThresholdLow; + myIMU.setTapConfig(tap); + } + + Serial.println(F("============================================================")); + Serial.println(); + Serial.println(F("Continuing to listen on the best axis. Ctrl-R to run the sweep again.")); +} + +void loop() +{ + uint16_t status = 0; + myIMU.getInterruptStatus(BMI330_INT1, status); + + if (status & BMI330_INT_STATUS_TAP) + { + sfe_bmi330_tap_event_t event; + myIMU.getTapEvent(event); + + Serial.print(F("tap: ")); + Serial.print(event.singleTap ? F("single ") : F("")); + Serial.print(event.doubleTap ? F("double ") : F("")); + Serial.print(event.tripleTap ? F("triple ") : F("")); + Serial.println(); + } + + delay(5); +} diff --git a/keywords.txt b/keywords.txt new file mode 100644 index 0000000..3d3754e --- /dev/null +++ b/keywords.txt @@ -0,0 +1,260 @@ +# Syntax Coloring Map for SparkFun BMI330 Arduino Library + +# Datatypes / Classes (KEYWORD1) +SfeBMI330ArdI2C KEYWORD1 +SfeBMI330ArdSPI KEYWORD1 +sfDevBMI330 KEYWORD1 +sfe_bmi330_odr_t KEYWORD1 +sfe_bmi330_accel_range_t KEYWORD1 +sfe_bmi330_gyro_range_t KEYWORD1 +sfe_bmi330_bandwidth_t KEYWORD1 +sfe_bmi330_averaging_t KEYWORD1 +sfe_bmi330_accel_mode_t KEYWORD1 +sfe_bmi330_gyro_mode_t KEYWORD1 +sfe_bmi330_int_pin_t KEYWORD1 +sfe_bmi330_int_source_t KEYWORD1 +sfe_bmi330_int_status_t KEYWORD1 +sfe_bmi330_int_pin_config_t KEYWORD1 +sfe_bmi330_raw_data_t KEYWORD1 +sfe_bmi330_data_t KEYWORD1 +sfe_bmi330_sensor_data_t KEYWORD1 +sfe_bmi330_saturation_t KEYWORD1 +sfe_bmi330_error_status_t KEYWORD1 +sfe_bmi330_fifo_source_t KEYWORD1 +sfe_bmi330_fifo_frame_t KEYWORD1 +sfe_bmi330_feature_addr_t KEYWORD1 +sfe_bmi330_feature_t KEYWORD1 +sfe_bmi330_motion_config_t KEYWORD1 +sfe_bmi330_sig_motion_config_t KEYWORD1 +sfe_bmi330_tilt_config_t KEYWORD1 +sfe_bmi330_orientation_config_t KEYWORD1 +sfe_bmi330_flat_config_t KEYWORD1 +sfe_bmi330_tap_axis_t KEYWORD1 +sfe_bmi330_tap_config_t KEYWORD1 +sfe_bmi330_tap_event_t KEYWORD1 +sfe_bmi330_orientation_t KEYWORD1 +sfe_bmi330_axis_map_t KEYWORD1 +sfe_bmi330_axis_remap_t KEYWORD1 +sfe_bmi330_self_test_t KEYWORD1 +sfe_bmi330_self_test_result_t KEYWORD1 +sfe_bmi330_self_calib_t KEYWORD1 +sfe_bmi330_self_calib_result_t KEYWORD1 + +# Methods and Functions (KEYWORD2) +begin KEYWORD2 +isConnected KEYWORD2 +getChipID KEYWORD2 +getAddress KEYWORD2 +getCSPin KEYWORD2 +findDevice KEYWORD2 +setCommunicationBus KEYWORD2 +softReset KEYWORD2 +enableFeatureEngine KEYWORD2 +getFeatureEngineStatus KEYWORD2 +getFeatureEngineError KEYWORD2 +setAccelConfig KEYWORD2 +getAccelConfig KEYWORD2 +enableAccel KEYWORD2 +setAccelMode KEYWORD2 +setAccelODR KEYWORD2 +setAccelRange KEYWORD2 +getAccelRange KEYWORD2 +setAccelBandwidth KEYWORD2 +setAccelAveraging KEYWORD2 +setGyroConfig KEYWORD2 +getGyroConfig KEYWORD2 +enableGyro KEYWORD2 +setGyroMode KEYWORD2 +setGyroODR KEYWORD2 +setGyroRange KEYWORD2 +getGyroRange KEYWORD2 +setGyroBandwidth KEYWORD2 +setGyroAveraging KEYWORD2 +getSensorData KEYWORD2 +getAccelData KEYWORD2 +getAccelDataRaw KEYWORD2 +getGyroData KEYWORD2 +getGyroDataRaw KEYWORD2 +getTemperature KEYWORD2 +getSensorTime KEYWORD2 +getDataReady KEYWORD2 +getErrorStatus KEYWORD2 +getSaturationFlags KEYWORD2 +accelRawToG KEYWORD2 +gyroRawToDps KEYWORD2 +rawToCelsius KEYWORD2 +accelSensitivity KEYWORD2 +gyroSensitivity KEYWORD2 +setInterruptPinConfig KEYWORD2 +setInterruptLatch KEYWORD2 +mapInterrupt KEYWORD2 +getInterruptStatus KEYWORD2 +setFifoConfig KEYWORD2 +getFifoConfig KEYWORD2 +setFifoWatermark KEYWORD2 +getFifoWatermark KEYWORD2 +getFifoFillLevel KEYWORD2 +flushFifo KEYWORD2 +readFifoRaw KEYWORD2 +readFifoData KEYWORD2 +frameLengthWords KEYWORD2 +enableFeature KEYWORD2 +getEnabledFeatures KEYWORD2 +readFeatureWords KEYWORD2 +writeFeatureWords KEYWORD2 +setAnyMotionConfig KEYWORD2 +getAnyMotionConfig KEYWORD2 +setNoMotionConfig KEYWORD2 +getNoMotionConfig KEYWORD2 +setSigMotionConfig KEYWORD2 +getSigMotionConfig KEYWORD2 +setTiltConfig KEYWORD2 +getTiltConfig KEYWORD2 +setOrientationConfig KEYWORD2 +getOrientationConfig KEYWORD2 +getOrientation KEYWORD2 +setFlatConfig KEYWORD2 +getFlatConfig KEYWORD2 +setTapConfig KEYWORD2 +getTapConfig KEYWORD2 +getTapEvent KEYWORD2 +getStepCount KEYWORD2 +resetStepCount KEYWORD2 +setStepCounterWatermark KEYWORD2 +getStepCounterWatermark KEYWORD2 +setAxisRemap KEYWORD2 +getAxisRemap KEYWORD2 +performSelfTest KEYWORD2 +performGyroSelfCalibration KEYWORD2 +setAccelOffset KEYWORD2 +getAccelOffset KEYWORD2 +setGyroOffset KEYWORD2 +getGyroOffset KEYWORD2 +readRegister KEYWORD2 +writeRegister KEYWORD2 +readRegisters KEYWORD2 +writeRegisters KEYWORD2 +sendCommand KEYWORD2 + +# Constants (LITERAL1) +SFE_BMI330_DEFAULT_ADDRESS LITERAL1 +SFE_BMI330_ALT_ADDRESS LITERAL1 +SFE_BMI330_CHIP_ID LITERAL1 +SFE_BMI330_SPI_CLOCK_DEFAULT LITERAL1 +BMI330_ODR_0_78_HZ LITERAL1 +BMI330_ODR_1_56_HZ LITERAL1 +BMI330_ODR_3_12_HZ LITERAL1 +BMI330_ODR_6_25_HZ LITERAL1 +BMI330_ODR_12_5_HZ LITERAL1 +BMI330_ODR_25_HZ LITERAL1 +BMI330_ODR_50_HZ LITERAL1 +BMI330_ODR_100_HZ LITERAL1 +BMI330_ODR_200_HZ LITERAL1 +BMI330_ODR_400_HZ LITERAL1 +BMI330_ODR_800_HZ LITERAL1 +BMI330_ODR_1600_HZ LITERAL1 +BMI330_ODR_3200_HZ LITERAL1 +BMI330_ODR_6400_HZ LITERAL1 +BMI330_ACCEL_RANGE_2G LITERAL1 +BMI330_ACCEL_RANGE_4G LITERAL1 +BMI330_ACCEL_RANGE_8G LITERAL1 +BMI330_ACCEL_RANGE_16G LITERAL1 +BMI330_GYRO_RANGE_125DPS LITERAL1 +BMI330_GYRO_RANGE_250DPS LITERAL1 +BMI330_GYRO_RANGE_500DPS LITERAL1 +BMI330_GYRO_RANGE_1000DPS LITERAL1 +BMI330_GYRO_RANGE_2000DPS LITERAL1 +BMI330_BW_ODR_HALF LITERAL1 +BMI330_BW_ODR_QUARTER LITERAL1 +BMI330_AVG_1 LITERAL1 +BMI330_AVG_2 LITERAL1 +BMI330_AVG_4 LITERAL1 +BMI330_AVG_8 LITERAL1 +BMI330_AVG_16 LITERAL1 +BMI330_AVG_32 LITERAL1 +BMI330_AVG_64 LITERAL1 +BMI330_ACCEL_MODE_DISABLE LITERAL1 +BMI330_ACCEL_MODE_LOW_POWER LITERAL1 +BMI330_ACCEL_MODE_NORMAL LITERAL1 +BMI330_ACCEL_MODE_HIGH_PERF LITERAL1 +BMI330_GYRO_MODE_DISABLE LITERAL1 +BMI330_GYRO_MODE_SUSPEND LITERAL1 +BMI330_GYRO_MODE_LOW_POWER LITERAL1 +BMI330_GYRO_MODE_NORMAL LITERAL1 +BMI330_GYRO_MODE_HIGH_PERF LITERAL1 +BMI330_INT_NONE LITERAL1 +BMI330_INT1 LITERAL1 +BMI330_INT2 LITERAL1 +BMI330_INT_SRC_NO_MOTION LITERAL1 +BMI330_INT_SRC_ANY_MOTION LITERAL1 +BMI330_INT_SRC_FLAT LITERAL1 +BMI330_INT_SRC_ORIENTATION LITERAL1 +BMI330_INT_SRC_STEP_DETECTOR LITERAL1 +BMI330_INT_SRC_STEP_COUNTER LITERAL1 +BMI330_INT_SRC_SIG_MOTION LITERAL1 +BMI330_INT_SRC_TILT LITERAL1 +BMI330_INT_SRC_TAP LITERAL1 +BMI330_INT_SRC_I3C LITERAL1 +BMI330_INT_SRC_ERR_STATUS LITERAL1 +BMI330_INT_SRC_TEMP_DRDY LITERAL1 +BMI330_INT_SRC_GYRO_DRDY LITERAL1 +BMI330_INT_SRC_ACCEL_DRDY LITERAL1 +BMI330_INT_SRC_FIFO_WATERMARK LITERAL1 +BMI330_INT_SRC_FIFO_FULL LITERAL1 +BMI330_INT_STATUS_NO_MOTION LITERAL1 +BMI330_INT_STATUS_ANY_MOTION LITERAL1 +BMI330_INT_STATUS_FLAT LITERAL1 +BMI330_INT_STATUS_ORIENTATION LITERAL1 +BMI330_INT_STATUS_STEP_DETECTOR LITERAL1 +BMI330_INT_STATUS_STEP_COUNTER LITERAL1 +BMI330_INT_STATUS_SIG_MOTION LITERAL1 +BMI330_INT_STATUS_TILT LITERAL1 +BMI330_INT_STATUS_TAP LITERAL1 +BMI330_INT_STATUS_I3C LITERAL1 +BMI330_INT_STATUS_ERROR LITERAL1 +BMI330_INT_STATUS_TEMP_DRDY LITERAL1 +BMI330_INT_STATUS_GYRO_DRDY LITERAL1 +BMI330_INT_STATUS_ACCEL_DRDY LITERAL1 +BMI330_INT_STATUS_FIFO_WATERMARK LITERAL1 +BMI330_INT_STATUS_FIFO_FULL LITERAL1 +BMI330_FIFO_TIME LITERAL1 +BMI330_FIFO_ACCEL LITERAL1 +BMI330_FIFO_GYRO LITERAL1 +BMI330_FIFO_TEMP LITERAL1 +BMI330_FIFO_ALL LITERAL1 +BMI330_FEAT_NO_MOTION LITERAL1 +BMI330_FEAT_NO_MOTION_X LITERAL1 +BMI330_FEAT_NO_MOTION_Y LITERAL1 +BMI330_FEAT_NO_MOTION_Z LITERAL1 +BMI330_FEAT_ANY_MOTION LITERAL1 +BMI330_FEAT_ANY_MOTION_X LITERAL1 +BMI330_FEAT_ANY_MOTION_Y LITERAL1 +BMI330_FEAT_ANY_MOTION_Z LITERAL1 +BMI330_FEAT_FLAT LITERAL1 +BMI330_FEAT_ORIENTATION LITERAL1 +BMI330_FEAT_STEP_DETECTOR LITERAL1 +BMI330_FEAT_STEP_COUNTER LITERAL1 +BMI330_FEAT_SIG_MOTION LITERAL1 +BMI330_FEAT_TILT LITERAL1 +BMI330_FEAT_SINGLE_TAP LITERAL1 +BMI330_FEAT_DOUBLE_TAP LITERAL1 +BMI330_FEAT_TRIPLE_TAP LITERAL1 +BMI330_TAP_AXIS_X LITERAL1 +BMI330_TAP_AXIS_Y LITERAL1 +BMI330_TAP_AXIS_Z LITERAL1 +BMI330_ORIENT_PORTRAIT_UPRIGHT LITERAL1 +BMI330_ORIENT_LANDSCAPE_LEFT LITERAL1 +BMI330_ORIENT_PORTRAIT_UPSIDE_DOWN LITERAL1 +BMI330_ORIENT_LANDSCAPE_RIGHT LITERAL1 +BMI330_AXIS_MAP_XYZ LITERAL1 +BMI330_AXIS_MAP_YXZ LITERAL1 +BMI330_AXIS_MAP_XZY LITERAL1 +BMI330_AXIS_MAP_ZXY LITERAL1 +BMI330_AXIS_MAP_YZX LITERAL1 +BMI330_AXIS_MAP_ZYX LITERAL1 +BMI330_SELF_TEST_ACCEL LITERAL1 +BMI330_SELF_TEST_GYRO LITERAL1 +BMI330_SELF_TEST_BOTH LITERAL1 +BMI330_SELF_CALIB_SENSITIVITY LITERAL1 +BMI330_SELF_CALIB_OFFSET LITERAL1 +BMI330_SELF_CALIB_BOTH LITERAL1 diff --git a/library.properties b/library.properties new file mode 100644 index 0000000..a794874 --- /dev/null +++ b/library.properties @@ -0,0 +1,11 @@ +name=SparkFun BMI330 Arduino Library +version=1.0.0 +author=SparkFun Electronics +maintainer=SparkFun Electronics +sentence=An Arduino library for the SparkFun Qwiic BMI330 breakout, built on the Bosch BMI330 6-DoF IMU. +paragraph=Drives the Bosch BMI330 accelerometer and gyroscope over I2C or SPI through the SparkFun Toolkit. Provides acceleration, angular rate, die temperature and sensor timestamps, full control of output data rate, range, bandwidth, hardware averaging and power modes, both interrupt pins with independent source routing, the 2 kB FIFO with watermark interrupts and frame decoding, and the on-chip feature engine - any-motion, no-motion, significant motion, tilt, orientation, flat, single/double/triple tap, step detector and step counter - plus axis remapping, self-test, gyroscope self-calibration and user offsets. Unlike the BMI270, the BMI330 needs no configuration file upload, so the library carries no vendor binary. +category=Sensors +url=https://github.com/sparkfun/SparkFun_BMI330_Arduino_Library +architectures=* +depends=SparkFun Toolkit (>=1.2.0) +includes=SparkFun_BMI330.h diff --git a/src/SparkFun_BMI330.h b/src/SparkFun_BMI330.h new file mode 100644 index 0000000..9771041 --- /dev/null +++ b/src/SparkFun_BMI330.h @@ -0,0 +1,251 @@ +/** + * @file SparkFun_BMI330.h + * @brief Arduino-specific implementation for the SparkFun Qwiic BMI330 6-DoF IMU breakout. + * + * @details + * This file provides the Arduino wrappers for the Bosch BMI330. SfeBMI330ArdI2C and + * SfeBMI330ArdSPI inherit from the platform-independent sfDevBMI330 driver and supply the bus - one + * over Arduino's Wire library, the other over SPI - through the SparkFun Toolkit. One of these two + * classes is all a sketch needs. + * + * THE BREAKOUT EXPOSES BOTH BUSES. I2C is the default, and the Qwiic connector makes it the path of + * least resistance: + * + * SfeBMI330ArdI2C imu; + * imu.begin(); // Qwiic, factory address 0x68 + * imu.begin(SFE_BMI330_ALT_ADDRESS); // ADR jumper closed, 0x69 + * + * The SPI pins are broken out as well, and the same driver drives them - nothing above the bus + * layer changes: + * + * SfeBMI330ArdSPI imu; + * imu.begin(10); // chip select on D10 + * + * SPI is worth the extra four wires when the application is sampling fast. At 6400 Hz with + * accelerometer and gyroscope both running, each sample is 16 bytes of payload; 400 kHz I2C leaves + * very little headroom for anything else on the bus, where a 4 MHz SPI link barely notices. SPI is + * also the answer to an I2C address collision that the single ADR jumper cannot resolve. + * + * BOTH INTERRUPT PINS ARE EXPOSED. INT1 and INT2 are independent, and any source can be routed to + * either - see mapInterrupt(). Putting data-ready on one pin and motion events on the other lets an + * ISR tell them apart without a bus transaction, which is the difference between a clean wake and a + * read that has to happen before the sketch knows whether it needed to. + * + * @author SparkFun Electronics + * @date September 2026 + * @copyright Copyright (c) 2026, SparkFun Electronics Inc. This project is released under the MIT License. + * + * SPDX-License-Identifier: MIT + * + * @see https://github.com/sparkfun/SparkFun_BMI330_Arduino_Library + */ + +#pragma once + +// clang-format off +#include +#include "sfTk/sfDevBMI330.h" +#include +// clang-format on + +/** + * @brief Default SPI clock for SfeBMI330ArdSPI, in hertz. + * + * @details The BMI330 is specified to 10 MHz. The default here is 4 MHz, which is quick enough to + * move a full 6-axis sample in about 40 us and forgiving of the jumper wires a breakout usually + * meets first. Pass a faster clock to begin() once the wiring is short. + */ +#define SFE_BMI330_SPI_CLOCK_DEFAULT 4000000 + +/////////////////////////////////////////////////////////////////////////////// +// SfeBMI330ArdI2C +/////////////////////////////////////////////////////////////////////////////// + +/** + * @class SfeBMI330ArdI2C + * @brief Arduino I2C implementation for the SparkFun Qwiic BMI330 breakout. + * + * @details + * Owns an sfTkArdI2C bus object wrapping Arduino's Wire library and hands it to sfDevBMI330. Every + * measurement, configuration, interrupt, FIFO and feature-engine method is inherited unchanged from + * sfDevBMI330 - this class adds only begin() and the address helpers. + * + * @see sfDevBMI330 + * @see SfeBMI330ArdSPI + */ +class SfeBMI330ArdI2C : public sfDevBMI330 +{ + public: + SfeBMI330ArdI2C() + { + } + + /** + * @brief Initialize the board over I2C. + * + * @details Brings up the Toolkit bus, confirms something is answering at @p address, checks the + * chip ID, soft resets, and starts the feature engine. + * + * @note Both sensors are left DISABLED, matching the device's power-on state. A sketch that + * calls begin() and then reads will get zeros until enableAccel() and enableGyro() - or + * setAccelConfig() / setGyroConfig() - have run. This is deliberate: the BMI330's power modes + * and rates are application choices, and guessing them would cost current on a battery design + * that wanted low-power mode. + * + * @param address 7-bit I2C address. Defaults to ::SFE_BMI330_DEFAULT_ADDRESS (0x68); close the + * board's ADR jumper for ::SFE_BMI330_ALT_ADDRESS (0x69). + * @param wirePort TwoWire instance to use (default: Wire). + * @return true if the device was found and initialized. + */ + bool begin(uint8_t address = SFE_BMI330_DEFAULT_ADDRESS, TwoWire &wirePort = Wire) + { + if (_theI2CBus.init(wirePort, address) != ksfTkErrOk) + return false; + + // Confirm something is at this address before reading registers, so a missing board reports + // a bus failure rather than a chip ID mismatch. + if (_theI2CBus.ping() != ksfTkErrOk) + return false; + + return sfDevBMI330::begin(&_theI2CBus) == ksfTkErrOk; + } + + /** + * @brief Initialize the board over I2C on an explicit Wire port at the default address. + * + * @details Convenience overload for boards with more than one I2C peripheral - an ESP32 with + * the IMU on Wire1, for instance. + * + * @param wirePort TwoWire instance to use. + * @return true if the device was found and initialized. + */ + bool begin(TwoWire &wirePort) + { + return begin(SFE_BMI330_DEFAULT_ADDRESS, wirePort); + } + + /** + * @brief Get the 7-bit I2C address this object is using. + * @return The configured I2C address. + */ + uint8_t getAddress(void) + { + return _theI2CBus.address(); + } + + /** + * @brief Check whether a BMI330 answers at either factory address on a given bus. + * + * @details A discovery helper for a sketch that does not know how the board's ADR jumper is + * set. It pings each address and reads the chip ID, so a device that merely answers at 0x68 - + * plenty of parts do - is not mistaken for a BMI330. + * + * @param address Receives the address where a BMI330 was found. + * @param wirePort TwoWire instance to search (default: Wire). + * @return true if a BMI330 was found. + */ + static bool findDevice(uint8_t &address, TwoWire &wirePort = Wire) + { + static const uint8_t kAddresses[2] = {SFE_BMI330_DEFAULT_ADDRESS, SFE_BMI330_ALT_ADDRESS}; + + for (uint8_t i = 0; i < 2; i++) + { + SfeBMI330ArdI2C candidate; + + if (candidate._theI2CBus.init(wirePort, kAddresses[i]) != ksfTkErrOk) + continue; + + if (candidate._theI2CBus.ping() != ksfTkErrOk) + continue; + + candidate.setCommunicationBus(&candidate._theI2CBus); + + if (!candidate.isConnected()) + continue; + + address = kAddresses[i]; + return true; + } + + return false; + } + + private: + /** @brief Arduino I2C bus interface used for all communication with the board. */ + sfTkArdI2C _theI2CBus; +}; + +/////////////////////////////////////////////////////////////////////////////// +// SfeBMI330ArdSPI +/////////////////////////////////////////////////////////////////////////////// + +/** + * @class SfeBMI330ArdSPI + * @brief Arduino SPI implementation for the SparkFun Qwiic BMI330 breakout. + * + * @details + * Owns an sfTkArdSPI bus object and hands it to sfDevBMI330. Everything above the bus is identical + * to the I2C class - a sketch can be moved from one to the other by changing the object's type and + * its begin() arguments, and nothing else. + * + * @note SPI IS SELECTED BY THE FIRST CS EDGE. The BMI330 powers up undecided about its interface + * and commits to SPI when it first sees CSB go low and back high. That first transfer therefore + * returns garbage by design, which is why the driver performs and discards one read before checking + * the chip ID - both in begin() and after every soft reset. A driver without that throwaway read + * fails to identify a perfectly healthy part. + * + * @see sfDevBMI330 + * @see SfeBMI330ArdI2C + */ +class SfeBMI330ArdSPI : public sfDevBMI330 +{ + public: + SfeBMI330ArdSPI() + { + } + + /** + * @brief Initialize the board over SPI. + * + * @details Drives the chip select pin high before switching it to an output - the order + * matters, since an output that starts low would look like the beginning of a transfer to a + * device that has not yet decided which interface it speaks. + * + * @note As with the I2C class, both sensors are left disabled. See SfeBMI330ArdI2C::begin(). + * + * @param csPin Chip select pin. + * @param clockFrequency SPI clock in hertz. Defaults to ::SFE_BMI330_SPI_CLOCK_DEFAULT; + * the device is specified to 10 MHz. + * @param spiPort SPIClass instance to use (default: SPI). + * @return true if the device was found and initialized. + */ + bool begin(uint8_t csPin, uint32_t clockFrequency = SFE_BMI330_SPI_CLOCK_DEFAULT, SPIClass &spiPort = SPI) + { + // High first, then output. Reversing these glitches CS low for a moment on most cores. + digitalWrite(csPin, HIGH); + pinMode(csPin, OUTPUT); + digitalWrite(csPin, HIGH); + + // Mode 0. The BMI330 also accepts mode 3, but mode 0 is what the BMI270 library uses and + // keeps behavior consistent across SparkFun's Bosch IMU boards. + SPISettings settings(clockFrequency, MSBFIRST, SPI_MODE0); + + if (_theSPIBus.init(spiPort, settings, csPin, true) != ksfTkErrOk) + return false; + + return sfDevBMI330::begin(&_theSPIBus) == ksfTkErrOk; + } + + /** + * @brief Get the chip select pin this object is using. + * @return The configured chip select pin. + */ + uint8_t getCSPin(void) + { + return _theSPIBus.cs(); + } + + private: + /** @brief Arduino SPI bus interface used for all communication with the board. */ + sfTkArdSPI _theSPIBus; +}; diff --git a/src/sfTk/sfDevBMI330.cpp b/src/sfTk/sfDevBMI330.cpp new file mode 100644 index 0000000..876c757 --- /dev/null +++ b/src/sfTk/sfDevBMI330.cpp @@ -0,0 +1,1804 @@ +/** + * @file sfDevBMI330.cpp + * @brief Implementation file for the SparkFun Bosch BMI330 6-DoF IMU Driver. + * + * @details + * Implements sfDevBMI330: 16-bit little-endian register access with the device's leading dummy + * bytes handled, the ACC_CONF / GYR_CONF bit packing, the headerless FIFO parser, the feature + * engine address window, and the self-test and self-calibration sequences. + * + * @author SparkFun Electronics + * @date September 2026 + * @copyright Copyright (c) 2026, SparkFun Electronics Inc. This project is released under the MIT License. + * + * SPDX-License-Identifier: MIT + * + * @see https://github.com/sparkfun/SparkFun_BMI330_Arduino_Library + */ + +#include "sfDevBMI330.h" + +/////////////////////////////////////////////////////////////////////////////// +// Gyroscope Filter Coefficients +/////////////////////////////////////////////////////////////////////////////// +// +// The BMI330's self-test and self-calibration routines run against a set of gyroscope filter +// coefficients held in feature memory at BMI330_FEAT_ADDR_SC_ST_COEFF. The BMI330 uses a DIFFERENT +// set from the BMI323, and a part seeded with the BMI323 values reports self-test failures while +// being perfectly healthy. primeGyroFilterCoefficients() writes these when what is already there +// does not match. +// +// Source: Bosch BMI3xy SensorAPI v2.4.0, BMI330 build (BMI3_SC_ST_VALUE_0 through _8). +static const uint16_t kGyroFilterCoefficients[9] = {0x5A2E, 0x9219, 0x5637, 0xFFE8, 0xFFEF, + 0x000D, 0x07CA, 0xFFCD, 0xEF6C}; + +// ========================== Setup and Identity ============================ + +sfTkError_t sfDevBMI330::begin(sfTkIBus *theBus) +{ + // Adopt the supplied bus if one was given; otherwise keep whatever a prior call set. + if (theBus != nullptr) + setCommunicationBus(theBus); + + if (_theBus == nullptr) + return ksfTkErrBusNotInit; + + // The device only selects its SPI interface after it has seen a rising edge on CSB, so the very + // first SPI read comes back as garbage by design. Burn one here rather than failing the chip ID + // check on a perfectly good board. + if (_theBus->type() == ksfTkBusTypeSPI) + { + uint16_t discard = 0; + readRegister(kRegChipId, discard); + } + + uint8_t chipId = 0, revId = 0; + sfTkError_t rc = getChipID(chipId, revId); + if (rc != ksfTkErrOk) + return rc; + + if (chipId != SFE_BMI330_CHIP_ID) + return ksfTkErrBusNoResponse; + + // The accelerometer user offset field narrowed from 14 bits to 13 on earlier silicon. Pick the + // width once, here, rather than guessing at every offset access. + _accelOffsetMask = (revId >= 1) ? kAccelOffsetMask14 : kAccelOffsetMask13; + + // Reset to a known state, bring the feature engine up, and resynchronize the range caches - + // softReset() does all three. Both sensors are left disabled, which is the power-on state. + return softReset(); +} + +void sfDevBMI330::setCommunicationBus(sfTkIBus *theBus) +{ + _theBus = theBus; + + // How many dummy bytes precede read data is a property of the interface, not the register, so + // settle it once here rather than testing the bus type on every read. + if (_theBus != nullptr && _theBus->type() == ksfTkBusTypeSPI) + _dummyBytes = kDummyBytesSPI; + else + _dummyBytes = kDummyBytesI2C; +} + +bool sfDevBMI330::isConnected(void) +{ + uint8_t chipId = 0, revId = 0; + + if (getChipID(chipId, revId) != ksfTkErrOk) + return false; + + return chipId == SFE_BMI330_CHIP_ID; +} + +sfTkError_t sfDevBMI330::getChipID(uint8_t &chipId, uint8_t &revId) +{ + chipId = 0; + revId = 0; + + uint16_t value = 0; + sfTkError_t rc = readRegister(kRegChipId, value); + if (rc != ksfTkErrOk) + return rc; + + // The word is [rev_id:15:12][reserved:11:8][chip_id:7:0]. Bosch reads the revision as + // (highByte & 0xF0) >> 4, which is bits 15:12 - not the low nibble of the high byte. + chipId = (uint8_t)(value & 0x00FF); + revId = (uint8_t)((value >> 12) & 0x0F); + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::softReset(void) +{ + sfTkError_t rc = sendCommand(kCmdSoftReset); + if (rc != ksfTkErrOk) + return rc; + + _pendingInt1 = 0; + + // Datasheet: 1.5 ms from reset to the serial interface being operational. + sftk_delay_ms(kSoftResetDelayMs); + + // A reset returns the interface to its undecided state, so SPI needs the same throwaway read + // that begin() performs. + if (_theBus != nullptr && _theBus->type() == ksfTkBusTypeSPI) + { + uint16_t discard = 0; + readRegister(kRegChipId, discard); + } + + sfTkError_t engineResult = enableFeatureEngine(); + if (engineResult != ksfTkErrOk) + return engineResult; + + // A reset returned ACC_CONF and GYR_CONF to their power-on values, so anything the caller had + // configured - including the full-scale ranges - is gone. Read them back rather than assuming + // what the reset values are. accelRawToG() and gyroRawToDps() divide by the cached sensitivity, + // so a cache that disagrees with the device does not produce an error; it quietly scales every + // reading by a constant factor, which is far harder to notice. Two register reads remove the + // assumption entirely. + sfe_bmi330_accel_mode_t accelMode; + sfe_bmi330_gyro_mode_t gyroMode; + sfe_bmi330_odr_t odr; + sfe_bmi330_accel_range_t accelRange; + sfe_bmi330_gyro_range_t gyroRange; + sfe_bmi330_bandwidth_t bandwidth; + sfe_bmi330_averaging_t averaging; + + rc = getAccelConfig(accelMode, odr, accelRange, bandwidth, averaging); + if (rc != ksfTkErrOk) + return rc; + + return getGyroConfig(gyroMode, odr, gyroRange, bandwidth, averaging); +} + +sfTkError_t sfDevBMI330::enableFeatureEngine(void) +{ + // Three steps, in this order: seed the start-up configuration, commit it, then set the enable + // bit. Skipping the commit leaves the engine sitting at its defaults and nothing reports an + // error - it simply never comes up. + sfTkError_t rc = writeRegister(kRegFeatureIO2, kFeatureStartupConfig); + if (rc != ksfTkErrOk) + return rc; + + rc = writeRegister(kRegFeatureIOStatus, kFeatureIOStatusCommit); + if (rc != ksfTkErrOk) + return rc; + + rc = writeRegister(kRegFeatureCtrl, kFeatureEngineEnable); + if (rc != ksfTkErrOk) + return rc; + + // The engine boots from ROM; poll until it says it is running. + for (uint8_t i = 0; i < kFeatureEngineRetries; i++) + { + sftk_delay_ms(kFeatureEngineStepMs); + + uint16_t status = 0; + if (readRegister(kRegFeatureIO1, status) != ksfTkErrOk) + continue; + + // The low nibble is a status CODE, not a set of flags: 0x1 (activated) and 0x5 (no error) + // mean running. 0x3 (config CRC error), 0x9, 0xB and 0xD also have bit 0 set, so testing + // bit 0 alone would report a failed start as success. + uint16_t code = (uint16_t)(status & kFeatureErrorMask); + if (code == kFeatureEngineEnable || code == kFeatureNoError) + return ksfTkErrOk; + } + + return ksfTkErrFail; +} + +bool sfDevBMI330::getFeatureEngineStatus(void) +{ + uint16_t status = 0; + + if (readRegister(kRegFeatureIO1, status) != ksfTkErrOk) + return false; + + uint16_t code = (uint16_t)(status & kFeatureErrorMask); + return code == kFeatureEngineEnable || code == kFeatureNoError; +} + +sfTkError_t sfDevBMI330::getFeatureEngineError(uint16_t &status) +{ + return readRegister(kRegFeatureIO1, status); +} + +// ====================== Accelerometer Configuration ======================= + +sfTkError_t sfDevBMI330::setAccelConfig(sfe_bmi330_accel_mode_t mode, sfe_bmi330_odr_t odr, + sfe_bmi330_accel_range_t range, sfe_bmi330_bandwidth_t bandwidth, + sfe_bmi330_averaging_t averaging) +{ + uint16_t config = 0; + + config |= ((uint16_t)odr & kConfOdrMask); + config |= (((uint16_t)range << kConfRangeShift) & kConfRangeMask); + config |= (((uint16_t)bandwidth << kConfBwShift) & kConfBwMask); + config |= (((uint16_t)averaging << kConfAvgShift) & kConfAvgMask); + config |= (((uint16_t)mode << kConfModeShift) & kConfModeMask); + + sfTkError_t rc = writeRegister(kRegAccelConf, config); + if (rc != ksfTkErrOk) + return rc; + + // The conversion math needs the range, and reading it back per sample would double the bus + // traffic of every measurement. Cache it. + _accelRange = range; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getAccelConfig(sfe_bmi330_accel_mode_t &mode, sfe_bmi330_odr_t &odr, + sfe_bmi330_accel_range_t &range, sfe_bmi330_bandwidth_t &bandwidth, + sfe_bmi330_averaging_t &averaging) +{ + uint16_t config = 0; + sfTkError_t rc = readRegister(kRegAccelConf, config); + if (rc != ksfTkErrOk) + return rc; + + odr = (sfe_bmi330_odr_t)(config & kConfOdrMask); + range = (sfe_bmi330_accel_range_t)((config & kConfRangeMask) >> kConfRangeShift); + bandwidth = (sfe_bmi330_bandwidth_t)((config & kConfBwMask) >> kConfBwShift); + averaging = (sfe_bmi330_averaging_t)((config & kConfAvgMask) >> kConfAvgShift); + mode = (sfe_bmi330_accel_mode_t)((config & kConfModeMask) >> kConfModeShift); + + // Resync the cache from the device - this is how a sketch picks up a configuration it did not + // set itself, for example after a warm reset of the host but not the sensor. + _accelRange = range; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::enableAccel(bool enable) +{ + return setAccelMode(enable ? BMI330_ACCEL_MODE_HIGH_PERF : BMI330_ACCEL_MODE_DISABLE); +} + +sfTkError_t sfDevBMI330::setAccelMode(sfe_bmi330_accel_mode_t mode) +{ + return updateRegister(kRegAccelConf, kConfModeMask, (uint16_t)((uint16_t)mode << kConfModeShift)); +} + +sfTkError_t sfDevBMI330::setAccelODR(sfe_bmi330_odr_t odr) +{ + return updateRegister(kRegAccelConf, kConfOdrMask, (uint16_t)odr); +} + +sfTkError_t sfDevBMI330::setAccelRange(sfe_bmi330_accel_range_t range) +{ + sfTkError_t rc = + updateRegister(kRegAccelConf, kConfRangeMask, (uint16_t)((uint16_t)range << kConfRangeShift)); + if (rc != ksfTkErrOk) + return rc; + + _accelRange = range; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::setAccelBandwidth(sfe_bmi330_bandwidth_t bandwidth) +{ + return updateRegister(kRegAccelConf, kConfBwMask, (uint16_t)((uint16_t)bandwidth << kConfBwShift)); +} + +sfTkError_t sfDevBMI330::setAccelAveraging(sfe_bmi330_averaging_t averaging) +{ + return updateRegister(kRegAccelConf, kConfAvgMask, (uint16_t)((uint16_t)averaging << kConfAvgShift)); +} + +// ======================= Gyroscope Configuration ========================== + +sfTkError_t sfDevBMI330::setGyroConfig(sfe_bmi330_gyro_mode_t mode, sfe_bmi330_odr_t odr, + sfe_bmi330_gyro_range_t range, sfe_bmi330_bandwidth_t bandwidth, + sfe_bmi330_averaging_t averaging) +{ + uint16_t config = 0; + + config |= ((uint16_t)odr & kConfOdrMask); + config |= (((uint16_t)range << kConfRangeShift) & kConfRangeMask); + config |= (((uint16_t)bandwidth << kConfBwShift) & kConfBwMask); + config |= (((uint16_t)averaging << kConfAvgShift) & kConfAvgMask); + config |= (((uint16_t)mode << kConfModeShift) & kConfModeMask); + + sfTkError_t rc = writeRegister(kRegGyroConf, config); + if (rc != ksfTkErrOk) + return rc; + + _gyroRange = range; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getGyroConfig(sfe_bmi330_gyro_mode_t &mode, sfe_bmi330_odr_t &odr, + sfe_bmi330_gyro_range_t &range, sfe_bmi330_bandwidth_t &bandwidth, + sfe_bmi330_averaging_t &averaging) +{ + uint16_t config = 0; + sfTkError_t rc = readRegister(kRegGyroConf, config); + if (rc != ksfTkErrOk) + return rc; + + odr = (sfe_bmi330_odr_t)(config & kConfOdrMask); + range = (sfe_bmi330_gyro_range_t)((config & kConfRangeMask) >> kConfRangeShift); + bandwidth = (sfe_bmi330_bandwidth_t)((config & kConfBwMask) >> kConfBwShift); + averaging = (sfe_bmi330_averaging_t)((config & kConfAvgMask) >> kConfAvgShift); + mode = (sfe_bmi330_gyro_mode_t)((config & kConfModeMask) >> kConfModeShift); + + _gyroRange = range; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::enableGyro(bool enable) +{ + return setGyroMode(enable ? BMI330_GYRO_MODE_HIGH_PERF : BMI330_GYRO_MODE_DISABLE); +} + +sfTkError_t sfDevBMI330::setGyroMode(sfe_bmi330_gyro_mode_t mode) +{ + return updateRegister(kRegGyroConf, kConfModeMask, (uint16_t)((uint16_t)mode << kConfModeShift)); +} + +sfTkError_t sfDevBMI330::setGyroODR(sfe_bmi330_odr_t odr) +{ + return updateRegister(kRegGyroConf, kConfOdrMask, (uint16_t)odr); +} + +sfTkError_t sfDevBMI330::setGyroRange(sfe_bmi330_gyro_range_t range) +{ + sfTkError_t rc = + updateRegister(kRegGyroConf, kConfRangeMask, (uint16_t)((uint16_t)range << kConfRangeShift)); + if (rc != ksfTkErrOk) + return rc; + + _gyroRange = range; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::setGyroBandwidth(sfe_bmi330_bandwidth_t bandwidth) +{ + return updateRegister(kRegGyroConf, kConfBwMask, (uint16_t)((uint16_t)bandwidth << kConfBwShift)); +} + +sfTkError_t sfDevBMI330::setGyroAveraging(sfe_bmi330_averaging_t averaging) +{ + return updateRegister(kRegGyroConf, kConfAvgMask, (uint16_t)((uint16_t)averaging << kConfAvgShift)); +} + +// ============================= Measurement ================================ + +sfTkError_t sfDevBMI330::getSensorData(sfe_bmi330_sensor_data_t &data) +{ + // Nine consecutive words: accel XYZ, gyro XYZ, temperature, sensor time low, sensor time high. + // One transaction means one instant - reading the axes separately would smear a fast rotation + // across several samples. + uint16_t raw[9] = {0}; + sfTkError_t rc = readRegisters(kRegAccelDataX, raw, 9); + if (rc != ksfTkErrOk) + return rc; + + data.accelRaw.x = (int16_t)raw[0]; + data.accelRaw.y = (int16_t)raw[1]; + data.accelRaw.z = (int16_t)raw[2]; + + data.gyroRaw.x = (int16_t)raw[3]; + data.gyroRaw.y = (int16_t)raw[4]; + data.gyroRaw.z = (int16_t)raw[5]; + + data.accel.x = accelRawToG(data.accelRaw.x); + data.accel.y = accelRawToG(data.accelRaw.y); + data.accel.z = accelRawToG(data.accelRaw.z); + + data.gyro.x = gyroRawToDps(data.gyroRaw.x); + data.gyro.y = gyroRawToDps(data.gyroRaw.y); + data.gyro.z = gyroRawToDps(data.gyroRaw.z); + + data.temperature = rawToCelsius((int16_t)raw[6]); + data.sensorTime = ((uint32_t)raw[8] << 16) | (uint32_t)raw[7]; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getAccelDataRaw(sfe_bmi330_raw_data_t &data) +{ + uint16_t raw[3] = {0}; + sfTkError_t rc = readRegisters(kRegAccelDataX, raw, 3); + if (rc != ksfTkErrOk) + return rc; + + data.x = (int16_t)raw[0]; + data.y = (int16_t)raw[1]; + data.z = (int16_t)raw[2]; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getAccelData(sfe_bmi330_data_t &data) +{ + sfe_bmi330_raw_data_t raw; + sfTkError_t rc = getAccelDataRaw(raw); + if (rc != ksfTkErrOk) + return rc; + + data.x = accelRawToG(raw.x); + data.y = accelRawToG(raw.y); + data.z = accelRawToG(raw.z); + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getGyroDataRaw(sfe_bmi330_raw_data_t &data) +{ + uint16_t raw[3] = {0}; + sfTkError_t rc = readRegisters(kRegGyroDataX, raw, 3); + if (rc != ksfTkErrOk) + return rc; + + data.x = (int16_t)raw[0]; + data.y = (int16_t)raw[1]; + data.z = (int16_t)raw[2]; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getGyroData(sfe_bmi330_data_t &data) +{ + sfe_bmi330_raw_data_t raw; + sfTkError_t rc = getGyroDataRaw(raw); + if (rc != ksfTkErrOk) + return rc; + + data.x = gyroRawToDps(raw.x); + data.y = gyroRawToDps(raw.y); + data.z = gyroRawToDps(raw.z); + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getTemperature(float &celsius) +{ + celsius = 0.0f; + + uint16_t raw = 0; + sfTkError_t rc = readRegister(kRegTempData, raw); + if (rc != ksfTkErrOk) + return rc; + + celsius = rawToCelsius((int16_t)raw); + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getSensorTime(uint32_t &sensorTime) +{ + sensorTime = 0; + + uint16_t raw[2] = {0}; + sfTkError_t rc = readRegisters(kRegSensorTime0, raw, 2); + if (rc != ksfTkErrOk) + return rc; + + sensorTime = ((uint32_t)raw[1] << 16) | (uint32_t)raw[0]; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getDataReady(bool &accelReady, bool &gyroReady, bool &tempReady) +{ + accelReady = false; + gyroReady = false; + tempReady = false; + + // Clear-on-read, so this single read consumes all three flags. + uint16_t status = 0; + sfTkError_t rc = readRegister(kRegStatus, status); + if (rc != ksfTkErrOk) + return rc; + + accelReady = (status & kStatusAccelDrdy) != 0; + gyroReady = (status & kStatusGyroDrdy) != 0; + tempReady = (status & kStatusTempDrdy) != 0; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getErrorStatus(sfe_bmi330_error_status_t &status) +{ + uint16_t raw = 0; + sfTkError_t rc = readRegister(kRegErrReg, raw); + if (rc != ksfTkErrOk) + return rc; + + status.fatalError = (raw & kErrFatal) != 0; + status.featEngOverload = (raw & kErrFeatEngOverload) != 0; + status.featEngWatchdog = (raw & kErrFeatEngWatchdog) != 0; + status.accelConfError = (raw & kErrAccelConf) != 0; + status.gyroConfError = (raw & kErrGyroConf) != 0; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getSaturationFlags(sfe_bmi330_saturation_t &flags) +{ + uint16_t raw = 0; + sfTkError_t rc = readRegister(kRegSatFlags, raw); + if (rc != ksfTkErrOk) + return rc; + + flags.accelX = (raw & kSatAccelX) != 0; + flags.accelY = (raw & kSatAccelY) != 0; + flags.accelZ = (raw & kSatAccelZ) != 0; + flags.gyroX = (raw & kSatGyroX) != 0; + flags.gyroY = (raw & kSatGyroY) != 0; + flags.gyroZ = (raw & kSatGyroZ) != 0; + + return ksfTkErrOk; +} + +// =========================== Unit Conversion ============================== + +float sfDevBMI330::accelSensitivity(sfe_bmi330_accel_range_t range) +{ + // 16-bit signed output spans the full range, so sensitivity is 32768 / range in g. + switch (range) + { + case BMI330_ACCEL_RANGE_2G: + return 16384.0f; + case BMI330_ACCEL_RANGE_4G: + return 8192.0f; + case BMI330_ACCEL_RANGE_8G: + return 4096.0f; + case BMI330_ACCEL_RANGE_16G: + return 2048.0f; + default: + return 16384.0f; + } +} + +float sfDevBMI330::gyroSensitivity(sfe_bmi330_gyro_range_t range) +{ + // 32768 / range in dps. These are not whole numbers, which is why they are spelled out. + switch (range) + { + case BMI330_GYRO_RANGE_125DPS: + return 262.144f; + case BMI330_GYRO_RANGE_250DPS: + return 131.072f; + case BMI330_GYRO_RANGE_500DPS: + return 65.536f; + case BMI330_GYRO_RANGE_1000DPS: + return 32.768f; + case BMI330_GYRO_RANGE_2000DPS: + return 16.384f; + default: + return 16.384f; + } +} + +float sfDevBMI330::accelRawToG(int16_t raw) +{ + return (float)raw / accelSensitivity(_accelRange); +} + +float sfDevBMI330::gyroRawToDps(int16_t raw) +{ + return (float)raw / gyroSensitivity(_gyroRange); +} + +float sfDevBMI330::rawToCelsius(int16_t raw) +{ + // BMI330: 256 LSB/K, zero at 23 degrees C. The BMI323 uses 512 here - see the header. + return ((float)raw / 256.0f) + 23.0f; +} + +// ============================== Interrupts ================================ + +sfTkError_t sfDevBMI330::setInterruptPinConfig(sfe_bmi330_int_pin_t pin, + const sfe_bmi330_int_pin_config_t &config) +{ + uint16_t mask = 0; + uint16_t value = 0; + + if (pin == BMI330_INT1) + { + mask = kInt1LevelMask | kInt1OpenDrainMask | kInt1OutputEnMask; + + if (config.activeHigh) + value |= kInt1LevelMask; + if (config.openDrain) + value |= kInt1OpenDrainMask; + if (config.enabled) + value |= kInt1OutputEnMask; + } + else if (pin == BMI330_INT2) + { + mask = kInt2LevelMask | kInt2OpenDrainMask | kInt2OutputEnMask; + + if (config.activeHigh) + value |= kInt2LevelMask; + if (config.openDrain) + value |= kInt2OpenDrainMask; + if (config.enabled) + value |= kInt2OutputEnMask; + } + else + { + return ksfTkErrInvalidParam; + } + + // Both pins live in one register, so this has to be read-modify-write or configuring INT2 would + // silently reset INT1. + return updateRegister(kRegIoIntCtrl, mask, value); +} + +sfTkError_t sfDevBMI330::setInterruptLatch(bool latched) +{ + return updateRegister(kRegIntConf, kIntLatchMask, latched ? kIntLatchMask : 0); +} + +sfTkError_t sfDevBMI330::mapInterrupt(sfe_bmi330_int_source_t source, sfe_bmi330_int_pin_t pin) +{ + // Each source owns a 2-bit field. The first eight sources live in INT_MAP1 and the rest in + // INT_MAP2, with the field position running 0, 2, 4 ... within each register. + uint8_t reg; + uint8_t shift; + + if (source < BMI330_INT_SRC_TAP) + { + reg = kRegIntMap1; + shift = (uint8_t)(source * 2); + } + else if (source < BMI330_INT_SRC_COUNT) + { + reg = kRegIntMap2; + shift = (uint8_t)((source - BMI330_INT_SRC_TAP) * 2); + } + else + { + return ksfTkErrInvalidParam; + } + + uint16_t mask = (uint16_t)(0x0003 << shift); + uint16_t value = (uint16_t)(((uint16_t)pin & 0x0003) << shift); + + return updateRegister(reg, mask, value); +} + +sfTkError_t sfDevBMI330::getInterruptStatus(sfe_bmi330_int_pin_t pin, uint16_t &status) +{ + status = 0; + + if (pin == BMI330_INT1) + { + sfTkError_t rc = readRegister(kRegIntStatusInt1, status); + if (rc != ksfTkErrOk) + return rc; + + // Hand back anything setAxisRemap() had to consume on the caller's behalf. + status |= _pendingInt1; + _pendingInt1 = 0; + return ksfTkErrOk; + } + + if (pin == BMI330_INT2) + return readRegister(kRegIntStatusInt2, status); + + return ksfTkErrInvalidParam; +} + +// ================================= FIFO =================================== + +uint8_t sfDevBMI330::frameLengthWords(uint16_t sources) +{ + uint8_t words = 0; + + if (sources & BMI330_FIFO_ACCEL) + words += 3; + if (sources & BMI330_FIFO_GYRO) + words += 3; + if (sources & BMI330_FIFO_TEMP) + words += 1; + if (sources & BMI330_FIFO_TIME) + words += 1; + + return words; +} + +sfTkError_t sfDevBMI330::setFifoConfig(uint16_t sources, bool stopOnFull) +{ + uint16_t value = (uint16_t)(sources & kFifoSourceMask); + + if (stopOnFull) + value |= kFifoStopOnFullMask; + + return updateRegister(kRegFifoConf, (uint16_t)(kFifoSourceMask | kFifoStopOnFullMask), value); +} + +sfTkError_t sfDevBMI330::getFifoConfig(uint16_t &sources, bool &stopOnFull) +{ + sources = 0; + stopOnFull = false; + + uint16_t value = 0; + sfTkError_t rc = readRegister(kRegFifoConf, value); + if (rc != ksfTkErrOk) + return rc; + + sources = (uint16_t)(value & kFifoSourceMask); + stopOnFull = (value & kFifoStopOnFullMask) != 0; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::setFifoWatermark(uint16_t words) +{ + return updateRegister(kRegFifoWatermark, kFifoWatermarkMask, (uint16_t)(words & kFifoWatermarkMask)); +} + +sfTkError_t sfDevBMI330::getFifoWatermark(uint16_t &words) +{ + words = 0; + + uint16_t value = 0; + sfTkError_t rc = readRegister(kRegFifoWatermark, value); + if (rc != ksfTkErrOk) + return rc; + + words = (uint16_t)(value & kFifoWatermarkMask); + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getFifoFillLevel(uint16_t &words) +{ + words = 0; + + uint16_t value = 0; + sfTkError_t rc = readRegister(kRegFifoFillLevel, value); + if (rc != ksfTkErrOk) + return rc; + + words = (uint16_t)(value & kFifoFillLevelMask); + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::flushFifo(void) +{ + return writeRegister(kRegFifoCtrl, kFifoFlushMask); +} + +sfTkError_t sfDevBMI330::readFifoWords(uint16_t *words, uint8_t count) +{ + if (_theBus == nullptr) + return ksfTkErrBusNotInit; + + if (words == nullptr || count == 0 || count > kMaxBurstWords) + return ksfTkErrInvalidParam; + + // FIFO_DATA does not auto-increment - every word of the burst streams from the same address - + // so this is one transfer rather than a loop of register reads. The dummy bytes are prepended + // once, at the start of the transfer, not per word. + // + // The staging buffer is deliberate: reading straight into the caller's array would need room + // for the dummy bytes at the front and would overrun it by one or two bytes. + uint8_t reg = kRegFifoData; + uint8_t buffer[(kMaxBurstWords * 2) + kDummyBytesI2C] = {0}; + size_t total = (size_t)(count * 2) + _dummyBytes; + size_t readBytes = 0; + + sfTkError_t rc = _theBus->readRegister(®, sizeof(reg), buffer, total, readBytes); + if (rc != ksfTkErrOk) + return rc; + + if (readBytes != total) + return ksfTkErrBusUnderRead; + + for (uint8_t i = 0; i < count; i++) + { + uint8_t lsb = buffer[_dummyBytes + (i * 2)]; + uint8_t msb = buffer[_dummyBytes + (i * 2) + 1]; + words[i] = (uint16_t)(((uint16_t)msb << 8) | (uint16_t)lsb); + } + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::readFifoRaw(uint16_t *words, uint16_t count) +{ + if (words == nullptr || count == 0) + return ksfTkErrInvalidParam; + + uint16_t remaining = count; + uint16_t offset = 0; + + while (remaining > 0) + { + uint8_t chunk = (remaining > kMaxBurstWords) ? kMaxBurstWords : (uint8_t)remaining; + + sfTkError_t rc = readFifoWords(&words[offset], chunk); + if (rc != ksfTkErrOk) + return rc; + + offset += chunk; + remaining -= chunk; + } + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::readFifoData(sfe_bmi330_fifo_frame_t *frames, uint8_t maxFrames, uint8_t &numFrames, + uint16_t sources) +{ + numFrames = 0; + + if (frames == nullptr || maxFrames == 0) + return ksfTkErrInvalidParam; + + uint8_t frameWords = frameLengthWords(sources); + if (frameWords == 0) + return ksfTkErrInvalidParam; + + uint16_t fillLevel = 0; + sfTkError_t rc = getFifoFillLevel(fillLevel); + if (rc != ksfTkErrOk) + return rc; + + // Only take whole frames. A partial frame left in the buffer is retransmitted from its start on + // the next read, so leaving it is free; tearing it in half is not recoverable. + uint16_t available = fillLevel / frameWords; + if (available == 0) + return ksfTkErrOk; + + uint8_t toRead = (available > maxFrames) ? maxFrames : (uint8_t)available; + + // Pull as many WHOLE frames per transaction as the burst buffer holds. Frame-aligned by + // construction, which matters: the device retransmits any frame that was only partially read + // out, so a chunk boundary inside a frame would hand back the same samples twice. + uint8_t framesPerBurst = (uint8_t)(kMaxBurstWords / frameWords); + if (framesPerBurst == 0) + framesPerBurst = 1; + + uint16_t words[kMaxBurstWords] = {0}; + uint8_t burstFrames = 0; + uint8_t burstIndex = 0; + + for (uint8_t f = 0; f < toRead; f++) + { + if (burstIndex >= burstFrames) + { + uint8_t want = (uint8_t)(toRead - f); + burstFrames = (want > framesPerBurst) ? framesPerBurst : want; + burstIndex = 0; + + rc = readFifoWords(words, (uint8_t)(burstFrames * frameWords)); + if (rc != ksfTkErrOk) + return rc; + } + + sfe_bmi330_fifo_frame_t &frame = frames[f]; + frame = sfe_bmi330_fifo_frame_t(); + + uint8_t idx = (uint8_t)(burstIndex * frameWords); + burstIndex++; + + // Frame layout is fixed by hardware: accel, gyro, temperature, time - with disabled sources + // simply absent. The FIFO carries no headers, so this order is the only thing telling the + // parser what it is looking at. + if (sources & BMI330_FIFO_ACCEL) + { + frame.accel.x = (int16_t)words[idx]; + frame.accel.y = (int16_t)words[idx + 1]; + frame.accel.z = (int16_t)words[idx + 2]; + // A sensor that was idle at capture time writes a sentinel rather than a sample. Report + // that as "no data" instead of handing back 0x7F01 as an acceleration. + frame.hasAccel = (words[idx] != kFifoAccelInvalid); + idx += 3; + } + + if (sources & BMI330_FIFO_GYRO) + { + frame.gyro.x = (int16_t)words[idx]; + frame.gyro.y = (int16_t)words[idx + 1]; + frame.gyro.z = (int16_t)words[idx + 2]; + frame.hasGyro = (words[idx] != kFifoGyroInvalid); + idx += 3; + } + + if (sources & BMI330_FIFO_TEMP) + { + frame.temperature = (int16_t)words[idx]; + frame.hasTemp = (words[idx] != kFifoTempInvalid); + idx += 1; + } + + if (sources & BMI330_FIFO_TIME) + { + frame.sensorTime = words[idx]; + frame.hasTime = true; + idx += 1; + } + + numFrames++; + } + + return ksfTkErrOk; +} + +// ========================= Feature Engine Access ========================== + +sfTkError_t sfDevBMI330::enableFeature(uint16_t features, bool enable) +{ + uint16_t current = 0; + sfTkError_t rc = readRegister(kRegFeatureIO0, current); + if (rc != ksfTkErrOk) + return rc; + + uint16_t updated = enable ? (uint16_t)(current | features) : (uint16_t)(current & ~features); + + // Clear the register before writing the new value, which is what Bosch's own driver does + // ("reset the register before updating new values"). FEATURE_IO0 is shared with the feature + // engine, and going straight from one non-zero value to another can leave the engine acting on + // the old enable set. Two writes instead of one, at startup only. + rc = writeRegister(kRegFeatureIO0, 0); + if (rc != ksfTkErrOk) + return rc; + + rc = writeRegister(kRegFeatureIO0, updated); + if (rc != ksfTkErrOk) + return rc; + + // The enable bits do nothing until the host tells the engine to look at them. Without this + // write the register reads back correctly and the feature stays off - a quiet failure worth + // knowing about. + return writeRegister(kRegFeatureIOStatus, kFeatureIOStatusCommit); +} + +sfTkError_t sfDevBMI330::getEnabledFeatures(uint16_t &features) +{ + return readRegister(kRegFeatureIO0, features); +} + +sfTkError_t sfDevBMI330::readFeatureWords(uint8_t baseAddr, uint16_t *words, uint8_t count) +{ + if (words == nullptr || count == 0 || count > kMaxBurstWords) + return ksfTkErrInvalidParam; + + // Point the window at the feature, then read consecutive words from the data port, which + // auto-increments inside extended memory. + sfTkError_t rc = writeRegister(kRegFeatureDataAddr, (uint16_t)(baseAddr & kFeatureAddrMask)); + if (rc != ksfTkErrOk) + return rc; + + return readRegisters(kRegFeatureDataTx, words, count); +} + +sfTkError_t sfDevBMI330::writeFeatureWords(uint8_t baseAddr, const uint16_t *words, uint8_t count) +{ + if (words == nullptr || count == 0 || count > kMaxBurstWords) + return ksfTkErrInvalidParam; + + sfTkError_t rc = writeRegister(kRegFeatureDataAddr, (uint16_t)(baseAddr & kFeatureAddrMask)); + if (rc != ksfTkErrOk) + return rc; + + return writeRegisters(kRegFeatureDataTx, words, count); +} + +sfTkError_t sfDevBMI330::updateFeatureWord(uint8_t baseAddr, uint8_t wordIndex, uint16_t mask, uint16_t value) +{ + // Read the whole block up to and including the word of interest, change one field, write it + // back. Feature blocks hold settings the library does not model, so rewriting a block wholesale + // from defaults would quietly undo anything the sketch had tuned by hand. + uint8_t count = (uint8_t)(wordIndex + 1); + if (count > kMaxBurstWords) + return ksfTkErrInvalidParam; + + uint16_t words[kMaxBurstWords] = {0}; + sfTkError_t rc = readFeatureWords(baseAddr, words, count); + if (rc != ksfTkErrOk) + return rc; + + words[wordIndex] = (uint16_t)((words[wordIndex] & ~mask) | (value & mask)); + + return writeFeatureWords(baseAddr, words, count); +} + +// ============================ Motion Features ============================= + +sfTkError_t sfDevBMI330::setMotionConfig(uint8_t baseAddr, const sfe_bmi330_motion_config_t &config) +{ + // Three words, identical layout for any-motion and no-motion: + // word 0: slope threshold [11:0], accel reference update [12] + // word 1: hysteresis [9:0] + // word 2: duration [12:0], wait time [15:13] + uint16_t words[3] = {0}; + + words[0] = (uint16_t)(config.slopeThreshold & kMotionSlopeThresholdMask); + if (config.accelRefUpdate) + words[0] |= kMotionAccelRefUpMask; + + words[1] = (uint16_t)(config.hysteresis & kMotionHysteresisMask); + + words[2] = (uint16_t)(config.duration & kMotionDurationMask); + words[2] |= (uint16_t)(((uint16_t)config.waitTime << kMotionWaitTimeShift) & kMotionWaitTimeMask); + + return writeFeatureWords(baseAddr, words, 3); +} + +sfTkError_t sfDevBMI330::getMotionConfig(uint8_t baseAddr, sfe_bmi330_motion_config_t &config) +{ + uint16_t words[3] = {0}; + sfTkError_t rc = readFeatureWords(baseAddr, words, 3); + if (rc != ksfTkErrOk) + return rc; + + config.slopeThreshold = (uint16_t)(words[0] & kMotionSlopeThresholdMask); + config.accelRefUpdate = (words[0] & kMotionAccelRefUpMask) != 0; + config.hysteresis = (uint16_t)(words[1] & kMotionHysteresisMask); + config.duration = (uint16_t)(words[2] & kMotionDurationMask); + config.waitTime = (uint8_t)((words[2] & kMotionWaitTimeMask) >> kMotionWaitTimeShift); + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::setAnyMotionConfig(const sfe_bmi330_motion_config_t &config) +{ + return setMotionConfig(BMI330_FEAT_ADDR_ANY_MOTION, config); +} + +sfTkError_t sfDevBMI330::getAnyMotionConfig(sfe_bmi330_motion_config_t &config) +{ + return getMotionConfig(BMI330_FEAT_ADDR_ANY_MOTION, config); +} + +sfTkError_t sfDevBMI330::setNoMotionConfig(const sfe_bmi330_motion_config_t &config) +{ + return setMotionConfig(BMI330_FEAT_ADDR_NO_MOTION, config); +} + +sfTkError_t sfDevBMI330::getNoMotionConfig(sfe_bmi330_motion_config_t &config) +{ + return getMotionConfig(BMI330_FEAT_ADDR_NO_MOTION, config); +} + +sfTkError_t sfDevBMI330::setSigMotionConfig(const sfe_bmi330_sig_motion_config_t &config) +{ + // Three words: + // word 0: block size [15:0] + // word 1: p2p min [9:0], mean crossing rate min [15:10] + // word 2: p2p max [9:0], mean crossing rate max [15:10] + uint16_t words[3] = {0}; + + words[0] = config.blockSize; + + words[1] = (uint16_t)(config.peakToPeakMin & kSigP2pMinMask); + words[1] |= (uint16_t)(((uint16_t)config.mcrMin << kSigMcrMinShift) & kSigMcrMinMask); + + words[2] = (uint16_t)(config.peakToPeakMax & kSigP2pMaxMask); + words[2] |= (uint16_t)(((uint16_t)config.mcrMax << kSigMcrMaxShift) & kSigMcrMaxMask); + + return writeFeatureWords(BMI330_FEAT_ADDR_SIG_MOTION, words, 3); +} + +sfTkError_t sfDevBMI330::getSigMotionConfig(sfe_bmi330_sig_motion_config_t &config) +{ + uint16_t words[3] = {0}; + sfTkError_t rc = readFeatureWords(BMI330_FEAT_ADDR_SIG_MOTION, words, 3); + if (rc != ksfTkErrOk) + return rc; + + config.blockSize = words[0]; + config.peakToPeakMin = (uint16_t)(words[1] & kSigP2pMinMask); + config.mcrMin = (uint8_t)((words[1] & kSigMcrMinMask) >> kSigMcrMinShift); + config.peakToPeakMax = (uint16_t)(words[2] & kSigP2pMaxMask); + config.mcrMax = (uint8_t)((words[2] & kSigMcrMaxMask) >> kSigMcrMaxShift); + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::setTiltConfig(const sfe_bmi330_tilt_config_t &config) +{ + // Two words: + // word 0: segment size [7:0], minimum tilt angle [15:8] + // word 1: beta accel mean [15:0] + uint16_t words[2] = {0}; + + words[0] = (uint16_t)(config.segmentSize & kTiltSegmentSizeMask); + words[0] |= (uint16_t)(((uint16_t)config.minTiltAngle << kTiltMinAngleShift) & kTiltMinAngleMask); + + words[1] = config.betaAccelMean; + + return writeFeatureWords(BMI330_FEAT_ADDR_TILT, words, 2); +} + +sfTkError_t sfDevBMI330::getTiltConfig(sfe_bmi330_tilt_config_t &config) +{ + uint16_t words[2] = {0}; + sfTkError_t rc = readFeatureWords(BMI330_FEAT_ADDR_TILT, words, 2); + if (rc != ksfTkErrOk) + return rc; + + config.segmentSize = (uint8_t)(words[0] & kTiltSegmentSizeMask); + config.minTiltAngle = (uint8_t)((words[0] & kTiltMinAngleMask) >> kTiltMinAngleShift); + config.betaAccelMean = words[1]; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::setOrientationConfig(const sfe_bmi330_orientation_config_t &config) +{ + // Two words: + // word 0: ud_en [0], mode [2:1], blocking [4:3], theta [10:5], hold time [15:11] + // word 1: slope threshold [7:0], hysteresis [15:8] + uint16_t words[2] = {0}; + + if (config.faceUpDownEnabled) + words[0] |= kOrientUdEnMask; + words[0] |= (uint16_t)(((uint16_t)config.mode << kOrientModeShift) & kOrientModeMask); + words[0] |= (uint16_t)(((uint16_t)config.blocking << kOrientBlockingShift) & kOrientBlockingMask); + words[0] |= (uint16_t)(((uint16_t)config.theta << kOrientThetaShift) & kOrientThetaMask); + words[0] |= (uint16_t)(((uint16_t)config.holdTime << kOrientHoldTimeShift) & kOrientHoldTimeMask); + + words[1] = (uint16_t)(config.slopeThreshold & kOrientSlopeThresMask); + words[1] |= (uint16_t)(((uint16_t)config.hysteresis << kOrientHystShift) & kOrientHystMask); + + return writeFeatureWords(BMI330_FEAT_ADDR_ORIENTATION, words, 2); +} + +sfTkError_t sfDevBMI330::getOrientationConfig(sfe_bmi330_orientation_config_t &config) +{ + uint16_t words[2] = {0}; + sfTkError_t rc = readFeatureWords(BMI330_FEAT_ADDR_ORIENTATION, words, 2); + if (rc != ksfTkErrOk) + return rc; + + config.faceUpDownEnabled = (words[0] & kOrientUdEnMask) != 0; + config.mode = (uint8_t)((words[0] & kOrientModeMask) >> kOrientModeShift); + config.blocking = (uint8_t)((words[0] & kOrientBlockingMask) >> kOrientBlockingShift); + config.theta = (uint8_t)((words[0] & kOrientThetaMask) >> kOrientThetaShift); + config.holdTime = (uint8_t)((words[0] & kOrientHoldTimeMask) >> kOrientHoldTimeShift); + config.slopeThreshold = (uint8_t)(words[1] & kOrientSlopeThresMask); + config.hysteresis = (uint8_t)((words[1] & kOrientHystMask) >> kOrientHystShift); + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getOrientation(sfe_bmi330_orientation_t &orientation, bool &faceDown) +{ + orientation = BMI330_ORIENT_PORTRAIT_UPRIGHT; + faceDown = false; + + uint16_t event = 0; + sfTkError_t rc = readRegister(kRegFeatureEventExt, event); + if (rc != ksfTkErrOk) + return rc; + + orientation = (sfe_bmi330_orientation_t)(event & kEventOrientationMask); + faceDown = (event & kEventFaceDownMask) != 0; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::setFlatConfig(const sfe_bmi330_flat_config_t &config) +{ + // Two words: + // word 0: theta [5:0], blocking [7:6], hold time [15:8] + // word 1: slope threshold [7:0], hysteresis [15:8] + uint16_t words[2] = {0}; + + words[0] = (uint16_t)(config.theta & kFlatThetaMask); + words[0] |= (uint16_t)(((uint16_t)config.blocking << kFlatBlockingShift) & kFlatBlockingMask); + words[0] |= (uint16_t)(((uint16_t)config.holdTime << kFlatHoldTimeShift) & kFlatHoldTimeMask); + + words[1] = (uint16_t)(config.slopeThreshold & kFlatSlopeThresMask); + words[1] |= (uint16_t)(((uint16_t)config.hysteresis << kFlatHystShift) & kFlatHystMask); + + return writeFeatureWords(BMI330_FEAT_ADDR_FLAT, words, 2); +} + +sfTkError_t sfDevBMI330::getFlatConfig(sfe_bmi330_flat_config_t &config) +{ + uint16_t words[2] = {0}; + sfTkError_t rc = readFeatureWords(BMI330_FEAT_ADDR_FLAT, words, 2); + if (rc != ksfTkErrOk) + return rc; + + config.theta = (uint8_t)(words[0] & kFlatThetaMask); + config.blocking = (uint8_t)((words[0] & kFlatBlockingMask) >> kFlatBlockingShift); + config.holdTime = (uint8_t)((words[0] & kFlatHoldTimeMask) >> kFlatHoldTimeShift); + config.slopeThreshold = (uint8_t)(words[1] & kFlatSlopeThresMask); + config.hysteresis = (uint8_t)((words[1] & kFlatHystMask) >> kFlatHystShift); + + return ksfTkErrOk; +} + +// ============================ Tap Detection =============================== + +sfTkError_t sfDevBMI330::setTapConfig(const sfe_bmi330_tap_config_t &config) +{ + // Three words: + // word 0: axis [1:0], wait for timeout [2], max peaks [5:3], mode [7:6] + // word 1: peak threshold [9:0], max gesture duration [15:10] + // word 2: max duration between peaks [3:0], shock settling [7:4], + // min quiet between taps [11:8], quiet after gesture [15:12] + uint16_t words[3] = {0}; + + words[0] = (uint16_t)((uint16_t)config.axis & kTapAxisMask); + if (config.waitForTimeout) + words[0] |= kTapWaitTimeoutMask; + words[0] |= (uint16_t)(((uint16_t)config.maxPeaksForTap << kTapMaxPeaksShift) & kTapMaxPeaksMask); + words[0] |= (uint16_t)(((uint16_t)config.mode << kTapModeShift) & kTapModeMask); + + words[1] = (uint16_t)(config.peakThreshold & kTapPeakThresMask); + words[1] |= (uint16_t)(((uint16_t)config.maxGestureDuration << kTapMaxGestDurShift) & kTapMaxGestDurMask); + + words[2] = (uint16_t)(config.maxDurationBetweenPeaks & kTapMaxDurBwPeaksMask); + words[2] |= + (uint16_t)(((uint16_t)config.shockSettlingDuration << kTapShockSettDurShift) & kTapShockSettDurMask); + words[2] |= (uint16_t)(((uint16_t)config.minQuietBetweenTaps << kTapMinQuietDurShift) & kTapMinQuietDurMask); + words[2] |= + (uint16_t)(((uint16_t)config.quietTimeAfterGesture << kTapQuietAfterGestShift) & kTapQuietAfterGestMask); + + return writeFeatureWords(BMI330_FEAT_ADDR_TAP, words, 3); +} + +sfTkError_t sfDevBMI330::getTapConfig(sfe_bmi330_tap_config_t &config) +{ + uint16_t words[3] = {0}; + sfTkError_t rc = readFeatureWords(BMI330_FEAT_ADDR_TAP, words, 3); + if (rc != ksfTkErrOk) + return rc; + + config.axis = (sfe_bmi330_tap_axis_t)(words[0] & kTapAxisMask); + config.waitForTimeout = (words[0] & kTapWaitTimeoutMask) != 0; + config.maxPeaksForTap = (uint8_t)((words[0] & kTapMaxPeaksMask) >> kTapMaxPeaksShift); + config.mode = (uint8_t)((words[0] & kTapModeMask) >> kTapModeShift); + + config.peakThreshold = (uint16_t)(words[1] & kTapPeakThresMask); + config.maxGestureDuration = (uint8_t)((words[1] & kTapMaxGestDurMask) >> kTapMaxGestDurShift); + + config.maxDurationBetweenPeaks = (uint8_t)(words[2] & kTapMaxDurBwPeaksMask); + config.shockSettlingDuration = (uint8_t)((words[2] & kTapShockSettDurMask) >> kTapShockSettDurShift); + config.minQuietBetweenTaps = (uint8_t)((words[2] & kTapMinQuietDurMask) >> kTapMinQuietDurShift); + config.quietTimeAfterGesture = (uint8_t)((words[2] & kTapQuietAfterGestMask) >> kTapQuietAfterGestShift); + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::getTapEvent(sfe_bmi330_tap_event_t &event) +{ + event = sfe_bmi330_tap_event_t(); + + uint16_t raw = 0; + sfTkError_t rc = readRegister(kRegFeatureEventExt, raw); + if (rc != ksfTkErrOk) + return rc; + + event.singleTap = (raw & kEventSingleTap) != 0; + event.doubleTap = (raw & kEventDoubleTap) != 0; + event.tripleTap = (raw & kEventTripleTap) != 0; + + return ksfTkErrOk; +} + +// ============================= Step Counter =============================== + +sfTkError_t sfDevBMI330::getStepCount(uint32_t &steps) +{ + steps = 0; + + // FEATURE_IO2 and FEATURE_IO3 are adjacent, so one two-word burst gets both halves of the + // counter from the same instant. Two separate reads could straddle a carry and report a count + // that never existed. + uint16_t words[2] = {0}; + sfTkError_t rc = readRegisters(kRegFeatureIO2, words, 2); + if (rc != ksfTkErrOk) + return rc; + + steps = ((uint32_t)words[1] << 16) | (uint32_t)words[0]; + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::resetStepCount(void) +{ + // Pulse the reset bit: set it, then clear it. Leaving it set would hold the counter at zero. + sfTkError_t rc = + updateFeatureWord(BMI330_FEAT_ADDR_STEP_COUNTER, 0, kStepResetCounterMask, kStepResetCounterMask); + if (rc != ksfTkErrOk) + return rc; + + // The feature engine runs at 50 Hz. Hold the bit for longer than one 20 ms cycle so the engine + // is guaranteed to see it before it is cleared. + sftk_delay_ms(25); + + return updateFeatureWord(BMI330_FEAT_ADDR_STEP_COUNTER, 0, kStepResetCounterMask, 0); +} + +sfTkError_t sfDevBMI330::setStepCounterWatermark(uint16_t watermark) +{ + return updateFeatureWord(BMI330_FEAT_ADDR_STEP_COUNTER, 0, kStepWatermarkMask, + (uint16_t)(watermark & kStepWatermarkMask)); +} + +sfTkError_t sfDevBMI330::getStepCounterWatermark(uint16_t &watermark) +{ + watermark = 0; + + uint16_t word = 0; + sfTkError_t rc = readFeatureWords(BMI330_FEAT_ADDR_STEP_COUNTER, &word, 1); + if (rc != ksfTkErrOk) + return rc; + + watermark = (uint16_t)(word & kStepWatermarkMask); + + return ksfTkErrOk; +} + +// ============================ Axis Remapping ============================== + +sfTkError_t sfDevBMI330::setAxisRemap(const sfe_bmi330_axis_remap_t &remap) +{ + uint16_t word = (uint16_t)((uint16_t)remap.map & kRemapAxisMask); + + if (remap.invertX) + word |= kRemapXSignMask; + if (remap.invertY) + word |= kRemapYSignMask; + if (remap.invertZ) + word |= kRemapZSignMask; + + sfTkError_t rc = writeFeatureWords(BMI330_FEAT_ADDR_AXIS_REMAP, &word, 1); + if (rc != ksfTkErrOk) + return rc; + + // The device will only accept an axis-map update while the ACCELEROMETER IS SUSPENDED. Find out + // what mode it is in so it can be put back afterwards. + uint16_t accelConf = 0; + rc = readRegister(kRegAccelConf, accelConf); + if (rc != ksfTkErrOk) + return rc; + + uint16_t savedMode = (uint16_t)(accelConf & kConfModeMask); + + // Accelerometer already off: issue the command and stop. There is no acknowledgement to wait + // for on this path - the completion flag is only raised by the suspend-and-restore sequence. + if (savedMode == 0) + return sendCommand(kCmdAxisMapUpdate); + + // INT_STATUS_INT1 is clear-on-read, and the commit below has to read it to remove the flag the + // update leaves behind. Capture what is genuinely pending first, so getInterruptStatus() can + // still report it. Nothing new can arrive while the accelerometer is suspended. + uint16_t pending = 0; + if (readRegister(kRegIntStatusInt1, pending) == ksfTkErrOk) + _pendingInt1 |= pending; + + rc = updateRegister(kRegAccelConf, kConfModeMask, 0); + if (rc != ksfTkErrOk) + return rc; + + rc = sendCommand(kCmdAxisMapUpdate); + if (rc != ksfTkErrOk) + { + // Put the sensor back before reporting the failure - leaving a caller's accelerometer + // suspended because a command write failed would be a nasty surprise. + updateRegister(kRegAccelConf, kConfModeMask, savedMode); + return rc; + } + + bool acknowledged = false; + + for (uint8_t i = 0; i < kAxisMapRetries; i++) + { + sftk_delay_ms(kAxisMapStepMs); + + uint16_t status = 0; + if (readRegister(kRegFeatureIO1, status) != ksfTkErrOk) + continue; + + if ((status & kAxisMapComplete) && ((status & kFeatureErrorMask) == kFeatureNoError)) + { + acknowledged = true; + break; + } + } + + // The update leaves a flag behind in the INT1 status register. Clear it by reading, before the + // accelerometer comes back, so the caller's next getInterruptStatus() is not answering for an + // event it did not ask about. Real events were captured into _pendingInt1 above. + uint16_t intStatus = 0; + readRegister(kRegIntStatusInt1, intStatus); + + rc = updateRegister(kRegAccelConf, kConfModeMask, savedMode); + if (rc != ksfTkErrOk) + return rc; + + return acknowledged ? ksfTkErrOk : ksfTkErrFail; +} + +sfTkError_t sfDevBMI330::getAxisRemap(sfe_bmi330_axis_remap_t &remap) +{ + uint16_t word = 0; + sfTkError_t rc = readFeatureWords(BMI330_FEAT_ADDR_AXIS_REMAP, &word, 1); + if (rc != ksfTkErrOk) + return rc; + + remap.map = (sfe_bmi330_axis_map_t)(word & kRemapAxisMask); + remap.invertX = (word & kRemapXSignMask) != 0; + remap.invertY = (word & kRemapYSignMask) != 0; + remap.invertZ = (word & kRemapZSignMask) != 0; + + return ksfTkErrOk; +} + +// ==================== Self-Test and Self-Calibration ====================== + +sfTkError_t sfDevBMI330::primeGyroFilterCoefficients(void) +{ + uint16_t current[9] = {0}; + sfTkError_t rc = readFeatureWords(BMI330_FEAT_ADDR_SC_ST_COEFF, current, 9); + if (rc != ksfTkErrOk) + return rc; + + bool matches = true; + for (uint8_t i = 0; i < 9; i++) + { + if (current[i] != kGyroFilterCoefficients[i]) + { + matches = false; + break; + } + } + + if (matches) + return ksfTkErrOk; + + return writeFeatureWords(BMI330_FEAT_ADDR_SC_ST_COEFF, kGyroFilterCoefficients, 9); +} + +sfTkError_t sfDevBMI330::waitForSelfTestComplete(uint16_t &featureIO1, uint16_t stepDelayMs) +{ + featureIO1 = 0; + + for (uint8_t i = 0; i < kSelfTestRetries; i++) + { + sftk_delay_ms(stepDelayMs); + + if (readRegister(kRegFeatureIO1, featureIO1) != ksfTkErrOk) + continue; + + if (featureIO1 & kScStComplete) + return ksfTkErrOk; + } + + return ksfTkErrBusTimeout; +} + +sfTkError_t sfDevBMI330::performSelfTest(sfe_bmi330_self_test_t selection, + sfe_bmi330_self_test_result_t &result) +{ + result = sfe_bmi330_self_test_result_t(); + + // The BMI330 carries its own gyroscope filter coefficients, distinct from the BMI323's. Without + // them the routine runs and reports failures on a healthy part. + sfTkError_t rc = primeGyroFilterCoefficients(); + if (rc != ksfTkErrOk) + return rc; + + // Tell the engine which sensors to exercise. + uint16_t selectWord = (uint16_t)selection; + rc = writeFeatureWords(BMI330_FEAT_ADDR_ST_SELECT, &selectWord, 1); + if (rc != ksfTkErrOk) + return rc; + + // Remember the caller's accelerometer configuration - the preconditions overwrite it. + sfe_bmi330_accel_mode_t savedMode; + sfe_bmi330_odr_t savedOdr; + sfe_bmi330_accel_range_t savedRange; + sfe_bmi330_bandwidth_t savedBw; + sfe_bmi330_averaging_t savedAvg; + + rc = getAccelConfig(savedMode, savedOdr, savedRange, savedBw, savedAvg); + if (rc != ksfTkErrOk) + return rc; + + // Testing the gyroscope requires the accelerometer running at 100 Hz in high-performance mode. + // An accelerometer-only test has no such precondition. + if (selection != BMI330_SELF_TEST_ACCEL) + { + rc = setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_100_HZ, savedRange, savedBw, savedAvg); + if (rc != ksfTkErrOk) + return rc; + } + + // Alternate configurations would fight the self-test for control of the sensors. From here on + // the caller's accelerometer configuration has been changed, so every exit path restores it. + rc = updateRegister(kRegAltAccelConf, kConfModeMask, 0); + if (rc == ksfTkErrOk) + rc = updateRegister(kRegAltGyroConf, kConfModeMask, 0); + + if (rc == ksfTkErrOk) + rc = sendCommand(kCmdSelfTestTrigger); + + if (rc != ksfTkErrOk) + { + setAccelConfig(savedMode, savedOdr, savedRange, savedBw, savedAvg); + return rc; + } + + uint16_t featureIO1 = 0; + sfTkError_t waitResult = waitForSelfTestComplete(featureIO1, kSelfTestStepMs); + + result.errorStatus = (uint8_t)(featureIO1 & kFeatureErrorMask); + result.completed = (waitResult == ksfTkErrOk); + + if (result.completed) + { + result.passed = (featureIO1 & kStResult) != 0; + + // Per-axis detail lives in feature memory, not in FEATURE_IO1. + uint16_t stResult = 0; + if (readFeatureWords(BMI330_FEAT_ADDR_ST_RESULT, &stResult, 1) == ksfTkErrOk) + { + // Only report the axes this run actually exercised. The bits belonging to a sensor that + // was not tested hold whatever the last run left there, so copying them out + // unconditionally makes a healthy part look like it failed - or, worse, makes a failed + // one look healthy on the strength of a previous test. + if (selection & BMI330_SELF_TEST_ACCEL) + { + result.accelXOk = (stResult & 0x0001) != 0; + result.accelYOk = (stResult & 0x0002) != 0; + result.accelZOk = (stResult & 0x0004) != 0; + } + + if (selection & BMI330_SELF_TEST_GYRO) + { + result.gyroXOk = (stResult & 0x0008) != 0; + result.gyroYOk = (stResult & 0x0010) != 0; + result.gyroZOk = (stResult & 0x0020) != 0; + result.gyroDriveOk = (stResult & 0x0040) != 0; + } + } + } + + // Put the accelerometer back the way the caller had it, whatever the verdict. + setAccelConfig(savedMode, savedOdr, savedRange, savedBw, savedAvg); + + return waitResult; +} + +sfTkError_t sfDevBMI330::performGyroSelfCalibration(sfe_bmi330_self_calib_t selection, bool applyCorrection, + sfe_bmi330_self_calib_result_t &result) +{ + result = sfe_bmi330_self_calib_result_t(); + + sfe_bmi330_accel_mode_t savedMode; + sfe_bmi330_odr_t savedOdr; + sfe_bmi330_accel_range_t savedRange; + sfe_bmi330_bandwidth_t savedBw; + sfe_bmi330_averaging_t savedAvg; + + sfTkError_t rc = getAccelConfig(savedMode, savedOdr, savedRange, savedBw, savedAvg); + if (rc != ksfTkErrOk) + return rc; + + // Self-calibration needs the BMI330's own gyro filter coefficients in feature memory, exactly + // as self-test does. After a soft reset they are not guaranteed to be there. + rc = primeGyroFilterCoefficients(); + if (rc != ksfTkErrOk) + return rc; + + // Fixed preconditions: high-performance mode, 100 Hz, +/-8 g. The routine's thresholds are + // calibrated against exactly this configuration. + rc = setAccelConfig(BMI330_ACCEL_MODE_HIGH_PERF, BMI330_ODR_100_HZ, BMI330_ACCEL_RANGE_8G, savedBw, + savedAvg); + if (rc != ksfTkErrOk) + return rc; + + rc = updateRegister(kRegAltAccelConf, kConfModeMask, 0); + if (rc == ksfTkErrOk) + rc = updateRegister(kRegAltGyroConf, kConfModeMask, 0); + if (rc != ksfTkErrOk) + { + setAccelConfig(savedMode, savedOdr, savedRange, savedBw, savedAvg); + return rc; + } + + // Bit 2 of the selection word asks the device to apply the correction it finds. + uint16_t selectWord = (uint16_t)selection; + if (applyCorrection) + selectWord |= 0x0004; + + rc = writeFeatureWords(BMI330_FEAT_ADDR_SC_SELECT, &selectWord, 1); + if (rc != ksfTkErrOk) + { + setAccelConfig(savedMode, savedOdr, savedRange, savedBw, savedAvg); + return rc; + } + + rc = sendCommand(kCmdSelfCalibTrigger); + if (rc != ksfTkErrOk) + { + setAccelConfig(savedMode, savedOdr, savedRange, savedBw, savedAvg); + return rc; + } + + uint16_t featureIO1 = 0; + sfTkError_t waitResult = waitForSelfTestComplete(featureIO1, kSelfCalibStepMs); + + result.errorStatus = (uint8_t)(featureIO1 & kFeatureErrorMask); + result.completed = (waitResult == ksfTkErrOk); + if (result.completed) + result.passed = (featureIO1 & kScResult) != 0; + + setAccelConfig(savedMode, savedOdr, savedRange, savedBw, savedAvg); + + return waitResult; +} + +// ============================= User Offsets =============================== + +sfTkError_t sfDevBMI330::setAccelOffset(int16_t x, int16_t y, int16_t z) +{ + // The three offsets are interleaved with their gain registers - offset X, gain X, offset Y, + // gain Y, ... - so they cannot be written as one burst without clobbering the gains. + sfTkError_t rc = writeRegister(kRegAccelDpOffX, (uint16_t)(x & _accelOffsetMask)); + if (rc != ksfTkErrOk) + return rc; + + rc = writeRegister((uint8_t)(kRegAccelDpOffX + 2), (uint16_t)(y & _accelOffsetMask)); + if (rc != ksfTkErrOk) + return rc; + + return writeRegister((uint8_t)(kRegAccelDpOffX + 4), (uint16_t)(z & _accelOffsetMask)); +} + +sfTkError_t sfDevBMI330::getAccelOffset(int16_t &x, int16_t &y, int16_t &z) +{ + // Read offsets and gains together, then pick out the offsets. One transaction instead of three. + uint16_t words[5] = {0}; + sfTkError_t rc = readRegisters(kRegAccelDpOffX, words, 5); + if (rc != ksfTkErrOk) + return rc; + + // 14 bits on revision 1 and later, 13 on earlier silicon - begin() picked the mask. + uint8_t bits = (_accelOffsetMask == kAccelOffsetMask14) ? 14 : 13; + + x = signExtend((uint16_t)(words[0] & _accelOffsetMask), bits); + y = signExtend((uint16_t)(words[2] & _accelOffsetMask), bits); + z = signExtend((uint16_t)(words[4] & _accelOffsetMask), bits); + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::setGyroOffset(int16_t x, int16_t y, int16_t z) +{ + sfTkError_t rc = writeRegister(kRegGyroDpOffX, (uint16_t)(x & kGyroOffsetMask)); + if (rc != ksfTkErrOk) + return rc; + + rc = writeRegister((uint8_t)(kRegGyroDpOffX + 2), (uint16_t)(y & kGyroOffsetMask)); + if (rc != ksfTkErrOk) + return rc; + + return writeRegister((uint8_t)(kRegGyroDpOffX + 4), (uint16_t)(z & kGyroOffsetMask)); +} + +sfTkError_t sfDevBMI330::getGyroOffset(int16_t &x, int16_t &y, int16_t &z) +{ + uint16_t words[5] = {0}; + sfTkError_t rc = readRegisters(kRegGyroDpOffX, words, 5); + if (rc != ksfTkErrOk) + return rc; + + x = signExtend((uint16_t)(words[0] & kGyroOffsetMask), 10); + y = signExtend((uint16_t)(words[2] & kGyroOffsetMask), 10); + z = signExtend((uint16_t)(words[4] & kGyroOffsetMask), 10); + + return ksfTkErrOk; +} + +int16_t sfDevBMI330::signExtend(uint16_t value, uint8_t bits) +{ + // The offsets are narrower than the register that holds them, so the sign bit sits in the + // middle of the word. Copy it upward rather than letting the value read back as a large + // positive number. + uint16_t signBit = (uint16_t)(1u << (bits - 1)); + + if (value & signBit) + return (int16_t)(value | (uint16_t)~(uint16_t)((1u << bits) - 1u)); + + return (int16_t)value; +} + +// ========================= Low-Level Register Access ====================== + +sfTkError_t sfDevBMI330::readRegisters(uint8_t reg, uint16_t *values, uint8_t count) +{ + if (_theBus == nullptr) + return ksfTkErrBusNotInit; + + if (values == nullptr || count == 0 || count > kMaxBurstWords) + return ksfTkErrInvalidParam; + + // Room for the device's leading dummy bytes plus the payload. Assembling the words by hand + // keeps the result independent of the bus object's byte-order setting and of the host's + // endianness. + uint8_t buffer[(kMaxBurstWords * 2) + kDummyBytesI2C] = {0}; + size_t total = (size_t)(count * 2) + _dummyBytes; + size_t readBytes = 0; + + sfTkError_t rc = _theBus->readRegister(®, sizeof(reg), buffer, total, readBytes); + if (rc != ksfTkErrOk) + return rc; + + if (readBytes != total) + return ksfTkErrBusUnderRead; + + for (uint8_t i = 0; i < count; i++) + { + uint8_t lsb = buffer[_dummyBytes + (i * 2)]; + uint8_t msb = buffer[_dummyBytes + (i * 2) + 1]; + values[i] = (uint16_t)(((uint16_t)msb << 8) | (uint16_t)lsb); + } + + return ksfTkErrOk; +} + +sfTkError_t sfDevBMI330::writeRegisters(uint8_t reg, const uint16_t *values, uint8_t count) +{ + if (_theBus == nullptr) + return ksfTkErrBusNotInit; + + if (values == nullptr || count == 0 || count > kMaxBurstWords) + return ksfTkErrInvalidParam; + + // Writes carry no dummy bytes - only reads do. + uint8_t buffer[kMaxBurstWords * 2] = {0}; + + for (uint8_t i = 0; i < count; i++) + { + buffer[i * 2] = (uint8_t)(values[i] & 0x00FF); + buffer[(i * 2) + 1] = (uint8_t)((values[i] >> 8) & 0x00FF); + } + + return _theBus->writeRegister(®, sizeof(reg), buffer, (size_t)(count * 2)); +} + +sfTkError_t sfDevBMI330::readRegister(uint8_t reg, uint16_t &value) +{ + return readRegisters(reg, &value, 1); +} + +sfTkError_t sfDevBMI330::writeRegister(uint8_t reg, uint16_t value) +{ + return writeRegisters(reg, &value, 1); +} + +sfTkError_t sfDevBMI330::updateRegister(uint8_t reg, uint16_t mask, uint16_t value) +{ + uint16_t current = 0; + sfTkError_t rc = readRegister(reg, current); + if (rc != ksfTkErrOk) + return rc; + + uint16_t updated = (uint16_t)((current & ~mask) | (value & mask)); + + // Skip the write when nothing changes. Saves a transaction on the many setters that get called + // repeatedly with the same value. + if (updated == current) + return ksfTkErrOk; + + return writeRegister(reg, updated); +} + +sfTkError_t sfDevBMI330::sendCommand(uint16_t command) +{ + return writeRegister(kRegCmd, command); +} diff --git a/src/sfTk/sfDevBMI330.h b/src/sfTk/sfDevBMI330.h new file mode 100644 index 0000000..5234871 --- /dev/null +++ b/src/sfTk/sfDevBMI330.h @@ -0,0 +1,2060 @@ +/** + * @file sfDevBMI330.h + * @brief Header file for the SparkFun Bosch BMI330 6-DoF IMU Driver. + * + * @details + * sfDevBMI330 is a comms-agnostic driver for the Bosch BMI330 inertial measurement unit, built on + * the SparkFun Toolkit. The BMI330 pairs a 16-bit triaxial accelerometer with a 16-bit triaxial + * gyroscope, a temperature sensor, a 2 kB FIFO, two interrupt pins, and an on-chip feature engine + * that detects motion events without host involvement. + * + * @note NO CONFIGURATION FILE. Unlike the BMI270 - which will not report a single sample until the + * host uploads an 8 kB firmware blob and waits for it to boot - every BMI330 feature lives in ROM. + * begin() is a chip ID check and a feature-engine enable, nothing more, and the library carries no + * vendor binary. That is why this driver talks to the register map directly rather than wrapping + * Bosch's SensorAPI: there is nothing the vendor code is uniquely required for, and going native + * buys I2C and SPI from one code path through sfTkIBus. + * + * @note SIXTEEN-BIT REGISTERS, AND DUMMY BYTES. Every BMI330 register is a 16-bit word, + * little-endian, and the register address auto-increments by one WORD per two bytes transferred. A + * read burst is therefore always an even number of bytes. More surprising: the device prepends + * DUMMY BYTES to the payload of every read - two on I2C, one on SPI - so a driver that forgets + * them reads garbage shifted by one byte. The count is settled once, in begin(), from + * sfTkIBus::type(), and readRegisters() skips them. See kDummyBytesI2C / kDummyBytesSPI. + * + * @note THE FEATURE ENGINE IS A SEPARATE ADDRESS SPACE. Any-motion, tap, step counting, self-test + * and the rest are not configured through the main register map. They live in extended memory + * reached through a two-register window: write a base address to FEATURE_DATA_ADDR (0x41), then + * read or write consecutive words at FEATURE_DATA_TX (0x42), which auto-increments. Enabling a + * feature is a third step again - a bit in FEATURE_IO0 (0x10), committed by writing 1 to + * FEATURE_IO_STATUS (0x14). readFeatureWords() / writeFeatureWords() / enableFeature() wrap all of + * this; the ::sfe_bmi330_feature_addr_t values are the window's base addresses. + * + * @note The feature engine must be running before any feature - including self-test and + * self-calibration - will respond. It starts during softReset(), which begin() calls, and + * getFeatureEngineStatus() reports whether it came up. + * + * @author SparkFun Electronics + * @date September 2026 + * @copyright Copyright (c) 2026, SparkFun Electronics Inc. This project is released under the MIT License. + * + * SPDX-License-Identifier: MIT + * + * @see https://github.com/sparkfun/SparkFun_BMI330_Arduino_Library + */ + +#pragma once + +#include +#include + +// SparkFun Toolkit core headers +#include +#include +#include +#include + +/////////////////////////////////////////////////////////////////////////////// +// Device Identity +/////////////////////////////////////////////////////////////////////////////// + +/** @brief Factory-default 7-bit I2C address (SDO / ADR pin low). SparkFun ships the Qwiic board this way. */ +#define SFE_BMI330_DEFAULT_ADDRESS 0x68 + +/** @brief Alternate 7-bit I2C address, selected by the board's ADR jumper (SDO / ADR pin high). */ +#define SFE_BMI330_ALT_ADDRESS 0x69 + +/** @brief Value in the low byte of CHIP_ID (0x00) for a BMI330. The BMI323 reads 0x43 here. */ +#define SFE_BMI330_CHIP_ID 0x48 + +/////////////////////////////////////////////////////////////////////////////// +// Output Data Rate +/////////////////////////////////////////////////////////////////////////////// + +/** + * @brief Output data rate settings, shared by the accelerometer and the gyroscope. + * + * @details Each sensor keeps its own ODR field, so the two may run at different rates. Not every + * rate is legal in every power mode: low-power mode tops out at 400 Hz, and rates below 12.5 Hz are + * only available in low-power mode. An illegal pairing is not rejected by the write - it raises + * acc_conf_err / gyr_conf_err in ERR_REG, which getErrorStatus() reports. + * + * @see setAccelODR(), setGyroODR(), getErrorStatus() + */ +typedef enum sfe_bmi330_odr_t : uint8_t +{ + BMI330_ODR_0_78_HZ = 0x01, ///< 0.78125 Hz. Low-power mode only. + BMI330_ODR_1_56_HZ = 0x02, ///< 1.5625 Hz. Low-power mode only. + BMI330_ODR_3_12_HZ = 0x03, ///< 3.125 Hz. Low-power mode only. + BMI330_ODR_6_25_HZ = 0x04, ///< 6.25 Hz. Low-power mode only. + BMI330_ODR_12_5_HZ = 0x05, ///< 12.5 Hz. + BMI330_ODR_25_HZ = 0x06, ///< 25 Hz. + BMI330_ODR_50_HZ = 0x07, ///< 50 Hz. Required by several feature-engine features. + BMI330_ODR_100_HZ = 0x08, ///< 100 Hz. The self-test and self-calibration rate. + BMI330_ODR_200_HZ = 0x09, ///< 200 Hz. + BMI330_ODR_400_HZ = 0x0A, ///< 400 Hz. Highest rate available in low-power mode. + BMI330_ODR_800_HZ = 0x0B, ///< 800 Hz. Normal or high-performance mode only. + BMI330_ODR_1600_HZ = 0x0C, ///< 1600 Hz. Normal or high-performance mode only. + BMI330_ODR_3200_HZ = 0x0D, ///< 3200 Hz. High-performance mode only. + BMI330_ODR_6400_HZ = 0x0E ///< 6400 Hz. High-performance mode only, accelerometer-friendly. +} sfe_bmi330_odr_t; + +/////////////////////////////////////////////////////////////////////////////// +// Measurement Ranges +/////////////////////////////////////////////////////////////////////////////// + +/** + * @brief Accelerometer full-scale range. + * + * @details The output is always 16-bit signed across the selected range, so sensitivity halves each + * time the range doubles: 16384 LSB/g at +/-2 g down to 2048 LSB/g at +/-16 g. Picking the smallest + * range the application can survive is free resolution. + */ +typedef enum sfe_bmi330_accel_range_t : uint8_t +{ + BMI330_ACCEL_RANGE_2G = 0x00, ///< +/-2 g, 16384 LSB/g. + BMI330_ACCEL_RANGE_4G = 0x01, ///< +/-4 g, 8192 LSB/g. + BMI330_ACCEL_RANGE_8G = 0x02, ///< +/-8 g, 4096 LSB/g. Required for gyro self-calibration. + BMI330_ACCEL_RANGE_16G = 0x03 ///< +/-16 g, 2048 LSB/g. +} sfe_bmi330_accel_range_t; + +/** + * @brief Gyroscope full-scale range. + * + * @details As with the accelerometer, the 16-bit output spans the selected range: 262.144 LSB/dps + * at +/-125 dps down to 16.384 LSB/dps at +/-2000 dps. + */ +typedef enum sfe_bmi330_gyro_range_t : uint8_t +{ + BMI330_GYRO_RANGE_125DPS = 0x00, ///< +/-125 dps, 262.144 LSB/dps. + BMI330_GYRO_RANGE_250DPS = 0x01, ///< +/-250 dps, 131.072 LSB/dps. + BMI330_GYRO_RANGE_500DPS = 0x02, ///< +/-500 dps, 65.536 LSB/dps. + BMI330_GYRO_RANGE_1000DPS = 0x03, ///< +/-1000 dps, 32.768 LSB/dps. + BMI330_GYRO_RANGE_2000DPS = 0x04 ///< +/-2000 dps, 16.384 LSB/dps. +} sfe_bmi330_gyro_range_t; + +/////////////////////////////////////////////////////////////////////////////// +// Filtering +/////////////////////////////////////////////////////////////////////////////// + +/** + * @brief 3 dB cutoff of the sensor's low-pass filter, expressed as a fraction of the ODR. + */ +typedef enum sfe_bmi330_bandwidth_t : uint8_t +{ + BMI330_BW_ODR_HALF = 0x00, ///< Cutoff at ODR / 2. + BMI330_BW_ODR_QUARTER = 0x01 ///< Cutoff at ODR / 4. Quieter, more group delay. +} sfe_bmi330_bandwidth_t; + +/** + * @brief Number of samples averaged before a result is published. + * + * @details Hardware averaging trades bandwidth and current for noise: averaging N samples drops + * broadband noise by sqrt(N). It does NOT change the output data rate - the device oversamples + * internally - so it is free bandwidth from the host's point of view, but it does raise the + * sensor's own sample rate and therefore its current draw. + */ +typedef enum sfe_bmi330_averaging_t : uint8_t +{ + BMI330_AVG_1 = 0x00, ///< No averaging. + BMI330_AVG_2 = 0x01, ///< Average 2 samples. + BMI330_AVG_4 = 0x02, ///< Average 4 samples. + BMI330_AVG_8 = 0x03, ///< Average 8 samples. + BMI330_AVG_16 = 0x04, ///< Average 16 samples. + BMI330_AVG_32 = 0x05, ///< Average 32 samples. + BMI330_AVG_64 = 0x06 ///< Average 64 samples. +} sfe_bmi330_averaging_t; + +/////////////////////////////////////////////////////////////////////////////// +// Power Modes +/////////////////////////////////////////////////////////////////////////////// + +/** + * @brief Accelerometer power mode. + * + * @details Both sensors are DISABLED at power-on - a fresh BMI330 produces no data until a mode is + * set. enableAccel() picks high-performance mode, which is what most sketches want. + */ +typedef enum sfe_bmi330_accel_mode_t : uint8_t +{ + BMI330_ACCEL_MODE_DISABLE = 0x00, ///< Accelerometer off. Power-on default. + BMI330_ACCEL_MODE_LOW_POWER = 0x03,///< Duty-cycled. ODR limited to 400 Hz and below. + BMI330_ACCEL_MODE_NORMAL = 0x04, ///< Continuous, reduced current. ODR up to 1600 Hz. + BMI330_ACCEL_MODE_HIGH_PERF = 0x07 ///< Continuous, lowest noise. All ODRs available. +} sfe_bmi330_accel_mode_t; + +/** + * @brief Gyroscope power mode. + * + * @note SUSPEND versus DISABLE. In DISABLE the drive circuit is off and the gyro needs its full + * start-up time (tens of milliseconds) to produce valid data again. SUSPEND keeps the drive running + * and the ADC off, so waking is fast, at the cost of standing current. Choose SUSPEND for something + * that must respond to an interrupt promptly; choose DISABLE for something that sleeps for seconds. + */ +typedef enum sfe_bmi330_gyro_mode_t : uint8_t +{ + BMI330_GYRO_MODE_DISABLE = 0x00, ///< Gyroscope off, drive off. Power-on default. + BMI330_GYRO_MODE_SUSPEND = 0x01, ///< Drive running, ADC off. Fast wake, higher idle current. + BMI330_GYRO_MODE_LOW_POWER = 0x03, ///< Duty-cycled. ODR limited to 400 Hz and below. + BMI330_GYRO_MODE_NORMAL = 0x04, ///< Continuous, reduced current. ODR up to 1600 Hz. + BMI330_GYRO_MODE_HIGH_PERF = 0x07 ///< Continuous, lowest noise. All ODRs available. +} sfe_bmi330_gyro_mode_t; + +/////////////////////////////////////////////////////////////////////////////// +// Interrupts +/////////////////////////////////////////////////////////////////////////////// + +/** + * @brief Interrupt routing target. + * + * @details Every interrupt source is routed independently. The SparkFun Qwiic BMI330 breakout + * brings both pins out, so a sketch can (for example) put data-ready on INT1 and motion events on + * INT2 and tell them apart without reading a status register. + */ +typedef enum sfe_bmi330_int_pin_t : uint8_t +{ + BMI330_INT_NONE = 0x00, ///< Source disabled - not routed to a pin. + BMI330_INT1 = 0x01, ///< Route to the INT1 pin. + BMI330_INT2 = 0x02 ///< Route to the INT2 pin. +} sfe_bmi330_int_pin_t; + +/** + * @brief Interrupt sources that can be routed to a pin. + * + * @details These are the routable sources, as laid out across INT_MAP1 (0x3A) and INT_MAP2 (0x3B). + * mapInterrupt() hides which map register a source lives in. + */ +typedef enum sfe_bmi330_int_source_t : uint8_t +{ + BMI330_INT_SRC_NO_MOTION = 0, ///< No-motion detected. + BMI330_INT_SRC_ANY_MOTION, ///< Any-motion detected. + BMI330_INT_SRC_FLAT, ///< Flat position detected. + BMI330_INT_SRC_ORIENTATION, ///< Orientation changed. + BMI330_INT_SRC_STEP_DETECTOR, ///< A single step was detected. + BMI330_INT_SRC_STEP_COUNTER, ///< Step counter reached its watermark. + BMI330_INT_SRC_SIG_MOTION, ///< Significant motion detected. + BMI330_INT_SRC_TILT, ///< Tilt detected. + BMI330_INT_SRC_TAP, ///< Single, double or triple tap detected. + BMI330_INT_SRC_I3C, ///< I3C sync event. + BMI330_INT_SRC_ERR_STATUS, ///< Feature engine error or status change. + BMI330_INT_SRC_TEMP_DRDY, ///< Temperature data ready. + BMI330_INT_SRC_GYRO_DRDY, ///< Gyroscope data ready. + BMI330_INT_SRC_ACCEL_DRDY, ///< Accelerometer data ready. + BMI330_INT_SRC_FIFO_WATERMARK, ///< FIFO reached its watermark. + BMI330_INT_SRC_FIFO_FULL, ///< FIFO full. + BMI330_INT_SRC_COUNT ///< Number of sources. Not a valid source. +} sfe_bmi330_int_source_t; + +/** + * @brief Bit masks for the INT_STATUS_INT1 (0x0D) and INT_STATUS_INT2 (0x0E) registers. + * + * @details Both status registers are CLEAR-ON-READ, and getInterruptStatus() reads the whole word, + * so a sketch must read once and test every bit it cares about in the returned value. Reading twice + * loses the first read's events. + */ +typedef enum sfe_bmi330_int_status_t : uint16_t +{ + BMI330_INT_STATUS_NO_MOTION = 0x0001, ///< No-motion detected. + BMI330_INT_STATUS_ANY_MOTION = 0x0002, ///< Any-motion detected. + BMI330_INT_STATUS_FLAT = 0x0004, ///< Flat position detected. + BMI330_INT_STATUS_ORIENTATION = 0x0008, ///< Orientation changed. + BMI330_INT_STATUS_STEP_DETECTOR = 0x0010, ///< A step was detected. + BMI330_INT_STATUS_STEP_COUNTER = 0x0020, ///< Step counter watermark reached. + BMI330_INT_STATUS_SIG_MOTION = 0x0040, ///< Significant motion detected. + BMI330_INT_STATUS_TILT = 0x0080, ///< Tilt detected. + BMI330_INT_STATUS_TAP = 0x0100, ///< Tap detected - see getTapEvent() for which kind. + BMI330_INT_STATUS_I3C = 0x0200, ///< I3C sync event. + BMI330_INT_STATUS_ERROR = 0x0400, ///< Feature engine error or status change. + BMI330_INT_STATUS_TEMP_DRDY = 0x0800, ///< Temperature data ready. + BMI330_INT_STATUS_GYRO_DRDY = 0x1000, ///< Gyroscope data ready. + BMI330_INT_STATUS_ACCEL_DRDY = 0x2000, ///< Accelerometer data ready. + BMI330_INT_STATUS_FIFO_WATERMARK = 0x4000, ///< FIFO watermark reached. + BMI330_INT_STATUS_FIFO_FULL = 0x8000 ///< FIFO full. +} sfe_bmi330_int_status_t; + +/** + * @brief Electrical configuration of an interrupt pin. + * + * @details Defaults match the SparkFun Qwiic BMI330 breakout: push-pull, active high, pulsed. Use + * open-drain with @p activeHigh false when several devices share one interrupt line into the host - + * the board has no interrupt pull-up fitted, so supply one externally if you do. + */ +struct sfe_bmi330_int_pin_config_t +{ + bool enabled = true; ///< Drive the pin at all. False leaves it high-impedance. + bool activeHigh = true; ///< true: idle low, assert high. false: idle high, assert low. + bool openDrain = false; ///< true: open-drain output (needs a pull-up). false: push-pull. +}; + +/////////////////////////////////////////////////////////////////////////////// +// Sensor Data +/////////////////////////////////////////////////////////////////////////////// + +/** + * @brief One triaxial reading in raw 16-bit signed counts, as the registers hold it. + * + * @details Convert with accelRawToG() / gyroRawToDps(), or let getSensorData() do it. Keeping the + * raw form available matters for anything logging at rate: three int16_t are six bytes, where three + * floats are twelve, and the conversion can happen off the critical path. + * + * @note A value pinned at -32768 or 32767 means the reading saturated the selected range, not that + * the motion really was that large. getSaturationFlags() reports which axes clipped. + */ +struct sfe_bmi330_raw_data_t +{ + int16_t x = 0; ///< X axis, raw counts. + int16_t y = 0; ///< Y axis, raw counts. + int16_t z = 0; ///< Z axis, raw counts. +}; + +/** + * @brief One triaxial reading converted to physical units - g for the accelerometer, dps for the + * gyroscope. + */ +struct sfe_bmi330_data_t +{ + float x = 0.0f; ///< X axis, g or dps. + float y = 0.0f; ///< Y axis, g or dps. + float z = 0.0f; ///< Z axis, g or dps. +}; + +/** + * @brief Everything one burst read of the data block produces. + * + * @details getSensorData() fills this from a single 9-word read of ACC_DATA_X (0x03) through + * SENSOR_TIME_1 (0x0B). One transaction, so the accelerometer, gyroscope, temperature and timestamp + * are all from the same instant - which is the whole point of reading them together rather than + * axis by axis. + */ +struct sfe_bmi330_sensor_data_t +{ + sfe_bmi330_data_t accel; ///< Acceleration in g. + sfe_bmi330_data_t gyro; ///< Angular rate in degrees per second. + sfe_bmi330_raw_data_t accelRaw; ///< Acceleration in raw counts. + sfe_bmi330_raw_data_t gyroRaw; ///< Angular rate in raw counts. + float temperature = 0.0f; ///< Die temperature in degrees Celsius. + uint32_t sensorTime = 0; ///< Sensor timestamp, 39.0625 us per LSB. +}; + +/** + * @brief Per-axis saturation flags from SAT_FLAGS (0x0C). + * + * @details A set flag means that axis hit the end of its full-scale range during the last + * conversion, so the reported value is a floor or a ceiling rather than a measurement. The usual + * fix is a wider range; the usual mistake is to average the clipped samples in anyway. + */ +struct sfe_bmi330_saturation_t +{ + bool accelX = false; ///< Accelerometer X saturated. + bool accelY = false; ///< Accelerometer Y saturated. + bool accelZ = false; ///< Accelerometer Z saturated. + bool gyroX = false; ///< Gyroscope X saturated. + bool gyroY = false; ///< Gyroscope Y saturated. + bool gyroZ = false; ///< Gyroscope Z saturated. +}; + +/** + * @brief Decoded contents of ERR_REG (0x01). + * + * @note accelConfError / gyroConfError are how an illegal ODR-and-power-mode pairing surfaces. The + * register write that caused it returned success; only this register says the device refused the + * combination. Anything unexpected about a sensor's data rate is worth one call here. + */ +struct sfe_bmi330_error_status_t +{ + bool fatalError = false; ///< Chip not operational. Cleared only by reset or power cycle. + bool featEngOverload = false;///< Feature engine overloaded. Clear-on-read. + bool featEngWatchdog = false;///< Feature engine watchdog fired. Clear-on-read. + bool accelConfError = false; ///< Unsupported accelerometer configuration. Clears when corrected. + bool gyroConfError = false; ///< Unsupported gyroscope configuration. Clears when corrected. +}; + +/////////////////////////////////////////////////////////////////////////////// +// FIFO +/////////////////////////////////////////////////////////////////////////////// + +/** + * @brief Which data sources the FIFO records, as bits of FIFO_CONF (0x36). + * + * @details Combine with bitwise OR. The BMI330 FIFO is HEADERLESS: frames carry no tag, and the + * order inside a frame is fixed by the hardware - accelerometer (3 words), gyroscope (3 words), + * temperature (1 word), sensor time (1 word) - with disabled sources simply absent. The parser has + * to be told the same enable set that was programmed, which is why readFifoData() takes it as an + * argument rather than guessing. + */ +typedef enum sfe_bmi330_fifo_source_t : uint16_t +{ + BMI330_FIFO_TIME = 0x0100, ///< Include the sensor timestamp (1 word per frame). + BMI330_FIFO_ACCEL = 0x0200, ///< Include accelerometer data (3 words per frame). + BMI330_FIFO_GYRO = 0x0400, ///< Include gyroscope data (3 words per frame). + BMI330_FIFO_TEMP = 0x0800, ///< Include temperature (1 word per frame). + BMI330_FIFO_ALL = 0x0F00 ///< All four sources (8 words per frame). +} sfe_bmi330_fifo_source_t; + +/** + * @brief One decoded FIFO frame. + * + * @details Fields belonging to sources that were not enabled are left at their defaults, and the + * matching @p has... flag stays false. A frame whose sensor was idle when it was captured carries + * the invalid pattern for that source, which readFifoData() reports as a false @p has... flag + * rather than silently handing back 0x7F01 as a reading. + */ +struct sfe_bmi330_fifo_frame_t +{ + sfe_bmi330_raw_data_t accel; ///< Accelerometer sample, raw counts. + sfe_bmi330_raw_data_t gyro; ///< Gyroscope sample, raw counts. + int16_t temperature = 0; ///< Temperature sample, raw counts. See rawToCelsius(). + uint16_t sensorTime = 0; ///< Low word of the sensor timestamp at capture. + bool hasAccel = false; ///< Accelerometer data present and valid. + bool hasGyro = false; ///< Gyroscope data present and valid. + bool hasTemp = false; ///< Temperature data present and valid. + bool hasTime = false; ///< Timestamp present. +}; + +/////////////////////////////////////////////////////////////////////////////// +// Feature Engine +/////////////////////////////////////////////////////////////////////////////// + +/** + * @brief Base addresses inside the feature engine's extended memory. + * + * @details These index the FEATURE_DATA_ADDR (0x41) / FEATURE_DATA_TX (0x42) window, not the main + * register map - readFeatureWords() and writeFeatureWords() take one of these. The spacing between + * consecutive entries is each feature's size in words. + */ +typedef enum sfe_bmi330_feature_addr_t : uint8_t +{ + BMI330_FEAT_ADDR_CONFIG_VERSION = 0x00, ///< Feature engine configuration version (1 word). + BMI330_FEAT_ADDR_AXIS_REMAP = 0x03, ///< Axis remap (2 words). + BMI330_FEAT_ADDR_ANY_MOTION = 0x05, ///< Any-motion configuration (3 words). + BMI330_FEAT_ADDR_NO_MOTION = 0x08, ///< No-motion configuration (3 words). + BMI330_FEAT_ADDR_FLAT = 0x0B, ///< Flat configuration (2 words). + BMI330_FEAT_ADDR_SIG_MOTION = 0x0D, ///< Significant motion configuration (3 words). + BMI330_FEAT_ADDR_STEP_COUNTER = 0x10, ///< Step counter configuration (12 words). + BMI330_FEAT_ADDR_ORIENTATION = 0x1C, ///< Orientation configuration (2 words). + BMI330_FEAT_ADDR_TAP = 0x1E, ///< Tap detector configuration (3 words). + BMI330_FEAT_ADDR_TILT = 0x21, ///< Tilt configuration (2 words). + BMI330_FEAT_ADDR_ST_RESULT = 0x24, ///< Self-test per-axis results (1 word). + BMI330_FEAT_ADDR_ST_SELECT = 0x25, ///< Self-test selection (1 word). + BMI330_FEAT_ADDR_SC_SELECT = 0x26, ///< Gyro self-calibration selection (1 word). + BMI330_FEAT_ADDR_SC_ST_CONF = 0x27, ///< Self-calibration / self-test configuration (1 word). + BMI330_FEAT_ADDR_SC_ST_COEFF = 0x28 ///< Gyro filter coefficients (9 words). See performSelfTest(). + // User offsets are NOT in feature memory - they are ordinary registers (ACC_DP_OFF_X 0x60, + // GYR_DP_OFF_X 0x66). Use setAccelOffset() / setGyroOffset(). +} sfe_bmi330_feature_addr_t; + +/** + * @brief Feature enable bits in FEATURE_IO0 (0x10). + * + * @details Combine with bitwise OR and pass to enableFeature(). Note that any-motion and no-motion + * are enabled PER AXIS - the feature runs when at least one of its axis bits is set - while the + * others are a single bit each. + */ +typedef enum sfe_bmi330_feature_t : uint16_t +{ + BMI330_FEAT_NO_MOTION_X = 0x0001, ///< No-motion on the X axis. + BMI330_FEAT_NO_MOTION_Y = 0x0002, ///< No-motion on the Y axis. + BMI330_FEAT_NO_MOTION_Z = 0x0004, ///< No-motion on the Z axis. + BMI330_FEAT_NO_MOTION = 0x0007, ///< No-motion on all three axes. + BMI330_FEAT_ANY_MOTION_X = 0x0008, ///< Any-motion on the X axis. + BMI330_FEAT_ANY_MOTION_Y = 0x0010, ///< Any-motion on the Y axis. + BMI330_FEAT_ANY_MOTION_Z = 0x0020, ///< Any-motion on the Z axis. + BMI330_FEAT_ANY_MOTION = 0x0038, ///< Any-motion on all three axes. + BMI330_FEAT_FLAT = 0x0040, ///< Flat detection. + BMI330_FEAT_ORIENTATION = 0x0080, ///< Orientation detection. + BMI330_FEAT_STEP_DETECTOR = 0x0100,///< Step detector (one interrupt per step). + BMI330_FEAT_STEP_COUNTER = 0x0200, ///< Step counter (counts, interrupts at the watermark). + BMI330_FEAT_SIG_MOTION = 0x0400, ///< Significant motion detection. + BMI330_FEAT_TILT = 0x0800, ///< Tilt detection. + BMI330_FEAT_SINGLE_TAP = 0x1000, ///< Single tap detection. + BMI330_FEAT_DOUBLE_TAP = 0x2000, ///< Double tap detection. + BMI330_FEAT_TRIPLE_TAP = 0x4000 ///< Triple tap detection. +} sfe_bmi330_feature_t; + +/** + * @brief Any-motion and no-motion detector settings. + * + * @details The two detectors are mirror images and share this structure. Any-motion fires when the + * slope of the acceleration signal stays ABOVE @p slopeThreshold for @p duration consecutive + * samples; no-motion fires when it stays BELOW. + * + * @note Units are the hardware's, not SI: slope threshold and hysteresis are 1.953 mg per LSB + * (1/512 g), and duration and wait time are 20 ms per LSB (a 50 Hz internal tick). The defaults + * here are the device's own power-on values. + */ +struct sfe_bmi330_motion_config_t +{ + uint16_t slopeThreshold = 10; ///< Slope threshold, 1.953 mg/LSB. Range 0 to 4095. + bool accelRefUpdate = true; ///< Continuously update the acceleration reference. + uint16_t hysteresis = 2; ///< Slope hysteresis, 1.953 mg/LSB. Range 0 to 1023. + uint16_t duration = 10; ///< Samples the condition must hold, 20 ms/LSB. Range 0 to 8191. + uint8_t waitTime = 3; ///< Delay before clearing the event, 20 ms/LSB. Range 0 to 7. +}; + +/** + * @brief Significant motion detector settings. + * + * @details Significant motion looks for sustained, human-scale movement - walking across a room + * rather than a door slamming nearby - by measuring the peak-to-peak acceleration magnitude and the + * mean-crossing rate over a block of samples, and requiring both to fall inside a window. Defaults + * are the device's power-on values. + */ +struct sfe_bmi330_sig_motion_config_t +{ + uint16_t blockSize = 250; ///< Samples evaluated per decision, 20 ms/LSB. + uint16_t peakToPeakMin = 30; ///< Minimum peak-to-peak magnitude, 1.953 mg/LSB. Range 0 to 1023. + uint8_t mcrMin = 17; ///< Minimum mean crossings per second. Range 0 to 63. + uint16_t peakToPeakMax = 253;///< Maximum peak-to-peak magnitude, 1.953 mg/LSB. Range 0 to 1023. + uint8_t mcrMax = 17; ///< Maximum mean crossings per second. Range 0 to 63. +}; + +/** + * @brief Tilt detector settings. + * + * @details Tilt fires when the device's gravity vector has moved more than @p minTiltAngle away + * from a slowly-updated reference vector. Defaults are the device's power-on values. + */ +struct sfe_bmi330_tilt_config_t +{ + uint8_t segmentSize = 100; ///< Samples averaged into the reference vector, 20 ms/LSB. + uint8_t minTiltAngle = 210; ///< Minimum angle to report, as 256 * cos(angle). 210 is about 35 degrees. + uint16_t betaAccelMean = 0xF069; ///< Exponential smoothing factor for the mean, Q15. +}; + +/** + * @brief Orientation detector settings. + * + * @details Orientation classifies the device as portrait or landscape (and, optionally, face up or + * face down) and interrupts on a change. Defaults are the device's power-on values. + */ +struct sfe_bmi330_orientation_config_t +{ + bool faceUpDownEnabled = false; ///< Also classify face up versus face down. + uint8_t mode = 0; ///< 0 symmetrical, 1 high asymmetrical, 2 low asymmetrical. + uint8_t blocking = 3; ///< Suppress detection during motion. 0 none to 3 most aggressive. + uint8_t theta = 40; ///< Blocking angle threshold. Range 0 to 63. + uint8_t holdTime = 5; ///< Stability required before reporting, 20 ms/LSB. Range 0 to 31. + uint8_t slopeThreshold = 0xC8; ///< Slope threshold used by blocking, 1.953 mg/LSB. + uint8_t hysteresis = 0x80; ///< Acceleration hysteresis, 1.953 mg/LSB. +}; + +/** + * @brief Flat detector settings. + * + * @details Flat fires when the device has been lying within @p theta of horizontal for + * @p holdTime. Defaults are the device's power-on values. + */ +struct sfe_bmi330_flat_config_t +{ + uint8_t theta = 8; ///< Angle threshold for "flat". Range 0 to 63. + uint8_t blocking = 2; ///< Suppress detection during motion. 0 none to 3 most aggressive. + uint8_t holdTime = 32; ///< Stability required before reporting, 20 ms/LSB. + uint8_t hysteresis = 9; ///< Angle hysteresis, same units as theta. + uint8_t slopeThreshold = 0xCD; ///< Slope threshold used by blocking, 1.953 mg/LSB. +}; + +/** + * @brief Which axis the tap detector watches. + */ +typedef enum sfe_bmi330_tap_axis_t : uint8_t +{ + BMI330_TAP_AXIS_X = 0x00, ///< Detect taps along X. + BMI330_TAP_AXIS_Y = 0x01, ///< Detect taps along Y. + BMI330_TAP_AXIS_Z = 0x02 ///< Detect taps along Z. Power-on default - the usual "tap the lid" axis. +} sfe_bmi330_tap_axis_t; + +/** + * @brief Tap detector settings. + * + * @details Tap detection is the fussiest feature on the part, and @p peakThreshold is the knob that + * matters: too low and footsteps register, too high and nothing does. It is worth sweeping against + * the actual enclosure - a tap through 3 mm of acrylic is a very different signal from a tap on a + * bare board. Defaults are the device's power-on values. + * + * @note All the duration fields are in 20 ms ticks. + */ +struct sfe_bmi330_tap_config_t +{ + sfe_bmi330_tap_axis_t axis = BMI330_TAP_AXIS_Z; ///< Axis to watch. + bool waitForTimeout = false; ///< Wait out the full gesture window before reporting. + uint8_t maxPeaksForTap = 6; ///< Zero crossings allowed around one tap. Range 0 to 7. + uint8_t mode = 0; ///< 0 sensitive, 1 normal, 2 robust. + uint16_t peakThreshold = 0x2D; ///< Minimum peak amplitude for a tap. Range 0 to 1023. + uint8_t maxGestureDuration = 16; ///< Window for a whole multi-tap gesture. Range 0 to 63. + uint8_t maxDurationBetweenPeaks = 4; ///< Positive-to-negative peak window. Range 0 to 15. + uint8_t shockSettlingDuration = 6; ///< How long a tap's impact is observed. Range 0 to 15. + uint8_t minQuietBetweenTaps = 8; ///< Quiet time required between taps. Range 0 to 15. + uint8_t quietTimeAfterGesture = 12; ///< Quiet time required after a gesture. Range 0 to 15. +}; + +/** + * @brief Which tap gesture the device just reported. + * + * @details Read from FEATURE_EVENT_EXT (0x47) after a ::BMI330_INT_STATUS_TAP interrupt. More than + * one flag can be set if gestures land close together. + */ +struct sfe_bmi330_tap_event_t +{ + bool singleTap = false; ///< A single tap was detected. + bool doubleTap = false; ///< A double tap was detected. + bool tripleTap = false; ///< A triple tap was detected. +}; + +/** + * @brief Device orientation as classified by the orientation feature. + */ +typedef enum sfe_bmi330_orientation_t : uint8_t +{ + BMI330_ORIENT_PORTRAIT_UPRIGHT = 0x00, ///< Portrait, upright. + BMI330_ORIENT_LANDSCAPE_LEFT = 0x01, ///< Landscape, left. + BMI330_ORIENT_PORTRAIT_UPSIDE_DOWN = 0x02, ///< Portrait, upside down. + BMI330_ORIENT_LANDSCAPE_RIGHT = 0x03 ///< Landscape, right. +} sfe_bmi330_orientation_t; + +/////////////////////////////////////////////////////////////////////////////// +// Axis Remapping +/////////////////////////////////////////////////////////////////////////////// + +/** + * @brief Which physical axis feeds which reported axis. + * + * @details Read the name as "reported X, Y, Z come from physical ...". BMI330_AXIS_MAP_YXZ means + * reported X is the physical Y axis, reported Y is the physical X axis, and Z is unchanged. + */ +typedef enum sfe_bmi330_axis_map_t : uint8_t +{ + BMI330_AXIS_MAP_XYZ = 0x00, ///< No swap. Power-on default. + BMI330_AXIS_MAP_YXZ = 0x01, ///< Swap X and Y. + BMI330_AXIS_MAP_XZY = 0x02, ///< Swap Y and Z. + BMI330_AXIS_MAP_ZXY = 0x03, ///< Rotate: X<-Z, Y<-X, Z<-Y. + BMI330_AXIS_MAP_YZX = 0x04, ///< Rotate: X<-Y, Y<-Z, Z<-X. + BMI330_AXIS_MAP_ZYX = 0x05 ///< Swap X and Z. +} sfe_bmi330_axis_map_t; + +/** + * @brief A complete axis remapping: one permutation plus three sign flips. + * + * @details This is applied inside the device, so it corrects the accelerometer, the gyroscope, the + * FIFO and every feature-engine feature at once. A board mounted upside down under a chassis is one + * setAxisRemap() call, not a scatter of minus signs through the sketch. + * + * @note Remapping is handled by the feature engine, so it takes effect only while the engine is + * running. The device only accepts the commit while the accelerometer is SUSPENDED; + * setAxisRemap() suspends and restores it around the commit. + */ +struct sfe_bmi330_axis_remap_t +{ + sfe_bmi330_axis_map_t map = BMI330_AXIS_MAP_XYZ; ///< Axis permutation. + bool invertX = false; ///< Negate the reported X axis. + bool invertY = false; ///< Negate the reported Y axis. + bool invertZ = false; ///< Negate the reported Z axis. +}; + +/////////////////////////////////////////////////////////////////////////////// +// Self-Test and Self-Calibration +/////////////////////////////////////////////////////////////////////////////// + +/** + * @brief Which sensors a self-test exercises. + */ +typedef enum sfe_bmi330_self_test_t : uint8_t +{ + BMI330_SELF_TEST_ACCEL = 0x01, ///< Accelerometer only. + BMI330_SELF_TEST_GYRO = 0x02, ///< Gyroscope only. + BMI330_SELF_TEST_BOTH = 0x03 ///< Both sensors. +} sfe_bmi330_self_test_t; + +/** + * @brief Outcome of a self-test. + * + * @details @p completed distinguishes "the test ran and the part failed" from "the test never + * finished", which are different problems. Check it before trusting anything else in here. + */ +struct sfe_bmi330_self_test_result_t +{ + bool completed = false; ///< The test ran to completion. + bool passed = false; ///< Overall pass. Valid only when @p completed is true. + bool accelXOk = false; ///< Accelerometer X sensitivity within limits. + bool accelYOk = false; ///< Accelerometer Y sensitivity within limits. + bool accelZOk = false; ///< Accelerometer Z sensitivity within limits. + bool gyroXOk = false; ///< Gyroscope X sensitivity within limits. + bool gyroYOk = false; ///< Gyroscope Y sensitivity within limits. + bool gyroZOk = false; ///< Gyroscope Z sensitivity within limits. + bool gyroDriveOk = false; ///< Gyroscope drive circuit healthy. + uint8_t errorStatus = 0; ///< Raw feature engine error nibble. 0x05 means no error. +}; + +/** + * @brief What a gyroscope self-calibration corrects. + */ +typedef enum sfe_bmi330_self_calib_t : uint8_t +{ + BMI330_SELF_CALIB_SENSITIVITY = 0x01, ///< Correct gyroscope sensitivity only. + BMI330_SELF_CALIB_OFFSET = 0x02, ///< Correct gyroscope offset only. + BMI330_SELF_CALIB_BOTH = 0x03 ///< Correct both. +} sfe_bmi330_self_calib_t; + +/** + * @brief Outcome of a gyroscope self-calibration. + */ +struct sfe_bmi330_self_calib_result_t +{ + bool completed = false; ///< The routine ran to completion. + bool passed = false; ///< Calibration succeeded. Valid only when @p completed is true. + uint8_t errorStatus = 0; ///< Raw feature engine error nibble. 0x05 means no error. +}; + +/////////////////////////////////////////////////////////////////////////////// +// sfDevBMI330 +/////////////////////////////////////////////////////////////////////////////// + +/** + * @class sfDevBMI330 + * @brief Platform-independent driver for the Bosch BMI330 6-DoF IMU. + * + * @details + * Holds every register address, bit field and conversion the part needs, and talks to the device + * through an sfTkIBus supplied by begin(). It knows nothing about Arduino, Wire or SPI - the + * Arduino wrappers in SparkFun_BMI330.h provide those. A sketch normally uses SfeBMI330ArdI2C or + * SfeBMI330ArdSPI, both of which inherit everything documented here. + * + * Methods that touch the bus return sfTkError_t (::ksfTkErrOk on success); methods that only read + * cached or computed state return the value directly. + * + * @see SfeBMI330ArdI2C + * @see SfeBMI330ArdSPI + */ +class sfDevBMI330 +{ + public: + /** + * @brief Default constructor. The device is not touched until begin(). + */ + sfDevBMI330() : _theBus{nullptr}, _dummyBytes{kDummyBytesI2C}, _accelRange{BMI330_ACCEL_RANGE_2G}, + _gyroRange{BMI330_GYRO_RANGE_2000DPS}, _accelOffsetMask{kAccelOffsetMask14}, + _pendingInt1{0} + { + } + + virtual ~sfDevBMI330() + { + } + + /////////////////////////////////////////////////////////////////////////// + // Setup and Identity + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Adopt a bus, verify the device, and bring the feature engine up. + * + * @details Determines the read dummy-byte count from the bus type, checks CHIP_ID, picks the + * accelerometer offset field width from the silicon revision, soft resets - which also starts + * the feature engine - and then reads ACC_CONF and GYR_CONF back so the cached ranges used by + * accelRawToG() and gyroRawToDps() describe the device rather than an assumed default. Both + * sensors are left DISABLED, matching the power-on state; call enableAccel() and enableGyro(), + * or setAccelConfig() / setGyroConfig(), before expecting data. + * + * @param theBus Bus to communicate over. Pass nullptr to keep a bus set earlier. + * @return ::ksfTkErrOk on success, ::ksfTkErrBusNotInit with no bus, or + * ::ksfTkErrBusNoResponse if the chip ID does not match. + */ + sfTkError_t begin(sfTkIBus *theBus = nullptr); + + /** + * @brief Set the communication bus without initializing the device. + * + * @details Also re-derives the dummy-byte count, since it follows the bus type. + * + * @param theBus Bus to use for communication. + */ + void setCommunicationBus(sfTkIBus *theBus); + + /** + * @brief Check that a BMI330 is responding. + * + * @return true if CHIP_ID reads ::SFE_BMI330_CHIP_ID. + */ + bool isConnected(void); + + /** + * @brief Read the chip and revision IDs from CHIP_ID (0x00). + * + * @param chipId Receives the chip ID - 0x48 for a BMI330, 0x43 for a BMI323. + * @param revId Receives the silicon revision from the upper nibble. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getChipID(uint8_t &chipId, uint8_t &revId); + + /** + * @brief Soft reset the device and restart the feature engine. + * + * @details Writes the soft-reset command, waits out the 1.5 ms reset time, performs the dummy + * read that re-selects SPI when on SPI, then runs the feature engine start-up sequence and + * polls for it to report active. All registers return to their power-on defaults, which means + * both sensors end up disabled and any configuration is lost. + * + * @note The cached full-scale ranges used by accelRawToG() and gyroRawToDps() are + * resynchronized from the device afterwards, so conversions stay correct across a reset. + * + * @return ::ksfTkErrOk on success, ::ksfTkErrFail if the feature engine does not start. + */ + sfTkError_t softReset(void); + + /** + * @brief Start the feature engine without a full reset. + * + * @details softReset() already does this, so it is rarely needed directly. Useful after the + * engine has reported an overload or watchdog error in ERR_REG. + * + * @return ::ksfTkErrOk if the engine reports active within the start-up window. + */ + sfTkError_t enableFeatureEngine(void); + + /** + * @brief Check whether the feature engine is running. + * + * @return true if FEATURE_IO1 reports the engine active. + */ + bool getFeatureEngineStatus(void); + + /** + * @brief Read the raw FEATURE_IO1 (0x11) word. + * + * @details The low nibble is an error code: 0x05 means no error, 0x03 a configuration CRC + * failure, 0x06 an axis-map error, 0x0C a self-test or self-calibration precondition failure. + * Higher bits carry the self-test and self-calibration completion and result flags. + * + * @param status Receives the register word. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getFeatureEngineError(uint16_t &status); + + /////////////////////////////////////////////////////////////////////////// + // Accelerometer Configuration + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Write the whole accelerometer configuration in one register write. + * + * @details ACC_CONF (0x20) is a single word holding mode, ODR, range, bandwidth and averaging, + * so setting them together is one transaction rather than five read-modify-writes. The range is + * cached here for the raw-to-g conversion. + * + * @param mode Power mode. + * @param odr Output data rate. + * @param range Full-scale range. + * @param bandwidth Filter cutoff as a fraction of ODR. + * @param averaging Hardware averaging. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setAccelConfig(sfe_bmi330_accel_mode_t mode, sfe_bmi330_odr_t odr, + sfe_bmi330_accel_range_t range, + sfe_bmi330_bandwidth_t bandwidth = BMI330_BW_ODR_HALF, + sfe_bmi330_averaging_t averaging = BMI330_AVG_1); + + /** + * @brief Read the accelerometer configuration back from ACC_CONF (0x20). + * + * @details Also refreshes the cached range, so a sketch that configured the part in a previous + * power cycle gets correct conversions after calling this. + * + * @param mode Receives the power mode. + * @param odr Receives the output data rate. + * @param range Receives the full-scale range. + * @param bandwidth Receives the filter setting. + * @param averaging Receives the averaging setting. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getAccelConfig(sfe_bmi330_accel_mode_t &mode, sfe_bmi330_odr_t &odr, + sfe_bmi330_accel_range_t &range, sfe_bmi330_bandwidth_t &bandwidth, + sfe_bmi330_averaging_t &averaging); + + /** + * @brief Turn the accelerometer on in high-performance mode, or off. + * + * @param enable true for high-performance mode, false to disable the sensor. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t enableAccel(bool enable = true); + + /** + * @brief Set the accelerometer power mode, leaving the rest of ACC_CONF alone. + * @param mode Power mode. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setAccelMode(sfe_bmi330_accel_mode_t mode); + + /** + * @brief Set the accelerometer output data rate. + * @param odr Output data rate. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setAccelODR(sfe_bmi330_odr_t odr); + + /** + * @brief Set the accelerometer full-scale range. + * @param range Full-scale range. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setAccelRange(sfe_bmi330_accel_range_t range); + + /** + * @brief Set the accelerometer filter bandwidth. + * @param bandwidth Cutoff as a fraction of ODR. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setAccelBandwidth(sfe_bmi330_bandwidth_t bandwidth); + + /** + * @brief Set the accelerometer hardware averaging. + * @param averaging Samples to average. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setAccelAveraging(sfe_bmi330_averaging_t averaging); + + /** + * @brief Get the cached accelerometer full-scale range. + * + * @details This is what the conversion math uses. It tracks setAccelConfig(), setAccelRange() + * and getAccelConfig(); it does not read the device. + * + * @return The cached range. + */ + sfe_bmi330_accel_range_t getAccelRange(void) + { + return _accelRange; + } + + /////////////////////////////////////////////////////////////////////////// + // Gyroscope Configuration + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Write the whole gyroscope configuration in one register write. + * + * @param mode Power mode. + * @param odr Output data rate. + * @param range Full-scale range. + * @param bandwidth Filter cutoff as a fraction of ODR. + * @param averaging Hardware averaging. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setGyroConfig(sfe_bmi330_gyro_mode_t mode, sfe_bmi330_odr_t odr, + sfe_bmi330_gyro_range_t range, + sfe_bmi330_bandwidth_t bandwidth = BMI330_BW_ODR_HALF, + sfe_bmi330_averaging_t averaging = BMI330_AVG_1); + + /** + * @brief Read the gyroscope configuration back from GYR_CONF (0x21). + * + * @param mode Receives the power mode. + * @param odr Receives the output data rate. + * @param range Receives the full-scale range. + * @param bandwidth Receives the filter setting. + * @param averaging Receives the averaging setting. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getGyroConfig(sfe_bmi330_gyro_mode_t &mode, sfe_bmi330_odr_t &odr, + sfe_bmi330_gyro_range_t &range, sfe_bmi330_bandwidth_t &bandwidth, + sfe_bmi330_averaging_t &averaging); + + /** + * @brief Turn the gyroscope on in high-performance mode, or off. + * @param enable true for high-performance mode, false to disable the sensor. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t enableGyro(bool enable = true); + + /** + * @brief Set the gyroscope power mode, leaving the rest of GYR_CONF alone. + * @param mode Power mode. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setGyroMode(sfe_bmi330_gyro_mode_t mode); + + /** + * @brief Set the gyroscope output data rate. + * @param odr Output data rate. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setGyroODR(sfe_bmi330_odr_t odr); + + /** + * @brief Set the gyroscope full-scale range. + * @param range Full-scale range. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setGyroRange(sfe_bmi330_gyro_range_t range); + + /** + * @brief Set the gyroscope filter bandwidth. + * @param bandwidth Cutoff as a fraction of ODR. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setGyroBandwidth(sfe_bmi330_bandwidth_t bandwidth); + + /** + * @brief Set the gyroscope hardware averaging. + * @param averaging Samples to average. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setGyroAveraging(sfe_bmi330_averaging_t averaging); + + /** + * @brief Get the cached gyroscope full-scale range. + * @return The cached range. + */ + sfe_bmi330_gyro_range_t getGyroRange(void) + { + return _gyroRange; + } + + /////////////////////////////////////////////////////////////////////////// + // Measurement + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Read accelerometer, gyroscope, temperature and timestamp in one transaction. + * + * @details One 9-word burst from ACC_DATA_X (0x03) to SENSOR_TIME_1 (0x0B), so every field + * describes the same instant. Both raw and converted forms are filled in - the conversion is a + * multiply per axis and costs nothing next to the bus traffic. + * + * @param data Receives the reading. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getSensorData(sfe_bmi330_sensor_data_t &data); + + /** + * @brief Read just the accelerometer, converted to g. + * @param data Receives the acceleration. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getAccelData(sfe_bmi330_data_t &data); + + /** + * @brief Read just the accelerometer, in raw counts. + * @param data Receives the raw acceleration. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getAccelDataRaw(sfe_bmi330_raw_data_t &data); + + /** + * @brief Read just the gyroscope, converted to degrees per second. + * @param data Receives the angular rate. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getGyroData(sfe_bmi330_data_t &data); + + /** + * @brief Read just the gyroscope, in raw counts. + * @param data Receives the raw angular rate. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getGyroDataRaw(sfe_bmi330_raw_data_t &data); + + /** + * @brief Read the die temperature in degrees Celsius. + * + * @note This is the temperature of the silicon, which runs warm - it tracks ambient but is not + * an ambient sensor. Its value is in compensating the gyroscope, not in reporting the weather. + * + * @param celsius Receives the temperature. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getTemperature(float &celsius); + + /** + * @brief Read the 32-bit sensor timestamp, in ticks of 39.0625 us. + * + * @details The counter runs from the device's own oscillator, so it timestamps samples far more + * precisely than millis() can - the host's read latency is not in the number. It wraps about + * about every 46.6 hours (2^32 ticks). + * + * @param sensorTime Receives the timestamp. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getSensorTime(uint32_t &sensorTime); + + /** + * @brief Read the STATUS register (0x02) data-ready flags. + * + * @note The register is CLEAR-ON-READ: this call consumes the flags. Read once and use all + * three results. + * + * @param accelReady Receives the accelerometer data-ready flag. + * @param gyroReady Receives the gyroscope data-ready flag. + * @param tempReady Receives the temperature data-ready flag. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getDataReady(bool &accelReady, bool &gyroReady, bool &tempReady); + + /** + * @brief Read and decode ERR_REG (0x01). + * @param status Receives the decoded error flags. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getErrorStatus(sfe_bmi330_error_status_t &status); + + /** + * @brief Read and decode the per-axis saturation flags in SAT_FLAGS (0x0C). + * @param flags Receives the decoded flags. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getSaturationFlags(sfe_bmi330_saturation_t &flags); + + /////////////////////////////////////////////////////////////////////////// + // Unit Conversion + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Convert a raw accelerometer count to g using the cached range. + * @param raw Raw 16-bit signed count. + * @return Acceleration in g. + */ + float accelRawToG(int16_t raw); + + /** + * @brief Convert a raw gyroscope count to degrees per second using the cached range. + * @param raw Raw 16-bit signed count. + * @return Angular rate in degrees per second. + */ + float gyroRawToDps(int16_t raw); + + /** + * @brief Convert a raw temperature count to degrees Celsius. + * + * @details The BMI330 reports 256 LSB per kelvin with zero at 23 degrees C, so the conversion + * is raw / 256 + 23. + * + * @note This divisor is NOT the same on the BMI323, which uses 512. A driver ported from the + * BMI323 without changing this reads roughly half the true excursion from 23 degrees. + * + * @param raw Raw 16-bit signed count. + * @return Temperature in degrees Celsius. + */ + static float rawToCelsius(int16_t raw); + + /** + * @brief Sensitivity of a given accelerometer range, in LSB per g. + * @param range Full-scale range. + * @return LSB per g: 16384, 8192, 4096 or 2048. + */ + static float accelSensitivity(sfe_bmi330_accel_range_t range); + + /** + * @brief Sensitivity of a given gyroscope range, in LSB per degree per second. + * @param range Full-scale range. + * @return LSB per dps. + */ + static float gyroSensitivity(sfe_bmi330_gyro_range_t range); + + /////////////////////////////////////////////////////////////////////////// + // Interrupts + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Configure the electrical behavior of one interrupt pin. + * + * @details Both pins share IO_INT_CTRL (0x38), so this is a read-modify-write and the other + * pin's settings are preserved. + * + * @param pin Which pin to configure. ::BMI330_INT_NONE is ignored. + * @param config Drive strength, polarity and enable. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setInterruptPinConfig(sfe_bmi330_int_pin_t pin, const sfe_bmi330_int_pin_config_t &config); + + /** + * @brief Choose between latched and pulsed interrupts. + * + * @details Pulsed (the default) asserts the pin briefly per event. Latched holds it asserted + * until the status register is read, which is what an edge-triggered host ISR that might miss a + * short pulse wants - at the cost of requiring the status read to happen. + * + * @note The setting is global: it applies to both pins and every source. + * + * @param latched true to latch until the status register is read. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setInterruptLatch(bool latched); + + /** + * @brief Route one interrupt source to a pin, or disconnect it. + * + * @param source The source to route. + * @param pin Destination pin, or ::BMI330_INT_NONE to disconnect. + * @return ::ksfTkErrOk on success, ::ksfTkErrInvalidParam for an unknown source. + */ + sfTkError_t mapInterrupt(sfe_bmi330_int_source_t source, sfe_bmi330_int_pin_t pin); + + /** + * @brief Read and clear a pin's interrupt status word. + * + * @note CLEAR-ON-READ. Every pending flag for that pin is consumed by this call, so test the + * returned word against the ::sfe_bmi330_int_status_t masks rather than calling again per bit. + * + * @param pin Which pin's status to read. + * @param status Receives the status word. + * @return ::ksfTkErrOk on success, ::ksfTkErrInvalidParam for ::BMI330_INT_NONE. + */ + sfTkError_t getInterruptStatus(sfe_bmi330_int_pin_t pin, uint16_t &status); + + /////////////////////////////////////////////////////////////////////////// + // FIFO + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Choose what the FIFO records, and what it does when full. + * + * @param sources Bitwise OR of ::sfe_bmi330_fifo_source_t values. Zero disables the FIFO. + * @param stopOnFull true to stop writing when full (keep the oldest data), false to overwrite + * the oldest data and keep the newest. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setFifoConfig(uint16_t sources, bool stopOnFull = false); + + /** + * @brief Read back the configured FIFO sources and full behavior. + * @param sources Receives the enabled source bits. + * @param stopOnFull Receives the stop-on-full setting. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getFifoConfig(uint16_t &sources, bool &stopOnFull); + + /** + * @brief Set the FIFO watermark, in 16-bit words. + * + * @details The watermark interrupt fires when the fill level reaches this many WORDS - not + * bytes and not frames. A frame is 3 words of accelerometer plus 3 of gyroscope plus 1 each of + * temperature and time, counting only enabled sources, so a watermark of 80 words is 10 frames + * of accelerometer-plus-gyroscope. frameLengthWords() does the arithmetic. + * + * @param words Watermark level in words. Range 0 to 1023. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setFifoWatermark(uint16_t words); + + /** + * @brief Read the FIFO watermark, in words. + * @param words Receives the watermark level. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getFifoWatermark(uint16_t &words); + + /** + * @brief Read how many words the FIFO currently holds. + * @param words Receives the fill level in words. The buffer holds 1024 words (2 kB). + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getFifoFillLevel(uint16_t &words); + + /** + * @brief Discard everything in the FIFO. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t flushFifo(void); + + /** + * @brief Read raw words out of FIFO_DATA (0x16) without interpreting them. + * + * @details The FIFO_DATA register does not auto-increment - the whole burst streams from one + * address. Useful for logging the FIFO verbatim and decoding later, off the device. + * + * @note Reads longer than ::kMaxBurstWords are split into several transactions, always on word + * boundaries. Because the device retransmits a frame that was only partially read out, ask for + * a whole number of frames, or an exact multiple of ::kMaxBurstWords - readFifoData() already + * does the former. + * + * @param words Destination buffer, at least @p count words. + * @param count Number of words to read. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t readFifoRaw(uint16_t *words, uint16_t count); + + /** + * @brief Read and decode complete frames from the FIFO. + * + * @details Reads only whole frames - if the FIFO holds a partial one, it is left in place for + * the next call rather than being torn in half. Frames whose source was idle at capture carry + * an invalid pattern, which is reported as a cleared @p has... flag. + * + * @param frames Destination array. + * @param maxFrames Capacity of @p frames. + * @param numFrames Receives the number of frames actually decoded. + * @param sources The same source bits that were passed to setFifoConfig(). The FIFO is + * headerless, so the parser cannot work this out on its own. + * @return ::ksfTkErrOk on success, including when zero frames were available. + */ + sfTkError_t readFifoData(sfe_bmi330_fifo_frame_t *frames, uint8_t maxFrames, uint8_t &numFrames, + uint16_t sources); + + /** + * @brief Words per FIFO frame for a given set of sources. + * @param sources Bitwise OR of ::sfe_bmi330_fifo_source_t values. + * @return Frame length in 16-bit words, or 0 if no source is enabled. + */ + static uint8_t frameLengthWords(uint16_t sources); + + /////////////////////////////////////////////////////////////////////////// + // Feature Engine Access + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Enable or disable feature-engine features. + * + * @details Read-modify-write of FEATURE_IO0 (0x10) followed by the commit write to + * FEATURE_IO_STATUS (0x14) that tells the engine to pick the change up. Without the commit the + * bits sit in the register and nothing happens. + * + * @param features Bitwise OR of ::sfe_bmi330_feature_t values. + * @param enable true to set those bits, false to clear them. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t enableFeature(uint16_t features, bool enable = true); + + /** + * @brief Read which features are currently enabled. + * @param features Receives the FEATURE_IO0 word. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getEnabledFeatures(uint16_t &features); + + /** + * @brief Read consecutive words from feature engine extended memory. + * + * @param baseAddr Feature base address - see ::sfe_bmi330_feature_addr_t. + * @param words Destination buffer. + * @param count Number of words to read. Maximum ::kMaxBurstWords. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t readFeatureWords(uint8_t baseAddr, uint16_t *words, uint8_t count); + + /** + * @brief Write consecutive words to feature engine extended memory. + * + * @param baseAddr Feature base address - see ::sfe_bmi330_feature_addr_t. + * @param words Source buffer. + * @param count Number of words to write. Maximum ::kMaxBurstWords. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t writeFeatureWords(uint8_t baseAddr, const uint16_t *words, uint8_t count); + + /////////////////////////////////////////////////////////////////////////// + // Motion Features + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Write the any-motion detector configuration. + * @param config Detector settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setAnyMotionConfig(const sfe_bmi330_motion_config_t &config); + + /** + * @brief Read the any-motion detector configuration. + * @param config Receives the settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getAnyMotionConfig(sfe_bmi330_motion_config_t &config); + + /** + * @brief Write the no-motion detector configuration. + * @param config Detector settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setNoMotionConfig(const sfe_bmi330_motion_config_t &config); + + /** + * @brief Read the no-motion detector configuration. + * @param config Receives the settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getNoMotionConfig(sfe_bmi330_motion_config_t &config); + + /** + * @brief Write the significant motion detector configuration. + * @param config Detector settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setSigMotionConfig(const sfe_bmi330_sig_motion_config_t &config); + + /** + * @brief Read the significant motion detector configuration. + * @param config Receives the settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getSigMotionConfig(sfe_bmi330_sig_motion_config_t &config); + + /** + * @brief Write the tilt detector configuration. + * @param config Detector settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setTiltConfig(const sfe_bmi330_tilt_config_t &config); + + /** + * @brief Read the tilt detector configuration. + * @param config Receives the settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getTiltConfig(sfe_bmi330_tilt_config_t &config); + + /** + * @brief Write the orientation detector configuration. + * @param config Detector settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setOrientationConfig(const sfe_bmi330_orientation_config_t &config); + + /** + * @brief Read the orientation detector configuration. + * @param config Receives the settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getOrientationConfig(sfe_bmi330_orientation_config_t &config); + + /** + * @brief Read the current orientation classification. + * @param orientation Receives the portrait / landscape classification. + * @param faceDown Receives the face-down flag. Meaningful only with face up / down enabled. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getOrientation(sfe_bmi330_orientation_t &orientation, bool &faceDown); + + /** + * @brief Write the flat detector configuration. + * @param config Detector settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setFlatConfig(const sfe_bmi330_flat_config_t &config); + + /** + * @brief Read the flat detector configuration. + * @param config Receives the settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getFlatConfig(sfe_bmi330_flat_config_t &config); + + /////////////////////////////////////////////////////////////////////////// + // Tap Detection + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Write the tap detector configuration. + * @param config Detector settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setTapConfig(const sfe_bmi330_tap_config_t &config); + + /** + * @brief Read the tap detector configuration. + * @param config Receives the settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getTapConfig(sfe_bmi330_tap_config_t &config); + + /** + * @brief Read which tap gesture was last detected. + * + * @details Call after a ::BMI330_INT_STATUS_TAP interrupt. The single, double and triple flags + * live in FEATURE_EVENT_EXT (0x47). + * + * @param event Receives the gesture flags. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getTapEvent(sfe_bmi330_tap_event_t &event); + + /////////////////////////////////////////////////////////////////////////// + // Step Counter + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Read the step count. + * + * @details The counter is a 32-bit value split across FEATURE_IO2 (0x12) and FEATURE_IO3 + * (0x13), read here as one two-word burst so the halves cannot disagree. + * + * @param steps Receives the step count. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getStepCount(uint32_t &steps); + + /** + * @brief Reset the step count to zero. + * + * @details Sets and then clears the reset bit in the step counter's first configuration word, + * leaving the rest of that 12-word block untouched. + * + * @return ::ksfTkErrOk on success. + */ + sfTkError_t resetStepCount(void); + + /** + * @brief Set how many steps trigger the step counter interrupt. + * + * @details The count is in units of 20 steps: a watermark of 1 interrupts every 20 steps. Zero + * disables the watermark interrupt while leaving the counter running, which is what a sketch + * that polls getStepCount() wants. + * + * @param watermark Watermark in units of 20 steps. Range 0 to 1023. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setStepCounterWatermark(uint16_t watermark); + + /** + * @brief Read the step counter watermark. + * @param watermark Receives the watermark, in units of 20 steps. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getStepCounterWatermark(uint16_t &watermark); + + /////////////////////////////////////////////////////////////////////////// + // Axis Remapping + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Apply an axis remapping inside the device. + * + * @details Writes the map into feature memory, then commits it. The commit is the fiddly part: + * the device will only accept an axis-map update while the ACCELEROMETER IS SUSPENDED, so this + * method saves the current accelerometer power mode, disables the sensor, issues the + * axis-map-update command, waits for the feature engine to acknowledge it, clears the INT1 + * status the update leaves behind, and restores the saved mode. INT1 events that were already + * pending are kept and returned by the next getInterruptStatus(BMI330_INT1). Sampling is interrupted for a + * few tens of milliseconds; the caller's configuration is not otherwise disturbed. + * + * @note If the accelerometer is already disabled when this is called, the command is issued and + * the method returns without polling - which is what Bosch's own driver does, because the + * acknowledgement is only produced on the suspend-and-restore path. + * + * @param remap Axis permutation and sign flips. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setAxisRemap(const sfe_bmi330_axis_remap_t &remap); + + /** + * @brief Read the current axis remapping. + * @param remap Receives the permutation and sign flips. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getAxisRemap(sfe_bmi330_axis_remap_t &remap); + + /////////////////////////////////////////////////////////////////////////// + // Self-Test and Self-Calibration + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Run the built-in self-test. + * + * @details Drives a known stimulus into the selected sensors and checks the response against + * factory limits. On the BMI330 this also involves seeding the gyroscope filter coefficients in + * feature memory, which the driver handles - the values differ from the BMI323's, and a test + * run without them reports failures on a healthy part. + * + * @note DISRUPTIVE AND SLOW. The routine forces the accelerometer to high-performance mode at + * 100 Hz, takes up to 350 ms, and leaves both sensors reconfigured. Run it at start-up or on + * demand, restore the configuration afterwards, and keep the device STILL while it runs. + * + * @note Per-axis flags are filled in only for the sensors named by @p selection. The bits + * belonging to a sensor that was not tested are not meaningful for that run, so they are left + * false rather than reported as failures. + * + * @param selection Which sensors to test. + * @param result Receives the outcome. + * @return ::ksfTkErrOk if the routine ran. Check @p result for the verdict. + */ + sfTkError_t performSelfTest(sfe_bmi330_self_test_t selection, sfe_bmi330_self_test_result_t &result); + + /** + * @brief Run the gyroscope self-calibration. + * + * @details Measures and corrects the gyroscope's offset, its sensitivity, or both, storing the + * result in the device's user registers. + * + * @note PRECONDITIONS MATTER. The device must be MOTIONLESS for the whole run, and the + * accelerometer must be in high-performance mode at 100 Hz and +/-8 g - which this method sets + * up and then restores. Expect up to 430 ms. A calibration run on a moving device reports + * success and leaves a worse offset than it started with. + * + * @param selection What to correct. + * @param applyCorrection true to have the device apply the result to its output immediately. + * @param result Receives the outcome. + * @return ::ksfTkErrOk if the routine ran. Check @p result for the verdict. + */ + sfTkError_t performGyroSelfCalibration(sfe_bmi330_self_calib_t selection, bool applyCorrection, + sfe_bmi330_self_calib_result_t &result); + + /////////////////////////////////////////////////////////////////////////// + // User Offsets + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Write the accelerometer user offsets. + * + * @details Offsets are signed values applied in the data path before the output registers, at a + * fixed 0.0039 g per LSB REGARDLESS of the selected range - changing the range does not rescale + * them. + * + * @note The field is 14 bits on revision 1 and later silicon and 13 bits on earlier parts. + * begin() reads the revision and picks; values are masked to whichever width this device has. + * + * @param x X axis offset, 14-bit signed. + * @param y Y axis offset, 14-bit signed. + * @param z Z axis offset, 14-bit signed. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setAccelOffset(int16_t x, int16_t y, int16_t z); + + /** + * @brief Read the accelerometer user offsets. + * @param x Receives the X axis offset. + * @param y Receives the Y axis offset. + * @param z Receives the Z axis offset. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getAccelOffset(int16_t &x, int16_t &y, int16_t &z); + + /** + * @brief Write the gyroscope user offsets. + * + * @details Offsets are 10-bit signed values at a fixed 0.061 dps per LSB, applied before the + * output registers and independent of the selected range. + * + * @param x X axis offset, 10-bit signed. + * @param y Y axis offset, 10-bit signed. + * @param z Z axis offset, 10-bit signed. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setGyroOffset(int16_t x, int16_t y, int16_t z); + + /** + * @brief Read the gyroscope user offsets. + * @param x Receives the X axis offset. + * @param y Receives the Y axis offset. + * @param z Receives the Z axis offset. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getGyroOffset(int16_t &x, int16_t &y, int16_t &z); + + /////////////////////////////////////////////////////////////////////////// + // Low-Level Register Access + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Read one 16-bit register, dummy bytes handled. + * @param reg Register address. + * @param value Receives the register contents. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t readRegister(uint8_t reg, uint16_t &value); + + /** + * @brief Write one 16-bit register. + * @param reg Register address. + * @param value Value to write. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t writeRegister(uint8_t reg, uint16_t value); + + /** + * @brief Read consecutive 16-bit registers in one transaction, dummy bytes handled. + * @param reg First register address. + * @param values Destination buffer. + * @param count Number of words. Maximum ::kMaxBurstWords. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t readRegisters(uint8_t reg, uint16_t *values, uint8_t count); + + /** + * @brief Write consecutive 16-bit registers in one transaction. + * @param reg First register address. + * @param values Source buffer. + * @param count Number of words. Maximum ::kMaxBurstWords. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t writeRegisters(uint8_t reg, const uint16_t *values, uint8_t count); + + /** + * @brief Send a command through the CMD register (0x7E). + * @param command Command word. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t sendCommand(uint16_t command); + + protected: + /////////////////////////////////////////////////////////////////////////// + // Internal Helpers + /////////////////////////////////////////////////////////////////////////// + + /** + * @brief Read-modify-write a bit field of a 16-bit register. + * @param reg Register address. + * @param mask Field mask. + * @param value Field value, already shifted into position. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t updateRegister(uint8_t reg, uint16_t mask, uint16_t value); + + /** + * @brief Read-modify-write a bit field of a feature engine word. + * @param baseAddr Feature base address. + * @param wordIndex Word offset within the feature block. + * @param mask Field mask. + * @param value Field value, already shifted into position. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t updateFeatureWord(uint8_t baseAddr, uint8_t wordIndex, uint16_t mask, uint16_t value); + + /** + * @brief Shared implementation for the any-motion and no-motion configuration writers. + * @param baseAddr ::BMI330_FEAT_ADDR_ANY_MOTION or ::BMI330_FEAT_ADDR_NO_MOTION. + * @param config Detector settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t setMotionConfig(uint8_t baseAddr, const sfe_bmi330_motion_config_t &config); + + /** + * @brief Shared implementation for the any-motion and no-motion configuration readers. + * @param baseAddr ::BMI330_FEAT_ADDR_ANY_MOTION or ::BMI330_FEAT_ADDR_NO_MOTION. + * @param config Receives the settings. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t getMotionConfig(uint8_t baseAddr, sfe_bmi330_motion_config_t &config); + + /** + * @brief Seed the gyroscope filter coefficients required by BMI330 self-test and + * self-calibration. + * + * @details The BMI330 ships with coefficients that differ from the BMI323's, and the routines + * only produce meaningful results against the BMI330 set. This writes them if what is already + * there does not match. + * + * @return ::ksfTkErrOk on success. + */ + sfTkError_t primeGyroFilterCoefficients(void); + + /** + * @brief Poll FEATURE_IO1 until the self-test / self-calibration completion bit is set. + * @param featureIO1 Receives the final FEATURE_IO1 word. + * @param stepDelayMs Delay between polls, in milliseconds. + * @return ::ksfTkErrOk if the bit set within the polling window, ::ksfTkErrBusTimeout otherwise. + */ + sfTkError_t waitForSelfTestComplete(uint16_t &featureIO1, uint16_t stepDelayMs); + + /** + * @brief Read up to ::kMaxBurstWords words from the FIFO in one transaction. + * + * @details Splitting a FIFO read at a frame boundary is safe; splitting it inside a frame is + * not, because the device retransmits a partially-read frame. Callers are responsible for + * choosing a frame-aligned @p count. + * + * @param words Destination buffer. + * @param count Number of words. Maximum ::kMaxBurstWords. + * @return ::ksfTkErrOk on success. + */ + sfTkError_t readFifoWords(uint16_t *words, uint8_t count); + + /** + * @brief Sign-extend a right-aligned two's complement field to a full int16_t. + * @param value Field value, right-aligned, upper bits zero. + * @param bits Width of the field in bits. + * @return The sign-extended value. + */ + static int16_t signExtend(uint16_t value, uint8_t bits); + + /** @brief The bus this driver communicates over. */ + sfTkIBus *_theBus; + + /** @brief Dummy bytes the device prepends to every read: 2 on I2C, 1 on SPI. */ + uint8_t _dummyBytes; + + /** @brief Cached accelerometer range, used by accelRawToG(). */ + sfe_bmi330_accel_range_t _accelRange; + + /** @brief Cached gyroscope range, used by gyroRawToDps(). */ + sfe_bmi330_gyro_range_t _gyroRange; + + /** + * @brief Mask for the accelerometer user offset field, set from the silicon revision. + * + * @details Revision 1 and later carry a 14-bit offset field; earlier silicon carries 13 bits. + * begin() reads the revision and picks. Using the wrong width writes a bit outside the field + * and sign-extends reads from the wrong place. + */ + uint16_t _accelOffsetMask; + + /** + * @brief INT1 status bits that setAxisRemap() had to read (and so clear) before its commit. + * + * @details INT_STATUS_INT1 is clear-on-read. setAxisRemap() captures whatever is pending before + * it suspends the accelerometer, and the next getInterruptStatus(BMI330_INT1) merges these bits + * back in, so a remap never swallows a caller's event. + */ + uint16_t _pendingInt1; + + /////////////////////////////////////////////////////////////////////////// + // Register Map + /////////////////////////////////////////////////////////////////////////// + // + // Every register is a 16-bit little-endian word. Reads carry leading dummy bytes; see the file + // header. + + static const uint8_t kRegChipId = 0x00; ///< Chip and revision ID. + static const uint8_t kRegErrReg = 0x01; ///< Error flags. + static const uint8_t kRegStatus = 0x02; ///< Data ready and POR flags. Clear-on-read. + static const uint8_t kRegAccelDataX = 0x03; ///< Accelerometer X. Start of the data block. + static const uint8_t kRegGyroDataX = 0x06; ///< Gyroscope X. + static const uint8_t kRegTempData = 0x09; ///< Temperature. + static const uint8_t kRegSensorTime0 = 0x0A; ///< Sensor time, low word. + static const uint8_t kRegSatFlags = 0x0C; ///< Per-axis saturation flags. + static const uint8_t kRegIntStatusInt1 = 0x0D; ///< INT1 status. Clear-on-read. + static const uint8_t kRegIntStatusInt2 = 0x0E; ///< INT2 status. Clear-on-read. + static const uint8_t kRegFeatureIO0 = 0x10; ///< Feature enable bits. + static const uint8_t kRegFeatureIO1 = 0x11; ///< Feature engine status and errors. + static const uint8_t kRegFeatureIO2 = 0x12; ///< Step count low word / start-up config. + static const uint8_t kRegFeatureIO3 = 0x13; ///< Step count high word / start-up config. + static const uint8_t kRegFeatureIOStatus = 0x14; ///< Commit register for FEATURE_IO0 changes. + static const uint8_t kRegFifoFillLevel = 0x15; ///< FIFO fill level, in words. + static const uint8_t kRegFifoData = 0x16; ///< FIFO read port. Does not auto-increment. + static const uint8_t kRegAccelConf = 0x20; ///< Accelerometer configuration. + static const uint8_t kRegGyroConf = 0x21; ///< Gyroscope configuration. + static const uint8_t kRegAltAccelConf = 0x28; ///< Alternate accelerometer configuration. + static const uint8_t kRegAltGyroConf = 0x29; ///< Alternate gyroscope configuration. + static const uint8_t kRegFifoWatermark = 0x35; ///< FIFO watermark, in words. + static const uint8_t kRegFifoConf = 0x36; ///< FIFO source enables and stop-on-full. + static const uint8_t kRegFifoCtrl = 0x37; ///< FIFO flush. + static const uint8_t kRegIoIntCtrl = 0x38; ///< Interrupt pin electrical configuration. + static const uint8_t kRegIntConf = 0x39; ///< Interrupt latch mode. + static const uint8_t kRegIntMap1 = 0x3A; ///< Interrupt routing, feature sources. + static const uint8_t kRegIntMap2 = 0x3B; ///< Interrupt routing, data and FIFO sources. + static const uint8_t kRegFeatureCtrl = 0x40; ///< Feature engine enable. + static const uint8_t kRegFeatureDataAddr = 0x41; ///< Feature memory address window. + static const uint8_t kRegFeatureDataTx = 0x42; ///< Feature memory data port. Auto-increments. + static const uint8_t kRegFeatureEventExt = 0x47; ///< Orientation and tap event outputs. + static const uint8_t kRegAccelDpOffX = 0x60; ///< Accelerometer X user offset. + static const uint8_t kRegGyroDpOffX = 0x66; ///< Gyroscope X user offset. + static const uint8_t kRegCmd = 0x7E; ///< Command register. + + /////////////////////////////////////////////////////////////////////////// + // Commands + /////////////////////////////////////////////////////////////////////////// + + static const uint16_t kCmdSelfTestTrigger = 0x0100; ///< Start a self-test. + static const uint16_t kCmdSelfCalibTrigger = 0x0101; ///< Start a gyroscope self-calibration. + static const uint16_t kCmdSelfCalibAbort = 0x0200; ///< Abort a self-calibration. + static const uint16_t kCmdAxisMapUpdate = 0x0300; ///< Commit an axis remap. + static const uint16_t kCmdSoftReset = 0xDEAF; ///< Soft reset. + + /////////////////////////////////////////////////////////////////////////// + // ACC_CONF / GYR_CONF Bit Fields + /////////////////////////////////////////////////////////////////////////// + // + // The two registers share a layout, so one set of masks serves both. + + static const uint16_t kConfOdrMask = 0x000F; ///< Output data rate, bits 3:0. + static const uint16_t kConfRangeMask = 0x0070; ///< Full-scale range, bits 6:4. + static const uint8_t kConfRangeShift = 4; ///< Range field position. + static const uint16_t kConfBwMask = 0x0080; ///< Bandwidth, bit 7. + static const uint8_t kConfBwShift = 7; ///< Bandwidth field position. + static const uint16_t kConfAvgMask = 0x0700; ///< Averaging, bits 10:8. + static const uint8_t kConfAvgShift = 8; ///< Averaging field position. + static const uint16_t kConfModeMask = 0x7000; ///< Power mode, bits 14:12. + static const uint8_t kConfModeShift = 12; ///< Power mode field position. + + /////////////////////////////////////////////////////////////////////////// + // STATUS, ERR_REG and SAT_FLAGS Bits + /////////////////////////////////////////////////////////////////////////// + + static const uint16_t kStatusTempDrdy = 0x0020; ///< Temperature data ready. + static const uint16_t kStatusGyroDrdy = 0x0040; ///< Gyroscope data ready. + static const uint16_t kStatusAccelDrdy = 0x0080; ///< Accelerometer data ready. + + static const uint16_t kErrFatal = 0x0001; ///< Fatal error. + static const uint16_t kErrFeatEngOverload = 0x0004; ///< Feature engine overload. + static const uint16_t kErrFeatEngWatchdog = 0x0010; ///< Feature engine watchdog. + static const uint16_t kErrAccelConf = 0x0020; ///< Unsupported accelerometer configuration. + static const uint16_t kErrGyroConf = 0x0040; ///< Unsupported gyroscope configuration. + + static const uint16_t kSatAccelX = 0x0001; ///< Accelerometer X saturated. + static const uint16_t kSatAccelY = 0x0002; ///< Accelerometer Y saturated. + static const uint16_t kSatAccelZ = 0x0004; ///< Accelerometer Z saturated. + static const uint16_t kSatGyroX = 0x0008; ///< Gyroscope X saturated. + static const uint16_t kSatGyroY = 0x0010; ///< Gyroscope Y saturated. + static const uint16_t kSatGyroZ = 0x0020; ///< Gyroscope Z saturated. + + /////////////////////////////////////////////////////////////////////////// + // Interrupt Configuration Bits + /////////////////////////////////////////////////////////////////////////// + + static const uint16_t kInt1LevelMask = 0x0001; ///< INT1 polarity, 1 = active high. + static const uint16_t kInt1OpenDrainMask = 0x0002;///< INT1 open-drain. + static const uint16_t kInt1OutputEnMask = 0x0004; ///< INT1 output enable. + static const uint16_t kInt2LevelMask = 0x0100; ///< INT2 polarity, 1 = active high. + static const uint16_t kInt2OpenDrainMask = 0x0200;///< INT2 open-drain. + static const uint16_t kInt2OutputEnMask = 0x0400; ///< INT2 output enable. + static const uint16_t kIntLatchMask = 0x0001; ///< Latched interrupt mode. + + /////////////////////////////////////////////////////////////////////////// + // FIFO Bits + /////////////////////////////////////////////////////////////////////////// + + static const uint16_t kFifoStopOnFullMask = 0x0001; ///< Stop writing when full. + static const uint16_t kFifoSourceMask = 0x0F00; ///< Source enable field. + static const uint16_t kFifoFlushMask = 0x0001; ///< Flush command bit in FIFO_CTRL. + static const uint16_t kFifoWatermarkMask = 0x03FF; ///< Watermark field. + static const uint16_t kFifoFillLevelMask = 0x07FF; ///< Fill level field. + + /** @brief Sentinel word written into a FIFO accelerometer slot when the sensor was idle. */ + static const uint16_t kFifoAccelInvalid = 0x7F01; + /** @brief Sentinel word written into a FIFO gyroscope slot when the sensor was idle. */ + static const uint16_t kFifoGyroInvalid = 0x7F02; + /** @brief Sentinel word written into a FIFO temperature slot when the sensor was idle. */ + static const uint16_t kFifoTempInvalid = 0x8000; + + /////////////////////////////////////////////////////////////////////////// + // Feature Engine Bits + /////////////////////////////////////////////////////////////////////////// + + static const uint16_t kFeatureEngineEnable = 0x0001; ///< Engine enable / active flag. + static const uint16_t kFeatureIOStatusCommit = 0x0001;///< Commit bit for FEATURE_IO0 changes. + static const uint16_t kFeatureStartupConfig = 0x012C; ///< Start-up value written to FEATURE_IO2. + static const uint16_t kFeatureErrorMask = 0x000F; ///< Error nibble in FEATURE_IO1. + static const uint16_t kFeatureNoError = 0x0005; ///< Error nibble value meaning "no error". + static const uint16_t kScStComplete = 0x0010; ///< Self-test / self-calibration complete. + static const uint16_t kScResult = 0x0020; ///< Gyroscope self-calibration result. + static const uint16_t kStResult = 0x0040; ///< Self-test result. + static const uint16_t kAxisMapComplete = 0x0400; ///< Axis map applied. + static const uint16_t kFeatureAddrMask = 0x07FF; ///< Address field of FEATURE_DATA_ADDR. + + /////////////////////////////////////////////////////////////////////////// + // Feature Output Bits (FEATURE_EVENT_EXT, 0x47) + /////////////////////////////////////////////////////////////////////////// + + static const uint16_t kEventOrientationMask = 0x0003; ///< Portrait / landscape classification. + static const uint16_t kEventFaceDownMask = 0x0004; ///< Face down flag. + static const uint16_t kEventSingleTap = 0x0008; ///< Single tap detected. + static const uint16_t kEventDoubleTap = 0x0010; ///< Double tap detected. + static const uint16_t kEventTripleTap = 0x0020; ///< Triple tap detected. + + /////////////////////////////////////////////////////////////////////////// + // Feature Configuration Field Masks + /////////////////////////////////////////////////////////////////////////// + + static const uint16_t kMotionSlopeThresholdMask = 0x0FFF; ///< Any/no-motion slope threshold. + static const uint16_t kMotionAccelRefUpMask = 0x1000; ///< Any/no-motion reference update. + static const uint16_t kMotionHysteresisMask = 0x03FF; ///< Any/no-motion hysteresis. + static const uint16_t kMotionDurationMask = 0x1FFF; ///< Any/no-motion duration. + static const uint16_t kMotionWaitTimeMask = 0xE000; ///< Any/no-motion wait time. + static const uint8_t kMotionWaitTimeShift = 13; ///< Wait time field position. + + static const uint16_t kSigP2pMinMask = 0x03FF; ///< Significant motion minimum peak-to-peak. + static const uint16_t kSigMcrMinMask = 0xFC00; ///< Significant motion minimum crossing rate. + static const uint8_t kSigMcrMinShift = 10; ///< Minimum crossing rate field position. + static const uint16_t kSigP2pMaxMask = 0x03FF; ///< Significant motion maximum peak-to-peak. + static const uint16_t kSigMcrMaxMask = 0xFC00; ///< Significant motion maximum crossing rate. + static const uint8_t kSigMcrMaxShift = 10; ///< Maximum crossing rate field position. + + static const uint16_t kTiltSegmentSizeMask = 0x00FF; ///< Tilt reference segment size. + static const uint16_t kTiltMinAngleMask = 0xFF00; ///< Tilt minimum angle. + static const uint8_t kTiltMinAngleShift = 8; ///< Minimum angle field position. + + static const uint16_t kOrientUdEnMask = 0x0001; ///< Orientation face up / down enable. + static const uint16_t kOrientModeMask = 0x0006; ///< Orientation mode. + static const uint8_t kOrientModeShift = 1; ///< Orientation mode field position. + static const uint16_t kOrientBlockingMask = 0x0018;///< Orientation blocking. + static const uint8_t kOrientBlockingShift = 3; ///< Blocking field position. + static const uint16_t kOrientThetaMask = 0x07E0; ///< Orientation theta. + static const uint8_t kOrientThetaShift = 5; ///< Theta field position. + static const uint16_t kOrientHoldTimeMask = 0xF800;///< Orientation hold time. + static const uint8_t kOrientHoldTimeShift = 11; ///< Hold time field position. + static const uint16_t kOrientSlopeThresMask = 0x00FF; ///< Orientation slope threshold. + static const uint16_t kOrientHystMask = 0xFF00; ///< Orientation hysteresis. + static const uint8_t kOrientHystShift = 8; ///< Hysteresis field position. + + static const uint16_t kFlatThetaMask = 0x003F; ///< Flat theta. + static const uint16_t kFlatBlockingMask = 0x00C0; ///< Flat blocking. + static const uint8_t kFlatBlockingShift = 6; ///< Blocking field position. + static const uint16_t kFlatHoldTimeMask = 0xFF00; ///< Flat hold time. + static const uint8_t kFlatHoldTimeShift = 8; ///< Hold time field position. + static const uint16_t kFlatSlopeThresMask = 0x00FF;///< Flat slope threshold. + static const uint16_t kFlatHystMask = 0xFF00; ///< Flat hysteresis. + static const uint8_t kFlatHystShift = 8; ///< Hysteresis field position. + + static const uint16_t kTapAxisMask = 0x0003; ///< Tap axis selection. + static const uint16_t kTapWaitTimeoutMask = 0x0004; ///< Tap wait-for-timeout. + static const uint16_t kTapMaxPeaksMask = 0x0038; ///< Tap maximum peaks. + static const uint8_t kTapMaxPeaksShift = 3; ///< Maximum peaks field position. + static const uint16_t kTapModeMask = 0x00C0; ///< Tap mode. + static const uint8_t kTapModeShift = 6; ///< Mode field position. + static const uint16_t kTapPeakThresMask = 0x03FF; ///< Tap peak threshold. + static const uint16_t kTapMaxGestDurMask = 0xFC00; ///< Tap maximum gesture duration. + static const uint8_t kTapMaxGestDurShift = 10; ///< Gesture duration field position. + static const uint16_t kTapMaxDurBwPeaksMask = 0x000F; ///< Tap maximum duration between peaks. + static const uint16_t kTapShockSettDurMask = 0x00F0; ///< Tap shock settling duration. + static const uint8_t kTapShockSettDurShift = 4; ///< Shock settling field position. + static const uint16_t kTapMinQuietDurMask = 0x0F00; ///< Tap minimum quiet duration. + static const uint8_t kTapMinQuietDurShift = 8; ///< Minimum quiet field position. + static const uint16_t kTapQuietAfterGestMask = 0xF000;///< Tap quiet time after gesture. + static const uint8_t kTapQuietAfterGestShift = 12; ///< Quiet after gesture field position. + + static const uint16_t kStepWatermarkMask = 0x03FF; ///< Step counter watermark. + static const uint16_t kStepResetCounterMask = 0x0400; ///< Step counter reset bit. + + static const uint16_t kRemapAxisMask = 0x0007; ///< Axis permutation. + static const uint16_t kRemapXSignMask = 0x0008; ///< Invert X. + static const uint16_t kRemapYSignMask = 0x0010; ///< Invert Y. + static const uint16_t kRemapZSignMask = 0x0020; ///< Invert Z. + + /////////////////////////////////////////////////////////////////////////// + // Offset Register Masks + /////////////////////////////////////////////////////////////////////////// + + static const uint16_t kAccelOffsetMask14 = 0x3FFF; ///< Accelerometer offset, 14-bit (rev 1+). + static const uint16_t kAccelOffsetMask13 = 0x1FFF; ///< Accelerometer offset, 13-bit (rev 0). + static const uint16_t kGyroOffsetMask = 0x03FF; ///< Gyroscope offset, 10-bit signed. + + /////////////////////////////////////////////////////////////////////////// + // Timing and Limits + /////////////////////////////////////////////////////////////////////////// + + /** @brief Dummy bytes prepended to a read on I2C. */ + static const uint8_t kDummyBytesI2C = 2; + /** @brief Dummy bytes prepended to a read on SPI. */ + static const uint8_t kDummyBytesSPI = 1; + /** + * @brief Largest word count a single readRegisters() / writeRegisters() call handles. + * + * @details Fifteen, not sixteen, and the reason is the transport. A read asks the bus for + * count * 2 + dummy bytes; the Toolkit's Arduino I2C layer splits anything longer than its + * 32-byte chunk size into separate transactions, and the BMI330 re-inserts its dummy bytes at + * the start of every read phase. A split burst would therefore return two dummy bytes in the + * middle of the payload, shifting everything after them by one word - and, because the byte + * COUNT still comes out right, reporting success. Fifteen words plus two dummy bytes is exactly + * 32, which also fits the 32-byte Wire buffer on AVR. + * + * @note This cap is load-bearing, not slack. readFifoData() packs as many whole frames into a + * burst as will fit, so an accelerometer-only FIFO reads the full fifteen words at a time. + * Raising this constant without also raising the bus object's chunk size reintroduces the + * silent corruption described above. + */ + static const uint8_t kMaxBurstWords = 15; + /** @brief Milliseconds to wait after a soft reset before touching the device. */ + static const uint8_t kSoftResetDelayMs = 2; + /** @brief Milliseconds between feature engine start-up polls. */ + static const uint8_t kFeatureEngineStepMs = 10; + /** @brief Feature engine start-up polls before giving up. */ + static const uint8_t kFeatureEngineRetries = 20; + /** @brief Milliseconds between self-test completion polls. */ + static const uint8_t kSelfTestStepMs = 35; + /** @brief Milliseconds between self-calibration completion polls. */ + static const uint8_t kSelfCalibStepMs = 43; + /** @brief Completion polls before a self-test or self-calibration is declared timed out. */ + static const uint8_t kSelfTestRetries = 10; + /** @brief Axis-map acknowledgement polls before giving up. */ + static const uint8_t kAxisMapRetries = 10; + /** @brief Milliseconds between axis-map acknowledgement polls. */ + static const uint8_t kAxisMapStepMs = 10; + /** @brief Words in the FIFO buffer (2048 bytes). */ + static const uint16_t kFifoSizeWords = 1024; +};