diff --git a/exercises/25_motioncal_tbeam/README.md b/exercises/25_motioncal_tbeam/README.md index 66a8997..86451bc 100644 --- a/exercises/25_motioncal_tbeam/README.md +++ b/exercises/25_motioncal_tbeam/README.md @@ -1,59 +1,225 @@ # Exercise 25: MotionCal T-Beam Bridge -Streams the T-Beam Supreme QMC6310 magnetometer in the ASCII format accepted by -Paul Stoffregen's MotionCal desktop tool. +This PlatformIO project turns a LilyGO T-Beam Supreme into a serial bridge for Paul Stoffregen's MotionCal desktop calibration tool. -https://github.com/PaulStoffregen/MotionCal.git (fetch) +The firmware reads the onboard QMC magnetometer through SensorLib and streams MotionCal-compatible ASCII `Raw:` lines over USB serial. MotionCal can then estimate both hard-iron offsets and the soft-iron correction matrix for the board. -See https://github.com/PaulStoffregen/MotionCal/issues/25 for Gentoo build tricks. +![](MotionCal_2026-04-25_11-22.png) +## Hardware -MotionCal expects: +Target board: + +```text +LilyGO T-Beam Supreme / ESP32-S3 +QMC6310/QMC5883-family magnetometer +SH1106 OLED display +``` + +The firmware probes these magnetometer I2C addresses: + +```text +0x1C QMC6310U +0x3C QMC6310N +0x2C QMC5883P +``` + +The default PlatformIO environment is `cy`, with additional board labels available in `platformio.ini`: + +```text +amy bob cy dan ed flo guy +``` + +## Serial Format + +MotionCal expects lines in this format: ```text Raw:accel_x,accel_y,accel_z,gyro_x,gyro_y,gyro_z,mag_x,mag_y,mag_z ``` -This exercise only has a magnetometer, so it sends a stationary accelerometer -placeholder and zero gyro: +This bridge only has a magnetometer, so it sends fixed placeholder accel/gyro values: ```text Raw:0,0,8192,0,0,0,mag_x,mag_y,mag_z ``` -The magnetic values are converted from SensorLib Gauss readings into MotionCal's -integer units where 1 count is 0.1 microtesla. +The placeholder values mean: + +```text +accel = 0,0,8192 approximately stationary upright in MotionCal's count convention +gyro = 0,0,0 no gyro data supplied +mag = live QMC magnetometer data +``` + +## Magnetometer Units + +SensorLib returns QMC magnetometer readings in Gauss. The firmware converts them like this: + +```text +1 gauss = 100 microtesla +MotionCal count = microtesla * 10 +1 MotionCal count = 0.1 microtesla +``` + +So a streamed value like this: + +```text +Raw:0,0,8192,0,0,0,-1735,428,793 +``` + +means: + +```text +X = -1735 counts = -173.5 uT = -1.735 gauss +Y = 428 counts = 42.8 uT = 0.428 gauss +Z = 793 counts = 79.3 uT = 0.793 gauss +``` + +These are MotionCal-compatible integer counts, not raw QMC register values. ## Build +Activate the Python environment that contains PlatformIO: + ```sh -cd /usr/local/src/microreticulum/microReticulumTbeam/exercises/25_motioncal_tbeam -source /home/jlpoole/rnsenv/bin/activate +source /home/jlpoole/pioenv/bin/activate +``` + +Build the default environment: + +```sh +cd /usr/local/src/microReticulumTbeam/exercises/25_motioncal_tbeam pio run ``` +Build a specific board label: + +```sh +pio run -e dan +``` + ## Upload +Upload the default environment: + ```sh -source /home/jlpoole/rnsenv/bin/activate +source /home/jlpoole/pioenv/bin/activate +cd /usr/local/src/microReticulumTbeam/exercises/25_motioncal_tbeam pio run -t upload ``` -Use a specific board environment if needed: +Upload a specific board label: ```sh -pio run -e guy -t upload +pio run -e dan -t upload ``` -## MotionCal +## Serial Monitor -Build and run MotionCal as before: +After flashing, monitor the USB serial stream at 115200 baud: + +```sh +pio device monitor -b 115200 --port /dev/ttytDAN +``` + +Expected boot/status output includes lines like: + +```text +exercise=Exercise 25 MotionCal T-Beam bridge +serial_format=Raw:accel_x,accel_y,accel_z,gyro_x,gyro_y,gyro_z,mag_x,mag_y,mag_z +mag_units=MotionCal integer counts, 1 count = 0.1 uT +magnetometer_init=ok label=QMC6310U addr=0x1C chip=0x.. +``` + +Expected streaming output looks like: + +```text +Raw:0,0,8192,0,0,0,-1578,447,1266 +Raw:0,0,8192,0,0,0,-1583,438,1258 +Raw:0,0,8192,0,0,0,-1585,442,1250 +``` + +If the firmware prints `read_fail` or `overflow` status lines, check I2C wiring/power and the configured magnetometer full-scale range. + +## Running MotionCal + +Build MotionCal if needed: ```sh cd /usr/local/src/MotionCal make WXCONFIG=wx-config LDFLAGS="-lglut -lGLU -lGL -lm" +``` + +Run it: + +```sh +cd /usr/local/src/MotionCal GDK_BACKEND=x11 ./MotionCal ``` -Select the T-Beam USB serial port in MotionCal. The firmware also accepts -MotionCal's 68-byte calibration packet and echoes `Cal1:` and `Cal2:` lines so -MotionCal can confirm the send. +Then select the T-Beam USB serial port in MotionCal. + +Move and rotate the T-Beam through as many orientations as possible. The goal is to cover the sphere well, not just wave it flat on the table. Better 3D coverage improves both hard-iron and soft-iron calibration. + +## Saving Calibration + +MotionCal computes: + +```text +magnetic_offset_uT hard-iron offset, in microtesla +magnetic_mapping_matrix inverse soft-iron correction matrix +magnetic_field_uT fitted local field magnitude +``` + +Example: + +```text +magnetic_offset_uT=-172.96843,43.0260162,78.8941956 +magnetic_field_uT=52.4668198 +magnetic_mapping_matrix= + 0.943139076 0.0439298451 0.0595370531 + 0.0439298451 1.04979992 -0.0347476006 + 0.0595370531 -0.0347476006 1.01706612 +``` + +The firmware also accepts MotionCal's 68-byte calibration packet and echoes `Cal1:` and `Cal2:` lines so MotionCal can confirm the send. + +## Applying Calibration + +MotionCal's calibration model is: + +```text +mag_uT = raw_motioncal_counts * 0.1 +centered = mag_uT - magnetic_offset_uT +corrected = magnetic_mapping_matrix * centered +``` + +Hard iron moves the center of the magnetometer cloud back to zero. Soft iron transforms the ellipsoid-shaped cloud back toward a sphere. + +## Troubleshooting + +If MotionCal does not show points: + +```text +Confirm the serial port is correct. +Confirm baud is 115200. +Confirm monitor output contains Raw: lines. +Close any serial monitor before opening the port in MotionCal. +``` + +If the values look 10x different from a notebook or script: + +```text +The notebook/script may be using MotionCal counts. +MotionCal's saved magnetic_offset_uT is already in microtesla. +Convert counts to uT with: uT = counts * 0.1 +``` + +If the fit is poor: + +```text +Collect more orientations. +Rotate around all axes. +Keep the board away from steel, speakers, motors, magnets, and high-current wiring. +Try a different location and repeat the calibration. +``` diff --git a/exercises/25_motioncal_tbeam/lib/SensorLib b/exercises/25_motioncal_tbeam/lib/SensorLib new file mode 120000 index 0000000..74d66cb --- /dev/null +++ b/exercises/25_motioncal_tbeam/lib/SensorLib @@ -0,0 +1 @@ +/usr/local/src/LilyGo-LoRa-Series/lib/SensorLib \ No newline at end of file diff --git a/exercises/25_motioncal_tbeam/platformio.ini b/exercises/25_motioncal_tbeam/platformio.ini index f004814..b11f1b5 100644 --- a/exercises/25_motioncal_tbeam/platformio.ini +++ b/exercises/25_motioncal_tbeam/platformio.ini @@ -16,14 +16,17 @@ lib_deps = olikraus/U8g2@^2.36.4 lewisxhe/XPowersLib@0.3.3 + +; MOTIONCAL_MAG_RANGE_G must be either 2 or 8 (less precision) build_flags = -I ../../shared/boards -I ../../external/microReticulum_Firmware - -I ../../../../LilyGo-LoRa-Series/lib/SensorLib/src + -I ../../../LilyGo-LoRa-Series/lib/SensorLib/src -D BOARD_MODEL=BOARD_TBEAM_S_V1 -D OLED_SDA=17 -D OLED_SCL=18 -D OLED_ADDR=0x3C + -D MOTIONCAL_MAG_RANGE_G=2 -D ARDUINO_USB_MODE=1 -D ARDUINO_USB_CDC_ON_BOOT=1 diff --git a/exercises/25_motioncal_tbeam/src/main.cpp b/exercises/25_motioncal_tbeam/src/main.cpp index 60de5ee..517a3a9 100644 --- a/exercises/25_motioncal_tbeam/src/main.cpp +++ b/exercises/25_motioncal_tbeam/src/main.cpp @@ -35,15 +35,30 @@ namespace { #define OLED_ADDR 0x3C #endif +#ifndef MOTIONCAL_MAG_RANGE_G +#define MOTIONCAL_MAG_RANGE_G 8 +#endif + #define STR_INNER(x) #x #define STR(x) STR_INNER(x) +#if MOTIONCAL_MAG_RANGE_G == 2 +static constexpr MagFullScaleRange kMagFullScaleRange = MagFullScaleRange::FS_2G; +static constexpr const char* kMagRangeLabel = "FS2G"; +#elif MOTIONCAL_MAG_RANGE_G == 8 +static constexpr MagFullScaleRange kMagFullScaleRange = MagFullScaleRange::FS_8G; +static constexpr const char* kMagRangeLabel = "FS8G"; +#else +#error "MOTIONCAL_MAG_RANGE_G must be 2 or 8" +#endif + static constexpr const char* kExerciseName = "Exercise 25"; static constexpr const char* kBoardId = BOARD_ID; static constexpr const char* kNodeLabel = NODE_LABEL; -static constexpr const char* kBuildUtc = STR(FW_BUILD_UTC); +static constexpr const char* kFirmwareVersion = STR(FW_BUILD_UTC); static constexpr uint32_t kSampleIntervalMs = 40; static constexpr uint32_t kDisplayIntervalMs = 250; +static constexpr uint32_t kStatusIntervalMs = 1000; static constexpr uint8_t kMagCandidateCount = 3; static constexpr uint8_t kMagCandidates[kMagCandidateCount] = {0x1C, 0x3C, 0x2C}; static constexpr uint16_t kCalibrationPacketSize = 68; @@ -61,7 +76,10 @@ uint8_t g_magChipId = 0; char g_magLabel[16] = "UNKNOWN"; uint32_t g_lastSampleMs = 0; uint32_t g_lastDisplayMs = 0; +uint32_t g_lastStatusMs = 0; uint32_t g_sampleCount = 0; +uint32_t g_readFailCount = 0; +uint32_t g_overflowCount = 0; int16_t g_lastMagCounts[3] = {0, 0, 0}; float g_lastMagUt[3] = {0.0f, 0.0f, 0.0f}; @@ -106,18 +124,20 @@ void drawLines(const char* l1, const char* l2 = nullptr, const char* l3 = nullptr, const char* l4 = nullptr, - const char* l5 = nullptr) { + const char* l5 = nullptr, + const char* l6 = nullptr) { if (!g_displayReady) { return; } g_oled.clearBuffer(); - g_oled.setFont(u8g2_font_6x12_tf); - if (l1) g_oled.drawUTF8(0, 12, l1); - if (l2) g_oled.drawUTF8(0, 24, l2); - if (l3) g_oled.drawUTF8(0, 36, l3); - if (l4) g_oled.drawUTF8(0, 48, l4); - if (l5) g_oled.drawUTF8(0, 60, l5); + g_oled.setFont(u8g2_font_5x8_tf); + if (l1) g_oled.drawUTF8(0, 10, l1); + if (l2) g_oled.drawUTF8(0, 20, l2); + if (l3) g_oled.drawUTF8(0, 30, l3); + if (l4) g_oled.drawUTF8(0, 40, l4); + if (l5) g_oled.drawUTF8(0, 50, l5); + if (l6) g_oled.drawUTF8(0, 60, l6); g_oled.sendBuffer(); } @@ -129,7 +149,7 @@ void initDisplay() { g_oled.begin(); g_oled.setPowerSave(0); g_displayReady = true; - drawLines(kExerciseName, "MotionCal Bridge", kBoardId, "starting..."); + drawLines(kExerciseName, kFirmwareVersion, kBoardId, "MotionCal Bridge", "starting..."); } bool probeI2cAddr(TwoWire& wire, uint8_t addr) { @@ -171,7 +191,7 @@ bool initMagnetometer() { g_magChipId = g_qmc.getChipID(); return g_qmc.configMagnetometer( OperationMode::CONTINUOUS_MEASUREMENT, - MagFullScaleRange::FS_2G, + kMagFullScaleRange, 100.0f, MagOverSampleRatio::OSR_4, MagDownSampleRatio::DSR_1); @@ -179,9 +199,10 @@ bool initMagnetometer() { void printBootSummary() { Serial.printf("exercise=%s MotionCal T-Beam bridge\r\n", kExerciseName); - Serial.printf("board_id=%s node_label=%s build=%s\r\n", kBoardId, kNodeLabel, kBuildUtc); + Serial.printf("board_id=%s node_label=%s build=%s\r\n", kBoardId, kNodeLabel, kFirmwareVersion); Serial.printf("serial_format=Raw:accel_x,accel_y,accel_z,gyro_x,gyro_y,gyro_z,mag_x,mag_y,mag_z\r\n"); Serial.printf("mag_units=MotionCal integer counts, 1 count = 0.1 uT\r\n"); + Serial.printf("mag_range=%s\r\n", kMagRangeLabel); } void streamMotionCalRaw(const MagnetometerData& data) { @@ -208,10 +229,10 @@ void updateDisplay() { char line4[28]; char line5[28]; - snprintf(line3, sizeof(line3), "%s 0x%02X id 0x%02X", g_magLabel, g_magAddress, g_magChipId); snprintf(line4, sizeof(line4), "%6.1f %6.1f", g_lastMagUt[0], g_lastMagUt[1]); snprintf(line5, sizeof(line5), "Z:%6.1f N:%lu", g_lastMagUt[2], (unsigned long)g_sampleCount); - drawLines(kExerciseName, "MotionCal Raw stream", line3, line4, line5); + snprintf(line3, sizeof(line3), "%s %s 0x%02X", kMagRangeLabel, g_magLabel, g_magAddress); + drawLines(kExerciseName, kFirmwareVersion, line3, line4, line5, "Raw stream"); } void printCalibrationEcho(const float* values) { @@ -293,7 +314,7 @@ void appSetup() { if (!tbeam_supreme::initPmuForPeripherals(g_pmu, &Serial)) { Serial.println("pmu_init=failed"); - drawLines(kExerciseName, "PMU init failed", kBoardId, "see serial"); + drawLines(kExerciseName, kFirmwareVersion, "PMU init failed", kBoardId, "see serial"); return; } Serial.println("pmu_init=ok"); @@ -301,15 +322,16 @@ void appSetup() { g_magReady = initMagnetometer(); if (!g_magReady) { Serial.println("magnetometer_init=failed"); - drawLines(kExerciseName, "MAG init failed", kBoardId, "see serial"); + drawLines(kExerciseName, kFirmwareVersion, "MAG init failed", kBoardId, "see serial"); return; } Serial.printf("magnetometer_init=ok label=%s addr=0x%02X chip=0x%02X\r\n", g_magLabel, g_magAddress, g_magChipId); - drawLines(kExerciseName, "MotionCal Bridge", g_magLabel, "streaming Raw..."); + drawLines(kExerciseName, kFirmwareVersion, "MotionCal Bridge", g_magLabel, "streaming Raw..."); g_lastSampleMs = millis(); g_lastDisplayMs = millis(); + g_lastStatusMs = millis(); } void appLoop() { @@ -324,7 +346,11 @@ void appLoop() { if ((uint32_t)(now - g_lastSampleMs) >= kSampleIntervalMs) { g_lastSampleMs = now; MagnetometerData data; - if (g_qmc.readData(data) && !data.overflow) { + if (!g_qmc.readData(data)) { + ++g_readFailCount; + } else if (data.overflow) { + ++g_overflowCount; + } else { streamMotionCalRaw(data); } } @@ -334,6 +360,16 @@ void appLoop() { updateDisplay(); } + if ((uint32_t)(now - g_lastStatusMs) >= kStatusIntervalMs) { + g_lastStatusMs = now; + if (g_readFailCount > 0 || g_overflowCount > 0) { + Serial.printf("motioncal_status=samples:%lu read_fail:%lu overflow:%lu\r\n", + (unsigned long)g_sampleCount, + (unsigned long)g_readFailCount, + (unsigned long)g_overflowCount); + } + } + delay(1); }