Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion lib/steami_config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,44 @@ The sensor class name is used for lookup (`HTS221` -> `"hts221"`).

---

## Magnetometer Calibration

Store and restore hard-iron and soft-iron calibration for the LIS2MDL.

### Store calibration

```python
config.set_magnetometer_calibration(
hard_iron_x=12.3, hard_iron_y=-5.1, hard_iron_z=0.8,
soft_iron_x=1.01, soft_iron_y=0.98, soft_iron_z=1.0,
)
```

### Read calibration

```python
cal = config.get_magnetometer_calibration()
# -> {"hard_iron_x": 12.3, ..., "soft_iron_z": 1.0} or None
```

### Apply calibration to a sensor

```python
from lis2mdl import LIS2MDL

mag = LIS2MDL(i2c)
config.apply_magnetometer_calibration(mag)
# mag.x_off, y_off, z_off, x_scale, y_scale, z_scale are now set
```

---

# JSON Format

Data is stored as compact JSON to fit within 1 KB:

```json
{"rev":3,"name":"STeaMi-01","tc":{"hts":{"g":1.0,"o":-0.5},"pad":{"g":1.0,"o":-1.73}}}
{"rev":3,"name":"STeaMi-01","tc":{"hts":{"g":1.0,"o":-0.5}},"cm":{"hx":12.3,"hy":-5.1,"hz":0.8,"sx":1.01,"sy":0.98,"sz":1.0}}
```

| Key | Content |
Expand All @@ -101,6 +133,9 @@ Data is stored as compact JSON to fit within 1 KB:
| `tc` | Temperature calibration dict |
| `tc.<key>.g` | Gain factor |
| `tc.<key>.o` | Offset in °C |
| `cm` | Magnetometer calibration dict |
| `cm.hx/hy/hz` | Hard-iron offsets (X, Y, Z) |
| `cm.sx/sy/sz` | Soft-iron scale factors (X, Y, Z) |

Sensor short keys: `hts` (HTS221), `mag` (LIS2MDL), `ism` (ISM330DL),
`hid` (WSEN-HIDS), `pad` (WSEN-PADS).
Expand All @@ -113,6 +148,7 @@ Sensor short keys: `hts` (HTS221), `mag` (LIS2MDL), `ism` (ISM330DL),
| ------- | ----------- |
| `show_config.py` | Display current board configuration |
| `calibrate_temperature.py` | Calibrate all sensors against WSEN-HIDS reference |
| `calibrate_magnetometer.py` | Calibrate LIS2MDL with OLED display and persistent storage |

Run with mpremote:

Expand Down
185 changes: 185 additions & 0 deletions lib/steami_config/examples/calibrate_magnetometer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Calibrate the LIS2MDL magnetometer and save to persistent config.

This example runs a 3D min/max calibration by collecting samples while
the user rotates the board in all directions. The computed hard-iron
offsets and soft-iron scale factors are stored in the config zone and
survive power cycles.

Instructions and a countdown are displayed on the SSD1327 OLED screen.
Press MENU to start the calibration.
"""

import gc
from time import sleep_ms

from daplink_flash import DaplinkFlash
from lis2mdl import LIS2MDL
from machine import I2C, SPI, Pin
from ssd1327 import WS_OLED_128X128_SPI
from steami_config import SteamiConfig

# --- Hardware init ---

i2c = I2C(1)
oled = WS_OLED_128X128_SPI(
SPI(1),
Pin("DATA_COMMAND_DISPLAY"),
Pin("RST_DISPLAY"),
Pin("CS_DISPLAY"),
)
btn_menu = Pin("MENU_BUTTON", Pin.IN, Pin.PULL_UP)

flash = DaplinkFlash(i2c)
config = SteamiConfig(flash)
config.load()
mag = LIS2MDL(i2c)
config.apply_magnetometer_calibration(mag)


# --- Helper functions ---


def show(lines):
"""Display centered text lines on the round OLED screen."""
oled.fill(0)
th = len(lines) * 12
ys = max(0, (128 - th) // 2)
for i, line in enumerate(lines):
x = max(0, (128 - len(line) * 8) // 2)
oled.text(line, x, ys + i * 12, 15)
oled.show()


def draw_degree(x, y, col=15):
"""Draw a tiny degree symbol (3x3 circle) at pixel position."""
oled.pixel(x + 1, y, col)
oled.pixel(x, y + 1, col)
oled.pixel(x + 2, y + 1, col)
oled.pixel(x + 1, y + 2, col)


def wait_menu():
"""Wait for MENU button press then release."""
while btn_menu.value() == 1:
sleep_ms(10)
while btn_menu.value() == 0:
sleep_ms(10)


# --- Step 1: Display instructions and wait for MENU ---

print("=== Magnetometer Calibration ===\n")
print("Current offsets: x={:.1f} y={:.1f} z={:.1f}".format(
mag.x_off, mag.y_off, mag.z_off))
print("Current scales: x={:.3f} y={:.3f} z={:.3f}\n".format(
mag.x_scale, mag.y_scale, mag.z_scale))

show([
"COMPAS",
"",
"Tournez la",
"carte dans",
"toutes les",
"directions",
"",
"MENU = demarrer",
])

print("Press MENU to start calibration...")
wait_menu()
print("Starting calibration...\n")

# --- Step 2: Acquisition with countdown ---

samples = 600
delay = 20
total_sec = (samples * delay) // 1000
xmin = ymin = zmin = 1e9
xmax = ymax = zmax = -1e9

for s in range(samples):
x, y, z = mag.magnetic_field()
xmin = min(xmin, x)
xmax = max(xmax, x)
ymin = min(ymin, y)
ymax = max(ymax, y)
zmin = min(zmin, z)
zmax = max(zmax, z)
if s % 50 == 0:
remain = total_sec - (s * delay) // 1000
show([
"COMPAS",
"",
"Acquisition...",
"",
"Continuez a",
"tourner",
"",
"{} sec".format(remain),
])
sleep_ms(delay)

mag.x_off = (xmax + xmin) / 2.0
mag.y_off = (ymax + ymin) / 2.0
mag.z_off = (zmax + zmin) / 2.0
mag.x_scale = (xmax - xmin) / 2.0 or 1.0
mag.y_scale = (ymax - ymin) / 2.0 or 1.0
mag.z_scale = (zmax - zmin) / 2.0 or 1.0

print("Calibration complete!")
print(" Hard-iron offsets: x={:.1f} y={:.1f} z={:.1f}".format(
mag.x_off, mag.y_off, mag.z_off))
print(" Soft-iron scales: x={:.3f} y={:.3f} z={:.3f}\n".format(
mag.x_scale, mag.y_scale, mag.z_scale))

# --- Step 3: Save to config zone ---

show(["COMPAS", "", "Sauvegarde..."])

config.set_magnetometer_calibration(
hard_iron_x=mag.x_off,
hard_iron_y=mag.y_off,
hard_iron_z=mag.z_off,
soft_iron_x=mag.x_scale,
soft_iron_y=mag.y_scale,
soft_iron_z=mag.z_scale,
)
config.save()
print("Calibration saved to config zone.\n")
sleep_ms(500)

# --- Step 4: Verify ---

show(["COMPAS", "", "Sauvegarde OK", "", "Verification..."])

gc.collect()
config2 = SteamiConfig(flash)
config2.load()

mag2 = LIS2MDL(i2c)
config2.apply_magnetometer_calibration(mag2)

print("Verification (5 heading readings after reload):")
result_lines = ["COMPAS", "", "Resultats:"]
for i in range(5):
heading = mag2.heading_flat_only()
line = " {}: cap={:.0f}".format(i + 1, heading)
print(" Reading {}: heading={:.1f} deg".format(i + 1, heading))
result_lines.append(line)
sleep_ms(500)

result_lines.append("")
result_lines.append("Termine !")

# Draw results with degree symbols
oled.fill(0)
th = len(result_lines) * 12
ys = max(0, (128 - th) // 2)
for i, line in enumerate(result_lines):
x = max(0, (128 - len(line) * 8) // 2)
oled.text(line, x, ys + i * 12, 15)
if "cap=" in line:
draw_degree(x + len(line) * 8 + 1, ys + i * 12)
oled.show()

print("\nDone! Calibration is stored and will be restored at next boot.")
71 changes: 71 additions & 0 deletions lib/steami_config/steami_config/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,74 @@ def apply_temperature_calibration(self, sensor_instance):
return
sensor_instance._temp_gain = cal["gain"]
sensor_instance._temp_offset = cal["offset"]

# --------------------------------------------------
# Magnetometer calibration
# --------------------------------------------------

def set_magnetometer_calibration(
self,
hard_iron_x=0.0,
hard_iron_y=0.0,
hard_iron_z=0.0,
soft_iron_x=1.0,
soft_iron_y=1.0,
soft_iron_z=1.0,
):
"""Store magnetometer hard-iron and soft-iron calibration.

Args:
hard_iron_x: X-axis hard-iron offset.
hard_iron_y: Y-axis hard-iron offset.
hard_iron_z: Z-axis hard-iron offset.
soft_iron_x: X-axis soft-iron scale factor.
soft_iron_y: Y-axis soft-iron scale factor.
soft_iron_z: Z-axis soft-iron scale factor.
"""
self._data["cm"] = {
"hx": hard_iron_x,
"hy": hard_iron_y,
"hz": hard_iron_z,
"sx": soft_iron_x,
"sy": soft_iron_y,
"sz": soft_iron_z,
}
Comment on lines +161 to +168
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The linked issue (#170) describes a different persisted JSON schema (cal_mag with hix/hiy/...) than the implementation here (cm with hx/hy/...). If any external docs/tools were written against the issue’s schema, this will be confusing; consider aligning the on-flash keys to the agreed schema or explicitly documenting the final schema (and any migration/back-compat) in a repo doc/CHANGELOG entry.

Copilot uses AI. Check for mistakes.

def get_magnetometer_calibration(self):
"""Return magnetometer calibration data.

Returns:
dict with hard_iron_x/y/z and soft_iron_x/y/z keys, or None.
"""
cm = self._data.get("cm")
if cm is None:
return None
return {
"hard_iron_x": cm.get("hx", 0.0),
"hard_iron_y": cm.get("hy", 0.0),
"hard_iron_z": cm.get("hz", 0.0),
"soft_iron_x": cm.get("sx", 1.0),
"soft_iron_y": cm.get("sy", 1.0),
"soft_iron_z": cm.get("sz", 1.0),
}
Comment on lines +176 to +186
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_magnetometer_calibration() will raise KeyError if the stored cm dict is present but missing any of the expected keys (hx/hy/hz/sx/sy/sz). Since config data can come from older firmware versions or be partially corrupted while still being valid JSON, consider treating missing/invalid entries as “not set” (return None) or applying defaults for the missing fields, instead of crashing.

Copilot uses AI. Check for mistakes.

def apply_magnetometer_calibration(self, lis2mdl_instance):
"""Apply stored magnetometer calibration to a LIS2MDL instance.

The instance must have x_off/y_off/z_off and x_scale/y_scale/z_scale
attributes. Only applies to LIS2MDL instances.

Args:
lis2mdl_instance: a LIS2MDL driver instance.
"""
if type(lis2mdl_instance).__name__.lower() != "lis2mdl":
return
cal = self.get_magnetometer_calibration()
if cal is None:
return
lis2mdl_instance.x_off = cal["hard_iron_x"]
lis2mdl_instance.y_off = cal["hard_iron_y"]
lis2mdl_instance.z_off = cal["hard_iron_z"]
lis2mdl_instance.x_scale = cal["soft_iron_x"]
lis2mdl_instance.y_scale = cal["soft_iron_y"]
lis2mdl_instance.z_scale = cal["soft_iron_z"]
Comment on lines +188 to +207
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apply_magnetometer_calibration() currently applies calibration to any object passed in (it will silently add x_off/x_scale attributes if they don’t already exist). For consistency with apply_temperature_calibration() (which checks the sensor class name), consider guarding on type(lis2mdl_instance).__name__.lower() == "lis2mdl" (or similar) and returning otherwise, to avoid accidentally mutating unrelated objects.

Copilot uses AI. Check for mistakes.
Loading
Loading