Settings app: add IMU calibration with check

This commit is contained in:
Thomas Farstrike
2025-12-05 20:48:00 +01:00
parent 92c2fcfec7
commit 56b7cc17e9
5 changed files with 1099 additions and 7 deletions
@@ -0,0 +1,362 @@
"""Calibrate IMU Activity.
Guides user through IMU calibration process:
1. Check current calibration quality
2. Ask if user wants to recalibrate
3. Check stationarity
4. Perform calibration
5. Verify results
6. Save to new location
"""
import lvgl as lv
import time
import _thread
import sys
from mpos.app.activity import Activity
import mpos.ui
import mpos.sensor_manager as SensorManager
import mpos.apps
class CalibrationState:
"""Enum for calibration states."""
IDLE = 0
CHECKING_QUALITY = 1
AWAITING_CONFIRMATION = 2
CHECKING_STATIONARITY = 3
CALIBRATING = 4
VERIFYING = 5
COMPLETE = 6
ERROR = 7
class CalibrateIMUActivity(Activity):
"""Guide user through IMU calibration process."""
# State
current_state = CalibrationState.IDLE
calibration_thread = None
# Widgets
title_label = None
status_label = None
progress_bar = None
detail_label = None
action_button = None
action_button_label = None
cancel_button = None
def __init__(self):
super().__init__()
self.is_desktop = sys.platform != "esp32"
def onCreate(self):
screen = lv.obj()
screen.set_style_pad_all(mpos.ui.pct_of_display_width(3), 0)
screen.set_flex_flow(lv.FLEX_FLOW.COLUMN)
screen.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER)
# Title
self.title_label = lv.label(screen)
self.title_label.set_text("IMU Calibration")
self.title_label.set_style_text_font(lv.font_montserrat_20, 0)
# Status label
self.status_label = lv.label(screen)
self.status_label.set_text("Initializing...")
self.status_label.set_style_text_font(lv.font_montserrat_16, 0)
self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP)
self.status_label.set_width(lv.pct(90))
# Progress bar (hidden initially)
self.progress_bar = lv.bar(screen)
self.progress_bar.set_size(lv.pct(90), 20)
self.progress_bar.set_value(0, False)
self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN)
# Detail label (for additional info)
self.detail_label = lv.label(screen)
self.detail_label.set_text("")
self.detail_label.set_style_text_font(lv.font_montserrat_12, 0)
self.detail_label.set_style_text_color(lv.color_hex(0x888888), 0)
self.detail_label.set_long_mode(lv.label.LONG_MODE.WRAP)
self.detail_label.set_width(lv.pct(90))
# Button container
btn_cont = lv.obj(screen)
btn_cont.set_width(lv.pct(100))
btn_cont.set_height(lv.SIZE_CONTENT)
btn_cont.set_style_border_width(0, 0)
btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW)
btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0)
# Action button
self.action_button = lv.button(btn_cont)
self.action_button.set_size(lv.pct(45), lv.SIZE_CONTENT)
self.action_button_label = lv.label(self.action_button)
self.action_button_label.set_text("Start")
self.action_button_label.center()
self.action_button.add_event_cb(self.action_button_clicked, lv.EVENT.CLICKED, None)
# Cancel button
self.cancel_button = lv.button(btn_cont)
self.cancel_button.set_size(lv.pct(45), lv.SIZE_CONTENT)
cancel_label = lv.label(self.cancel_button)
cancel_label.set_text("Cancel")
cancel_label.center()
self.cancel_button.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None)
self.setContentView(screen)
def onResume(self, screen):
super().onResume(screen)
# Check if IMU is available
if not self.is_desktop and not SensorManager.is_available():
self.set_state(CalibrationState.ERROR)
self.status_label.set_text("IMU not available on this device")
self.action_button.add_state(lv.STATE.DISABLED)
return
# Start by checking current quality
self.set_state(CalibrationState.IDLE)
self.action_button_label.set_text("Check Quality")
def onPause(self, screen):
# Stop any running calibration
if self.current_state == CalibrationState.CALIBRATING:
# Calibration will detect activity is no longer in foreground
pass
super().onPause(screen)
def set_state(self, new_state):
"""Update state and UI accordingly."""
self.current_state = new_state
self.update_ui_for_state()
def update_ui_for_state(self):
"""Update UI based on current state."""
if self.current_state == CalibrationState.IDLE:
self.status_label.set_text("Ready to check calibration quality")
self.action_button_label.set_text("Check Quality")
self.action_button.remove_state(lv.STATE.DISABLED)
self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN)
elif self.current_state == CalibrationState.CHECKING_QUALITY:
self.status_label.set_text("Checking current calibration...")
self.action_button.add_state(lv.STATE.DISABLED)
self.progress_bar.remove_flag(lv.obj.FLAG.HIDDEN)
self.progress_bar.set_value(20, True)
elif self.current_state == CalibrationState.AWAITING_CONFIRMATION:
# Status will be set by quality check result
self.action_button_label.set_text("Calibrate Now")
self.action_button.remove_state(lv.STATE.DISABLED)
self.progress_bar.set_value(30, True)
elif self.current_state == CalibrationState.CHECKING_STATIONARITY:
self.status_label.set_text("Checking if device is stationary...")
self.detail_label.set_text("Keep device still on flat surface")
self.action_button.add_state(lv.STATE.DISABLED)
self.progress_bar.set_value(40, True)
elif self.current_state == CalibrationState.CALIBRATING:
self.status_label.set_text("Calibrating IMU...")
self.detail_label.set_text("Do not move device!\nCollecting samples...")
self.action_button.add_state(lv.STATE.DISABLED)
self.progress_bar.set_value(60, True)
elif self.current_state == CalibrationState.VERIFYING:
self.status_label.set_text("Verifying calibration...")
self.action_button.add_state(lv.STATE.DISABLED)
self.progress_bar.set_value(90, True)
elif self.current_state == CalibrationState.COMPLETE:
self.status_label.set_text("Calibration complete!")
self.action_button_label.set_text("Done")
self.action_button.remove_state(lv.STATE.DISABLED)
self.progress_bar.set_value(100, True)
elif self.current_state == CalibrationState.ERROR:
self.action_button_label.set_text("Retry")
self.action_button.remove_state(lv.STATE.DISABLED)
self.progress_bar.add_flag(lv.obj.FLAG.HIDDEN)
def action_button_clicked(self, event):
"""Handle action button clicks based on current state."""
if self.current_state == CalibrationState.IDLE:
self.start_quality_check()
elif self.current_state == CalibrationState.AWAITING_CONFIRMATION:
self.start_calibration_process()
elif self.current_state == CalibrationState.COMPLETE:
self.finish()
elif self.current_state == CalibrationState.ERROR:
self.set_state(CalibrationState.IDLE)
def start_quality_check(self):
"""Check current calibration quality."""
self.set_state(CalibrationState.CHECKING_QUALITY)
# Run in background thread
_thread.stack_size(mpos.apps.good_stack_size())
_thread.start_new_thread(self.quality_check_thread, ())
def quality_check_thread(self):
"""Background thread for quality check."""
try:
if self.is_desktop:
quality = self.get_mock_quality()
else:
quality = SensorManager.check_calibration_quality(samples=50)
if quality is None:
self.update_ui_threadsafe_if_foreground(self.handle_quality_error, "Failed to read IMU")
return
# Update UI with results
self.update_ui_threadsafe_if_foreground(self.show_quality_results, quality)
except Exception as e:
print(f"[CalibrateIMU] Quality check error: {e}")
self.update_ui_threadsafe_if_foreground(self.handle_quality_error, str(e))
def show_quality_results(self, quality):
"""Show quality check results and ask for confirmation."""
rating = quality['quality_rating']
score = quality['quality_score']
issues = quality['issues']
# Build status message
if rating == "Good":
msg = f"Current calibration: {rating} ({score*100:.0f}%)\n\nCalibration looks good!"
else:
msg = f"Current calibration: {rating} ({score*100:.0f}%)\n\nRecommend recalibrating."
if issues:
msg += "\n\nIssues found:\n" + "\n".join(f"- {issue}" for issue in issues[:3]) # Show first 3
self.status_label.set_text(msg)
self.set_state(CalibrationState.AWAITING_CONFIRMATION)
def handle_quality_error(self, error_msg):
"""Handle error during quality check."""
self.set_state(CalibrationState.ERROR)
self.status_label.set_text(f"Error: {error_msg}")
self.detail_label.set_text("Check IMU connection and try again")
def start_calibration_process(self):
"""Start the calibration process."""
self.set_state(CalibrationState.CHECKING_STATIONARITY)
# Run in background thread
_thread.stack_size(mpos.apps.good_stack_size())
_thread.start_new_thread(self.calibration_thread_func, ())
def calibration_thread_func(self):
"""Background thread for calibration process."""
try:
# Step 1: Check stationarity
if self.is_desktop:
stationarity = {'is_stationary': True, 'message': 'Mock: Stationary'}
else:
stationarity = SensorManager.check_stationarity(samples=30)
if stationarity is None or not stationarity['is_stationary']:
msg = stationarity['message'] if stationarity else "Stationarity check failed"
self.update_ui_threadsafe_if_foreground(self.handle_calibration_error,
f"Device not stationary!\n\n{msg}\n\nPlace on flat surface and try again.")
return
# Step 2: Perform calibration
self.update_ui_threadsafe_if_foreground(lambda: self.set_state(CalibrationState.CALIBRATING))
time.sleep(0.5) # Brief pause for user to see status change
if self.is_desktop:
# Mock calibration
time.sleep(2)
accel_offsets = (0.1, -0.05, 0.15)
gyro_offsets = (0.2, -0.1, 0.05)
else:
# Real calibration
accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER)
gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE)
if accel:
accel_offsets = SensorManager.calibrate_sensor(accel, samples=100)
else:
accel_offsets = None
if gyro:
gyro_offsets = SensorManager.calibrate_sensor(gyro, samples=100)
else:
gyro_offsets = None
# Step 3: Verify results
self.update_ui_threadsafe_if_foreground(lambda: self.set_state(CalibrationState.VERIFYING))
time.sleep(0.5)
if self.is_desktop:
verify_quality = self.get_mock_quality(good=True)
else:
verify_quality = SensorManager.check_calibration_quality(samples=50)
if verify_quality is None:
self.update_ui_threadsafe_if_foreground(self.handle_calibration_error,
"Calibration completed but verification failed")
return
# Step 4: Show results
rating = verify_quality['quality_rating']
score = verify_quality['quality_score']
result_msg = f"Calibration successful!\n\nNew quality: {rating} ({score*100:.0f}%)"
if accel_offsets:
result_msg += f"\n\nAccel offsets:\nX:{accel_offsets[0]:.3f} Y:{accel_offsets[1]:.3f} Z:{accel_offsets[2]:.3f}"
if gyro_offsets:
result_msg += f"\n\nGyro offsets:\nX:{gyro_offsets[0]:.3f} Y:{gyro_offsets[1]:.3f} Z:{gyro_offsets[2]:.3f}"
self.update_ui_threadsafe_if_foreground(self.show_calibration_complete, result_msg)
except Exception as e:
print(f"[CalibrateIMU] Calibration error: {e}")
self.update_ui_threadsafe_if_foreground(self.handle_calibration_error, str(e))
def show_calibration_complete(self, result_msg):
"""Show calibration completion message."""
self.status_label.set_text(result_msg)
self.detail_label.set_text("Calibration saved to Settings")
self.set_state(CalibrationState.COMPLETE)
def handle_calibration_error(self, error_msg):
"""Handle error during calibration."""
self.set_state(CalibrationState.ERROR)
self.status_label.set_text(f"Calibration failed:\n\n{error_msg}")
self.detail_label.set_text("")
def get_mock_quality(self, good=False):
"""Generate mock quality data for desktop testing."""
import random
if good:
# Simulate excellent calibration after calibration
return {
'accel_mean': (random.uniform(-0.05, 0.05), random.uniform(-0.05, 0.05), 9.8 + random.uniform(-0.1, 0.1)),
'accel_variance': (random.uniform(0.001, 0.02), random.uniform(0.001, 0.02), random.uniform(0.001, 0.02)),
'gyro_mean': (random.uniform(-0.1, 0.1), random.uniform(-0.1, 0.1), random.uniform(-0.1, 0.1)),
'gyro_variance': (random.uniform(0.01, 0.2), random.uniform(0.01, 0.2), random.uniform(0.01, 0.2)),
'quality_score': random.uniform(0.90, 0.99),
'quality_rating': "Good",
'issues': []
}
else:
# Simulate mediocre calibration before calibration
return {
'accel_mean': (random.uniform(-1.0, 1.0), random.uniform(-1.0, 1.0), 9.8 + random.uniform(-2.0, 2.0)),
'accel_variance': (random.uniform(0.2, 0.5), random.uniform(0.2, 0.5), random.uniform(0.2, 0.5)),
'gyro_mean': (random.uniform(-3.0, 3.0), random.uniform(-3.0, 3.0), random.uniform(-3.0, 3.0)),
'gyro_variance': (random.uniform(2.0, 5.0), random.uniform(2.0, 5.0), random.uniform(2.0, 5.0)),
'quality_score': random.uniform(0.4, 0.6),
'quality_rating': "Fair",
'issues': ["High accelerometer variance", "Gyro not near zero"]
}
@@ -0,0 +1,238 @@
"""Check IMU Calibration Activity.
Shows current IMU calibration quality with real-time sensor values,
variance, expected value comparison, and overall quality score.
"""
import lvgl as lv
import time
import sys
from mpos.app.activity import Activity
import mpos.ui
import mpos.sensor_manager as SensorManager
class CheckIMUCalibrationActivity(Activity):
"""Display IMU calibration quality with real-time monitoring."""
# Update interval for real-time display (milliseconds)
UPDATE_INTERVAL = 100
# State
updating = False
update_timer = None
# Widgets
status_label = None
quality_label = None
accel_labels = [] # [x_label, y_label, z_label]
gyro_labels = []
issues_label = None
quality_score_label = None
def __init__(self):
super().__init__()
self.is_desktop = sys.platform != "esp32"
def onCreate(self):
screen = lv.obj()
screen.set_style_pad_all(mpos.ui.pct_of_display_width(2), 0)
screen.set_flex_flow(lv.FLEX_FLOW.COLUMN)
# Title
title = lv.label(screen)
title.set_text("IMU Calibration Check")
title.set_style_text_font(lv.font_montserrat_20, 0)
# Status label
self.status_label = lv.label(screen)
self.status_label.set_text("Checking...")
self.status_label.set_style_text_font(lv.font_montserrat_14, 0)
# Separator
sep1 = lv.obj(screen)
sep1.set_size(lv.pct(100), 2)
sep1.set_style_bg_color(lv.color_hex(0x666666), 0)
# Quality score (large, prominent)
self.quality_score_label = lv.label(screen)
self.quality_score_label.set_text("Quality: --")
self.quality_score_label.set_style_text_font(lv.font_montserrat_20, 0)
# Accelerometer section
accel_title = lv.label(screen)
accel_title.set_text("Accelerometer (m/s²)")
accel_title.set_style_text_font(lv.font_montserrat_14, 0)
for axis in ['X', 'Y', 'Z']:
label = lv.label(screen)
label.set_text(f"{axis}: --")
label.set_style_text_font(lv.font_montserrat_12, 0)
self.accel_labels.append(label)
# Gyroscope section
gyro_title = lv.label(screen)
gyro_title.set_text("Gyroscope (deg/s)")
gyro_title.set_style_text_font(lv.font_montserrat_14, 0)
for axis in ['X', 'Y', 'Z']:
label = lv.label(screen)
label.set_text(f"{axis}: --")
label.set_style_text_font(lv.font_montserrat_12, 0)
self.gyro_labels.append(label)
# Separator
sep2 = lv.obj(screen)
sep2.set_size(lv.pct(100), 2)
sep2.set_style_bg_color(lv.color_hex(0x666666), 0)
# Issues label
self.issues_label = lv.label(screen)
self.issues_label.set_text("Issues: None")
self.issues_label.set_style_text_font(lv.font_montserrat_12, 0)
self.issues_label.set_style_text_color(lv.color_hex(0xFF6666), 0)
self.issues_label.set_long_mode(lv.label.LONG_MODE.WRAP)
self.issues_label.set_width(lv.pct(95))
# Button container
btn_cont = lv.obj(screen)
btn_cont.set_width(lv.pct(100))
btn_cont.set_height(lv.SIZE_CONTENT)
btn_cont.set_style_border_width(0, 0)
btn_cont.set_flex_flow(lv.FLEX_FLOW.ROW)
btn_cont.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, 0)
# Back button
back_btn = lv.button(btn_cont)
back_btn.set_size(lv.pct(45), lv.SIZE_CONTENT)
back_label = lv.label(back_btn)
back_label.set_text("Back")
back_label.center()
back_btn.add_event_cb(lambda e: self.finish(), lv.EVENT.CLICKED, None)
# Calibrate button
calibrate_btn = lv.button(btn_cont)
calibrate_btn.set_size(lv.pct(45), lv.SIZE_CONTENT)
calibrate_label = lv.label(calibrate_btn)
calibrate_label.set_text("Calibrate")
calibrate_label.center()
calibrate_btn.add_event_cb(self.start_calibration, lv.EVENT.CLICKED, None)
self.setContentView(screen)
def onResume(self, screen):
super().onResume(screen)
# Check if IMU is available
if not self.is_desktop and not SensorManager.is_available():
self.status_label.set_text("IMU not available on this device")
self.quality_score_label.set_text("N/A")
return
# Start real-time updates
self.updating = True
self.update_timer = lv.timer_create(self.update_display, self.UPDATE_INTERVAL, None)
def onPause(self, screen):
# Stop updates
self.updating = False
if self.update_timer:
self.update_timer.delete()
self.update_timer = None
super().onPause(screen)
def update_display(self, timer=None):
"""Update display with current sensor values and quality."""
if not self.updating:
return
try:
# Get quality check (desktop or hardware)
if self.is_desktop:
quality = self.get_mock_quality()
else:
quality = SensorManager.check_calibration_quality(samples=30)
if quality is None:
self.status_label.set_text("Error reading IMU")
return
# Update quality score
score = quality['quality_score']
rating = quality['quality_rating']
self.quality_score_label.set_text(f"Quality: {rating} ({score*100:.0f}%)")
# Color based on rating
if rating == "Good":
color = 0x66FF66 # Green
elif rating == "Fair":
color = 0xFFFF66 # Yellow
else:
color = 0xFF6666 # Red
self.quality_score_label.set_style_text_color(lv.color_hex(color), 0)
# Update accelerometer values
accel_mean = quality['accel_mean']
accel_var = quality['accel_variance']
for i, (mean, var) in enumerate(zip(accel_mean, accel_var)):
axis = ['X', 'Y', 'Z'][i]
self.accel_labels[i].set_text(f"{axis}: {mean:6.2f} (var: {var:.3f})")
# Update gyroscope values
gyro_mean = quality['gyro_mean']
gyro_var = quality['gyro_variance']
for i, (mean, var) in enumerate(zip(gyro_mean, gyro_var)):
axis = ['X', 'Y', 'Z'][i]
self.gyro_labels[i].set_text(f"{axis}: {mean:6.2f} (var: {var:.3f})")
# Update issues
issues = quality['issues']
if issues:
issues_text = "Issues:\n" + "\n".join(f"- {issue}" for issue in issues)
else:
issues_text = "Issues: None - calibration looks good!"
self.issues_label.set_text(issues_text)
self.status_label.set_text("Real-time monitoring (place on flat surface)")
except:
# Widgets were deleted (activity closed), stop updating
self.updating = False
def get_mock_quality(self):
"""Generate mock quality data for desktop testing."""
import random
# Simulate good calibration with small random noise
return {
'accel_mean': (
random.uniform(-0.2, 0.2),
random.uniform(-0.2, 0.2),
9.8 + random.uniform(-0.3, 0.3)
),
'accel_variance': (
random.uniform(0.01, 0.1),
random.uniform(0.01, 0.1),
random.uniform(0.01, 0.1)
),
'gyro_mean': (
random.uniform(-0.5, 0.5),
random.uniform(-0.5, 0.5),
random.uniform(-0.5, 0.5)
),
'gyro_variance': (
random.uniform(0.1, 1.0),
random.uniform(0.1, 1.0),
random.uniform(0.1, 1.0)
),
'quality_score': random.uniform(0.75, 0.95),
'quality_rating': "Good",
'issues': []
}
def start_calibration(self, event):
"""Navigate to calibration activity."""
from mpos.content.intent import Intent
from calibrate_imu import CalibrateIMUActivity
intent = Intent(activity_class=CalibrateIMUActivity)
self.startActivity(intent)
@@ -1,3 +1,4 @@
import lvgl as lv
from mpos.apps import Activity, Intent
from mpos.activity_navigator import ActivityNavigator
@@ -7,6 +8,10 @@ import mpos.config
import mpos.ui
import mpos.time
# Import IMU calibration activities
from check_imu_calibration import CheckIMUCalibrationActivity
from calibrate_imu import CalibrateIMUActivity
# Used to list and edit all settings:
class SettingsActivity(Activity):
def __init__(self):
@@ -39,6 +44,8 @@ class SettingsActivity(Activity):
]
self.settings = [
# Novice settings, alphabetically:
{"title": "Calibrate IMU", "key": "calibrate_imu", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CalibrateIMUActivity"},
{"title": "Check IMU Calibration", "key": "check_imu_calibration", "value_label": None, "cont": None, "ui": "activity", "activity_class": "CheckIMUCalibrationActivity"},
{"title": "Light/Dark Theme", "key": "theme_light_dark", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("Light", "light"), ("Dark", "dark")]},
{"title": "Theme Color", "key": "theme_primary_color", "value_label": None, "cont": None, "placeholder": "HTML hex color, like: EC048C", "ui": "dropdown", "ui_options": theme_colors},
{"title": "Timezone", "key": "timezone", "value_label": None, "cont": None, "ui": "dropdown", "ui_options": self.get_timezone_tuples(), "changed_callback": lambda : mpos.time.refresh_timezone_preference()},
@@ -104,6 +111,20 @@ class SettingsActivity(Activity):
focusgroup.add_obj(setting_cont)
def startSettingActivity(self, setting):
ui_type = setting.get("ui")
# Handle activity-based settings (NEW)
if ui_type == "activity":
activity_class_name = setting.get("activity_class")
if activity_class_name == "CheckIMUCalibrationActivity":
intent = Intent(activity_class=CheckIMUCalibrationActivity)
self.startActivity(intent)
elif activity_class_name == "CalibrateIMUActivity":
intent = Intent(activity_class=CalibrateIMUActivity)
self.startActivity(intent)
return
# Handle traditional settings (existing code)
intent = Intent(activity_class=SettingActivity)
intent.putExtra("setting", setting)
self.startActivity(intent)
+258 -7
View File
@@ -271,6 +271,238 @@ def calibrate_sensor(sensor, samples=100):
_lock.release()
# Helper functions for calibration quality checking (module-level to avoid nested def issues)
def _calc_mean_variance(samples_list):
"""Calculate mean and variance for a list of samples."""
if not samples_list:
return 0.0, 0.0
n = len(samples_list)
mean = sum(samples_list) / n
variance = sum((x - mean) ** 2 for x in samples_list) / n
return mean, variance
def _calc_variance(samples_list):
"""Calculate variance for a list of samples."""
if not samples_list:
return 0.0
n = len(samples_list)
mean = sum(samples_list) / n
return sum((x - mean) ** 2 for x in samples_list) / n
def check_calibration_quality(samples=50):
"""Check quality of current calibration.
Args:
samples: Number of samples to collect (default 50)
Returns:
dict with:
- accel_mean: (x, y, z) mean values in m/s²
- accel_variance: (x, y, z) variance values
- gyro_mean: (x, y, z) mean values in deg/s
- gyro_variance: (x, y, z) variance values
- quality_score: float 0.0-1.0 (1.0 = perfect)
- quality_rating: string ("Good", "Fair", "Poor")
- issues: list of strings describing problems
None if IMU not available
"""
if not is_available():
return None
if _lock:
_lock.acquire()
try:
accel = get_default_sensor(TYPE_ACCELEROMETER)
gyro = get_default_sensor(TYPE_GYROSCOPE)
# Collect samples
accel_samples = [[], [], []] # x, y, z lists
gyro_samples = [[], [], []]
for _ in range(samples):
if accel:
data = read_sensor(accel)
if data:
ax, ay, az = data
accel_samples[0].append(ax)
accel_samples[1].append(ay)
accel_samples[2].append(az)
if gyro:
data = read_sensor(gyro)
if data:
gx, gy, gz = data
gyro_samples[0].append(gx)
gyro_samples[1].append(gy)
gyro_samples[2].append(gz)
time.sleep_ms(10)
# Calculate statistics using module-level helper
accel_stats = [_calc_mean_variance(s) for s in accel_samples]
gyro_stats = [_calc_mean_variance(s) for s in gyro_samples]
accel_mean = tuple(s[0] for s in accel_stats)
accel_variance = tuple(s[1] for s in accel_stats)
gyro_mean = tuple(s[0] for s in gyro_stats)
gyro_variance = tuple(s[1] for s in gyro_stats)
# Calculate quality score (0.0 - 1.0)
issues = []
scores = []
# Check accelerometer
if accel:
# Variance check (lower is better)
accel_max_variance = max(accel_variance)
variance_score = max(0.0, 1.0 - (accel_max_variance / 1.0)) # 1.0 m/s² variance threshold
scores.append(variance_score)
if accel_max_variance > 0.5:
issues.append(f"High accelerometer variance: {accel_max_variance:.3f} m/s²")
# Expected values check (X≈0, Y≈0, Z≈9.8)
ax, ay, az = accel_mean
xy_error = (abs(ax) + abs(ay)) / 2.0
z_error = abs(az - _GRAVITY)
expected_score = max(0.0, 1.0 - ((xy_error + z_error) / 5.0)) # 5.0 m/s² error threshold
scores.append(expected_score)
if xy_error > 1.0:
issues.append(f"Accel X/Y not near zero: X={ax:.2f}, Y={ay:.2f} m/s²")
if z_error > 1.0:
issues.append(f"Accel Z not near 9.8: Z={az:.2f} m/s²")
# Check gyroscope
if gyro:
# Variance check
gyro_max_variance = max(gyro_variance)
variance_score = max(0.0, 1.0 - (gyro_max_variance / 10.0)) # 10 deg/s variance threshold
scores.append(variance_score)
if gyro_max_variance > 5.0:
issues.append(f"High gyroscope variance: {gyro_max_variance:.3f} deg/s")
# Expected values check (all ≈0)
gx, gy, gz = gyro_mean
error = (abs(gx) + abs(gy) + abs(gz)) / 3.0
expected_score = max(0.0, 1.0 - (error / 10.0)) # 10 deg/s error threshold
scores.append(expected_score)
if error > 2.0:
issues.append(f"Gyro not near zero: X={gx:.2f}, Y={gy:.2f}, Z={gz:.2f} deg/s")
# Overall quality score
quality_score = sum(scores) / len(scores) if scores else 0.0
# Rating
if quality_score >= 0.8:
quality_rating = "Good"
elif quality_score >= 0.5:
quality_rating = "Fair"
else:
quality_rating = "Poor"
return {
'accel_mean': accel_mean,
'accel_variance': accel_variance,
'gyro_mean': gyro_mean,
'gyro_variance': gyro_variance,
'quality_score': quality_score,
'quality_rating': quality_rating,
'issues': issues
}
except Exception as e:
print(f"[SensorManager] Error checking calibration quality: {e}")
return None
finally:
if _lock:
_lock.release()
def check_stationarity(samples=30, variance_threshold_accel=0.5, variance_threshold_gyro=5.0):
"""Check if device is stationary (required for calibration).
Args:
samples: Number of samples to collect (default 30)
variance_threshold_accel: Max acceptable accel variance in m/s² (default 0.5)
variance_threshold_gyro: Max acceptable gyro variance in deg/s (default 5.0)
Returns:
dict with:
- is_stationary: bool
- accel_variance: max variance across axes
- gyro_variance: max variance across axes
- message: string describing result
None if IMU not available
"""
if not is_available():
return None
if _lock:
_lock.acquire()
try:
accel = get_default_sensor(TYPE_ACCELEROMETER)
gyro = get_default_sensor(TYPE_GYROSCOPE)
# Collect samples
accel_samples = [[], [], []]
gyro_samples = [[], [], []]
for _ in range(samples):
if accel:
data = read_sensor(accel)
if data:
ax, ay, az = data
accel_samples[0].append(ax)
accel_samples[1].append(ay)
accel_samples[2].append(az)
if gyro:
data = read_sensor(gyro)
if data:
gx, gy, gz = data
gyro_samples[0].append(gx)
gyro_samples[1].append(gy)
gyro_samples[2].append(gz)
time.sleep_ms(10)
# Calculate variance using module-level helper
accel_var = [_calc_variance(s) for s in accel_samples]
gyro_var = [_calc_variance(s) for s in gyro_samples]
max_accel_var = max(accel_var) if accel_var else 0.0
max_gyro_var = max(gyro_var) if gyro_var else 0.0
# Check thresholds
accel_stationary = max_accel_var < variance_threshold_accel
gyro_stationary = max_gyro_var < variance_threshold_gyro
is_stationary = accel_stationary and gyro_stationary
# Generate message
if is_stationary:
message = "Device is stationary - ready to calibrate"
else:
problems = []
if not accel_stationary:
problems.append(f"movement detected (accel variance: {max_accel_var:.3f})")
if not gyro_stationary:
problems.append(f"rotation detected (gyro variance: {max_gyro_var:.3f})")
message = f"Device NOT stationary: {', '.join(problems)}"
return {
'is_stationary': is_stationary,
'accel_variance': max_accel_var,
'gyro_variance': max_gyro_var,
'message': message
}
except Exception as e:
print(f"[SensorManager] Error checking stationarity: {e}")
return None
finally:
if _lock:
_lock.release()
# ============================================================================
# Internal driver abstraction layer
# ============================================================================
@@ -571,16 +803,34 @@ def _register_mcu_temperature_sensor():
# ============================================================================
def _load_calibration():
"""Load calibration from SharedPreferences."""
"""Load calibration from SharedPreferences (with migration support)."""
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")
# Try NEW location first
prefs_new = SharedPreferences("com.micropythonos.settings", filename="sensors.json")
accel_offsets = prefs_new.get_list("accel_offsets")
gyro_offsets = prefs_new.get_list("gyro_offsets")
# If not found, try OLD location and migrate
if not accel_offsets and not gyro_offsets:
prefs_old = SharedPreferences("com.micropythonos.sensors")
accel_offsets = prefs_old.get_list("accel_offsets")
gyro_offsets = prefs_old.get_list("gyro_offsets")
if accel_offsets or gyro_offsets:
print("[SensorManager] Migrating calibration from old to new location...")
# Save to new location
editor = prefs_new.edit()
if accel_offsets:
editor.put_list("accel_offsets", accel_offsets)
if gyro_offsets:
editor.put_list("gyro_offsets", gyro_offsets)
editor.commit()
print("[SensorManager] Migration complete")
if accel_offsets or gyro_offsets:
_imu_driver.set_calibration(accel_offsets, gyro_offsets)
@@ -590,13 +840,14 @@ def _load_calibration():
def _save_calibration():
"""Save calibration to SharedPreferences."""
"""Save calibration to SharedPreferences (new location)."""
if not _imu_driver:
return
try:
from mpos.config import SharedPreferences
prefs = SharedPreferences("com.micropythonos.sensors")
# NEW LOCATION: com.micropythonos.settings/sensors.json
prefs = SharedPreferences("com.micropythonos.settings", filename="sensors.json")
editor = prefs.edit()
cal = _imu_driver.get_calibration()
@@ -604,6 +855,6 @@ def _save_calibration():
editor.put_list("gyro_offsets", list(cal['gyro_offsets']))
editor.commit()
print(f"[SensorManager] Saved calibration: accel={cal['accel_offsets']}, gyro={cal['gyro_offsets']}")
print(f"[SensorManager] Saved calibration to settings: accel={cal['accel_offsets']}, gyro={cal['gyro_offsets']}")
except Exception as e:
print(f"[SensorManager] Failed to save calibration: {e}")
+220
View File
@@ -0,0 +1,220 @@
"""
Graphical test for IMU calibration activities.
Tests both CheckIMUCalibrationActivity and CalibrateIMUActivity
with mock data on desktop.
Usage:
Desktop: ./tests/unittest.sh tests/test_graphical_imu_calibration.py
Device: ./tests/unittest.sh tests/test_graphical_imu_calibration.py --ondevice
"""
import unittest
import lvgl as lv
import mpos.apps
import mpos.ui
import os
import sys
import time
from mpos.ui.testing import (
wait_for_render,
capture_screenshot,
find_label_with_text,
verify_text_present,
print_screen_labels,
simulate_click,
get_widget_coords,
find_button_with_text
)
class TestIMUCalibration(unittest.TestCase):
"""Test suite for IMU calibration activities."""
def setUp(self):
"""Set up test fixtures."""
# Get screenshot directory
if sys.platform == "esp32":
self.screenshot_dir = "tests/screenshots"
else:
self.screenshot_dir = "/home/user/MicroPythonOS/tests/screenshots"
# Ensure directory exists
try:
os.mkdir(self.screenshot_dir)
except OSError:
pass
def tearDown(self):
"""Clean up after test."""
# Navigate back to launcher
try:
for _ in range(3): # May need multiple backs
mpos.ui.back_screen()
wait_for_render(5)
except:
pass
def test_check_calibration_activity_loads(self):
"""Test that CheckIMUCalibrationActivity loads and displays."""
print("\n=== Testing CheckIMUCalibrationActivity ===")
# Navigate: Launcher -> Settings -> Check IMU Calibration
result = mpos.apps.start_app("com.micropythonos.settings")
self.assertTrue(result, "Failed to start Settings app")
wait_for_render(15)
# Initialize touch device with dummy click
simulate_click(10, 10)
wait_for_render(10)
# Find and click "Check IMU Calibration" setting
screen = lv.screen_active()
check_cal_label = find_label_with_text(screen, "Check IMU Calibration")
self.assertIsNotNone(check_cal_label, "Could not find 'Check IMU Calibration' setting")
# Click on the setting container
coords = get_widget_coords(check_cal_label.get_parent())
self.assertIsNotNone(coords, "Could not get coordinates of setting")
simulate_click(coords['center_x'], coords['center_y'])
wait_for_render(30)
# Verify CheckIMUCalibrationActivity loaded
screen = lv.screen_active()
self.assertTrue(verify_text_present(screen, "IMU Calibration Check"),
"CheckIMUCalibrationActivity title not found")
# Wait for real-time updates to populate
wait_for_render(20)
# Verify key elements are present
print_screen_labels(screen)
self.assertTrue(verify_text_present(screen, "Quality:"),
"Quality label not found")
self.assertTrue(verify_text_present(screen, "Accelerometer"),
"Accelerometer label not found")
self.assertTrue(verify_text_present(screen, "Gyroscope"),
"Gyroscope label not found")
# Capture screenshot
screenshot_path = f"{self.screenshot_dir}/check_imu_calibration.raw"
print(f"Capturing screenshot: {screenshot_path}")
capture_screenshot(screenshot_path)
# Verify screenshot saved
stat = os.stat(screenshot_path)
self.assertTrue(stat[6] > 0, "Screenshot file is empty")
print("=== CheckIMUCalibrationActivity test complete ===")
def test_calibrate_activity_flow(self):
"""Test CalibrateIMUActivity full calibration flow."""
print("\n=== Testing CalibrateIMUActivity Flow ===")
# Navigate: Launcher -> Settings -> Calibrate IMU
result = mpos.apps.start_app("com.micropythonos.settings")
self.assertTrue(result, "Failed to start Settings app")
wait_for_render(15)
# Initialize touch device with dummy click
simulate_click(10, 10)
wait_for_render(10)
# Find and click "Calibrate IMU" setting
screen = lv.screen_active()
calibrate_label = find_label_with_text(screen, "Calibrate IMU")
self.assertIsNotNone(calibrate_label, "Could not find 'Calibrate IMU' setting")
coords = get_widget_coords(calibrate_label.get_parent())
self.assertIsNotNone(coords)
simulate_click(coords['center_x'], coords['center_y'])
wait_for_render(30)
# Verify activity loaded
screen = lv.screen_active()
self.assertTrue(verify_text_present(screen, "IMU Calibration"),
"CalibrateIMUActivity title not found")
# Capture initial state
screenshot_path = f"{self.screenshot_dir}/calibrate_imu_01_initial.raw"
capture_screenshot(screenshot_path)
# Step 1: Click "Check Quality" button
check_btn = find_button_with_text(screen, "Check Quality")
self.assertIsNotNone(check_btn, "Could not find 'Check Quality' button")
coords = get_widget_coords(check_btn)
simulate_click(coords['center_x'], coords['center_y'])
wait_for_render(10)
# Wait for quality check to complete (mock is fast)
time.sleep(2.5) # Allow thread to complete
wait_for_render(15)
# Verify quality check completed
screen = lv.screen_active()
print_screen_labels(screen)
self.assertTrue(verify_text_present(screen, "Current calibration:"),
"Quality check results not shown")
# Capture after quality check
screenshot_path = f"{self.screenshot_dir}/calibrate_imu_02_quality.raw"
capture_screenshot(screenshot_path)
# Step 2: Click "Calibrate Now" button
calibrate_btn = find_button_with_text(screen, "Calibrate Now")
self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate Now' button")
coords = get_widget_coords(calibrate_btn)
simulate_click(coords['center_x'], coords['center_y'])
wait_for_render(10)
# Wait for calibration to complete (mock takes ~3 seconds)
time.sleep(4.0)
wait_for_render(15)
# Verify calibration completed
screen = lv.screen_active()
print_screen_labels(screen)
self.assertTrue(verify_text_present(screen, "Calibration successful!") or
verify_text_present(screen, "Calibration complete!"),
"Calibration completion message not found")
# Capture completion state
screenshot_path = f"{self.screenshot_dir}/calibrate_imu_03_complete.raw"
capture_screenshot(screenshot_path)
print("=== CalibrateIMUActivity flow test complete ===")
def test_navigation_from_check_to_calibrate(self):
"""Test navigation from Check to Calibrate activity via button."""
print("\n=== Testing Check -> Calibrate Navigation ===")
# Navigate to Check activity
result = mpos.apps.start_app("com.micropythonos.settings")
self.assertTrue(result)
wait_for_render(15)
# Initialize touch device with dummy click
simulate_click(10, 10)
wait_for_render(10)
screen = lv.screen_active()
check_cal_label = find_label_with_text(screen, "Check IMU Calibration")
coords = get_widget_coords(check_cal_label.get_parent())
simulate_click(coords['center_x'], coords['center_y'])
wait_for_render(30) # Wait for real-time updates
# Click "Calibrate" button
screen = lv.screen_active()
calibrate_btn = find_button_with_text(screen, "Calibrate")
self.assertIsNotNone(calibrate_btn, "Could not find 'Calibrate' button")
coords = get_widget_coords(calibrate_btn)
simulate_click(coords['center_x'], coords['center_y'])
wait_for_render(15)
# Verify CalibrateIMUActivity loaded
screen = lv.screen_active()
self.assertTrue(verify_text_present(screen, "Check Quality"),
"Did not navigate to CalibrateIMUActivity")
print("=== Navigation test complete ===")