diff --git a/lib/lis2mdl/README.md b/lib/lis2mdl/README.md index adefa275..0da65bef 100644 --- a/lib/lis2mdl/README.md +++ b/lib/lis2mdl/README.md @@ -215,22 +215,20 @@ print("Register dump:", regs) --- -## Example: Continuous Compass Loop - -```python -from machine import I2C, Pin -from lis2mdl import LIS2MDL -import time - -i2c = I2C(1, scl=Pin(22), sda=Pin(21)) -mag = LIS2MDL(i2c) -mag.set_declination(2.3) - -while True: - heading = mag.heading_flat_only() - print("Heading: {:.1f}° {}".format(heading, mag.direction_label(heading))) - time.sleep(0.5) -``` +## Examples + +| Example | Description | +|-----------------------------|-------------| +| basic_read.py | Read raw magnetic field (X, Y, Z) in microtesla and temperature in a loop. Simplest entry point to the LIS2MDL driver. | +| calibrate_2d.py | Interactive 2D hard-iron calibration. The user rotates the board flat to compute offsets using `calibrate_minmax_2d()`, then evaluates calibration quality with `calibrate_quality()`. | +| tilt_compensated_heading.py | Tilt-compensated heading using both magnetometer and accelerometer. Combines `heading_with_tilt_compensation()` from LIS2MDL with acceleration data from the ISM330DL driver. **Dependency: `ism330dl` driver required.** | +| metal_detector.py | Detect nearby metal objects by monitoring magnetic field magnitude changes. Displays an intensity bar and triggers a buzzer with stronger beeps for stronger disturbances. **Dependency: board PWM/buzzer support required, no extra driver needed.** | +| door_sensor.py | Detect door open/close using a magnet. Compares live magnetic field magnitude to a closed-door baseline and prints state changes with timestamps. | +| field_logger.py | Log magnetic field (X, Y, Z) and temperature to a CSV file every second for 60 seconds. The file is written to DAPLink flash and can be read later over USB mass storage. **Dependency: `daplink_flash` driver/module required (`set_filename`, `write_line`).** | +| field_map.py | Real-time spatial magnetic field mapping. Displays X, Y, Z, field magnitude, and min/max tracking for each axis while the board is moved around. | +| low_power_one_shot.py | Energy-efficient sampling example. Uses `power_off()` between readings and `read_one_shot()` every 10 seconds, then prints values and free memory. | +| magnet_compass.py | Flat compass example that computes heading and cardinal direction from the LIS2MDL magnetic field. Useful for basic orientation demos. | +| magnet_fieldForce.py | Magnetic field magnitude example that shows total field strength in microtesla. Useful for observing magnetic disturbances and relative field changes. | --- diff --git a/lib/lis2mdl/examples/basic_read.py b/lib/lis2mdl/examples/basic_read.py new file mode 100644 index 00000000..6f25a826 --- /dev/null +++ b/lib/lis2mdl/examples/basic_read.py @@ -0,0 +1,28 @@ +""" +Read raw magnetic field (X, Y, Z) in microtesla and temperature in a loop. +Simplest possible example — good entry point for beginners. +""" + +from time import sleep_ms + +from lis2mdl import LIS2MDL +from machine import I2C + +i2c = I2C(1) +mag = LIS2MDL(i2c) + +print("LIS2MDL basic read example") +print("Press Ctrl+C to stop.") +print() + +while True: + x_ut, y_ut, z_ut = mag.magnetic_field_ut() + temp_c = mag.temperature() + + print( + "Magnetic field: X={:.2f} uT Y={:.2f} uT Z={:.2f} uT Temp={:.2f} C".format( + x_ut, y_ut, z_ut, temp_c + ) + ) + + sleep_ms(500) diff --git a/lib/lis2mdl/examples/calibrate_2d.py b/lib/lis2mdl/examples/calibrate_2d.py new file mode 100644 index 00000000..e5217259 --- /dev/null +++ b/lib/lis2mdl/examples/calibrate_2d.py @@ -0,0 +1,60 @@ +""" +Interactive hard-iron calibration. +Ask the user to slowly rotate the board flat on a table. +Uses calibrate_minmax_2d() to compute offsets, then displays before/after heading quality with calibrate_quality(). +Demonstrates the full calibration workflow. +""" +from time import sleep_ms + +from lis2mdl import LIS2MDL +from machine import I2C + + +def print_quality(title, quality): + print(title) + print(" mean_xy = ({:.3f}, {:.3f})".format(quality["mean_xy"][0], quality["mean_xy"][1])) + print(" mean_z = {:.3f}".format(quality["mean_z"])) + print(" std_xy = ({:.3f}, {:.3f})".format(quality["std_xy"][0], quality["std_xy"][1])) + print(" std_z = {:.3f}".format(quality["std_z"])) + print(" r_mean_xy = {:.3f}".format(quality["r_mean_xy"])) + print(" r_std_xy = {:.3f}".format(quality["r_std_xy"])) + print(" anisotropy_xy = {:.3f}".format(quality["anisotropy_xy"])) + print() + + +i2c = I2C(1) +mag = LIS2MDL(i2c) + +print("2D hard-iron calibration example") +print("Keep the board flat on a table.") +print("Slowly rotate it through full circles.") +print() + +print("Checking heading quality before calibration...") +quality_before = mag.calibrate_quality(samples_check=120, delay_ms=15) +print_quality("Before calibration:", quality_before) + +print("Starting 2D calibration now...") +mag.calibrate_minmax_2d(samples=300, delay_ms=20) + +print("Calibration values:") +print( + " x_off={:.2f} y_off={:.2f} x_scale={:.2f} y_scale={:.2f}".format( + mag.x_off, mag.y_off, mag.x_scale, mag.y_scale + ) +) +print() + +print("Checking heading quality after calibration...") +quality_after = mag.calibrate_quality(samples_check=120, delay_ms=15) +print_quality("After calibration:", quality_after) + +print("Live heading preview after calibration") +print("Press Ctrl+C to stop.") +print() + +while True: + heading = mag.heading_flat_only() + direction = mag.direction_label(heading) + print("Heading: {:7.2f} deg Direction: {}".format(heading, direction)) + sleep_ms(200) diff --git a/lib/lis2mdl/examples/door_sensor.py b/lib/lis2mdl/examples/door_sensor.py new file mode 100644 index 00000000..408315db --- /dev/null +++ b/lib/lis2mdl/examples/door_sensor.py @@ -0,0 +1,59 @@ +""" +Detect door open/close using a magnet. +Measure baseline field with door closed (magnet near sensor). +Loop and detect large magnitude drop = door opened. Print state changes with timestamp. +Demonstrates a practical IoT use case. +""" + +from time import sleep_ms, ticks_diff, ticks_ms + +from lis2mdl import LIS2MDL +from machine import I2C + +BASELINE_SAMPLES = 60 +OPEN_DROP_UT = 10.0 +CLOSE_RECOVER_UT = 6.0 + + +def elapsed_seconds(start_ms): + return ticks_diff(ticks_ms(), start_ms) / 1000.0 + + +i2c = I2C(1) +mag = LIS2MDL(i2c) + +print("Door sensor example") +print("Place the board with the door closed and the magnet in its normal closed position.") +print("Measuring closed-door baseline...") +print() + +baseline = 0.0 +for _ in range(BASELINE_SAMPLES): + baseline += mag.magnitude_ut() + sleep_ms(100) + +baseline /= BASELINE_SAMPLES +start_ms = ticks_ms() +state = "CLOSED" + +print("Closed baseline: {:.2f} uT".format(baseline)) +print("Monitoring door state changes...") +print() + +while True: + magnitude = mag.magnitude_ut() + drop = baseline - magnitude + + if state == "CLOSED" and drop >= OPEN_DROP_UT: + state = "OPEN" + print("[t+{:.1f}s] Door opened |B|={:.2f} uT drop={:.2f} uT".format( + elapsed_seconds(start_ms), magnitude, drop + )) + + elif state == "OPEN" and drop <= CLOSE_RECOVER_UT: + state = "CLOSED" + print("[t+{:.1f}s] Door closed |B|={:.2f} uT drop={:.2f} uT".format( + elapsed_seconds(start_ms), magnitude, drop + )) + + sleep_ms(200) diff --git a/lib/lis2mdl/examples/field_logger.py b/lib/lis2mdl/examples/field_logger.py new file mode 100644 index 00000000..68daddf9 --- /dev/null +++ b/lib/lis2mdl/examples/field_logger.py @@ -0,0 +1,61 @@ +""" +Log magnetic field X, Y, Z and temperature to DAPLink flash as CSV every second for 60 seconds. +Uses daplink_flash (set_filename, write_line). +File is then accessible via USB mass storage for analysis in a spreadsheet. +""" + +from time import sleep_ms, ticks_diff, ticks_ms + +from daplink_flash import DaplinkFlash +from lis2mdl import LIS2MDL +from machine import I2C + +# Set to True to erase the DAPLink flash on startup (DESTRUCTIVE). +ERASE_FLASH_ON_START = False + +LOG_DURATION_S = 60 +SAMPLE_PERIOD_MS = 1000 + + +i2c = I2C(1) +sensor = LIS2MDL(i2c) +flash = DaplinkFlash(i2c) + +flash.set_filename("LIS2MDL", "CSV") +if ERASE_FLASH_ON_START: + flash.clear_flash() + sleep_ms(500) + print("Flash erased.") + +start_ms = ticks_ms() + +header = "timestamp_s,x_ut,y_ut,z_ut,magnitude_ut,temperature_c" +print(header) +flash.write_line(header) + +while True: + elapsed_s = ticks_diff(ticks_ms(), start_ms) // 1000 + x_ut, y_ut, z_ut = sensor.magnetic_field_ut() + magnitude_ut = sensor.magnitude_ut() + temperature_c = sensor.temperature() + + line = "{},{:.2f},{:.2f},{:.2f},{:.2f},{:.2f}".format( + elapsed_s, + x_ut, + y_ut, + z_ut, + magnitude_ut, + temperature_c, + ) + + print(line) + flash.write_line(line) + + if elapsed_s >= LOG_DURATION_S: + break + + sleep_ms(SAMPLE_PERIOD_MS) + +print() +print("Logging complete.") +print("The CSV file is available from the DAPLink USB mass storage.") diff --git a/lib/lis2mdl/examples/field_map.py b/lib/lis2mdl/examples/field_map.py new file mode 100644 index 00000000..aee19b97 --- /dev/null +++ b/lib/lis2mdl/examples/field_map.py @@ -0,0 +1,58 @@ +""" +Spatial field mapping. +Print X, Y, Z field values and magnitude as a formatted table, updating every 500ms. +Move the board around to see how the field changes. +Includes min/max tracking for each axis to show the range explored. +""" + +from math import sqrt +from time import sleep_ms + +from lis2mdl import LIS2MDL +from machine import I2C + + +def update_minmax(value, current_min, current_max): + current_min = min(current_min, value) + current_max = max(current_max, value) + return current_min, current_max + + +i2c = I2C(1) +mag = LIS2MDL(i2c) + +x_min = y_min = z_min = 1e9 +x_max = y_max = z_max = -1e9 + +print("Field map example") +print("Move the board around and watch how the field changes.") +print() + +header = "{:>8} {:>8} {:>8} {:>10} {:>17} {:>17} {:>17}".format( + "X(uT)", "Y(uT)", "Z(uT)", "|B|(uT)", + "X range", "Y range", "Z range" +) +print(header) +print("-" * len(header)) + +while True: + x_ut, y_ut, z_ut = mag.magnetic_field_ut() + magnitude = sqrt(x_ut * x_ut + y_ut * y_ut + z_ut * z_ut) + + x_min, x_max = update_minmax(x_ut, x_min, x_max) + y_min, y_max = update_minmax(y_ut, y_min, y_max) + z_min, z_max = update_minmax(z_ut, z_min, z_max) + + print( + "{:8.2f} {:8.2f} {:8.2f} {:10.2f} {:>17} {:>17} {:>17}".format( + x_ut, + y_ut, + z_ut, + magnitude, + "[{:.2f}, {:.2f}]".format(x_min, x_max), + "[{:.2f}, {:.2f}]".format(y_min, y_max), + "[{:.2f}, {:.2f}]".format(z_min, z_max), + ) + ) + + sleep_ms(500) diff --git a/lib/lis2mdl/examples/low_power_one_shot.py b/lib/lis2mdl/examples/low_power_one_shot.py new file mode 100644 index 00000000..6de8e779 --- /dev/null +++ b/lib/lis2mdl/examples/low_power_one_shot.py @@ -0,0 +1,49 @@ +""" +Energy-efficient sampling. +Use power_off() between readings, trigger read_one_shot() every 10s. +Print values and free memory. +Demonstrates idle mode for battery-powered deployments. +""" + +import gc +from math import sqrt +from time import sleep_ms + +from lis2mdl import LIS2MDL +from machine import I2C + +MAG_LSB_TO_UT = 0.15 +SAMPLE_INTERVAL_MS = 10000 + + +i2c = I2C(1) +mag = LIS2MDL(i2c) + +print("Low-power one-shot example") +print("The sensor stays in idle mode between readings.") +print("One sample every 10 seconds.") +print() + +while True: + mag.power_off() + + # Sleep in small steps so Ctrl+C remains responsive. + for _ in range(SAMPLE_INTERVAL_MS // 100): + sleep_ms(100) + + raw_x, raw_y, raw_z = mag.read_one_shot() + temp_c = mag.temperature() + + x_ut = raw_x * MAG_LSB_TO_UT + y_ut = raw_y * MAG_LSB_TO_UT + z_ut = raw_z * MAG_LSB_TO_UT + magnitude_ut = sqrt(x_ut * x_ut + y_ut * y_ut + z_ut * z_ut) + + gc.collect() + free_mem = gc.mem_free() + + print( + "One-shot read: X={:.2f} uT Y={:.2f} uT Z={:.2f} uT |B|={:.2f} uT Temp={:.2f} C Free mem={} bytes".format( + x_ut, y_ut, z_ut, magnitude_ut, temp_c, free_mem + ) + ) diff --git a/lib/lis2mdl/examples/magnet_test.py b/lib/lis2mdl/examples/magnet_test.py deleted file mode 100644 index 84bee76f..00000000 --- a/lib/lis2mdl/examples/magnet_test.py +++ /dev/null @@ -1,675 +0,0 @@ -import math -from time import sleep_ms - -from lis2mdl.const import * -from lis2mdl.device import LIS2MDL -from machine import I2C - -# Définition des constantes pour remplacer les valeurs magiques -MAGNETIC_FIELD_MIN = 5.0 -MAGNETIC_FIELD_MAX = 200.0 -TEMP_MIN = -100.0 -TEMP_MAX = 150.0 -CENTER_TOLERANCE = 0.2 -ROUND_TOLERANCE = 1.4 -CENTER_TOLERANCE_3D = 0.3 -ROUND_TOLERANCE_3D = 1.6 -ANGLE_DIFF_MIN = 14.0 -ANGLE_DIFF_MAX = 20.0 -ANGLE_DIFF_WRAP_MIN = 340.0 -ANGLE_DIFF_WRAP_MAX = 346.0 -SPAN_MIN = 300.0 -FILTER_DIFF_MAX = 90.0 - - -def _bits(v, hi, lo): - m = (1 << (hi - lo + 1)) - 1 - return (v >> lo) & m - - -def test_sets(dev): - ok = True - - # --- MODE --- - dev.set_mode("continuous") - r = dev._read_reg(LIS2MDL_CFG_REG_A) - exp = 0b00 - print( - "set_mode(continuous): MD=", - _bits(r, 1, 0), - "expected", - exp, - "=>", - "OK" if _bits(r, 1, 0) == exp else "FAIL", - ) - ok &= _bits(r, 1, 0) == exp - - dev.set_mode("single") - r = dev._read_reg(LIS2MDL_CFG_REG_A) - exp = 0b01 - print( - "set_mode(single): MD=", - _bits(r, 1, 0), - "expected", - exp, - "=>", - "OK" if _bits(r, 1, 0) == exp else "FAIL", - ) - ok &= _bits(r, 1, 0) == exp - - dev.set_mode("idle") - r = dev._read_reg(LIS2MDL_CFG_REG_A) - exp = 0b11 - print( - "set_mode(idle): MD=", - _bits(r, 1, 0), - "expected", - exp, - "=>", - "OK" if _bits(r, 1, 0) == exp else "FAIL", - ) - ok &= _bits(r, 1, 0) == exp - - # --- ODR --- - dev.set_odr(50) - r = dev._read_reg(LIS2MDL_CFG_REG_A) - exp = 0b10 - print( - "set_odr(50): ODR=", - _bits(r, 3, 2), - "expected", - exp, - "=>", - "OK" if _bits(r, 3, 2) == exp else "FAIL", - ) - ok &= _bits(r, 3, 2) == exp - - dev.set_odr(100) - r = dev._read_reg(LIS2MDL_CFG_REG_A) - exp = 0b11 - print( - "set_odr(100): ODR=", - _bits(r, 3, 2), - "expected", - exp, - "=>", - "OK" if _bits(r, 3, 2) == exp else "FAIL", - ) - ok &= _bits(r, 3, 2) == exp - - # --- Low power --- - dev.set_low_power(True) - r = dev._read_reg(LIS2MDL_CFG_REG_A) - print( - "set_low_power(True): LP=", - (r >> 4) & 1, - "expected 1 =>", - "OK" if ((r >> 4) & 1) == 1 else "FAIL", - ) - ok &= ((r >> 4) & 1) == 1 - dev.set_low_power(False) - r = dev._read_reg(LIS2MDL_CFG_REG_A) - print( - "set_low_power(False): LP=", - (r >> 4) & 1, - "expected 0 =>", - "OK" if ((r >> 4) & 1) == 0 else "FAIL", - ) - ok &= ((r >> 4) & 1) == 0 - - # --- LPF --- - dev.set_low_pass(True) - r = dev._read_reg(LIS2MDL_CFG_REG_B) - print( - "set_low_pass(True): LPF=", - r & 1, - "expected 1 =>", - "OK" if (r & 1) == 1 else "FAIL", - ) - ok &= (r & 1) == 1 - dev.set_low_pass(False) - r = dev._read_reg(LIS2MDL_CFG_REG_B) - print( - "set_low_pass(False): LPF=", - r & 1, - "expected 0 =>", - "OK" if (r & 1) == 0 else "FAIL", - ) - ok &= (r & 1) == 0 - - # --- Offset cancellation --- - dev.set_offset_cancellation(True, oneshot=False) - r = dev._read_reg(LIS2MDL_CFG_REG_B) - print( - "set_offset_cancellation(True,False): OFF_CANC(bit1)=", - (r >> 1) & 1, - "ONE_SHOT(bit4)=", - (r >> 4) & 1, - "expected 1,0 =>", - "OK" if ((r >> 1) & 1) == 1 and ((r >> 4) & 1) == 0 else "FAIL", - ) - ok &= ((r >> 1) & 1) == 1 and ((r >> 4) & 1) == 0 - - dev.set_offset_cancellation(True, oneshot=True) - r = dev._read_reg(LIS2MDL_CFG_REG_B) - print( - "set_offset_cancellation(True,True): OFF_CANC(bit1)=", - (r >> 1) & 1, - "ONE_SHOT(bit4)=", - (r >> 4) & 1, - "expected 1,1 =>", - "OK" if ((r >> 1) & 1) == 1 and ((r >> 4) & 1) == 1 else "FAIL", - ) - ok &= ((r >> 1) & 1) == 1 and ((r >> 4) & 1) == 1 - - # --- BDU / Endianness / SPI4 --- - dev.set_bdu(True) - r = dev._read_reg(LIS2MDL_CFG_REG_C) - print( - "set_bdu(True): BDU(bit4)=", - (r >> 4) & 1, - "expected 1 =>", - "OK" if ((r >> 4) & 1) == 1 else "FAIL", - ) - ok &= ((r >> 4) & 1) == 1 - - dev.set_endianness(True) - r = dev._read_reg(LIS2MDL_CFG_REG_C) - print( - "set_endianness(True): BLE(bit3)=", - (r >> 3) & 1, - "expected 1 =>", - "OK" if ((r >> 3) & 1) == 1 else "FAIL", - ) - ok &= ((r >> 3) & 1) == 1 - - dev.use_spi_4wire(True) - r = dev._read_reg(LIS2MDL_CFG_REG_C) - print( - "use_spi_4wire(True): 4WSPI(bit2)=", - (r >> 2) & 1, - "expected 1 =>", - "OK" if ((r >> 2) & 1) == 1 else "FAIL", - ) - ok &= ((r >> 2) & 1) == 1 - - # --- Software offsets / declination --- - dev.set_heading_offset(15.0) - dev.set_declination(2.0) - # Instant flat measurement: the angle should increase by ~17° compared to your raw calculation - ang1 = ( - dev.heading_flat_only() - ) # (remember to add offset+declination in your method) - print( - "heading_flat_only with offset+declination: angle≈raw+17° (check visually) =>", - f"{ang1:.2f}°", - ) - - # --- set_calibrate_step / set_hw_offsets --- - # Apply a dummy calibration, then verify the read-back fields - dev.set_calibrate_step(10, -20, 30, 300, 300, 300) - xoff, yoff, zoff, xs, ys, zs = dev.read_calibration() - print( - "set_calibrate_step(...): applied offsets/scales =>", - (xoff, yoff, zoff, xs, ys, zs), - ) - - # If you want to push the correction into the sensor: - dev.set_hw_offsets(0, 0, 0) # e.g., reset to 0 - # You can read the registers to verify (optional) - oxL = dev._read_reg(LIS2MDL_OFFSET_X_REG_L) - oxH = dev._read_reg(LIS2MDL_OFFSET_X_REG_L + 1) - print( - "set_hw_offsets(...): OFFSET_X* =", (oxH << 8) | oxL, "expected written value" - ) - - print("\n=== Overall summary:", "OK" if ok else "Some tests FAIL ===") - - -def _approx_equal(a, b, tol): - return abs(a - b) <= tol - - -def test_reads(dev): - ok = True - print("\n=== TEST READS ===") - - # WHO_AM_I - who = dev.device_id() - print(f"WHO_AM_I=0x{who:02X} expected 0x40 =>", "OK" if who == 0x40 else "FAIL") - ok &= who == 0x40 - - # DATA READY - sleep_ms(50) - ready = dev.data_ready() - print("data_ready():", ready, "=>", "OK" if isinstance(ready, bool) else "FAIL") - ok &= isinstance(ready, bool) - - # MAG RAW - xr, yr, zr = dev.magnetic_field_raw() - print( - f"magnetic_field_raw: (X,Y,Z)=({xr},{yr},{zr}) LSB =>", - "OK" if all(isinstance(v, int) for v in (xr, yr, zr)) else "FAIL", - ) - ok &= all(isinstance(v, int) for v in (xr, yr, zr)) - - # MAG µT vs RAW - xu, yu, zu = dev.magnetic_field_ut() - print(f"magnetic_field_ut: (X,Y,Z)=({xu:.2f},{yu:.2f},{zu:.2f}) µT") - # check consistency of conversion µT ≈ raw*0.15 - ok_conv = ( - _approx_equal(xu, xr * 0.15, 0.5) - and _approx_equal(yu, yr * 0.15, 0.5) - and _approx_equal(zu, zr * 0.15, 0.5) - ) - print("Conversion µT vs RAW*0.15 =>", "OK" if ok_conv else "FAIL") - ok &= ok_conv - - # MAGNITUDE - B = dev.magnitude_ut() - print( - f"magnitude_ut: |B|={B:.1f} µT (Earth ~25-65 µT). =>", - "OK" if MAGNETIC_FIELD_MIN <= B <= MAGNETIC_FIELD_MAX else "FAIL", - ) - ok &= MAGNETIC_FIELD_MIN <= B <= MAGNETIC_FIELD_MAX # wide, since local disturbances are possible - - # CALIBRATION NORM - xc, yc, zc = dev.calibrated_field() - print(f"calibrated_field: ({xc:.3f},{yc:.3f},{zc:.3f})") - # expected: magnitudes ~[-2..+2] after simple calibration - ok_cal_rng = abs(xc) < 5 and abs(yc) < 5 and abs(zc) < 5 - print("Calibration norm (|val|<5) =>", "OK" if ok_cal_rng else "WARN") - ok &= ok_cal_rng - - # TEMP - t1 = dev.temperature() - sleep_ms(50) - t2 = dev.temperature() - print(f"TempC: t1={t1:.2f}°C, t2={t2:.2f}°C (8 LSB/°C, absolute offset unknown)") - # test: type & broad plausible range - ok_temp = ( - isinstance(t1, float) - and isinstance(t2, float) - and (TEMP_MIN < t1 < TEMP_MAX) - and (TEMP_MIN < t2 < TEMP_MAX) - ) - print("Temp check =>", "OK" if ok_temp else "FAIL") - ok &= ok_temp - - # INT SOURCE (without IT config, should be 0) - ints = dev.read_int_source() - print( - f"INT_SOURCE=0x{ints:02X} (often 0 if no interrupt configured) =>", - "OK" if isinstance(ints, int) else "FAIL", - ) - ok &= isinstance(ints, int) - - # REGISTER DUMP (sanity) - dump = dev.read_registers(LIS2MDL_CFG_REG_A, 8) # A..H ~ 0x60..0x67 - print( - f"Dump 0x60..0x67: {dump} =>", - "OK" if isinstance(dump, (bytes, bytearray)) and len(dump) == 8 else "FAIL", - ) - ok &= isinstance(dump, (bytes, bytearray)) and len(dump) == 8 - - print("\n=== Overall READS result:", "OK ✅" if ok else "Some checks FAIL ❌") - return ok - - -def _fmt_tuple(t): - return "({:.3f},{:.3f})".format(t[0], t[1]) - - -def test_calibrate_2d(dev): - print("\n=== 2D CALIBRATION (flat, 360° rotation) ===") - print("Rotate the board FLAT for ~{} samples...".format(300)) - dev.calibrate_minmax_2d(samples=300, delay_ms=20) - xoff, yoff, _, xs, ys, _ = ( - dev.x_off, - dev.y_off, - dev.z_off, - dev.x_scale, - dev.y_scale, - dev.z_scale, - ) - print("XY Offsets:", xoff, yoff, " XY Scales:", xs, ys) - - # quality - print("Quick check (move a bit while flat during capture)...") - q = dev.calibrate_quality(samples_check=200, delay_ms=10) - print("mean_xy =", _fmt_tuple(q["mean_xy"]), " (expected close to 0,0)") - print( - "anisotropy_xy =", - "{:.2f}".format(q["anisotropy_xy"]), - " (≈1.0 if circle is nicely round)", - ) - print("r_std_xy =", "{:.3f}".format(q["r_std_xy"]), " (smaller = better)") - - ok_center = abs(q["mean_xy"][0]) < CENTER_TOLERANCE and abs(q["mean_xy"][1]) < CENTER_TOLERANCE - ok_round = q["anisotropy_xy"] < ROUND_TOLERANCE # realistic tolerances - print("=> Center close to 0 :", "OK" if ok_center else "WARN") - print("=> Circle ≈ round :", "OK" if ok_round else "WARN") - return ok_center and ok_round - - -def test_calibrate_3d(dev): - print("\n=== 3D CALIBRATION (all orientations) ===") - print("Rotate the board IN ALL DIRECTIONS for ~{} samples...".format(600)) - dev.calibrate_minmax_3d(samples=600, delay_ms=20) - print("Offsets:", dev.x_off, dev.y_off, dev.z_off) - print("Scales :", dev.x_scale, dev.y_scale, dev.z_scale) - - q = dev.calibrate_quality(samples_check=200, delay_ms=10) - print( - "mean_xy =", _fmt_tuple(q["mean_xy"]), " mean_z = {:.3f}".format(q["mean_z"]) - ) - print( - "std_xy = ({:.3f},{:.3f}) std_z = {:.3f}".format( - q["std_xy"][0], q["std_xy"][1], q["std_z"] - ) - ) - print("anisotropy_xy =", "{:.2f}".format(q["anisotropy_xy"])) - print( - "r_mean_xy =", - "{:.3f}".format(q["r_mean_xy"]), - " r_std_xy =", - "{:.3f}".format(q["r_std_xy"]), - ) - - ok_center = abs(q["mean_xy"][0]) < CENTER_TOLERANCE_3D and abs(q["mean_xy"][1]) < CENTER_TOLERANCE_3D - ok_round = q["anisotropy_xy"] < ROUND_TOLERANCE_3D - print("=> Center close to 0 :", "OK" if ok_center else "WARN") - print("=> Circle ≈ round :", "OK" if ok_round else "WARN") - return ok_center and ok_round - - -def test_heading_after_calib(dev, n=200, delay_ms=20): - """ - Verify that the angle moves from 0..360° when rotating flat. - (qualitative test: we look at the span of angles) - """ - print("\n=== HEADING after calibration (qualitative) ===") - angles = [] - for _ in range(n): - ang = dev.heading_flat_only() # make sure you have atan2(y, x) inside - angles.append(ang) - sleep_ms(delay_ms) - minA = min(angles) - maxA = max(angles) - span = (maxA - minA) % 360.0 - print("Angle min={:.1f}°, max={:.1f}°, span~{:.1f}°".format(minA, maxA, span)) - print("=> If you rotated ~one complete turn flat, we expect ~300-360° span.") - - -def run_all_calibration_tests(dev): - ok2d = test_calibrate_2d(dev) - test_heading_after_calib(dev) - ok3d = test_calibrate_3d(dev) - print( - "\n=== Calibration summary:", - "OK ✅" if (ok2d and ok3d) else "Partial ⚠️ (see WARN/notes)", - ) - - -def test_heading_flat_basic(dev, n=10, delay_ms=50): - print("\n=== TEST heading_flat_only (basic reading) ===") - dev.set_heading_filter(0.0) # no filter - angles = [] - for _ in range(n): - a = dev.heading_flat_only() - print(f"angle={a:.2f}° dir={dev.direction_label(a)}") - angles.append(a) - sleep_ms(delay_ms) - ok_types = all(isinstance(a, float) for a in angles) - print("Float types =>", "OK" if ok_types else "FAIL") - return ok_types - - -def test_heading_offset_declination(dev): - print("\n=== TEST offset + declination ===") - dev.set_heading_filter(0.0) - # reference angle without corrections - dev.set_heading_offset(0.0) - dev.set_declination(0.0) - a0 = dev.heading_flat_only() - # apply +15° offset +2° declination - dev.set_heading_offset(15.0) - dev.set_declination(2.0) - a1 = dev.heading_flat_only() - # difference mod 360 - diff = (a1 - a0) % 360.0 - # accept ~17° ±3° (due to noise/quantization/filtering) - ok = (ANGLE_DIFF_MIN <= diff <= ANGLE_DIFF_MAX) or (ANGLE_DIFF_WRAP_MIN <= diff <= ANGLE_DIFF_WRAP_MAX) # wrap - print( - f"angle0={a0:.2f}°, angle1={a1:.2f}°, diff≈{diff:.2f}° =>", - "OK" if ok else "FAIL", - ) - return ok - - -def test_heading_span_turn(dev, duration_ms=6000, step_ms=50): - """ - Rotate the board FLAT in roughly one turn for ~duration. - We check that the angle sweeps ~300..360°. - """ - print("\n=== TEST SPAN (Do one turn on table) ===") - dev.set_heading_filter(0.2) # gentle smoothing - dev.set_heading_offset(0.0) - dev.set_declination(0.0) - angles = [] - t = 0 - while t < duration_ms: - a = dev.heading_flat_only() - angles.append(a) - sleep_ms(step_ms) - t += step_ms - minA = min(angles) - maxA = max(angles) - # span modulo 360 (handles wrap) - span = maxA - minA - if span < 0: - span += 360.0 - print(f"min={minA:.1f}°, max={maxA:.1f}°, span≈{span:.1f}°") - ok = span > SPAN_MIN # we expect almost 360° for a full turn - print("SPAN =>", "OK" if ok else "WARN (do a more complete/slower turn)") - return ok - - -def test_heading_filter_wrap(dev): - """ - Synthetic test of the vector filter around 0/360°. - We inject angles near 360->0 and verify there's no 'jump'. - """ - print("\n=== TEST filter & wrap ===") - dev.set_heading_filter(0.3) - dev._hf_cos = None - dev._hf_sin = None # reset filter - # sequence near 360° then 0° - seq = [350, 355, 0, 5, 10] - outs = [] - # we 'fake' a reading by forcing heading_from_vectors with synthetic vectors - - for ang in seq: - # unit vectors in the XY plane - x = math.cos(math.radians(ang)) - y = math.sin(math.radians(ang)) - out = dev.heading_from_vectors(x, y, 0, calibrated=False) - outs.append(out) - print(f"in={ang:>3}° -> out_filt={out:>6.2f}°") - # Check that the output is monotonically increasing (no jump around ~180°) - ok = all((outs[i] - outs[i - 1]) % 360.0 < FILTER_DIFF_MAX for i in range(1, len(outs))) - print("Wrap-safe filter =>", "OK" if ok else "FAIL") - return ok - - -def run_heading_tests(dev): - all_ok = True - all_ok &= test_heading_flat_basic(dev) - all_ok &= test_heading_offset_declination(dev) - # Run this if you can rotate the board flat: - all_ok &= test_heading_span_turn(dev) - all_ok &= test_heading_filter_wrap(dev) - print("\n=== HEADING summary:", "OK ✅" if all_ok else "Partial ⚠️ (see details)") - -def test_power_modes(dev): - print("\n=== TEST POWER MODES ===") - ok = True - - # Wake in continuous - dev.power_on("continuous") - r = dev._read_reg(LIS2MDL_CFG_REG_A) - md = _bits(r, 1, 0) - print( - "wake('continuous') => MD=", - md, - "expected 0b00 =>", - "OK" if md == 0b00 else "FAIL", - ) - ok &= md == 0b00 - - # Wake in single - dev.power_on("single") - r = dev._read_reg(LIS2MDL_CFG_REG_A) - md = _bits(r, 1, 0) - print( - "wake('single') => MD=", - md, - "expected 0b01 =>", - "OK" if md == 0b01 else "FAIL", - ) - ok &= md == 0b01 - - # Power down - dev.power_off() - r = dev._read_reg(LIS2MDL_CFG_REG_A) - md = _bits(r, 1, 0) - print( - "power_off() => MD=", - md, - "expected 0b11 =>", - "OK" if md == 0b11 else "FAIL", - ) - ok &= md == 0b11 - print( - "is_idle():", - dev.is_idle(), - "expected True =>", - "OK" if dev.is_idle() else "FAIL", - ) - ok &= dev.is_idle() - - # Back to continuous - dev.power_on("continuous") - r = dev._read_reg(LIS2MDL_CFG_REG_A) - md = _bits(r, 1, 0) - print( - "wake('continuous') => MD=", - md, - "expected 0b00 =>", - "OK" if md == 0b00 else "FAIL", - ) - ok &= md == 0b00 - - return ok - - -def test_soft_reset(dev): - print("\n=== TEST SOFT RESET ===") - ok = True - - # Put into a non-default state - dev.set_odr(100) # ODR bits = 11 - dev.set_low_power(True) # LP bit4 = 1 - dev.set_low_pass(True) # CFG_B bit0 = 1 - dev.set_bdu(True) # CFG_C bit4 = 1 - - ra_before = dev._read_reg(LIS2MDL_CFG_REG_A) - rb_before = dev._read_reg(LIS2MDL_CFG_REG_B) - rc_before = dev._read_reg(LIS2MDL_CFG_REG_C) - print( - f"Before reset: CFG_A=0x{ra_before:02X} CFG_B=0x{rb_before:02X} CFG_C=0x{rc_before:02X}" - ) - - # Soft reset - dev.soft_reset(wait_ms=15) - - # Read after reset - ra = dev._read_reg(LIS2MDL_CFG_REG_A) - rb = dev._read_reg(LIS2MDL_CFG_REG_B) - rc = dev._read_reg(LIS2MDL_CFG_REG_C) - print(f"After reset: CFG_A=0x{ra:02X} CFG_B=0x{rb:02X} CFG_C=0x{rc:02X}") - - # Realistic expectations (typical default values): - # - MD (bits1..0) = 11 (idle) - # - ODR (bits3..2) = 00 - # - LP (bit4) = 0 - # - CFG_B.LPF (bit0) = 0 - # - CFG_C.BDU (bit4) = 0 - md_ok = _bits(ra, 1, 0) == 0b11 - odr_ok = _bits(ra, 3, 2) == 0b00 - lp_ok = ((ra >> 4) & 1) == 0 - lpf_ok = (rb & 1) == 0 - bdu_ok = ((rc >> 4) & 1) == 0 - - print("MD=idle (11) =>", "OK" if md_ok else "FAIL") - ok &= md_ok - print("ODR=00 =>", "OK" if odr_ok else "FAIL") - ok &= odr_ok - print("LP=0 =>", "OK" if lp_ok else "FAIL") - ok &= lp_ok - print("LPF=0 =>", "OK" if lpf_ok else "FAIL") - ok &= lpf_ok - print("BDU=0 =>", "OK" if bdu_ok else "FAIL") - ok &= bdu_ok - - # Verify that the SOFT_RST bit has cleared back to 0 (auto-clear) - soft_rst_cleared = ((ra >> 5) & 1) == 0 - print("SOFT_RST auto-clear =>", "OK" if soft_rst_cleared else "FAIL") - ok &= soft_rst_cleared - - return ok - - -def test_reboot(dev): - print("\n=== TEST REBOOT ===") - ok = True - - # Put into a known state - dev.set_odr(20) # ODR=01 - ra_before = dev._read_reg(LIS2MDL_CFG_REG_A) - print(f"Before reboot: CFG_A=0x{ra_before:02X}") - - # Reboot - dev.reboot(wait_ms=15) - ra = dev._read_reg(LIS2MDL_CFG_REG_A) - print(f"After reboot: CFG_A=0x{ra:02X}") - - # The REBOOT bit (bit6) must have cleared back to 0 - reboot_cleared = ((ra >> 6) & 1) == 0 - print("REBOOT auto-clear =>", "OK" if reboot_cleared else "FAIL") - ok &= reboot_cleared - - # WHO_AM_I still correct - who = dev.device_id() - print(f"WHO_AM_I=0x{who:02X} expected 0x40 =>", "OK" if who == 0x40 else "FAIL") - ok &= who == 0x40 - - return ok - - -def run_power_reset_tests(dev): - all_ok = True - all_ok &= test_power_modes(dev) - all_ok &= test_soft_reset(dev) - all_ok &= test_reboot(dev) - print("\n=== POWER/RESET summary:", "OK ✅" if all_ok else "Partial ⚠️ (see logs)") - - -# ---- Run the tests ---- -i2c = I2C(1) -dev = LIS2MDL(i2c) -test_reads(dev) -test_sets(dev) -run_all_calibration_tests(dev) -run_heading_tests(dev) -run_power_reset_tests(dev) diff --git a/lib/lis2mdl/examples/metal_detector.py b/lib/lis2mdl/examples/metal_detector.py new file mode 100644 index 00000000..e528980b --- /dev/null +++ b/lib/lis2mdl/examples/metal_detector.py @@ -0,0 +1,106 @@ +""" +Detect nearby metal objects by monitoring field magnitude changes. +Measure a baseline, then loop and print an alert (with intensity bar) +when magnitude deviates significantly. Higher-pitched buzzer beep for +stronger fields — like a real metal detector. + +The buzzer uses hardware PWM (Timer 1 channel 4 on PA11/SPEAKER) so the +tone is generated in the background while the CPU continues reading the +magnetometer. +""" + +from time import sleep_ms + +from lis2mdl import LIS2MDL +from machine import I2C, Pin +from pyb import Timer + +BASELINE_SAMPLES = 30 +BASELINE_DELAY_MS = 100 + +MIN_ALERT_DELTA_UT = 8.0 +MAX_ALERT_DELTA_UT = 60.0 +BAR_WIDTH = 20 + +# Hardware PWM on SPEAKER pin (PA11 = TIM1_CH4) +buzzer_tim = Timer(1, freq=1000) +buzzer_ch = buzzer_tim.channel(4, Timer.PWM, pin=Pin("SPEAKER")) +buzzer_ch.pulse_width_percent(0) + + +def tone(freq, duration_ms): + """Play a tone at the given frequency for duration_ms using hardware PWM.""" + if freq <= 0: + buzzer_ch.pulse_width_percent(0) + sleep_ms(duration_ms) + return + buzzer_tim.freq(freq) + buzzer_ch.pulse_width_percent(50) + sleep_ms(duration_ms) + buzzer_ch.pulse_width_percent(0) + + +def no_tone(): + """Silence the buzzer.""" + buzzer_ch.pulse_width_percent(0) + + +def make_bar(value, max_value, width=20): + """Build a simple text bar for alert intensity.""" + clamped = min(max(value, 0.0), max_value) + filled = int((clamped / max_value) * width) + return "#" * filled + "-" * (width - filled) + + +def alert_tone(delta_ut): + """Play a higher-pitched tone when the magnetic disturbance is larger.""" + normalized = min(delta_ut, MAX_ALERT_DELTA_UT) / MAX_ALERT_DELTA_UT + freq = int(800 + normalized * 2200) # 800 -> 3000 Hz + duration_ms = int(40 + normalized * 80) + tone(freq, duration_ms) + + +i2c = I2C(1) +sensor = LIS2MDL(i2c) + +print("LIS2MDL metal detector example") +print("Keep the board away from metal during baseline capture.") +print() + +baseline_values = [] +for _ in range(BASELINE_SAMPLES): + baseline_values.append(sensor.magnitude_ut()) + sleep_ms(BASELINE_DELAY_MS) + +baseline_ut = sum(baseline_values) / len(baseline_values) + +print("Baseline magnitude: {:.2f} uT".format(baseline_ut)) +print("Move a metal object near the sensor.") +print("Press Ctrl+C to stop.") +print() + +while True: + magnitude_ut = sensor.magnitude_ut() + delta_ut = abs(magnitude_ut - baseline_ut) + bar = make_bar(delta_ut, MAX_ALERT_DELTA_UT, BAR_WIDTH) + + if delta_ut >= MIN_ALERT_DELTA_UT: + print( + "ALERT |B|={:.2f} uT delta={:.2f} uT [{}]".format( + magnitude_ut, + delta_ut, + bar, + ) + ) + alert_tone(delta_ut) + else: + print( + "CLEAR |B|={:.2f} uT delta={:.2f} uT [{}]".format( + magnitude_ut, + delta_ut, + bar, + ) + ) + sleep_ms(100) + + sleep_ms(100) diff --git a/lib/lis2mdl/examples/tilt_compensated_heading.py b/lib/lis2mdl/examples/tilt_compensated_heading.py new file mode 100644 index 00000000..ed59d222 --- /dev/null +++ b/lib/lis2mdl/examples/tilt_compensated_heading.py @@ -0,0 +1,51 @@ +""" +Heading that works even when the board is tilted. +Combine heading_with_tilt_compensation() with the ISM330DL accelerometer. +Show heading + pitch/roll. Demonstrates cross-sensor fusion (magnetometer + accelerometer). +""" + +from math import atan2, degrees, sqrt +from time import sleep_ms + +from ism330dl import ISM330DL +from lis2mdl import LIS2MDL +from machine import I2C + + +def pitch_roll_from_accel(ax, ay, az): + roll_rad = atan2(ay, az) + pitch_rad = atan2(-ax, sqrt(ay * ay + az * az)) + return degrees(pitch_rad), degrees(roll_rad) + + +i2c = I2C(1) +mag = LIS2MDL(i2c) +imu = ISM330DL(i2c) + +print("Tilt-compensated heading example") +print("This example uses LIS2MDL + ISM330DL.") +print("Rotate the board in all directions for 3D magnetometer calibration.") +print() + +mag.calibrate_minmax_3d(samples=400, delay_ms=20) +mag.set_heading_filter(0.2) + +print("Calibration done.") +print("Press Ctrl+C to stop.") +print() + +while True: + accel = imu.acceleration_g() + ax, ay, az = accel + + heading = mag.heading_with_tilt_compensation(lambda: accel) + pitch_deg, roll_deg = pitch_roll_from_accel(ax, ay, az) + direction = mag.direction_label(heading) + + print( + "Heading={:7.2f} deg Dir={} Pitch={:7.2f} deg Roll={:7.2f} deg".format( + heading, direction, pitch_deg, roll_deg + ) + ) + + sleep_ms(200)