diff --git a/CLAUDE.md b/CLAUDE.md index 083bee20..f6bacf39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -449,6 +449,8 @@ Current stable version: 0.3.3 (as of latest CHANGELOG entry) - Config/preferences: `internal_filesystem/lib/mpos/config.py` - Top menu/drawer: `internal_filesystem/lib/mpos/ui/topmenu.py` - Activity navigation: `internal_filesystem/lib/mpos/activity_navigator.py` +- Sensor management: `internal_filesystem/lib/mpos/sensor_manager.py` +- IMU drivers: `internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py` and `wsen_isds.py` ## Common Utilities and Helpers @@ -642,6 +644,7 @@ def defocus_handler(self, obj): - `mpos.sdcard.SDCardManager`: SD card mounting and management - `mpos.clipboard`: System clipboard access - `mpos.battery_voltage`: Battery level reading (ESP32 only) +- `mpos.sensor_manager`: Unified sensor access (accelerometer, gyroscope, temperature) ## Audio System (AudioFlinger) @@ -849,6 +852,173 @@ class LEDAnimationActivity(Activity): - **Thread-safe**: No locking (single-threaded usage recommended) - **Desktop**: Functions return `False` (no-op) on desktop builds +## Sensor System (SensorManager) + +MicroPythonOS provides a unified sensor framework called **SensorManager** (Android-inspired) that provides easy access to motion sensors (accelerometer, gyroscope) and temperature sensors across different hardware platforms. + +### Supported Sensors + +**IMU Sensors:** +- **QMI8658** (Waveshare ESP32-S3): Accelerometer, Gyroscope, Temperature +- **WSEN_ISDS** (Fri3d Camp 2024 Badge): Accelerometer, Gyroscope + +**Temperature Sensors:** +- **ESP32 MCU Temperature**: Internal SoC temperature sensor +- **IMU Chip Temperature**: QMI8658 chip temperature + +### Basic Usage + +**Check availability and read sensors**: +```python +import mpos.sensor_manager as SensorManager + +# Check if sensors are available +if SensorManager.is_available(): + # Get sensors + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + temp = SensorManager.get_default_sensor(SensorManager.TYPE_SOC_TEMPERATURE) + + # Read data (returns standard SI units) + accel_data = SensorManager.read_sensor(accel) # Returns (x, y, z) in m/s² + gyro_data = SensorManager.read_sensor(gyro) # Returns (x, y, z) in deg/s + temperature = SensorManager.read_sensor(temp) # Returns °C + + if accel_data: + ax, ay, az = accel_data + print(f"Acceleration: {ax:.2f}, {ay:.2f}, {az:.2f} m/s²") +``` + +### Sensor Types + +```python +# Motion sensors +SensorManager.TYPE_ACCELEROMETER # m/s² (meters per second squared) +SensorManager.TYPE_GYROSCOPE # deg/s (degrees per second) + +# Temperature sensors +SensorManager.TYPE_SOC_TEMPERATURE # °C (MCU internal temperature) +SensorManager.TYPE_IMU_TEMPERATURE # °C (IMU chip temperature) +``` + +### Tilt-Controlled Game Example + +```python +from mpos.app.activity import Activity +import mpos.sensor_manager as SensorManager +import mpos.ui +import time + +class TiltBallActivity(Activity): + def onCreate(self): + self.screen = lv.obj() + + # Get accelerometer + self.accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + + # Create ball UI + self.ball = lv.obj(self.screen) + self.ball.set_size(20, 20) + self.ball.set_style_radius(10, 0) + + # Physics state + self.ball_x = 160.0 + self.ball_y = 120.0 + self.ball_vx = 0.0 + self.ball_vy = 0.0 + self.last_time = time.ticks_ms() + + self.setContentView(self.screen) + + def onResume(self, screen): + self.last_time = time.ticks_ms() + mpos.ui.task_handler.add_event_cb(self.update_physics, 1) + + def onPause(self, screen): + mpos.ui.task_handler.remove_event_cb(self.update_physics) + + def update_physics(self, a, b): + current_time = time.ticks_ms() + delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0 + self.last_time = current_time + + # Read accelerometer + accel = SensorManager.read_sensor(self.accel) + if accel: + ax, ay, az = accel + + # Apply acceleration to velocity + self.ball_vx += (ax * 5.0) * delta_time + self.ball_vy -= (ay * 5.0) * delta_time # Flip Y + + # Update position + self.ball_x += self.ball_vx + self.ball_y += self.ball_vy + + # Update ball position + self.ball.set_pos(int(self.ball_x), int(self.ball_y)) +``` + +### Calibration + +Calibration removes sensor drift and improves accuracy. The device must be **stationary** during calibration. + +```python +# Calibrate accelerometer and gyroscope +accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) +gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + +# Calibrate (100 samples, device must be flat and still) +accel_offsets = SensorManager.calibrate_sensor(accel, samples=100) +gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=100) + +# Calibration is automatically saved to SharedPreferences +# and loaded on next boot +``` + +### Performance Recommendations + +**Polling rate recommendations:** +- **Games**: 20-30 Hz (responsive but not excessive) +- **UI feedback**: 10-15 Hz (smooth for tilt UI) +- **Background monitoring**: 1-5 Hz (screen rotation, pedometer) + +```python +# ❌ BAD: Poll every frame (60 Hz) +def update_frame(self, a, b): + accel = SensorManager.read_sensor(self.accel) # Too frequent! + +# ✅ GOOD: Poll every other frame (30 Hz) +def update_frame(self, a, b): + self.frame_count += 1 + if self.frame_count % 2 == 0: + accel = SensorManager.read_sensor(self.accel) +``` + +### Hardware Support Matrix + +| Platform | Accelerometer | Gyroscope | IMU Temp | MCU Temp | +|----------|---------------|-----------|----------|----------| +| Waveshare ESP32-S3 | ✅ QMI8658 | ✅ QMI8658 | ✅ QMI8658 | ✅ ESP32 | +| Fri3d 2024 Badge | ✅ WSEN_ISDS | ✅ WSEN_ISDS | ❌ | ✅ ESP32 | +| Desktop/Linux | ❌ | ❌ | ❌ | ❌ | + +### Implementation Details + +- **Location**: `lib/mpos/sensor_manager.py` +- **Pattern**: Module-level singleton (similar to `battery_voltage.py`) +- **Units**: Standard SI (m/s² for acceleration, deg/s for gyroscope, °C for temperature) +- **Calibration**: Persistent via SharedPreferences (`data/com.micropythonos.sensors/config.json`) +- **Thread-safe**: Uses locks for concurrent access +- **Auto-detection**: Identifies IMU type via chip ID registers +- **Desktop**: Functions return `None` (graceful fallback) on desktop builds + +### Driver Locations + +- **QMI8658**: `lib/mpos/hardware/drivers/qmi8658.py` +- **WSEN_ISDS**: `lib/mpos/hardware/drivers/wsen_isds.py` +- **Board init**: `lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py` and `lib/mpos/board/fri3d_2024.py` + ## Animations and Game Loops MicroPythonOS supports frame-based animations and game loops using the TaskHandler event system. This pattern is used for games, particle effects, and smooth animations. diff --git a/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py b/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py index 569c47e1..4cf3cb51 100644 --- a/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py +++ b/internal_filesystem/apps/com.micropythonos.imu/assets/imu.py @@ -1,8 +1,11 @@ from mpos.apps import Activity +import mpos.sensor_manager as SensorManager class IMU(Activity): - sensor = None + accel_sensor = None + gyro_sensor = None + temp_sensor = None refresh_timer = None # widgets: @@ -30,12 +33,16 @@ class IMU(Activity): self.slidergz = lv.slider(screen) self.slidergz.align(lv.ALIGN.CENTER, 0, 90) try: - from machine import Pin, I2C - from qmi8658 import QMI8658 - import machine - self.sensor = QMI8658(I2C(0, sda=machine.Pin(48), scl=machine.Pin(47))) - print("IMU sensor initialized") - #print(f"{self.sensor.temperature=} {self.sensor.acceleration=} {self.sensor.gyro=}") + if SensorManager.is_available(): + self.accel_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.gyro_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + # Get IMU temperature (not MCU temperature) + self.temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_IMU_TEMPERATURE) + print("IMU sensors initialized via SensorManager") + print(f"Available sensors: {SensorManager.get_sensor_list()}") + else: + print("Warning: No IMU sensors available") + self.templabel.set_text("No IMU sensors available") except Exception as e: warning = f"Warning: could not initialize IMU hardware:\n{e}" print(warning) @@ -68,22 +75,45 @@ class IMU(Activity): def refresh(self, timer): #print("refresh timer") - if self.sensor: - #print(f"{self.sensor.temperature=} {self.sensor.acceleration=} {self.sensor.gyro=}") - temp = self.sensor.temperature - ax = self.sensor.acceleration[0] - axp = int((ax * 100 + 100)/2) - ay = self.sensor.acceleration[1] - ayp = int((ay * 100 + 100)/2) - az = self.sensor.acceleration[2] - azp = int((az * 100 + 100)/2) - # values between -200 and 200 => /4 becomes -50 and 50 => +50 becomes 0 and 100 - gx = self.convert_percentage(self.sensor.gyro[0]) - gy = self.convert_percentage(self.sensor.gyro[1]) - gz = self.convert_percentage(self.sensor.gyro[2]) - self.templabel.set_text(f"IMU chip temperature: {temp:.2f}°C") + if self.accel_sensor and self.gyro_sensor: + # Read sensor data via SensorManager (returns m/s² for accel, deg/s for gyro) + accel = SensorManager.read_sensor(self.accel_sensor) + gyro = SensorManager.read_sensor(self.gyro_sensor) + temp = SensorManager.read_sensor(self.temp_sensor) if self.temp_sensor else None + + if accel and gyro: + # Convert m/s² to G for display (divide by 9.80665) + # Range: ±8G → ±1G = ±10% of range → map to 0-100 + ax, ay, az = accel + ax_g = ax / 9.80665 # Convert m/s² to G + ay_g = ay / 9.80665 + az_g = az / 9.80665 + axp = int((ax_g * 100 + 100)/2) # Map ±1G to 0-100 + ayp = int((ay_g * 100 + 100)/2) + azp = int((az_g * 100 + 100)/2) + + # Gyro already in deg/s, map ±200 DPS to 0-100 + gx, gy, gz = gyro + gx = self.convert_percentage(gx) + gy = self.convert_percentage(gy) + gz = self.convert_percentage(gz) + + if temp is not None: + self.templabel.set_text(f"IMU chip temperature: {temp:.2f}°C") + else: + self.templabel.set_text("IMU active (no temperature sensor)") + else: + # Sensor read failed, show random data + import random + randomnr = random.randint(0,100) + axp = randomnr + ayp = 50 + azp = 75 + gx = 45 + gy = 50 + gz = 55 else: - #temp = 12.34 + # No sensors available, show random data import random randomnr = random.randint(0,100) axp = randomnr @@ -92,6 +122,7 @@ class IMU(Activity): gx = 45 gy = 50 gz = 55 + self.sliderx.set_value(axp, False) self.slidery.set_value(ayp, False) self.sliderz.set_value(azp, False) diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 45edf505..0a510c44 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -317,7 +317,15 @@ import mpos.lights as LightsManager # Initialize 5 NeoPixel LEDs (GPIO 12) LightsManager.init(neopixel_pin=12, num_leds=5) -print("Fri3d hardware: Audio and LEDs initialized") +# === SENSOR HARDWARE === +import mpos.sensor_manager as SensorManager + +# Create I2C bus for IMU (different pins from display) +from machine import I2C +imu_i2c = I2C(0, sda=Pin(9), scl=Pin(18)) +SensorManager.init(imu_i2c, address=0x6B) + +print("Fri3d hardware: Audio, LEDs, and sensors initialized") # === STARTUP "WOW" EFFECT === import time @@ -375,7 +383,7 @@ def startup_wow_effect(): except Exception as e: print(f"Startup effect error: {e}") -_thread.stack_size(mpos.apps.good_stack_size()) +_thread.stack_size(mpos.apps.good_stack_size()) # default stack size won't work, crashes! _thread.start_new_thread(startup_wow_effect, ()) print("boot.py finished") diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index 913a16d0..d5c3b6ee 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -110,6 +110,11 @@ AudioFlinger.init( # Note: Desktop builds have no LED hardware # LightsManager will not be initialized (functions will return False) +# === SENSOR HARDWARE === +# Note: Desktop builds have no sensor hardware +import mpos.sensor_manager as SensorManager +# Don't call init() - SensorManager functions will return None/False + print("linux.py finished") diff --git a/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py index c2133f6c..096e64c9 100644 --- a/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py +++ b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py @@ -126,4 +126,11 @@ AudioFlinger.init( # Note: Waveshare board has no NeoPixel LEDs # LightsManager will not be initialized (functions will return False) +# === SENSOR HARDWARE === +import mpos.sensor_manager as SensorManager + +# IMU is on I2C0 (same bus as touch): SDA=48, SCL=47, addr=0x6B +# i2c_bus was created on line 75 for touch, reuse it for IMU +SensorManager.init(i2c_bus, address=0x6B) + print("boot.py finished") diff --git a/internal_filesystem/lib/mpos/hardware/drivers/__init__.py b/internal_filesystem/lib/mpos/hardware/drivers/__init__.py new file mode 100644 index 00000000..119fb43d --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/drivers/__init__.py @@ -0,0 +1 @@ +# IMU and sensor drivers for MicroPythonOS diff --git a/internal_filesystem/lib/qmi8658.py b/internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py similarity index 100% rename from internal_filesystem/lib/qmi8658.py rename to internal_filesystem/lib/mpos/hardware/drivers/qmi8658.py diff --git a/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py new file mode 100644 index 00000000..eaefeb74 --- /dev/null +++ b/internal_filesystem/lib/mpos/hardware/drivers/wsen_isds.py @@ -0,0 +1,435 @@ +"""WSEN_ISDS 6-axis IMU driver for MicroPython. + +This driver is for the Würth Elektronik WSEN-ISDS IMU sensor. +Source: https://github.com/Fri3dCamp/badge_2024_micropython/pull/10 + +MIT License + +Copyright (c) 2024 Fri3d Camp contributors + +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. +""" + +import time + + +class Wsen_Isds: + """Driver for WSEN-ISDS 6-axis IMU (accelerometer + gyroscope).""" + + _ISDS_STATUS_REG = 0x1E # Status data register + _ISDS_WHO_AM_I = 0x0F # WHO_AM_I register + + _REG_G_X_OUT_L = 0x22 + _REG_G_Y_OUT_L = 0x24 + _REG_G_Z_OUT_L = 0x26 + + _REG_A_X_OUT_L = 0x28 + _REG_A_Y_OUT_L = 0x2A + _REG_A_Z_OUT_L = 0x2C + + _REG_A_TAP_CFG = 0x58 + + _options = { + 'acc_range': { + 'reg': 0x10, 'mask': 0b11110011, 'shift_left': 2, + 'val_to_bits': {"2g": 0b00, "4g": 0b10, "8g": 0b11, "16g": 0b01} + }, + 'acc_data_rate': { + 'reg': 0x10, 'mask': 0b00001111, 'shift_left': 4, + 'val_to_bits': { + "0": 0b0000, "1.6Hz": 0b1011, "12.5Hz": 0b0001, + "26Hz": 0b0010, "52Hz": 0b0011, "104Hz": 0b0100, + "208Hz": 0b0101, "416Hz": 0b0110, "833Hz": 0b0111, + "1.66kHz": 0b1000, "3.33kHz": 0b1001, "6.66kHz": 0b1010} + }, + 'gyro_range': { + 'reg': 0x11, 'mask': 0b11110000, 'shift_left': 0, + 'val_to_bits': { + "125dps": 0b0010, "250dps": 0b0000, + "500dps": 0b0100, "1000dps": 0b1000, "2000dps": 0b1100} + }, + 'gyro_data_rate': { + 'reg': 0x11, 'mask': 0b00001111, 'shift_left': 4, + 'val_to_bits': { + "0": 0b0000, "12.5Hz": 0b0001, "26Hz": 0b0010, + "52Hz": 0b0011, "104Hz": 0b0100, "208Hz": 0b0101, + "416Hz": 0b0110, "833Hz": 0b0111, "1.66kHz": 0b1000, + "3.33kHz": 0b1001, "6.66kHz": 0b1010} + }, + 'tap_double_enable': { + 'reg': 0x5B, 'mask': 0b01111111, 'shift_left': 7, + 'val_to_bits': {True: 0b01, False: 0b00} + }, + 'tap_threshold': { + 'reg': 0x59, 'mask': 0b11100000, 'shift_left': 0, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11, 4: 0b100, 5: 0b101, + 6: 0b110, 7: 0b111, 8: 0b1000, 9: 0b1001} + }, + 'tap_quiet_time': { + 'reg': 0x5A, 'mask': 0b11110011, 'shift_left': 2, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11} + }, + 'tap_duration_time': { + 'reg': 0x5A, 'mask': 0b00001111, 'shift_left': 2, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11, 4: 0b100, 5: 0b101, + 6: 0b110, 7: 0b111, 8: 0b1000, 9: 0b1001} + }, + 'tap_shock_time': { + 'reg': 0x5A, 'mask': 0b11111100, 'shift_left': 0, + 'val_to_bits': {0: 0b00, 1: 0b01, 2: 0b10, 3: 0b11} + }, + 'tap_single_to_int0': { + 'reg': 0x5E, 'mask': 0b10111111, 'shift_left': 6, + 'val_to_bits': {0: 0b00, 1: 0b01} + }, + 'tap_double_to_int0': { + 'reg': 0x5E, 'mask': 0b11110111, 'shift_left': 3, + 'val_to_bits': {0: 0b00, 1: 0b01} + }, + 'int1_on_int0': { + 'reg': 0x13, 'mask': 0b11011111, 'shift_left': 5, + 'val_to_bits': {0: 0b00, 1: 0b01} + }, + 'ctrl_do_soft_reset': { + 'reg': 0x12, 'mask': 0b11111110, 'shift_left': 0, + 'val_to_bits': {True: 0b01, False: 0b00} + }, + 'ctrl_do_reboot': { + 'reg': 0x12, 'mask': 0b01111111, 'shift_left': 7, + 'val_to_bits': {True: 0b01, False: 0b00} + }, + } + + def __init__(self, i2c, address=0x6B, acc_range="2g", acc_data_rate="1.6Hz", + gyro_range="125dps", gyro_data_rate="12.5Hz"): + """Initialize WSEN-ISDS IMU. + + Args: + i2c: I2C bus instance + address: I2C address (default 0x6B) + acc_range: Accelerometer range ("2g", "4g", "8g", "16g") + acc_data_rate: Accelerometer data rate ("0", "1.6Hz", "12.5Hz", ...) + gyro_range: Gyroscope range ("125dps", "250dps", "500dps", "1000dps", "2000dps") + gyro_data_rate: Gyroscope data rate ("0", "12.5Hz", "26Hz", ...) + """ + self.i2c = i2c + self.address = address + + self.acc_offset_x = 0 + self.acc_offset_y = 0 + self.acc_offset_z = 0 + self.acc_range = 0 + self.acc_sensitivity = 0 + + self.gyro_offset_x = 0 + self.gyro_offset_y = 0 + self.gyro_offset_z = 0 + self.gyro_range = 0 + self.gyro_sensitivity = 0 + + self.ACC_NUM_SAMPLES_CALIBRATION = 5 + self.ACC_CALIBRATION_DELAY_MS = 10 + + self.GYRO_NUM_SAMPLES_CALIBRATION = 5 + self.GYRO_CALIBRATION_DELAY_MS = 10 + + self.set_acc_range(acc_range) + self.set_acc_data_rate(acc_data_rate) + self.set_gyro_range(gyro_range) + self.set_gyro_data_rate(gyro_data_rate) + + def get_chip_id(self): + """Get chip ID for detection. Returns WHO_AM_I register value.""" + try: + return self.i2c.readfrom_mem(self.address, self._ISDS_WHO_AM_I, 1)[0] + except: + return 0 + + def _write_option(self, option, value): + """Write configuration option to sensor register.""" + opt = Wsen_Isds._options[option] + try: + bits = opt["val_to_bits"][value] + config_value = self.i2c.readfrom_mem(self.address, opt["reg"], 1)[0] + config_value &= opt["mask"] + config_value |= (bits << opt["shift_left"]) + self.i2c.writeto_mem(self.address, opt["reg"], bytes([config_value])) + except KeyError as err: + print(f"Invalid option: {option}, or invalid option value: {value}.", err) + + def set_acc_range(self, acc_range): + """Set accelerometer range.""" + self._write_option('acc_range', acc_range) + self.acc_range = acc_range + self._acc_calc_sensitivity() + + def set_acc_data_rate(self, acc_rate): + """Set accelerometer data rate.""" + self._write_option('acc_data_rate', acc_rate) + + def set_gyro_range(self, gyro_range): + """Set gyroscope range.""" + self._write_option('gyro_range', gyro_range) + self.gyro_range = gyro_range + self._gyro_calc_sensitivity() + + def set_gyro_data_rate(self, gyro_rate): + """Set gyroscope data rate.""" + self._write_option('gyro_data_rate', gyro_rate) + + def _gyro_calc_sensitivity(self): + """Calculate gyroscope sensitivity based on range.""" + sensitivity_mapping = { + "125dps": 4.375, + "250dps": 8.75, + "500dps": 17.5, + "1000dps": 35, + "2000dps": 70 + } + + if self.gyro_range in sensitivity_mapping: + self.gyro_sensitivity = sensitivity_mapping[self.gyro_range] + else: + print("Invalid range value:", self.gyro_range) + + def soft_reset(self): + """Perform soft reset of the sensor.""" + self._write_option('ctrl_do_soft_reset', True) + + def reboot(self): + """Reboot the sensor.""" + self._write_option('ctrl_do_reboot', True) + + def set_interrupt(self, interrupts_enable=False, inact_en=False, slope_fds=False, + tap_x_en=True, tap_y_en=True, tap_z_en=True): + """Configure interrupt for tap gestures on INT0 pad.""" + config_value = 0b00000000 + + if interrupts_enable: + config_value |= (1 << 7) + if inact_en: + inact_en = 0x01 + config_value |= (inact_en << 5) + if slope_fds: + config_value |= (1 << 4) + if tap_x_en: + config_value |= (1 << 3) + if tap_y_en: + config_value |= (1 << 2) + if tap_z_en: + config_value |= (1 << 1) + + self.i2c.writeto_mem(self.address, Wsen_Isds._REG_A_TAP_CFG, + bytes([config_value])) + + self._write_option('tap_double_enable', False) + self._write_option('tap_threshold', 9) + self._write_option('tap_quiet_time', 1) + self._write_option('tap_duration_time', 5) + self._write_option('tap_shock_time', 2) + self._write_option('tap_single_to_int0', 1) + self._write_option('tap_double_to_int0', 1) + self._write_option('int1_on_int0', 1) + + def acc_calibrate(self, samples=None): + """Calibrate accelerometer by averaging samples while device is stationary. + + Args: + samples: Number of samples to average (default: ACC_NUM_SAMPLES_CALIBRATION) + """ + if samples is None: + samples = self.ACC_NUM_SAMPLES_CALIBRATION + + self.acc_offset_x = 0 + self.acc_offset_y = 0 + self.acc_offset_z = 0 + + for _ in range(samples): + x, y, z = self._read_raw_accelerations() + self.acc_offset_x += x + self.acc_offset_y += y + self.acc_offset_z += z + time.sleep_ms(self.ACC_CALIBRATION_DELAY_MS) + + self.acc_offset_x //= samples + self.acc_offset_y //= samples + self.acc_offset_z //= samples + + def _acc_calc_sensitivity(self): + """Calculate accelerometer sensitivity based on range (in mg/digit).""" + sensitivity_mapping = { + "2g": 0.061, + "4g": 0.122, + "8g": 0.244, + "16g": 0.488 + } + if self.acc_range in sensitivity_mapping: + self.acc_sensitivity = sensitivity_mapping[self.acc_range] + else: + print("Invalid range value:", self.acc_range) + + def read_accelerations(self): + """Read calibrated accelerometer data. + + Returns: + Tuple (x, y, z) in mg (milligrams) + """ + raw_a_x, raw_a_y, raw_a_z = self._read_raw_accelerations() + + a_x = (raw_a_x - self.acc_offset_x) * self.acc_sensitivity + a_y = (raw_a_y - self.acc_offset_y) * self.acc_sensitivity + a_z = (raw_a_z - self.acc_offset_z) * self.acc_sensitivity + + return a_x, a_y, a_z + + def _read_raw_accelerations(self): + """Read raw accelerometer data.""" + if not self._acc_data_ready(): + raise Exception("sensor data not ready") + + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_A_X_OUT_L, 6) + + raw_a_x = self._convert_from_raw(raw[0], raw[1]) + raw_a_y = self._convert_from_raw(raw[2], raw[3]) + raw_a_z = self._convert_from_raw(raw[4], raw[5]) + + return raw_a_x, raw_a_y, raw_a_z + + def gyro_calibrate(self, samples=None): + """Calibrate gyroscope by averaging samples while device is stationary. + + Args: + samples: Number of samples to average (default: GYRO_NUM_SAMPLES_CALIBRATION) + """ + if samples is None: + samples = self.GYRO_NUM_SAMPLES_CALIBRATION + + self.gyro_offset_x = 0 + self.gyro_offset_y = 0 + self.gyro_offset_z = 0 + + for _ in range(samples): + x, y, z = self._read_raw_angular_velocities() + self.gyro_offset_x += x + self.gyro_offset_y += y + self.gyro_offset_z += z + time.sleep_ms(self.GYRO_CALIBRATION_DELAY_MS) + + self.gyro_offset_x //= samples + self.gyro_offset_y //= samples + self.gyro_offset_z //= samples + + def read_angular_velocities(self): + """Read calibrated gyroscope data. + + Returns: + Tuple (x, y, z) in mdps (milli-degrees per second) + """ + raw_g_x, raw_g_y, raw_g_z = self._read_raw_angular_velocities() + + g_x = (raw_g_x - self.gyro_offset_x) * self.gyro_sensitivity + g_y = (raw_g_y - self.gyro_offset_y) * self.gyro_sensitivity + g_z = (raw_g_z - self.gyro_offset_z) * self.gyro_sensitivity + + return g_x, g_y, g_z + + def _read_raw_angular_velocities(self): + """Read raw gyroscope data.""" + if not self._gyro_data_ready(): + raise Exception("sensor data not ready") + + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_G_X_OUT_L, 6) + + raw_g_x = self._convert_from_raw(raw[0], raw[1]) + raw_g_y = self._convert_from_raw(raw[2], raw[3]) + raw_g_z = self._convert_from_raw(raw[4], raw[5]) + + return raw_g_x, raw_g_y, raw_g_z + + def read_angular_velocities_accelerations(self): + """Read both gyroscope and accelerometer in one call. + + Returns: + Tuple (gx, gy, gz, ax, ay, az) where gyro is in mdps, accel is in mg + """ + raw_g_x, raw_g_y, raw_g_z, raw_a_x, raw_a_y, raw_a_z = \ + self._read_raw_gyro_acc() + + g_x = (raw_g_x - self.gyro_offset_x) * self.gyro_sensitivity + g_y = (raw_g_y - self.gyro_offset_y) * self.gyro_sensitivity + g_z = (raw_g_z - self.gyro_offset_z) * self.gyro_sensitivity + + a_x = (raw_a_x - self.acc_offset_x) * self.acc_sensitivity + a_y = (raw_a_y - self.acc_offset_y) * self.acc_sensitivity + a_z = (raw_a_z - self.acc_offset_z) * self.acc_sensitivity + + return g_x, g_y, g_z, a_x, a_y, a_z + + def _read_raw_gyro_acc(self): + """Read raw gyroscope and accelerometer data in one call.""" + acc_data_ready, gyro_data_ready = self._acc_gyro_data_ready() + if not acc_data_ready or not gyro_data_ready: + raise Exception("sensor data not ready") + + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._REG_G_X_OUT_L, 12) + + raw_g_x = self._convert_from_raw(raw[0], raw[1]) + raw_g_y = self._convert_from_raw(raw[2], raw[3]) + raw_g_z = self._convert_from_raw(raw[4], raw[5]) + + raw_a_x = self._convert_from_raw(raw[6], raw[7]) + raw_a_y = self._convert_from_raw(raw[8], raw[9]) + raw_a_z = self._convert_from_raw(raw[10], raw[11]) + + return raw_g_x, raw_g_y, raw_g_z, raw_a_x, raw_a_y, raw_a_z + + @staticmethod + def _convert_from_raw(b_l, b_h): + """Convert two bytes (little-endian) to signed 16-bit integer.""" + c = (b_h << 8) | b_l + if c & (1 << 15): + c -= 1 << 16 + return c + + def _acc_data_ready(self): + """Check if accelerometer data is ready.""" + return self._get_status_reg()[0] + + def _gyro_data_ready(self): + """Check if gyroscope data is ready.""" + return self._get_status_reg()[1] + + def _acc_gyro_data_ready(self): + """Check if both accelerometer and gyroscope data are ready.""" + status_reg = self._get_status_reg() + return status_reg[0], status_reg[1] + + def _get_status_reg(self): + """Read status register. + + Returns: + Tuple (acc_data_ready, gyro_data_ready, temp_data_ready) + """ + raw = self.i2c.readfrom_mem(self.address, Wsen_Isds._ISDS_STATUS_REG, 4) + + acc_data_ready = True if raw[0] == 1 else False + gyro_data_ready = True if raw[1] == 1 else False + temp_data_ready = True if raw[2] == 1 else False + + return acc_data_ready, gyro_data_ready, temp_data_ready diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py new file mode 100644 index 00000000..4bca56e4 --- /dev/null +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -0,0 +1,603 @@ +"""Android-inspired SensorManager for MicroPythonOS. + +Provides unified access to IMU sensors (QMI8658, WSEN_ISDS) and other sensors. +Follows module-level singleton pattern (like AudioFlinger, LightsManager). + +Example usage: + import mpos.sensor_manager as SensorManager + + # In board init file: + SensorManager.init(i2c_bus, address=0x6B) + + # In app: + if SensorManager.is_available(): + accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + ax, ay, az = SensorManager.read_sensor(accel) # Returns m/s² + +MIT License +Copyright (c) 2024 MicroPythonOS contributors +""" + +import time +try: + import _thread + _lock = _thread.allocate_lock() +except ImportError: + _lock = None + +# Sensor type constants (matching Android SensorManager) +TYPE_ACCELEROMETER = 1 # Units: m/s² (meters per second squared) +TYPE_GYROSCOPE = 4 # Units: deg/s (degrees per second) +TYPE_TEMPERATURE = 13 # Units: °C (generic, returns first available - deprecated) +TYPE_IMU_TEMPERATURE = 14 # Units: °C (IMU chip temperature) +TYPE_SOC_TEMPERATURE = 15 # Units: °C (MCU/SoC internal temperature) + +# Gravity constant for unit conversions +_GRAVITY = 9.80665 # m/s² + +# Module state +_initialized = False +_imu_driver = None +_sensor_list = [] +_i2c_bus = None +_i2c_address = None +_has_mcu_temperature = False + + +class Sensor: + """Sensor metadata (lightweight data class, Android-inspired).""" + + def __init__(self, name, sensor_type, vendor, version, max_range, resolution, power_ma): + """Initialize sensor metadata. + + Args: + name: Human-readable sensor name + sensor_type: Sensor type constant (TYPE_ACCELEROMETER, etc.) + vendor: Sensor vendor/manufacturer + version: Driver version + max_range: Maximum measurement range (with units) + resolution: Measurement resolution (with units) + power_ma: Power consumption in mA (or 0 if unknown) + """ + self.name = name + self.type = sensor_type + self.vendor = vendor + self.version = version + self.max_range = max_range + self.resolution = resolution + self.power = power_ma + + def __repr__(self): + return f"Sensor({self.name}, type={self.type})" + + +def init(i2c_bus, address=0x6B): + """Initialize SensorManager with I2C bus. Auto-detects IMU type and MCU temperature. + + Tries to detect QMI8658 (chip ID 0x05) or WSEN_ISDS (WHO_AM_I 0x6A). + Also detects ESP32 MCU internal temperature sensor. + Loads calibration from SharedPreferences if available. + + Args: + i2c_bus: machine.I2C instance (can be None if only MCU temperature needed) + address: I2C address (default 0x6B for both QMI8658 and WSEN_ISDS) + + Returns: + bool: True if any sensor detected and initialized successfully + """ + global _initialized, _imu_driver, _sensor_list, _i2c_bus, _i2c_address, _has_mcu_temperature + + if _initialized: + print("[SensorManager] Already initialized") + return True + + _i2c_bus = i2c_bus + _i2c_address = address + imu_detected = False + + # Try QMI8658 first (Waveshare board) + if i2c_bus: + try: + from mpos.hardware.drivers.qmi8658 import QMI8658, _QMI8685_PARTID, _REG_PARTID + chip_id = i2c_bus.readfrom_mem(address, _REG_PARTID, 1)[0] + if chip_id == _QMI8685_PARTID: + print("[SensorManager] Detected QMI8658 IMU") + _imu_driver = _QMI8658Driver(i2c_bus, address) + _register_qmi8658_sensors() + _load_calibration() + imu_detected = True + except Exception as e: + print(f"[SensorManager] QMI8658 detection failed: {e}") + + # Try WSEN_ISDS (Fri3d badge) + if not imu_detected: + try: + from mpos.hardware.drivers.wsen_isds import Wsen_Isds + chip_id = i2c_bus.readfrom_mem(address, 0x0F, 1)[0] # WHO_AM_I register + if chip_id == 0x6A: # WSEN_ISDS WHO_AM_I value + print("[SensorManager] Detected WSEN_ISDS IMU") + _imu_driver = _WsenISDSDriver(i2c_bus, address) + _register_wsen_isds_sensors() + _load_calibration() + imu_detected = True + except Exception as e: + print(f"[SensorManager] WSEN_ISDS detection failed: {e}") + + # Try MCU internal temperature sensor (ESP32) + try: + import esp32 + # Test if mcu_temperature() is available + _ = esp32.mcu_temperature() + _has_mcu_temperature = True + _register_mcu_temperature_sensor() + print("[SensorManager] Detected MCU internal temperature sensor") + except Exception as e: + print(f"[SensorManager] MCU temperature not available: {e}") + + _initialized = True + + if not imu_detected and not _has_mcu_temperature: + print("[SensorManager] No sensors detected") + return False + + return True + + +def is_available(): + """Check if sensors are available. + + Returns: + bool: True if SensorManager is initialized with hardware + """ + return _initialized and _imu_driver is not None + + +def get_sensor_list(): + """Get list of all available sensors. + + Returns: + list: List of Sensor objects + """ + return _sensor_list.copy() if _sensor_list else [] + + +def get_default_sensor(sensor_type): + """Get default sensor of given type. + + Args: + sensor_type: Sensor type constant (TYPE_ACCELEROMETER, etc.) + + Returns: + Sensor object or None if not available + """ + for sensor in _sensor_list: + if sensor.type == sensor_type: + return sensor + return None + + +def read_sensor(sensor): + """Read sensor data synchronously. + + Args: + sensor: Sensor object from get_default_sensor() + + Returns: + For motion sensors: tuple (x, y, z) in appropriate units + For scalar sensors: single value + None if sensor not available or error + """ + if sensor is None: + return None + + if _lock: + _lock.acquire() + + try: + if sensor.type == TYPE_ACCELEROMETER: + if _imu_driver: + return _imu_driver.read_acceleration() + elif sensor.type == TYPE_GYROSCOPE: + if _imu_driver: + return _imu_driver.read_gyroscope() + elif sensor.type == TYPE_IMU_TEMPERATURE: + if _imu_driver: + return _imu_driver.read_temperature() + elif sensor.type == TYPE_SOC_TEMPERATURE: + if _has_mcu_temperature: + import esp32 + return esp32.mcu_temperature() + elif sensor.type == TYPE_TEMPERATURE: + # Generic temperature - return first available (backward compatibility) + if _imu_driver: + temp = _imu_driver.read_temperature() + if temp is not None: + return temp + if _has_mcu_temperature: + import esp32 + return esp32.mcu_temperature() + return None + except Exception as e: + print(f"[SensorManager] Error reading sensor {sensor.name}: {e}") + return None + finally: + if _lock: + _lock.release() + + +def calibrate_sensor(sensor, samples=100): + """Calibrate sensor and save to SharedPreferences. + + Device must be stationary for accelerometer/gyroscope calibration. + + Args: + sensor: Sensor object to calibrate + samples: Number of samples to average (default 100) + + Returns: + tuple: Calibration offsets (x, y, z) or None if failed + """ + if not is_available() or sensor is None: + return None + + if _lock: + _lock.acquire() + + try: + offsets = None + if sensor.type == TYPE_ACCELEROMETER: + offsets = _imu_driver.calibrate_accelerometer(samples) + print(f"[SensorManager] Accelerometer calibrated: {offsets}") + elif sensor.type == TYPE_GYROSCOPE: + offsets = _imu_driver.calibrate_gyroscope(samples) + print(f"[SensorManager] Gyroscope calibrated: {offsets}") + else: + print(f"[SensorManager] Sensor type {sensor.type} does not support calibration") + return None + + # Save calibration + if offsets: + _save_calibration() + + return offsets + except Exception as e: + print(f"[SensorManager] Error calibrating sensor {sensor.name}: {e}") + return None + finally: + if _lock: + _lock.release() + + +# ============================================================================ +# Internal driver abstraction layer +# ============================================================================ + +class _IMUDriver: + """Base class for IMU drivers (internal use only).""" + + def read_acceleration(self): + """Returns (x, y, z) in m/s²""" + raise NotImplementedError + + def read_gyroscope(self): + """Returns (x, y, z) in deg/s""" + raise NotImplementedError + + def read_temperature(self): + """Returns temperature in °C""" + raise NotImplementedError + + def calibrate_accelerometer(self, samples): + """Calibrate accel, return (x, y, z) offsets in m/s²""" + raise NotImplementedError + + def calibrate_gyroscope(self, samples): + """Calibrate gyro, return (x, y, z) offsets in deg/s""" + raise NotImplementedError + + def get_calibration(self): + """Return dict with 'accel_offsets' and 'gyro_offsets' keys""" + raise NotImplementedError + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration offsets from saved values""" + raise NotImplementedError + + +class _QMI8658Driver(_IMUDriver): + """Wrapper for QMI8658 IMU (Waveshare board).""" + + def __init__(self, i2c_bus, address): + from mpos.hardware.drivers.qmi8658 import QMI8658, _ACCELSCALE_RANGE_8G, _GYROSCALE_RANGE_256DPS + self.sensor = QMI8658( + i2c_bus, + address=address, + accel_scale=_ACCELSCALE_RANGE_8G, + gyro_scale=_GYROSCALE_RANGE_256DPS + ) + # Software calibration offsets (QMI8658 has no built-in calibration) + self.accel_offset = [0.0, 0.0, 0.0] + self.gyro_offset = [0.0, 0.0, 0.0] + + def read_acceleration(self): + """Read acceleration in m/s² (converts from G).""" + ax, ay, az = self.sensor.acceleration + # Convert G to m/s² and apply calibration + return ( + (ax * _GRAVITY) - self.accel_offset[0], + (ay * _GRAVITY) - self.accel_offset[1], + (az * _GRAVITY) - self.accel_offset[2] + ) + + def read_gyroscope(self): + """Read gyroscope in deg/s (already in correct units).""" + gx, gy, gz = self.sensor.gyro + # Apply calibration + return ( + gx - self.gyro_offset[0], + gy - self.gyro_offset[1], + gz - self.gyro_offset[2] + ) + + def read_temperature(self): + """Read temperature in °C.""" + return self.sensor.temperature + + def calibrate_accelerometer(self, samples): + """Calibrate accelerometer (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + ax, ay, az = self.sensor.acceleration + # Convert to m/s² + sum_x += ax * _GRAVITY + sum_y += ay * _GRAVITY + sum_z += az * _GRAVITY + time.sleep_ms(10) + + # Average offsets (assuming Z-axis should read +9.8 m/s²) + self.accel_offset[0] = sum_x / samples + self.accel_offset[1] = sum_y / samples + self.accel_offset[2] = (sum_z / samples) - _GRAVITY # Expect +1G on Z + + return tuple(self.accel_offset) + + def calibrate_gyroscope(self, samples): + """Calibrate gyroscope (device must be stationary).""" + sum_x, sum_y, sum_z = 0.0, 0.0, 0.0 + + for _ in range(samples): + gx, gy, gz = self.sensor.gyro + sum_x += gx + sum_y += gy + sum_z += gz + time.sleep_ms(10) + + # Average offsets (should be 0 when stationary) + self.gyro_offset[0] = sum_x / samples + self.gyro_offset[1] = sum_y / samples + self.gyro_offset[2] = sum_z / samples + + return tuple(self.gyro_offset) + + def get_calibration(self): + """Get current calibration.""" + return { + 'accel_offsets': self.accel_offset, + 'gyro_offsets': self.gyro_offset + } + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration from saved values.""" + if accel_offsets: + self.accel_offset = list(accel_offsets) + if gyro_offsets: + self.gyro_offset = list(gyro_offsets) + + +class _WsenISDSDriver(_IMUDriver): + """Wrapper for WSEN_ISDS IMU (Fri3d badge).""" + + def __init__(self, i2c_bus, address): + from mpos.hardware.drivers.wsen_isds import Wsen_Isds + self.sensor = Wsen_Isds( + i2c_bus, + address=address, + acc_range="8g", + acc_data_rate="104Hz", + gyro_range="500dps", + gyro_data_rate="104Hz" + ) + + def read_acceleration(self): + """Read acceleration in m/s² (converts from mg).""" + ax, ay, az = self.sensor.read_accelerations() + # Convert mg to m/s²: mg → g → m/s² + return ( + (ax / 1000.0) * _GRAVITY, + (ay / 1000.0) * _GRAVITY, + (az / 1000.0) * _GRAVITY + ) + + def read_gyroscope(self): + """Read gyroscope in deg/s (converts from mdps).""" + gx, gy, gz = self.sensor.read_angular_velocities() + # Convert mdps to deg/s + return ( + gx / 1000.0, + gy / 1000.0, + gz / 1000.0 + ) + + def read_temperature(self): + """Read temperature in °C (not implemented in WSEN_ISDS driver).""" + # WSEN_ISDS has temperature sensor but not exposed in current driver + return None + + def calibrate_accelerometer(self, samples): + """Calibrate accelerometer using hardware calibration.""" + self.sensor.acc_calibrate(samples) + # Return offsets in m/s² (convert from raw offsets) + return ( + (self.sensor.acc_offset_x * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY, + (self.sensor.acc_offset_y * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY, + (self.sensor.acc_offset_z * self.sensor.acc_sensitivity / 1000.0) * _GRAVITY + ) + + def calibrate_gyroscope(self, samples): + """Calibrate gyroscope using hardware calibration.""" + self.sensor.gyro_calibrate(samples) + # Return offsets in deg/s (convert from raw offsets) + return ( + (self.sensor.gyro_offset_x * self.sensor.gyro_sensitivity) / 1000.0, + (self.sensor.gyro_offset_y * self.sensor.gyro_sensitivity) / 1000.0, + (self.sensor.gyro_offset_z * self.sensor.gyro_sensitivity) / 1000.0 + ) + + def get_calibration(self): + """Get current calibration (raw offsets from hardware).""" + return { + 'accel_offsets': [ + self.sensor.acc_offset_x, + self.sensor.acc_offset_y, + self.sensor.acc_offset_z + ], + 'gyro_offsets': [ + self.sensor.gyro_offset_x, + self.sensor.gyro_offset_y, + self.sensor.gyro_offset_z + ] + } + + def set_calibration(self, accel_offsets, gyro_offsets): + """Set calibration from saved values (raw offsets).""" + if accel_offsets: + self.sensor.acc_offset_x = accel_offsets[0] + self.sensor.acc_offset_y = accel_offsets[1] + self.sensor.acc_offset_z = accel_offsets[2] + if gyro_offsets: + self.sensor.gyro_offset_x = gyro_offsets[0] + self.sensor.gyro_offset_y = gyro_offsets[1] + self.sensor.gyro_offset_z = gyro_offsets[2] + + +# ============================================================================ +# Sensor registration (internal) +# ============================================================================ + +def _register_qmi8658_sensors(): + """Register QMI8658 sensors in sensor list.""" + global _sensor_list + _sensor_list = [ + Sensor( + name="QMI8658 Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="QST Corporation", + version=1, + max_range="±8G (78.4 m/s²)", + resolution="0.0024 m/s²", + power_ma=0.2 + ), + Sensor( + name="QMI8658 Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="QST Corporation", + version=1, + max_range="±256 deg/s", + resolution="0.002 deg/s", + power_ma=0.7 + ), + Sensor( + name="QMI8658 Temperature", + sensor_type=TYPE_IMU_TEMPERATURE, + vendor="QST Corporation", + version=1, + max_range="-40°C to +85°C", + resolution="0.004°C", + power_ma=0 + ) + ] + + +def _register_wsen_isds_sensors(): + """Register WSEN_ISDS sensors in sensor list.""" + global _sensor_list + _sensor_list = [ + Sensor( + name="WSEN_ISDS Accelerometer", + sensor_type=TYPE_ACCELEROMETER, + vendor="Würth Elektronik", + version=1, + max_range="±8G (78.4 m/s²)", + resolution="0.0024 m/s²", + power_ma=0.2 + ), + Sensor( + name="WSEN_ISDS Gyroscope", + sensor_type=TYPE_GYROSCOPE, + vendor="Würth Elektronik", + version=1, + max_range="±500 deg/s", + resolution="0.0175 deg/s", + power_ma=0.65 + ) + ] + + +def _register_mcu_temperature_sensor(): + """Register MCU internal temperature sensor in sensor list.""" + global _sensor_list + _sensor_list.append( + Sensor( + name="ESP32 MCU Temperature", + sensor_type=TYPE_SOC_TEMPERATURE, + vendor="Espressif", + version=1, + max_range="-40°C to +125°C", + resolution="0.5°C", + power_ma=0 + ) + ) + + +# ============================================================================ +# Calibration persistence (internal) +# ============================================================================ + +def _load_calibration(): + """Load calibration from SharedPreferences.""" + if not _imu_driver: + return + + try: + from mpos.config import SharedPreferences + prefs = SharedPreferences("com.micropythonos.sensors") + + accel_offsets = prefs.get_list("accel_offsets") + gyro_offsets = prefs.get_list("gyro_offsets") + + if accel_offsets or gyro_offsets: + _imu_driver.set_calibration(accel_offsets, gyro_offsets) + print(f"[SensorManager] Loaded calibration: accel={accel_offsets}, gyro={gyro_offsets}") + except Exception as e: + print(f"[SensorManager] Failed to load calibration: {e}") + + +def _save_calibration(): + """Save calibration to SharedPreferences.""" + if not _imu_driver: + return + + try: + from mpos.config import SharedPreferences + prefs = SharedPreferences("com.micropythonos.sensors") + editor = prefs.edit() + + cal = _imu_driver.get_calibration() + editor.put_list("accel_offsets", list(cal['accel_offsets'])) + editor.put_list("gyro_offsets", list(cal['gyro_offsets'])) + editor.commit() + + print(f"[SensorManager] Saved calibration: accel={cal['accel_offsets']}, gyro={cal['gyro_offsets']}") + except Exception as e: + print(f"[SensorManager] Failed to save calibration: {e}") diff --git a/internal_filesystem/lib/mpos/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index b37a1232..7911c957 100644 --- a/internal_filesystem/lib/mpos/ui/topmenu.py +++ b/internal_filesystem/lib/mpos/ui/topmenu.py @@ -163,16 +163,22 @@ def create_notification_bar(): else: wifi_icon.add_flag(lv.obj.FLAG.HIDDEN) - can_check_temperature = False - try: - import esp32 - can_check_temperature = True - except Exception as e: - print("Warning: can't check temperature sensor:", str(e)) - + # Get temperature sensor via SensorManager + import mpos.sensor_manager as SensorManager + temp_sensor = None + if SensorManager.is_available(): + # Prefer MCU temperature (more stable) over IMU temperature + temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_SOC_TEMPERATURE) + if not temp_sensor: + temp_sensor = SensorManager.get_default_sensor(SensorManager.TYPE_IMU_TEMPERATURE) + def update_temperature(timer): - if can_check_temperature: - temp_label.set_text(f"{esp32.mcu_temperature()}°C") + if temp_sensor: + temp = SensorManager.read_sensor(temp_sensor) + if temp is not None: + temp_label.set_text(f"{round(temp)}°C") + else: + temp_label.set_text("--°C") else: temp_label.set_text("42°C")