ESP32 + dual ICM-20948 IMUs — Calibration applied but corrected data becomes invalid; saturation ignores selected range

Hi everyone,

I'm working on a research project where I use an ESP32 with two ICM-20948 IMUs (addresses 0x68 and 0x69) and an AD7124 ADC to measure paddle biomechanics.
Streaming works, acquisition works, and raw values look correct — but I’m experiencing major issues with IMU calibration AND with sensor saturation, and after weeks of debugging I still can’t understand what is going on.

I am attaching here:

:pushpin: rest.csv → IMUs completely at rest
:pushpin: paddling → IMUs measuring paddle strokes
:pushpin: Full ESP32 code (complete .ino) → exactly what I’m running

These files should help anyone reproduce and understand the behaviour I’m getting.


1. How I calibrate the IMUs (MATLAB)

I calibrate both IMUs externally in MATLAB:

:check_mark: Accelerometer — 6-pose method

  • +X, –X

  • +Y, –Y

  • +Z, –Z

  • compute mean raw value for each pose

  • solve for bias (3×1) and gain/scale (3×1)

:check_mark: Gyroscope

  • keep IMUs still → compute bias per axis

:check_mark: Magnetometer

  • rotate in all directions

  • compute 3×1 hard-iron bias

  • compute 3×3 soft-iron matrix

The resulting coefficients seem consistent.


2. How I apply calibration on the ESP32

Accelerometer:

ax = (ax_raw - A_BIAS[0]) * A_GAIN[0];
ay = (ay_raw - A_BIAS[1]) * A_GAIN[1];
az = (az_raw - A_BIAS[2]) * A_GAIN[2];

Gyroscope:

gx -= G_BIAS[0];
gy -= G_BIAS[1];
gz -= G_BIAS[2];

Magnetometer:

float v[3] = {mx - M_BIAS[0], my - M_BIAS[1], mz - M_BIAS[2]};
mx = M_K[0][0]*v[0] + M_K[0][1]*v[1] + M_K[0][2]*v[2];
my = M_K[1][0]*v[0] + M_K[1][1]*v[1] + M_K[1][2]*v[2];
mz = M_K[2][0]*v[0] + M_K[2][1]*v[1] + M_K[2][2]*v[2];

Everything follows standard IMU calibration procedures.


3. What goes wrong (with CSV proof attached)

:red_exclamation_mark: A) Raw acceleration is correct, but calibrated acceleration becomes physically impossible

From paddle_001.csv (resting IMUs):

IMU1:

  • raw magnitude ≈ 9.9 m/s²

  • calibrated magnitude ≈ 9.9 m/s²
    OK

IMU2:

  • raw magnitude ≈ 9.8 m/s² (correct)

  • calibrated magnitude ≈ 6.1 m/s² (WRONG)
    physically impossible for a static sensor

The raw data shows IMU2 is working, but the calibrated output is totally wrong even though I am applying the same calibration method used successfully in other projects.


:red_exclamation_mark: B) Gyroscope calibration behaves the same

IMU1 → low bias, stable.
IMU2 → bias corrected values still show ~1.5 dps at rest.

Again, raw data looks normal — the corrected data does not.


:red_exclamation_mark: C) Saturation happens at the same values regardless of selected range

Even when I use:

imu.setAccRange(ICM20948_ACC_RANGE_4G);
imu.setAccRange(ICM20948_ACC_RANGE_8G);
imu.setAccRange(ICM20948_ACC_RANGE_16G);

I always get clipping at ~1.6 g on both IMUs.

Same for the gyroscope: clipping threshold does not change even though I configure ranges up to ±2000 dps.

This suggests that either the library is not setting the registers, or something overwrites them after configuration.


4. What I have already tried

  • removed imu.autoOffsets() completely

  • verified unit consistency (g → m/s², dps → rad/s)

  • confirmed that raw values differ between ranges (but they don’t)

  • compared to known-good calibrations

  • checked if the library resets configs after readSensor()

  • repeated calibration several times

  • swapped IMU addresses (same behaviour)

  • inspected the GitHub issues for ICM20948_WE (no clear answer)


5. What I need help understanding

A) Why does IMU2 calibration fail even though raw data is correct?

  • Could the library be internally re-scaling the sensors after reading?

  • Are my bias/gain equations being applied incorrectly?

  • Is the ICM20948_WE library changing full-scale ranges silently?

B) Why do both ACC and GYRO saturate at the same limits regardless of selected range?

  • Is setAccRange() known to fail?

  • Should I write directly to the accelerometer/gyroscope register banks?

  • Could this be an MPU-specific bug?

C) Is there something fundamentally incompatible between the library output and external calibration?


files.zip (473.9 KB)

If needed, I can also upload:

  • calibration MATLAB scripts

  • plots of raw vs calibrated magnitude

  • register dump of both IMUs before and after configuration

I would really appreciate any guidance — this issue is blocking sensor validation and I’m running out of debugging ideas.

Thank you!

/* =======================================================================
   PADDLE LOGGER — Aquisição (ESP32 + 2x ICM-20948 + AD7124)
   -> Guarda QUATERNIONS (q0..q3), RAW & CORR (acc, gyro) + EXTENSÓMETROS RAW
   -> Amostragem alvo: 80 Hz
   ======================================================================= */

#include <Arduino.h>
#include <SPI.h>
#include <SPIFFS.h>
#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
#include <Wire.h>

#include <ICM20948_WE.h>
#include <NHB_AD7124.h>

// AHRS (Adafruit)
#include <Adafruit_AHRS.h>  // Library: "Adafruit AHRS"
Adafruit_Madgwick madg1;    // IMU1
Adafruit_Madgwick madg2;    // IMU2

/* ---------------- Wi-Fi / HTTP / WS ---------------- */
const char* AP_SSID = "PaddleLogger";
const char* AP_PASS = "12345678";
WebServer server(80);
WebSocketsServer ws(81);

/* ---------------- IMUs ---------------- */
#define ICM20948_ADDR_1 0x68
#define ICM20948_ADDR_2 0x69
ICM20948_WE imu1(ICM20948_ADDR_1);
ICM20948_WE imu2(ICM20948_ADDR_2);

/* ---------------- AD7124 (2 canais diferenciais) ---------------- */
const uint8_t ADC_CS = 5;
Ad7124 adc(ADC_CS, 8000000);
const int CH_COUNT = 2;  // 2 extensómetros (raw)
const AD7124_GainSel PGA_GAIN = AD7124_Gain_128;
const bool BIPOLAR = true;
const AD7124_Filters FILT = AD7124_Filter_SINC4;
const uint16_t FS_BITS = 4;

/* ---------------- Amostragem / Tempo ---------------- */
const float FS_HZ = 80.0f;  // alvo 80 Hz
const uint32_t DT_US = (uint32_t)(1000000.0f / FS_HZ);

/* ---------------- Estados ---------------- */
volatile bool stream_on = false;
volatile bool record_on = false;
File logFile;
uint32_t fileIndex = 0;

/* ======================================================
   CALIBRAÇÃO FINAL — IMU1 & IMU2
   Valores obtidos nos testes:
   - 6 poses (ACC)
   - repouso (GYRO)
   - esfera (MAG)
   ====================================================== */

/* ---------------- CALIBRAÇÕES (vão ser atualizadas) ---------------- */

// IMU1 - ACC
const float A1_BIAS[3] = { -0.0009460f, 0.5182857f, 0.1845963f };
const float A1_GAIN[3] = { 1.0116220f, 1.0199814f, 1.0162910f };

// IMU1 - GYRO
const float G1_BIAS[3] = { 0.3220197f, 2.0629826f, -0.8690901f };

// IMU1 - MAG
const float M1_BIAS[3] = { -19.36425f, -4.84686f, 19.18474f };
const float M1_K[3][3] = {{ 1.00574812f, 0.01188779f, 0.01074084f },
  { 0.01188779f, 0.96194669f, -0.00903937f },
  { 0.01074084f, -0.00903937f, 1.03397035f },
};

// IMU2 - ACC
const float A2_BIAS[3] = { -0.0752043f, -0.1399603f, 0.4248510f };
const float A2_GAIN[3] = { 1.0131034f, 1.0192566f, 1.0003757f };

// IMU2 - GYRO
const float G2_BIAS[3] = { -1.7944321f, 0.0404470f, 0.1796118f };

// IMU2 - MAG
const float M2_BIAS[3] = { -17.41713f, -11.81091f, 33.52335f };
const float M2_K[3][3] = { { 1.01723582f, 0.02765768f, 0.01634341f },
  { 0.02765768f, 0.98203040f, -0.03115845f },
  { 0.01634341f, -0.03115845f, 1.00309211f },
};

/* ---------------- HTML (UI) ---------------- */
static void handleRoot();
void onWs(uint8_t num, WStype_t type, uint8_t* payload, size_t len);
extern const char INDEX_HTML[] PROGMEM;

/* ---------------- Utils / Helpers ---------------- */
static inline void sendLog(const String& s) {
  ws.broadcastTXT(s.c_str());
}

/* Leitura IMU única (uma chamada a readSensor por amostra) */
static inline void readIMURaw(ICM20948_WE& imu,
                              float& ax, float& ay, float& az,  // g
                              float& gx, float& gy, float& gz,
                              float& mx, float& my, float& mz) {
  imu.readSensor();
  xyzFloat a, g, m;
  imu.getGValues(&a);
  imu.getGyrValues(&g);
  imu.getMagValues(&m);  // <--- NOVO
  ax = a.x;
  ay = a.y;
  az = a.z;
  gx = g.x;
  gy = g.y;
  gz = g.z;
  mx = m.x;
  my = m.y;
  mz = m.z;
}

/* Correções (bias + ganho no ACC; bias no GYRO) */
static inline void fixAcc1(float& ax, float& ay, float& az) {
  ax = (ax - A1_BIAS[0]) * A1_GAIN[0];
  ay = (ay - A1_BIAS[1]) * A1_GAIN[1];
  az = (az - A1_BIAS[2]) * A1_GAIN[2];
}
static inline void fixAcc2(float& ax, float& ay, float& az) {
  ax = (ax - A2_BIAS[0]) * A2_GAIN[0];
  ay = (ay - A2_BIAS[1]) * A2_GAIN[1];
  az = (az - A2_BIAS[2]) * A2_GAIN[2];
}
static inline void fixGyro1(float& gx, float& gy, float& gz) {
  gx -= G1_BIAS[0];
  gy -= G1_BIAS[1];
  gz -= G1_BIAS[2];
}
static inline void fixGyro2(float& gx, float& gy, float& gz) {
  gx -= G2_BIAS[0];
  gy -= G2_BIAS[1];
  gz -= G2_BIAS[2];
}
static inline void fixMag1(float& mx, float& my, float& mz) {
  float v[3] = { mx - M1_BIAS[0], my - M1_BIAS[1], mz - M1_BIAS[2] };

  float out_x = M1_K[0][0] * v[0] + M1_K[0][1] * v[1] + M1_K[0][2] * v[2];
  float out_y = M1_K[1][0] * v[0] + M1_K[1][1] * v[1] + M1_K[1][2] * v[2];
  float out_z = M1_K[2][0] * v[0] + M1_K[2][1] * v[1] + M1_K[2][2] * v[2];

  mx = out_x;
  my = out_y;
  mz = out_z;
}

static inline void fixMag2(float& mx, float& my, float& mz) {
  float v[3] = { mx - M2_BIAS[0], my - M2_BIAS[1], mz - M2_BIAS[2] };

  float out_x = M2_K[0][0] * v[0] + M2_K[0][1] * v[1] + M2_K[0][2] * v[2];
  float out_y = M2_K[1][0] * v[0] + M2_K[1][1] * v[1] + M2_K[1][2] * v[2];
  float out_z = M2_K[2][0] * v[0] + M2_K[2][1] * v[1] + M2_K[2][2] * v[2];

  mx = out_x;
  my = out_y;
  mz = out_z;
}

/* AD7124: configuração e leitura */
static void configureAdc() {
  if (!adc.begin(SPI)) {
    Serial.println("[ADC] begin falhou");
    while (1) delay(1000);
  }
  adc.setAdcControl(AD7124_OpMode_Continuous, AD7124_FullPower, true, AD7124_Clk_Internal);
  for (int i = 0; i < CH_COUNT; i++) {
    adc.setup[i].setConfig(AD7124_Ref_Internal, PGA_GAIN, BIPOLAR, AD7124_Burnout_Off, 2.5);
    adc.setup[i].setFilter(FILT, FS_BITS);
  }
  adc.setChannel(0, 0, AD7124_Input_AIN2, AD7124_Input_AIN3, true);
  adc.setChannel(1, 1, AD7124_Input_AIN4, AD7124_Input_AIN5, true);
  //Serial.println("[ADC] OK (2ch diff, G=128, SINC4, FS=4)");
}
static inline void readAdcPairAvg(double& ch0, double& ch1) {
  const int N = 4;
  double s0 = 0, s1 = 0;
  Ad7124_Readings r[CH_COUNT];
  for (int i = 0; i < N; i++) {
    if (adc.readRaw(r, CH_COUNT) >= 0) {
      s0 += (double)r[0].value;
      s1 += (double)r[1].value;
    }
  }
  ch0 = s0 / N;
  ch1 = s1 / N;
}

/* IMU init comum */
static bool setupIMU(ICM20948_WE& imu) {
  if (!imu.init()) return false;

  
  imu.setAccRange(ICM20948_ACC_RANGE_8G);
  imu.setGyrRange(ICM20948_GYRO_RANGE_2000);
  imu.setAccDLPF(ICM20948_DLPF_6);
  imu.setGyrDLPF(ICM20948_DLPF_6);
  imu.setAccSampleRateDivider(5);
  imu.setGyrSampleRateDivider(5);


  imu.autoOffsets();

  // --- MAGNETÓMETRO: OBRIGATÓRIO ---
  if (!imu.initMagnetometer()) {
    Serial.println("[IMU] Magnetometer init FAIL");
  } else {
    Serial.println("[IMU] Magnetometer OK");
    imu.setMagOpMode(AK09916_CONT_MODE_100HZ);
  }

  return true;
}

/* ---------------- CSV: header e gravação ---------------- */
static String csvHeader() {
  return "t_s,"
         // IMU 1
         "g1x_raw_dps,g1y_raw_dps,g1z_raw_dps,"
         "a1x_raw_g,a1y_raw_g,a1z_raw_g,"
         "m1x_raw_uT,m1y_raw_uT,m1z_raw_uT,"  // <--- NOVO
         "g1x_dps,g1y_dps,g1z_dps,"
         "a1x_ms2,a1y_ms2,a1z_ms2,"
         "q10,q11,q12,q13,"
         // IMU 2
         "g2x_raw_dps,g2y_raw_dps,g2z_raw_dps,"
         "a2x_raw_g,a2y_raw_g,a2z_raw_g,"
         "m2x_raw_uT,m2y_raw_uT,m2z_raw_uT,"  // <--- NOVO
         "g2x_dps,g2y_dps,g2z_dps,"
         "a2x_ms2,a2y_ms2,a2z_ms2,"
         "q20,q21,q22,q23,"
         "strain0_raw,strain1_raw\n";
}
static bool startRecording() {
  if (record_on && logFile) return true;
  String fname = "/log_" + String(++fileIndex) + ".csv";
  logFile = SPIFFS.open(fname, FILE_WRITE);
  if (!logFile) {
    sendLog("STAT:REC open FAIL");
    return false;
  }
  logFile.print(csvHeader());
  logFile.flush();
  record_on = true;
  sendLog("STAT:REC started -> " + fname);
  return true;
}
static void stopRecording() {
  if (logFile) {
    logFile.flush();
    logFile.close();
  }
  record_on = false;
  sendLog("STAT:REC stopped");
}

/* ---------------- HTTP / WS ---------------- */
static void handleRoot() {
  server.send_P(200, "text/html", INDEX_HTML);
}

void onWs(uint8_t num, WStype_t type, uint8_t* payload, size_t len) {
  if (type == WStype_CONNECTED) {
    ws.sendTXT(num, "STAT:Conectado.");
    ws.sendTXT(num, String("STAT:Stream=") + (stream_on ? "ON" : "OFF") + " Rec=" + (record_on ? "ON" : "OFF"));
    return;
  }
  if (type != WStype_TEXT) return;

  String msg((char*)payload, len);
  msg.trim();

  if (msg == "STREAM_ON") {
    stream_on = true;
    sendLog("STAT:STREAM ON");
  } else if (msg == "STREAM_OFF") {
    stream_on = false;
    sendLog("STAT:STREAM OFF");
  } else if (msg == "REC_START") {
    startRecording();
  } else if (msg == "REC_STOP") {
    stopRecording();
  } else if (msg == "NEWFILE") {
    ++fileIndex;
    sendLog("STAT:Next file index " + String(fileIndex + 1));
  } else if (msg == "PING") {
    ws.sendTXT(num, "PONG");
  }
}

/* ---------------- Binary Packet (Browser CSV) ---------------- */
struct SamplePacket {
  uint32_t sample_id;
  float t_s;

  float g1x_raw, g1y_raw, g1z_raw;
  float a1x_raw, a1y_raw, a1z_raw;
  float m1x_raw, m1y_raw, m1z_raw;  // <--- NOVO

  float g1x, g1y, g1z;
  float a1x, a1y, a1z;

  float q10, q11, q12, q13;

  float g2x_raw, g2y_raw, g2z_raw;
  float a2x_raw, a2y_raw, a2z_raw;
  float m2x_raw, m2y_raw, m2z_raw;  // <--- NOVO

  float g2x, g2y, g2z;
  float a2x, a2y, a2z;

  float q20, q21, q22, q23;

  int32_t strain0;
  int32_t strain1;
};

static uint32_t sample_id = 0;

/* ---------------- setup / loop ---------------- */
void setup() {
  Serial.begin(115200);
  delay(100);

  // FS
  if (!SPIFFS.begin(true)) Serial.println("[SPIFFS] falhou");

  // Wi-Fi + HTTP + WS
  WiFi.softAP(AP_SSID, AP_PASS);
  server.on("/", handleRoot);
  server.begin();
  ws.begin();
  ws.onEvent(onWs);

  // I2C
  Wire.begin();
  Wire.setClock(400000);

  // ADC + IMUs
  configureAdc();
  if (!setupIMU(imu1)) Serial.println("[IMU1] init falhou");
  if (!setupIMU(imu2)) Serial.println("[IMU2] init falhou");

  // AHRS
  madg1.begin(FS_HZ);
  madg2.begin(FS_HZ);

  Serial.println("[PaddleLogger] pronto em http://192.168.4.1/");
}

void loop() {
  server.handleClient();
  ws.loop();

  static uint32_t next_us = micros();  // agenda 1ª amostra agora
  const uint32_t now = micros();
  if ((int32_t)(now - next_us) < 0) return;  // ainda não é hora
  next_us += DT_US;

  // 1) IMU RAW
  float a1x_g, a1y_g, a1z_g, g1x_dps, g1y_dps, g1z_dps;
  float m1x_uT, m1y_uT, m1z_uT;  // <--- NOVO
  float a2x_g, a2y_g, a2z_g, g2x_dps, g2y_dps, g2z_dps;
  float m2x_uT, m2y_uT, m2z_uT;  // <--- NOVO

  readIMURaw(imu1, a1x_g, a1y_g, a1z_g, g1x_dps, g1y_dps, g1z_dps, m1x_uT, m1y_uT, m1z_uT);
  readIMURaw(imu2, a2x_g, a2y_g, a2z_g, g2x_dps, g2y_dps, g2z_dps, m2x_uT, m2y_uT, m2z_uT);


  const float g1x_raw = g1x_dps, g1y_raw = g1y_dps, g1z_raw = g1z_dps;
  const float a1x_raw = a1x_g, a1y_raw = a1y_g, a1z_raw = a1z_g;
  const float m1x_raw = m1x_uT, m1y_raw = m1y_uT, m1z_raw = m1z_uT;  // <--- NOVO
  const float g2x_raw = g2x_dps, g2y_raw = g2y_dps, g2z_raw = g2z_dps;
  const float a2x_raw = a2x_g, a2y_raw = a2y_g, a2z_raw = a2z_g;
  const float m2x_raw = m2x_uT, m2y_raw = m2y_uT, m2z_raw = m2z_uT;  // <--- NOVO

  // 3) Calibração (bias/ganho)
  fixGyro1(g1x_dps, g1y_dps, g1z_dps);
  fixAcc1(a1x_g, a1y_g, a1z_g);
  fixMag1(m1x_uT, m1y_uT, m1z_uT);

  fixGyro2(g2x_dps, g2y_dps, g2z_dps);
  fixAcc2(a2x_g, a2y_g, a2z_g);
  fixMag2(m2x_uT, m2y_uT, m2z_uT);

  // 4) Converter p/ SI (Madgwick)
  const float G = 9.80665f;
  const float a1x_ms2 = a1x_g * G, a1y_ms2 = a1y_g * G, a1z_ms2 = a1z_g * G;
  const float a2x_ms2 = a2x_g * G, a2y_ms2 = a2y_g * G, a2z_ms2 = a2z_g * G;
  const float g1x_rads = g1x_dps * DEG_TO_RAD, g1y_rads = g1y_dps * DEG_TO_RAD, g1z_rads = g1z_dps * DEG_TO_RAD;
  const float g2x_rads = g2x_dps * DEG_TO_RAD, g2y_rads = g2y_dps * DEG_TO_RAD, g2z_rads = g2z_dps * DEG_TO_RAD;

  // 5) AHRS -> quaternions (USAR 9-DoF)
  // madg.update(gx, gy, gz, ax, ay, az, mx, my, mz) <--- Madgwick 9-DoF
  madg1.update(g1x_rads, g1y_rads, g1z_rads, a1x_ms2, a1y_ms2, a1z_ms2, m1x_uT, m1y_uT, m1z_uT);  // <--- ALTERADO
  madg2.update(g2x_rads, g2y_rads, g2z_rads, a2x_ms2, a2y_ms2, a2z_ms2, m2x_uT, m2y_uT, m2z_uT);  // <--- ALTERADO

  float q10, q11, q12, q13, q20, q21, q22, q23;
  madg1.getQuaternion(&q10, &q11, &q12, &q13);
  madg2.getQuaternion(&q20, &q21, &q22, &q23);

  // 6) Extensómetros RAW
  double s0, s1;
  readAdcPairAvg(s0, s1);

  // 7) Tempo relativo (s)
  static uint32_t t_start = micros();
  const float t_s = (micros() - t_start) / 1e6f;

  // 8) CSV line
  String line;
  line.reserve(300);  // Aumentar reserva
  line += String(t_s, 6) + ",";
  line += String(g1x_raw, 3) + "," + String(g1y_raw, 3) + "," + String(g1z_raw, 3) + ",";
  line += String(a1x_raw, 4) + "," + String(a1y_raw, 4) + "," + String(a1z_raw, 4) + ",";
  line += String(m1x_raw, 2) + "," + String(m1y_raw, 2) + "," + String(m1z_raw, 2) + ",";  // <--- NOVO
  line += String(g1x_dps, 3) + "," + String(g1y_dps, 3) + "," + String(g1z_dps, 3) + ",";
  line += String(a1x_ms2, 3) + "," + String(a1y_ms2, 3) + "," + String(a1z_ms2, 3) + ",";
  line += String(q10, 6) + "," + String(q11, 6) + "," + String(q12, 6) + "," + String(q13, 6) + ",";
  line += String(g2x_raw, 3) + "," + String(g2y_raw, 3) + "," + String(g2z_raw, 3) + ",";
  line += String(a2x_raw, 4) + "," + String(a2y_raw, 4) + "," + String(a2z_raw, 4) + ",";
  line += String(m2x_raw, 2) + "," + String(m2y_raw, 2) + "," + String(m2z_raw, 2) + ",";  // <--- NOVO
  line += String(g2x_dps, 3) + "," + String(g2y_dps, 3) + "," + String(g2z_dps, 3) + ",";
  line += String(a2x_ms2, 3) + "," + String(a2y_ms2, 3) + "," + String(a2z_ms2, 3) + ",";
  line += String(q20, 6) + "," + String(q21, 6) + "," + String(q22, 6) + "," + String(q23, 6) + ",";
  line += String((int32_t)s0) + "," + String((int32_t)s1) + "\n";

  //if (stream_on) ws.broadcastTXT(line);
  // if (stream_on) ws.broadcastTXT(line);  // ← desativado (agora binário)

  /* ---- Envio Binário ---- */
  if (stream_on) {
    SamplePacket pkt;
    pkt.sample_id = sample_id++;
    pkt.t_s = t_s;

    pkt.g1x_raw = g1x_raw;
    pkt.g1y_raw = g1y_raw;
    pkt.g1z_raw = g1z_raw;
    pkt.a1x_raw = a1x_raw;
    pkt.a1y_raw = a1y_raw;
    pkt.a1z_raw = a1z_raw;
    pkt.m1x_raw = m1x_raw;
    pkt.m1y_raw = m1y_raw;
    pkt.m1z_raw = m1z_raw;  // <--- NOVO

    pkt.g1x = g1x_dps;
    pkt.g1y = g1y_dps;
    pkt.g1z = g1z_dps;
    pkt.a1x = a1x_ms2;
    pkt.a1y = a1y_ms2;
    pkt.a1z = a1z_ms2;

    pkt.q10 = q10;
    pkt.q11 = q11;
    pkt.q12 = q12;
    pkt.q13 = q13;

    pkt.g2x_raw = g2x_raw;
    pkt.g2y_raw = g2y_raw;
    pkt.g2z_raw = g2z_raw;
    pkt.a2x_raw = a2x_raw;
    pkt.a2y_raw = a2y_raw;
    pkt.a2z_raw = a2z_raw;
    pkt.m2x_raw = m2x_uT;
    pkt.m2y_raw = m2y_uT;
    pkt.m2z_raw = m2z_uT;  // <--- NOVO

    pkt.g2x = g2x_dps;
    pkt.g2y = g2y_dps;
    pkt.g2z = g2z_dps;
    pkt.a2x = a2x_ms2;
    pkt.a2y = a2y_ms2;
    pkt.a2z = a2z_ms2;

    pkt.q20 = q20;
    pkt.q21 = q21;
    pkt.q22 = q22;
    pkt.q23 = q23;

    pkt.strain0 = (int32_t)s0;
    pkt.strain1 = (int32_t)s1;

    ws.broadcastBIN((uint8_t*)&pkt, sizeof(pkt));
  }

  if (record_on && logFile) {
    logFile.print(line);
    static uint32_t n = 0;
    if ((++n % 80) == 0) logFile.flush();
  }
}

const char INDEX_HTML[] PROGMEM = R"HTML(
<!doctype html><html><head>
<meta charset="utf-8">
<title>Paddle Logger</title>
<style>
  body{font-family:Arial,Helvetica,sans-serif;margin:16px;background:#fafafa;color:#222}
  h2{margin:0 0 12px 0;font-size:20px}
  .row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
  button{padding:10px 14px;border:1px solid #bbb;border-radius:8px;background:#fff;cursor:pointer}
  button:active{transform:translateY(1px)}
  #log{background:#fff;border:1px solid #ddd;border-radius:8px;padding:10px;height:45vh;overflow:auto;white-space:pre;font-size:12px}
</style>
</head><body>

<h2>Paddle Logger</h2>

<div class="row">
  <button id="bStart">Start Stream</button>
  <button id="bStop">Stop Stream</button>
  <button id="bDownload">Download CSV</button>
</div>

<pre id="log">Pronto.</pre>

<script>
let ws;
let rows = [];
let fileIndex = 1;

function log(s){
  const el = document.getElementById('log');
  el.textContent += "\n" + s;
  el.scrollTop = el.scrollHeight;
}

function connect(){
  ws = new WebSocket("ws://" + location.hostname + ":81/");
  ws.binaryType = "arraybuffer";

  ws.onopen = ()=> log("[WS] Conectado");
  ws.onclose = ()=>{ log("[WS] Desligado — a reconectar..."); setTimeout(connect, 1000); };

  ws.onmessage = ev => {
    if (typeof ev.data === "string") {
      log(ev.data);
      return;
    }

    const dv = new DataView(ev.data);
    let o = 0;

    const sample_id = dv.getUint32(o, true); o+=4;
    const t_s       = dv.getFloat32(o, true); o+=4;

    // IMU 1 RAW
    const g1x_raw = dv.getFloat32(o,true); o+=4;
    const g1y_raw = dv.getFloat32(o,true); o+=4;
    const g1z_raw = dv.getFloat32(o,true); o+=4;
    const a1x_raw = dv.getFloat32(o,true); o+=4;
    const a1y_raw = dv.getFloat32(o,true); o+=4;
    const a1z_raw = dv.getFloat32(o,true); o+=4;
    const m1x_raw = dv.getFloat32(o,true); o+=4; // <-- NOVO
    const m1y_raw = dv.getFloat32(o,true); o+=4; // <-- NOVO
    const m1z_raw = dv.getFloat32(o,true); o+=4; // <-- NOVO

    // IMU 1 CALIBRATED / AHRS
    const g1x = dv.getFloat32(o,true); o+=4;
    const g1y = dv.getFloat32(o,true); o+=4;
    const g1z = dv.getFloat32(o,true); o+=4;
    const a1x = dv.getFloat32(o,true); o+=4;
    const a1y = dv.getFloat32(o,true); o+=4;
    const a1z = dv.getFloat32(o,true); o+=4;

    const q10 = dv.getFloat32(o,true); o+=4;
    const q11 = dv.getFloat32(o,true); o+=4;
    const q12 = dv.getFloat32(o,true); o+=4;
    const q13 = dv.getFloat32(o,true); o+=4;

    // IMU 2 RAW
    const g2x_raw = dv.getFloat32(o,true); o+=4;
    const g2y_raw = dv.getFloat32(o,true); o+=4;
    const g2z_raw = dv.getFloat32(o,true); o+=4;
    const a2x_raw = dv.getFloat32(o,true); o+=4;
    const a2y_raw = dv.getFloat32(o,true); o+=4;
    const a2z_raw = dv.getFloat32(o,true); o+=4;
    const m2x_raw = dv.getFloat32(o,true); o+=4; // <-- NOVO
    const m2y_raw = dv.getFloat32(o,true); o+=4; // <-- NOVO
    const m2z_raw = dv.getFloat32(o,true); o+=4; // <-- NOVO

    // IMU 2 CALIBRATED / AHRS
    const g2x = dv.getFloat32(o,true); o+=4;
    const g2y = dv.getFloat32(o,true); o+=4;
    const g2z = dv.getFloat32(o,true); o+=4;
    const a2x = dv.getFloat32(o,true); o+=4;
    const a2y = dv.getFloat32(o,true); o+=4;
    const a2z = dv.getFloat32(o,true); o+=4;

    const q20 = dv.getFloat32(o,true); o+=4;
    const q21 = dv.getFloat32(o,true); o+=4;
    const q22 = dv.getFloat32(o,true); o+=4;
    const q23 = dv.getFloat32(o,true); o+=4;

    // Strain Gauges
    const strain0 = dv.getInt32(o,true); o+=4;
    const strain1 = dv.getInt32(o,true); o+=4;

    rows.push([
      sample_id, t_s,
      // IMU 1 RAW
      g1x_raw,g1y_raw,g1z_raw,
      a1x_raw,a1y_raw,a1z_raw,
      m1x_raw,m1y_raw,m1z_raw, // <-- NOVO
      // IMU 1 CALIBRATED
      g1x,g1y,g1z,
      a1x,a1y,a1z,
      q10,q11,q12,q13,
      // IMU 2 RAW
      g2x_raw,g2y_raw,g2z_raw,
      a2x_raw,a2y_raw,a2z_raw,
      m2x_raw,m2y_raw,m2z_raw, // <-- NOVO
      // IMU 2 CALIBRATED
      g2x,g2y,g2z,
      a2x,a2y,a2z,
      q20,q21,q22,q23,
      // STRAIN
      strain0,strain1
    ]);
  };
}
connect();

function send(cmd){ if(ws && ws.readyState===1) ws.send(cmd); }

document.getElementById('bStart').onclick = ()=>{
  rows = [];
  send("STREAM_ON");
  log("[CMD] START");
};

document.getElementById('bStop').onclick = ()=>{
  send("STREAM_OFF");
  log("[CMD] STOP");
};

document.getElementById('bDownload').onclick = ()=>{
  if (rows.length === 0) { alert("Sem dados!"); return; }

  const header = [
    "sample_id","t_s",
    // IMU 1
    "g1x_raw","g1y_raw","g1z_raw",
    "a1x_raw","a1y_raw","a1z_raw",
    "m1x_raw","m1y_raw","m1z_raw", // <-- NOVO
    "g1x","g1y","g1z",
    "a1x","a1y","a1z",
    "q10","q11","q12","q13",
    // IMU 2
    "g2x_raw","g2y_raw","g2z_raw",
    "a2x_raw","a2y_raw","a2z_raw",
    "m2x_raw","m2y_raw","m2z_raw", // <-- NOVO
    "g2x","g2y","g2z",
    "a2x","a2y","a2z",
    "q20","q21","q22","q23",
    "strain0","strain1"
  ];

  let csv = header.join(",") + "\r\n";
  rows.forEach(r => { csv += r.join(",") + "\r\n"; });

  const blob = new Blob([csv], {type:"text/csv"});
  const a = document.createElement("a");
  const fname = "paddle_" + String(fileIndex).padStart(3,"0") + ".csv";
  fileIndex++;
  a.href = URL.createObjectURL(blob);
  a.download = fname;
  a.click();
};
</script>
</body></html>
)HTML";

These (above) files did not get uploaded here.

image

Please, unzip and post files as text in <CODE> blocks. Thank you.

is it possible for you to try a third ICM-20948 ?