diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py new file mode 100644 index 00000000..a563d346 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/calibrate_imu.py @@ -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"] + } diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py new file mode 100644 index 00000000..18c0bf4e --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/check_imu_calibration.py @@ -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) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py index 56331915..8dac9420 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -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) diff --git a/internal_filesystem/lib/mpos/sensor_manager.py b/internal_filesystem/lib/mpos/sensor_manager.py index 0f0d9563..ee2be064 100644 --- a/internal_filesystem/lib/mpos/sensor_manager.py +++ b/internal_filesystem/lib/mpos/sensor_manager.py @@ -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}") diff --git a/tests/test_graphical_imu_calibration.py b/tests/test_graphical_imu_calibration.py new file mode 100644 index 00000000..56087a11 --- /dev/null +++ b/tests/test_graphical_imu_calibration.py @@ -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 ===")