diff --git a/README.md b/README.md index df6d0b7..535bd0a 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,16 @@ Press and continuously hold the touchscreen for five seconds from the Timer Read * **Timer Mode** retains the existing track, rest, and Launch Mode workflow. * **G Mode** calibrates the stationary QMI8658 baseline, then presents a responsive graphical round G meter rather than numeric telemetry. The green filled marker and short trail show the current filtered acceleration vector at the LCD's display-limited refresh rate. The red hollow marker records the maximum vector, while the red perimeter arc shows peak magnitude relative to the 4 g visual scale. Double-tap resets the trail and peak. Hold for five seconds to return to the mode menu. -* **Settings** provides 25%, 50%, 75%, and 100% brightness choices with immediate preview. It also supports 0°, 90°, 180°, and 270° clockwise mounting angles: the LCD counter-rotates its output and touch gestures remain relative to the text on screen. Swipe up saves a preview; swipe down cancels and restores the previous brightness or orientation. **Restore defaults** requires confirmation, then restores Timer Mode, 100% brightness, 0° rotation, 20-minute track/rest sessions, and disabled Launch Mode. +* **Settings** provides 25%, 50%, 75%, and 100% brightness choices with immediate preview. Rotation offers **Auto** plus fixed 0°, 90°, 180°, and 270° clockwise mounting angles. Auto uses the onboard IMU to keep the display upright as the device turns; fixed choices continue to work without the IMU. In every case, touch gestures remain relative to the text on screen. Swipe up saves a preview; swipe down cancels and restores the previous brightness or orientation. **Restore defaults** requires confirmation, then restores Timer Mode, 100% brightness, fixed 0° rotation, 20-minute track/rest sessions, and disabled Launch Mode. If the IMU is unavailable in G Mode, the firmware shows an actionable message and safely returns to Timer Mode. The timer remains usable. +### Automatic orientation + +Auto rotation samples the QMI8658 at a bounded 10 Hz and filters the gravity vector before selecting one of the four display orientations. A new angle must remain stable for 300 ms and clearly dominate the adjacent axis, preventing rapid changes near 45° boundaries or during short acceleration spikes. The LCD controller and touch-direction mapping change together, and the current framebuffer is redrawn without allocating another 240×240 buffer. Active track/rest timing is neither reset nor paused. + +An accelerometer cannot determine rotation around gravity when the screen is nearly horizontal. In that position Auto deliberately retains the last reliable angle until the device is upright enough to resolve again. Detected angles remain in RAM to avoid flash wear; `user.json` stores only the `auto` selection. If the IMU is missing or later fails, Auto freezes safely at its last angle and Timer Mode remains available. Choose a fixed angle to operate without automatic sensing. + ## Supported hardware Version 4.1.0 supports the integrated [Waveshare RP2040-Touch-LCD-1.28](https://www.waveshare.com/product/rp2040-touch-lcd-1.28.htm). This board combines the RP2040, GC9A01A 240x240 LCD, CST816S touchscreen, and QMI8658 IMU used by the firmware. The standalone 1.28-inch Touch LCD connected to a separate Raspberry Pi Pico uses a different pin map and is not currently supported. @@ -166,7 +172,7 @@ The second command should identify an RP2040 MicroPython board. Run these commands from the repository root. Supporting files and font assets are copied first; `main.py` is installed last as the automatic entry point. ```sh -mpremote connect auto fs cp application.py battery.py configuration.py font_data.py font_renderer.py g_meter.py hardware.py hardware_splash.py hold_detector.py launch.py lcd_1inch28.py live_display.py operating_modes.py orientation.py params.json qmi8658.py ready_screen.py settings.py splash.py timer_mode.py timing.py touch_drive.py font_data*.bin startup_splash.rgb565 : +mpremote connect auto fs cp application.py auto_rotation.py battery.py configuration.py font_data.py font_renderer.py g_meter.py hardware.py hardware_splash.py hold_detector.py launch.py lcd_1inch28.py live_display.py operating_modes.py orientation.py params.json qmi8658.py ready_screen.py settings.py splash.py timer_mode.py timing.py touch_drive.py font_data*.bin startup_splash.rgb565 : mpremote connect auto fs cp main.py : mpremote connect auto reset ``` @@ -196,14 +202,14 @@ If first boot fails: The touchscreen is required for safe operation. A transient touchscreen I2C failure is retried three times at 100 ms intervals; an unexpected chip ID is not retried. If detection still fails, the firmware shows an actionable error, logs the detailed cause over serial, and stops before using an incomplete touch object. -The QMI8658 IMU is optional unless a non-zero Launch Mode sensitivity is selected. It is not initialized when Launch Mode is off. Transient initialization failures receive the same three attempts, while an unexpected chip ID fails immediately. If initialization or a launch-time sample fails, Launch Mode is disabled for the current run and the standard swipe-down timer remains available. The saved sensitivity is retained so the firmware can retry after a restart or the next Launch Mode configuration change. +The QMI8658 IMU is optional unless a non-zero Launch Mode sensitivity, G Mode, or Auto rotation is selected. It is not initialized when none of those features needs it. Transient initialization failures receive three attempts, while an unexpected chip ID fails immediately. If initialization or a runtime sample fails, sensor-dependent behavior degrades safely and the standard timer remains available. Auto retains its last reliable orientation, and manual rotation choices remain usable. Saved choices are retained so the firmware can retry after a restart. ## Configuration files Version 4.1.0 uses two separate configuration scopes: * `params.json` contains system-owned choices and display behavior: `DURATION_VALUES`, `LAUNCH_SENSE_VALUES`, `VERSION`, `DISPLAY_DELAY_REST`, `DISPLAY_DELAY_REST_COLOUR`, `STARTUP_SPLASH_DURATION_SEC`, `HARDWARE_SPLASH_DURATION_SEC`, and `MODE_MENU_HOLD_SEC`. -* `user.json` contains the current user selections: `RACE_LENGTH` (track-session minutes), `REST_LENGTH` (pit-rest minutes), `SENSITIVITY` (launch threshold; `0` disables Launch Mode), `OPERATING_MODE` (`timer` or `g`), `BRIGHTNESS_PERCENT`, and `DISPLAY_ROTATION_DEG` (clockwise device mounting angle: `0`, `90`, `180`, or `270`). +* `user.json` contains the current user selections: `RACE_LENGTH` (track-session minutes), `REST_LENGTH` (pit-rest minutes), `SENSITIVITY` (launch threshold; `0` disables Launch Mode), `OPERATING_MODE` (`timer` or `g`), `BRIGHTNESS_PERCENT`, and `DISPLAY_ROTATION_DEG` (`auto` or the fixed clockwise device mounting angle `0`, `90`, `180`, or `270`). Launch sensitivity is the filtered change in acceleration-vector magnitude from a 0.4-second stationary baseline, measured in g. This removes gravity and mounting orientation and handles acceleration on either side of every axis. Lower non-zero values are more sensitive. Detection requires three consecutive samples above the threshold; double-tap cancels the wait, and a 30-second timeout returns to the Ready screen. See `User Guide.md` for the practical meaning of every configured value. @@ -217,4 +223,4 @@ Run the hardware-independent regression suite with: python -m unittest discover -s tests -v ``` -The suite uses fakes for time, continuous holds, touch gestures, all four display rotations, mode/settings navigation, graphical G vectors, display calls, filesystem operations, accelerometer samples, battery readings, and USB power state. Version 4.0.0 was additionally validated on the supported Waveshare board for both startup screens, Timer and G Mode boots, native G-meter rendering, LCD/font rendering, CST816S touch-state detection, QMI8658 sampling, saved settings, launch behavior, and the Ready-screen battery indicator. +The suite uses fakes for time, continuous holds, touch gestures, automatic and fixed display rotation, gravity filtering/hysteresis, mode/settings navigation, graphical G vectors, display calls, filesystem operations, accelerometer samples, battery readings, and USB power state. Version 4.0.0 was additionally validated on the supported Waveshare board for both startup screens, Timer and G Mode boots, native G-meter rendering, LCD/font rendering, CST816S touch-state detection, QMI8658 sampling, saved settings, launch behavior, and the Ready-screen battery indicator. diff --git a/User Guide.md b/User Guide.md index aa9876f..42833cf 100644 --- a/User Guide.md +++ b/User Guide.md @@ -44,10 +44,14 @@ The graph removes the stationary gravity/mounting baseline and does not rely on Choose ``Settings`` from the operating-mode menu. * ``Brightness`` offers 25%, 50%, 75%, and 100%. Left/right previews each level immediately, ``Swipe UP`` saves, and ``Swipe DOWN`` cancels and restores the previous level. -* ``Rotation`` supports mounting the device at 0°, 90°, 180°, or 270°. The value is the physical device rotation clockwise from its original 0° position. Left/right previews the orientation immediately; the text and directional gestures rotate together, so swipes stay relative to the displayed instructions. ``Swipe UP`` saves, while ``Swipe DOWN`` safely restores the previous orientation. +* ``Rotation`` offers ``Auto`` followed by fixed 0°, 90°, 180°, and 270° choices. Fixed values are the physical device rotation clockwise from its original 0° position. ``Auto`` uses the QMI8658 gravity reading to keep the screen upright and shows the currently detected angle during preview. Left/right previews immediately; text and directional gestures rotate together, so swipes stay relative to the displayed instructions. ``Swipe UP`` saves, while ``Swipe DOWN`` safely restores the previous orientation. * ``Restore defaults`` requires an explicit confirmation. Confirming restores Timer Mode, 100% brightness, 0° rotation, 20-minute track and rest sessions, and disabled Launch Mode. Cancelling changes nothing. * Choose ``Back`` or swipe down to return to the operating-mode menu, then select or cancel back to an operating mode. +Auto rotation continues through Ready, configuration, active track/rest timing, Launch Mode, G Mode, menus, and timed information screens. A turn must remain clear and stable for about 0.3 seconds before the display changes, which prevents flicker from road vibration or positions near a diagonal. Changing orientation does not reset or pause a session, and detected angles are not written repeatedly to flash. + +When the display is nearly horizontal, gravity points mostly through the screen and cannot identify which edge is physically upward. Auto therefore keeps the last reliable orientation until the display is upright enough again. If the IMU is unavailable, the preview reports ``IMU unavailable`` and Auto retains a safe fixed angle; Timer Mode and all four manual rotation choices continue to work. + ### Session Duration It is possible to change the duration of both the ``Track Session`` and the ``Rest in Pits``. diff --git a/application.py b/application.py index 0bbdeef..01ffd45 100644 --- a/application.py +++ b/application.py @@ -2,7 +2,6 @@ import gc import sys -import time PARAMS_FILE = "params.json" @@ -12,7 +11,6 @@ TIMER_MODULES = ( "timer_mode", - "battery", "configuration", "hold_detector", "launch", @@ -55,6 +53,21 @@ def _show_imu_degraded(lcd, error): ) +def _show_auto_rotation_degraded(lcd, error, launch_disabled=False): + from hardware import show_hardware_message + + print("Automatic orientation unavailable: {}".format(error)) + middle_line = ( + "Launch also disabled" if launch_disabled else "Normal timer works" + ) + show_hardware_message( + lcd, + "Auto rotate paused", + ["IMU not available", middle_line, "Use fixed rotation"], + background=lcd.brown, + ) + + def _show_g_mode_unavailable(lcd, error): from hardware import show_hardware_message @@ -74,7 +87,7 @@ def _initialize_imu(sensitivity): return initialize_optional_imu(sensitivity, QMI8658) -def _open_mode_menu(touch, lcd, user_params): +def _open_mode_menu(touch, lcd, user_params, auto_rotation): from operating_modes import configure_operating_mode result = configure_operating_mode( @@ -82,10 +95,11 @@ def _open_mode_menu(touch, lcd, user_params): lcd, user_params, USER_FILE, + auto_rotation=auto_rotation, ) del configure_operating_mode _unload_modules(("operating_modes",)) - return result + return result[0], result[1], auto_rotation.sensor def _persist_timer_fallback(user_params): @@ -101,14 +115,23 @@ def _persist_timer_fallback(user_params): def run_application(lcd): """Initialize shared hardware and dispatch one active feature at a time.""" + from auto_rotation import AUTO_ROTATION, AutoRotationController from hardware import PeripheralError, initialize_with_retry from hardware_splash import run_startup_screens from settings import load_configuration from touch_drive import Touch_CST816T + # Import this small Timer dependency before automatic orientation begins + # producing filtered sample objects. Its module allocation otherwise lands + # in the RP2040 heap's most fragmented startup phase. + from battery import BatteryMonitor + del BatteryMonitor + system_params, user_params = load_configuration(PARAMS_FILE, USER_FILE) print("User Parameters: " + str(user_params)) - lcd.set_rotation(user_params["DISPLAY_ROTATION_DEG"]) + rotation_setting = user_params["DISPLAY_ROTATION_DEG"] + initial_rotation = 0 if rotation_setting == AUTO_ROTATION else rotation_setting + lcd.set_rotation(initial_rotation) _apply_brightness(lcd, user_params["BRIGHTNESS_PERCENT"]) try: @@ -116,16 +139,43 @@ def run_application(lcd): lambda: Touch_CST816T( mode=1, LCD=lcd, - rotation=user_params["DISPLAY_ROTATION_DEG"], + rotation=initial_rotation, ), "CST816T", ) + + active_mode = user_params["OPERATING_MODE"] + imu_requirement = user_params["SENSITIVITY"] + if ( + active_mode == MODE_G + or rotation_setting == AUTO_ROTATION + ) and imu_requirement <= 0: + imu_requirement = 1 + qmi8658, imu_error = _initialize_imu(imu_requirement) + + auto_rotation = AutoRotationController( + qmi8658, + lcd, + touch, + initial_rotation=initial_rotation, + sensor_factory=lambda: _initialize_imu(1), + sensor_error=imu_error, + ) + touch.Set_Auto_Rotation(auto_rotation) + if rotation_setting == AUTO_ROTATION: + auto_rotation.enable(initialize=False) + auto_rotation.prime() + if not auto_rotation.available: + qmi8658 = None + imu_error = auto_rotation.error + run_startup_screens( touch, lcd, firmware_version=system_params["VERSION"], startup_duration_sec=system_params["STARTUP_SPLASH_DURATION_SEC"], hardware_duration_sec=system_params["HARDWARE_SPLASH_DURATION_SEC"], + wait=touch.Wait, ) except PeripheralError as error: _show_touch_failure(lcd, error) @@ -134,19 +184,36 @@ def run_application(lcd): del run_startup_screens _unload_modules(("hardware_splash", "splash")) - active_mode = user_params["OPERATING_MODE"] - imu_requirement = user_params["SENSITIVITY"] - if active_mode == MODE_G and imu_requirement <= 0: - imu_requirement = 1 - qmi8658, imu_error = _initialize_imu(imu_requirement) + # Release one-time startup imports before Timer Mode loads its dependency + # set. On the RP2040 these references are enough to decide whether the next + # small module allocation can fit beside the framebuffer. + del AutoRotationController + del AUTO_ROTATION + del initialize_with_retry + del load_configuration + del Touch_CST816T + del initial_rotation + del imu_requirement + gc.collect() + if imu_error is not None: if active_mode == MODE_G: _show_g_mode_unavailable(lcd, imu_error) user_params = _persist_timer_fallback(user_params) active_mode = MODE_TIMER + elif rotation_setting == AUTO_ROTATION: + _show_auto_rotation_degraded( + lcd, + imu_error, + launch_disabled=user_params["SENSITIVITY"] > 0, + ) else: _show_imu_degraded(lcd, imu_error) - time.sleep(2) + touch.Wait(lcd, 2) + + del rotation_setting + del imu_error + gc.collect() while True: if active_mode == MODE_TIMER: @@ -161,27 +228,31 @@ def run_application(lcd): qmi8658, _initialize_imu, _show_imu_degraded, + auto_rotation, ) except PeripheralError as error: _show_touch_failure(lcd, error) return False del run_timer_mode _unload_modules(TIMER_MODULES) - user_params, active_mode = _open_mode_menu( + user_params, active_mode, qmi8658 = _open_mode_menu( touch, lcd, user_params, + auto_rotation, ) continue if qmi8658 is None: qmi8658, imu_error = _initialize_imu(1) if imu_error is not None: + auto_rotation.set_sensor(None, imu_error) _show_g_mode_unavailable(lcd, imu_error) - time.sleep(2) + touch.Wait(lcd, 2) user_params = _persist_timer_fallback(user_params) active_mode = MODE_TIMER continue + auto_rotation.set_sensor(qmi8658) from g_meter import run_g_mode @@ -199,16 +270,18 @@ def run_application(lcd): _show_touch_failure(lcd, error) return False qmi8658 = None + auto_rotation.set_sensor(None, error) _show_g_mode_unavailable(lcd, error) - time.sleep(2) + touch.Wait(lcd, 2) user_params = _persist_timer_fallback(user_params) active_mode = MODE_TIMER continue del run_g_mode _unload_modules(("g_meter", "hold_detector")) - user_params, active_mode = _open_mode_menu( + user_params, active_mode, qmi8658 = _open_mode_menu( touch, lcd, user_params, + auto_rotation, ) diff --git a/auto_rotation.py b/auto_rotation.py new file mode 100644 index 0000000..c1667b9 --- /dev/null +++ b/auto_rotation.py @@ -0,0 +1,273 @@ +"""Filtered, four-way automatic display orientation.""" + +import time + +from hardware import PeripheralError +from orientation import validate_rotation + + +AUTO_ROTATION = "auto" +FILTER_ALPHA = 0.30 +MIN_PLANE_G = 0.55 +MIN_TOTAL_G = 0.70 +MAX_TOTAL_G = 1.35 +DOMINANCE_RATIO = 1.25 +STABLE_MS = 300 +SAMPLE_INTERVAL_MS = 100 +PRIME_SAMPLES = 5 + + +def _ticks_ms(clock): + ticks_ms = getattr(clock, "ticks_ms", None) + if ticks_ms is not None: + return ticks_ms() + monotonic = getattr(clock, "monotonic", None) + if monotonic is not None: + return int(monotonic() * 1000) + return int(clock.time() * 1000) + + +def _ticks_diff(clock, current, previous): + ticks_diff = getattr(clock, "ticks_diff", None) + if ticks_diff is not None: + return ticks_diff(current, previous) + return current - previous + + +def _sleep_ms(clock, milliseconds): + sleep_ms = getattr(clock, "sleep_ms", None) + if sleep_ms is not None: + sleep_ms(milliseconds) + else: + clock.sleep(milliseconds / 1000) + + +def _axes(sample): + if len(sample) < 3: + raise ValueError("Accelerometer sample must contain x, y, and z axes") + return float(sample[0]), float(sample[1]), float(sample[2]) + + +def classify_rotation( + sample, + minimum_plane_g=MIN_PLANE_G, + minimum_total_g=MIN_TOTAL_G, + maximum_total_g=MAX_TOTAL_G, + dominance_ratio=DOMINANCE_RATIO, +): + """Return a stable quarter-turn candidate, or ``None`` if ambiguous.""" + x_axis, y_axis, z_axis = _axes(sample) + x_abs = abs(x_axis) + y_abs = abs(y_axis) + plane_squared = (x_axis * x_axis) + (y_axis * y_axis) + total_squared = plane_squared + (z_axis * z_axis) + + if plane_squared < float(minimum_plane_g) ** 2: + return None + if total_squared < float(minimum_total_g) ** 2: + return None + if total_squared > float(maximum_total_g) ** 2: + return None + + ratio = float(dominance_ratio) + if x_abs >= y_abs * ratio: + # The board silkscreen has +X toward native screen down. A stationary + # accelerometer reports specific force toward physical screen up. + return 0 if x_axis < 0 else 180 + if y_abs >= x_abs * ratio: + # +Y points toward native screen right. + return 90 if y_axis < 0 else 270 + return None + + +class AutoOrientationDetector: + """Low-pass and debounce gravity into a retained mount rotation.""" + + def __init__( + self, + initial_rotation=0, + filter_alpha=FILTER_ALPHA, + stable_ms=STABLE_MS, + clock=time, + ): + alpha = float(filter_alpha) + if alpha <= 0 or alpha > 1: + raise ValueError("filter_alpha must be greater than 0 and at most 1") + if int(stable_ms) < 0: + raise ValueError("stable_ms cannot be negative") + self.filter_alpha = alpha + self.stable_ms = int(stable_ms) + self.clock = clock + self.current_rotation = validate_rotation(initial_rotation) + self.filtered = None + self.candidate_rotation = None + self.candidate_since = None + self.reliable = False + + def reset(self, current_rotation=None): + if current_rotation is not None: + self.current_rotation = validate_rotation(current_rotation) + self.filtered = None + self.candidate_rotation = None + self.candidate_since = None + self.reliable = False + + def update(self, sample, now_ms=None): + axes = _axes(sample) + if self.filtered is None: + self.filtered = axes + else: + alpha = self.filter_alpha + self.filtered = tuple( + previous + alpha * (value - previous) + for previous, value in zip(self.filtered, axes) + ) + + candidate = classify_rotation(self.filtered) + if candidate is None: + self.reliable = False + self.candidate_rotation = None + self.candidate_since = None + return None + + self.reliable = True + if candidate == self.current_rotation: + self.candidate_rotation = None + self.candidate_since = None + return None + + if now_ms is None: + now_ms = _ticks_ms(self.clock) + if candidate != self.candidate_rotation: + self.candidate_rotation = candidate + self.candidate_since = now_ms + return None + + if _ticks_diff(self.clock, now_ms, self.candidate_since) < self.stable_ms: + return None + + self.current_rotation = candidate + self.candidate_rotation = None + self.candidate_since = None + return candidate + + +class AutoRotationController: + """Coordinate sensor detection, LCD rotation, and touch mapping.""" + + def __init__( + self, + sensor, + lcd, + touch, + initial_rotation=0, + sensor_factory=None, + sensor_error=None, + detector=None, + clock=time, + sample_interval_ms=SAMPLE_INTERVAL_MS, + logger=print, + ): + if int(sample_interval_ms) < 0: + raise ValueError("sample_interval_ms cannot be negative") + self.sensor = sensor + self.lcd = lcd + self.touch = touch + self.sensor_factory = sensor_factory + self.error = sensor_error + self.detector = detector or AutoOrientationDetector( + initial_rotation=initial_rotation, + clock=clock, + ) + self.clock = clock + self.sample_interval_ms = int(sample_interval_ms) + self.logger = logger + self.enabled = False + self.last_sample_ms = None + self.initialization_attempted = sensor is not None or sensor_error is not None + + @property + def current_rotation(self): + return self.detector.current_rotation + + @property + def available(self): + return self.sensor is not None and self.error is None + + def set_sensor(self, sensor, error=None): + self.sensor = sensor + self.error = error + self.initialization_attempted = sensor is not None or error is not None + self.last_sample_ms = None + self.detector.reset(self.current_rotation) + + def _initialize_sensor(self): + if self.sensor_factory is None or self.initialization_attempted: + return self.available + self.initialization_attempted = True + result = self.sensor_factory() + if isinstance(result, tuple): + sensor, error = result + else: + sensor, error = result, None + self.set_sensor(sensor, error) + return self.available + + def enable(self, initialize=True): + self.enabled = True + if self.sensor is None and initialize: + self._initialize_sensor() + return self.available + + def disable(self, current_rotation=None): + self.enabled = False + if current_rotation is not None: + self.detector.reset(current_rotation) + + def status_text(self): + if not self.available: + return "IMU unavailable" + if not self.detector.reliable: + return "Hold device upright" + return "Detected: {} deg".format(self.current_rotation) + + def update(self, force=False, redraw=True): + if not self.enabled or not self.available: + return False + + now_ms = _ticks_ms(self.clock) + if ( + not force + and self.last_sample_ms is not None + and _ticks_diff(self.clock, now_ms, self.last_sample_ms) + < self.sample_interval_ms + ): + return False + self.last_sample_ms = now_ms + + try: + sample = self.sensor.Read_XYZ() + except (PeripheralError, OSError) as error: + self.sensor = None + self.error = error + self.logger("Auto rotation paused: {}".format(error)) + return False + + rotation = self.detector.update(sample, now_ms=now_ms) + if rotation is None: + return False + + self.lcd.set_rotation(rotation) + self.touch.Set_Rotation(rotation) + if redraw: + self.lcd.show() + return True + + def prime(self, samples=PRIME_SAMPLES): + """Collect enough startup samples to resolve a stable first angle.""" + count = max(0, int(samples)) + for index in range(count): + self.update(force=True, redraw=False) + if index < count - 1: + _sleep_ms(self.clock, self.sample_interval_ms) + return self.current_rotation diff --git a/g_meter.py b/g_meter.py index 6d5e196..344dda4 100644 --- a/g_meter.py +++ b/g_meter.py @@ -276,7 +276,7 @@ def run_g_mode(sensor, touch, lcd, hold_seconds, clock=time): state.reset_peak() draw_g_meter_frame(lcd, state, mode_hold_seconds=hold_seconds) - if hold_detector.update(touch.IsPressed()): + if hold_detector.update(touch.IsPressed(lcd)): touch.ClearPendingInput() return state delay_ms = remaining_frame_delay_ms(clock, frame_started) diff --git a/hardware.py b/hardware.py index 13ab350..a0de8da 100644 --- a/hardware.py +++ b/hardware.py @@ -73,7 +73,7 @@ def initialize_with_retry( def initialize_optional_imu(sensitivity, factory, **retry_options): - """Initialize the IMU only for Launch Mode and degrade on failure.""" + """Initialize the IMU only when a feature requires it, degrading safely.""" if float(sensitivity) <= 0: return None, None @@ -82,7 +82,7 @@ def initialize_optional_imu(sensitivity, factory, **retry_options): sensor = initialize_with_retry(factory, "QMI8658", **retry_options) return sensor, None except PeripheralError as error: - logger("Launch Mode disabled: {}".format(error)) + logger("IMU unavailable: {}".format(error)) return None, error diff --git a/hardware_splash.py b/hardware_splash.py index 910bb51..2897b73 100644 --- a/hardware_splash.py +++ b/hardware_splash.py @@ -128,10 +128,17 @@ def run_startup_screens( hardware_duration_sec, clock=time, details=None, + wait=None, ): """Show artwork, then hardware details, for their configured durations.""" + def pause(duration): + if wait is None: + clock.sleep(duration) + else: + wait(lcd, duration) + touch.BootScreen(lcd, version_number=firmware_version) - clock.sleep(startup_duration_sec) + pause(startup_duration_sec) if details is None: details = collect_device_details(firmware_version) @@ -140,5 +147,5 @@ def run_startup_screens( text_array=hardware_splash_lines(details), back_colour=HARDWARE_SPLASH_BACKGROUND, ) - clock.sleep(hardware_duration_sec) + pause(hardware_duration_sec) return details diff --git a/operating_modes.py b/operating_modes.py index b697b34..f238f7b 100644 --- a/operating_modes.py +++ b/operating_modes.py @@ -1,5 +1,6 @@ """On-device operating-mode and settings menus.""" +from auto_rotation import AUTO_ROTATION from settings import ( BRIGHTNESS_VALUES, DEFAULT_USER_PARAMS, @@ -155,24 +156,42 @@ def draw(): return original, False -def apply_rotation(lcd, touch, degrees): +def apply_rotation(lcd, touch, degrees, auto_rotation=None): """Apply one mount angle to rendering and directional gestures.""" + if degrees == AUTO_ROTATION: + if auto_rotation is not None: + auto_rotation.enable() + auto_rotation.update(force=True, redraw=False) + return + if auto_rotation is not None: + auto_rotation.disable(current_rotation=degrees) lcd.set_rotation(degrees) touch.Set_Rotation(degrees) -def rotation_lines(degrees): +def rotation_lines(value, auto_rotation=None): + if value == AUTO_ROTATION: + status = ( + auto_rotation.status_text() + if auto_rotation is not None + else "IMU unavailable" + ) + label = "AUTO" + detail = status + else: + label = "{} deg".format(value) + detail = "Device clockwise" return [ ["Mount rotation", None, 35, 2, "white"], - ["{} deg".format(degrees), None, 88, 4, "white"], - ["Device clockwise", None, 150, 1, "white"], + [label, None, 88, 4, "white"], + [detail, None, 150, 1, "white"], ["L/R: rotate", None, 180, 1, "white"], ["UP: save", None, 202, 1, "white"], ["DOWN: cancel", None, 220, 1, "white"], ] -def select_rotation(touch, lcd, current): +def select_rotation(touch, lcd, current, auto_rotation=None): """Preview mount rotations and return ``(value, should_save)``.""" values = list(DISPLAY_ROTATION_VALUES) try: @@ -180,18 +199,36 @@ def select_rotation(touch, lcd, current): except ValueError: index = values.index(DEFAULT_USER_PARAMS["DISPLAY_ROTATION_DEG"]) original = values[index] + last_auto_state = [None] + + def auto_state(): + if values[index] != AUTO_ROTATION or auto_rotation is None: + return None + return ( + auto_rotation.current_rotation, + auto_rotation.available, + auto_rotation.detector.reliable, + ) def draw(): - apply_rotation(lcd, touch, values[index]) + apply_rotation( + lcd, + touch, + values[index], + auto_rotation=auto_rotation, + ) touch.ControlScreen( lcd, - text_array=rotation_lines(values[index]), + text_array=rotation_lines(values[index], auto_rotation), back_colour="black", ) + last_auto_state[0] = auto_state() draw() while True: gesture = touch.GetGesture(lcd) + if auto_state() != last_auto_state[0]: + draw() if gesture == "left": index = (index - 1) % len(values) draw() @@ -201,7 +238,12 @@ def draw(): elif gesture == "up": return values[index], True elif gesture == "down": - apply_rotation(lcd, touch, original) + apply_rotation( + lcd, + touch, + original, + auto_rotation=auto_rotation, + ) return original, False @@ -239,7 +281,13 @@ def draw(): return False -def _run_settings(touch, lcd, user_params, user_file): +def _run_settings( + touch, + lcd, + user_params, + user_file, + auto_rotation=None, +): while True: action = select_settings_action(touch, lcd) if action is None or action == "back": @@ -263,7 +311,12 @@ def _run_settings(touch, lcd, user_params, user_file): elif action == "rotation": previous = user_params["DISPLAY_ROTATION_DEG"] - selected, should_save = select_rotation(touch, lcd, previous) + selected, should_save = select_rotation( + touch, + lcd, + previous, + auto_rotation=auto_rotation, + ) if not should_save: continue updated, saved = persist_setting( @@ -275,7 +328,12 @@ def _run_settings(touch, lcd, user_params, user_file): if saved: user_params = updated else: - apply_rotation(lcd, touch, previous) + apply_rotation( + lcd, + touch, + previous, + auto_rotation=auto_rotation, + ) elif action == "restore" and confirm_restore_defaults(touch, lcd): defaults, saved = restore_user_defaults(user_file) @@ -285,11 +343,18 @@ def _run_settings(touch, lcd, user_params, user_file): lcd, touch, defaults["DISPLAY_ROTATION_DEG"], + auto_rotation=auto_rotation, ) return defaults, True -def configure_operating_mode(touch, lcd, user_params, user_file): +def configure_operating_mode( + touch, + lcd, + user_params, + user_file, + auto_rotation=None, +): """Run mode/settings UI and return ``(params, selected_mode)``.""" previous_mode = user_params["OPERATING_MODE"] while True: @@ -303,6 +368,7 @@ def configure_operating_mode(touch, lcd, user_params, user_file): lcd, user_params, user_file, + auto_rotation=auto_rotation, ) if restored: return user_params, MODE_TIMER diff --git a/settings.py b/settings.py index 74d54ec..bcfaac2 100644 --- a/settings.py +++ b/settings.py @@ -30,7 +30,7 @@ OPERATING_MODES = ("timer", "g") BRIGHTNESS_VALUES = (25, 50, 75, 100) -DISPLAY_ROTATION_VALUES = (0, 90, 180, 270) +DISPLAY_ROTATION_VALUES = (0, 90, 180, 270, "auto") LEGACY_USER_KEYS = { "TRACK_LENGTH": "RACE_LENGTH", @@ -190,8 +190,7 @@ def normalize_user_params(data, system_params=None): ): brightness_percent = DEFAULT_USER_PARAMS["BRIGHTNESS_PERCENT"] if ( - not isinstance(display_rotation, int) - or isinstance(display_rotation, bool) + isinstance(display_rotation, bool) or display_rotation not in DISPLAY_ROTATION_VALUES ): display_rotation = DEFAULT_USER_PARAMS["DISPLAY_ROTATION_DEG"] diff --git a/tests/test_auto_rotation.py b/tests/test_auto_rotation.py new file mode 100644 index 0000000..660e0b3 --- /dev/null +++ b/tests/test_auto_rotation.py @@ -0,0 +1,190 @@ +import unittest + +from auto_rotation import ( + AutoOrientationDetector, + AutoRotationController, + classify_rotation, +) +from hardware import PeripheralIOError + + +class FakeClock: + def __init__(self): + self.now = 0 + self.sleeps = [] + + def ticks_ms(self): + return self.now + + def ticks_diff(self, current, previous): + return current - previous + + def sleep_ms(self, milliseconds): + self.sleeps.append(milliseconds) + self.now += milliseconds + + +class FakeSensor: + def __init__(self, samples=None, error=None): + self.samples = list(samples or []) + self.error = error + self.reads = 0 + + def Read_XYZ(self): + self.reads += 1 + if self.error is not None: + raise self.error + if len(self.samples) > 1: + return self.samples.pop(0) + return self.samples[0] + + +class FakeLCD: + def __init__(self): + self.rotations = [] + self.shows = 0 + + def set_rotation(self, rotation): + self.rotations.append(rotation) + + def show(self): + self.shows += 1 + + +class FakeTouch: + def __init__(self): + self.rotations = [] + + def Set_Rotation(self, rotation): + self.rotations.append(rotation) + + +class AutoRotationTests(unittest.TestCase): + def test_board_axes_map_to_all_four_mount_rotations(self): + expected = { + (0.0, -1.0, 0.0): 90, + (-1.0, 0.0, 0.0): 0, + (0.0, 1.0, 0.0): 270, + (1.0, 0.0, 0.0): 180, + } + for sample, rotation in expected.items(): + with self.subTest(sample=sample): + self.assertEqual(rotation, classify_rotation(sample)) + + def test_diagonal_flat_and_dynamic_samples_are_ambiguous(self): + for sample in ( + (-0.70, -0.70, 0.0), + (0.10, 0.10, 0.98), + (2.0, 0.0, 0.0), + ): + with self.subTest(sample=sample): + self.assertIsNone(classify_rotation(sample)) + + def test_candidate_must_remain_stable_before_switching(self): + clock = FakeClock() + detector = AutoOrientationDetector( + initial_rotation=0, + filter_alpha=1, + stable_ms=300, + clock=clock, + ) + + self.assertIsNone(detector.update((0, -1, 0))) + clock.now = 299 + self.assertIsNone(detector.update((0, -1, 0))) + self.assertEqual(0, detector.current_rotation) + clock.now = 300 + self.assertEqual(90, detector.update((0, -1, 0))) + self.assertEqual(90, detector.current_rotation) + + def test_ambiguous_sample_cancels_pending_switch(self): + clock = FakeClock() + detector = AutoOrientationDetector( + initial_rotation=0, + filter_alpha=1, + stable_ms=300, + clock=clock, + ) + + detector.update((0, -1, 0)) + clock.now = 200 + detector.update((-0.70, -0.70, 0)) + clock.now = 500 + self.assertIsNone(detector.update((0, -1, 0))) + self.assertEqual(0, detector.current_rotation) + + def test_unreliable_position_retains_last_stable_rotation(self): + detector = AutoOrientationDetector(initial_rotation=180) + + self.assertIsNone(detector.update((0.1, 0.1, 0.98), now_ms=0)) + self.assertEqual(180, detector.current_rotation) + self.assertFalse(detector.reliable) + + def test_controller_updates_lcd_touch_and_existing_frame_once(self): + clock = FakeClock() + sensor = FakeSensor([(0, -1, 0)]) + lcd = FakeLCD() + touch = FakeTouch() + detector = AutoOrientationDetector( + initial_rotation=0, + filter_alpha=1, + stable_ms=100, + clock=clock, + ) + controller = AutoRotationController( + sensor, + lcd, + touch, + detector=detector, + clock=clock, + sample_interval_ms=100, + ) + controller.enable() + + self.assertFalse(controller.update()) + self.assertFalse(controller.update()) + self.assertEqual(1, sensor.reads) + clock.now = 100 + self.assertTrue(controller.update()) + + self.assertEqual([90], lcd.rotations) + self.assertEqual([90], touch.rotations) + self.assertEqual(1, lcd.shows) + + def test_controller_freezes_safely_after_sensor_failure(self): + error = PeripheralIOError("QMI8658", "sample read", "disconnected") + controller = AutoRotationController( + FakeSensor(error=error), + FakeLCD(), + FakeTouch(), + logger=lambda _message: None, + ) + controller.enable() + + self.assertFalse(controller.update(force=True)) + self.assertFalse(controller.available) + self.assertEqual(0, controller.current_rotation) + self.assertEqual("IMU unavailable", controller.status_text()) + + def test_sensor_is_initialized_lazily_for_auto_preview(self): + sensor = FakeSensor([(-1, 0, 0)]) + attempts = [] + + def factory(): + attempts.append(True) + return sensor, None + + controller = AutoRotationController( + None, + FakeLCD(), + FakeTouch(), + sensor_factory=factory, + ) + + self.assertTrue(controller.enable()) + self.assertEqual([True], attempts) + self.assertIs(sensor, controller.sensor) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_operating_modes.py b/tests/test_operating_modes.py index 5126413..29e4ae2 100644 --- a/tests/test_operating_modes.py +++ b/tests/test_operating_modes.py @@ -62,6 +62,39 @@ def Set_Rotation(self, degrees): self.rotations.append(degrees) +class FakeDetector: + def __init__(self): + self.reliable = True + + +class FakeAutoRotation: + def __init__(self, current_rotation=0, available=True): + self.current_rotation = current_rotation + self.available = available + self.detector = FakeDetector() + self.enabled = False + self.disabled_at = [] + self.updates = 0 + + def enable(self): + self.enabled = True + return self.available + + def disable(self, current_rotation=None): + self.enabled = False + self.current_rotation = current_rotation + self.disabled_at.append(current_rotation) + + def update(self, force=False, redraw=True): + self.updates += 1 + return False + + def status_text(self): + if not self.available: + return "IMU unavailable" + return "Detected: {} deg".format(self.current_rotation) + + class OperatingModeTests(unittest.TestCase): def assert_round_fit(self, lines): radius = 120 @@ -138,6 +171,9 @@ def test_all_menu_text_fits_round_display_at_every_rotation(self): for brightness in (25, 50, 75, 100): self.assert_round_fit(brightness_lines(brightness)) self.assert_round_fit(rotation_lines(rotation)) + self.assert_round_fit( + rotation_lines("auto", FakeAutoRotation(rotation)) + ) for selected in ("Cancel", "RESTORE"): self.assert_round_fit(restore_confirmation_lines(selected)) @@ -195,6 +231,51 @@ def test_rotation_setting_is_previewed_and_persisted(self): self.assertEqual(90, lcd.rotations[-1]) self.assertEqual(90, touch.rotations[-1]) + def test_auto_rotation_is_previewed_and_persisted(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + auto_rotation = FakeAutoRotation(current_rotation=270) + + updated, mode = configure_operating_mode( + FakeTouch( + [ + "left", "up", # Settings + "right", "up", # Rotation + "left", "up", # Auto, save + "down", # leave Settings + "down", # cancel mode menu + ] + ), + FakeLCD(), + dict(DEFAULT_USER_PARAMS), + path, + auto_rotation=auto_rotation, + ) + + self.assertEqual("timer", mode) + self.assertEqual("auto", updated["DISPLAY_ROTATION_DEG"]) + self.assertEqual( + "auto", + file_in(path, debug=False)["DISPLAY_ROTATION_DEG"], + ) + self.assertTrue(auto_rotation.enabled) + self.assertGreater(auto_rotation.updates, 0) + + def test_auto_preview_cancel_restores_previous_fixed_rotation(self): + auto_rotation = FakeAutoRotation(current_rotation=0) + + selected, should_save = select_rotation( + FakeTouch(["left", "down"]), + FakeLCD(), + 0, + auto_rotation=auto_rotation, + ) + + self.assertEqual(0, selected) + self.assertFalse(should_save) + self.assertFalse(auto_rotation.enabled) + self.assertEqual(0, auto_rotation.disabled_at[-1]) + def test_selected_mode_is_persisted(self): with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "user.json") diff --git a/tests/test_peripherals.py b/tests/test_peripherals.py index c8a62b0..d4a3799 100644 --- a/tests/test_peripherals.py +++ b/tests/test_peripherals.py @@ -86,12 +86,14 @@ def import_drivers(): class PeripheralDriverTests(unittest.TestCase): - def make_touch(self, bus): + def make_touch(self, bus, clock=None): + if clock is None: + clock = FakeClock() return touch_drive.Touch_CST816T( bus=bus, pin_factory=FakePin, timer_factory=FakeTimer, - clock=FakeClock(), + clock=clock, ) def test_qmi_wrong_chip_id_is_incompatible_hardware(self): @@ -125,6 +127,29 @@ def test_pending_touch_input_can_be_cleared(self): self.assertEqual(0, touch.Gestures) self.assertEqual(0, touch.Flag) + def test_timed_screens_continue_auto_rotation_polling(self): + class FakeAutoRotation: + def __init__(self): + self.calls = [] + + def update(self, redraw=True): + self.calls.append(redraw) + return False + + clock = FakeClock() + touch = self.make_touch( + FakeBus({0xA7: 0xB5, 0xA9: 1}), + clock=clock, + ) + clock.sleeps = [] + auto_rotation = FakeAutoRotation() + touch.Set_Auto_Rotation(auto_rotation) + + touch.Wait(object(), 0.25, poll_interval_ms=100) + + self.assertEqual([True, True, True], auto_rotation.calls) + self.assertEqual([100, 100, 50], clock.sleeps) + def test_absent_qmi_is_a_retryable_io_failure(self): with self.assertRaises(PeripheralIOError) as raised: qmi8658.QMI8658(bus=FakeBus(read_error=OSError("no device"))) diff --git a/tests/test_settings.py b/tests/test_settings.py index cfcb7a4..2c3a1a0 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -211,8 +211,8 @@ def test_invalid_mode_and_brightness_use_defaults(self): self.assertEqual("timer", normalized["OPERATING_MODE"]) self.assertEqual(100, normalized["BRIGHTNESS_PERCENT"]) - def test_rotation_accepts_four_angles_and_rejects_other_values(self): - for rotation in (0, 90, 180, 270): + def test_rotation_accepts_auto_and_four_angles(self): + for rotation in (0, 90, 180, 270, "auto"): with self.subTest(rotation=rotation): user = dict(DEFAULT_USER_PARAMS) user["DISPLAY_ROTATION_DEG"] = rotation @@ -223,7 +223,7 @@ def test_rotation_accepts_four_angles_and_rejects_other_values(self): self.assertFalse(changed) self.assertEqual(rotation, normalized["DISPLAY_ROTATION_DEG"]) - for invalid in (True, 45, 360, "90"): + for invalid in (True, 45, 360, "90", "automatic"): with self.subTest(invalid=invalid): user = dict(DEFAULT_USER_PARAMS) user["DISPLAY_ROTATION_DEG"] = invalid diff --git a/timer_mode.py b/timer_mode.py index 5957319..777398c 100644 --- a/timer_mode.py +++ b/timer_mode.py @@ -32,6 +32,7 @@ def run_timer_mode( qmi8658, initialize_imu, show_imu_degraded, + auto_rotation=None, ): """Run complete timer sessions until a safe Ready-screen hold requests menu.""" duration_values = system_params["DURATION_VALUES"] @@ -71,7 +72,7 @@ def run_timer_mode( ) ready_screen_dirty = False - if hold_detector.update(touch.IsPressed()): + if hold_detector.update(touch.IsPressed(lcd)): touch.ClearPendingInput() return user_params, qmi8658 @@ -128,9 +129,11 @@ def run_timer_mode( ) if configured_sensitivity > 0 and qmi8658 is None: qmi8658, imu_error = initialize_imu(configured_sensitivity) + if auto_rotation is not None: + auto_rotation.set_sensor(qmi8658, imu_error) if imu_error is not None: show_imu_degraded(lcd, imu_error) - time.sleep(2) + touch.Wait(lcd, 2) sensitivity = ( configured_sensitivity if qmi8658 is not None else 0 ) @@ -159,8 +162,10 @@ def run_timer_mode( ) except PeripheralError as error: qmi8658 = None + if auto_rotation is not None: + auto_rotation.set_sensor(None, error) show_imu_degraded(lcd, error) - time.sleep(2) + touch.Wait(lcd, 2) continue if not launch_detected: print("Launch mode cancelled or timed out.") @@ -179,7 +184,7 @@ def run_timer_mode( text_array=[PLINE1, PLINE2], back_colour=display_delay_rest_colour, ) - time.sleep(display_delay_rest) + touch.Wait(lcd, display_delay_rest) rest_session.start_session(debug=True) run_live_display( diff --git a/touch_drive.py b/touch_drive.py index 387138b..e52b08e 100644 --- a/touch_drive.py +++ b/touch_drive.py @@ -44,6 +44,7 @@ def __init__( self._address = address #Set slave address self._clock = clock self._configured_mode = None + self._auto_rotation = None self.Set_Rotation(rotation) try: self._bus = bus @@ -161,8 +162,9 @@ def get_point(self): self.X_point=x_point self.Y_point=y_point - def IsPressed(self): + def IsPressed(self, LCD=None): """Return whether the controller currently reports a physical touch.""" + self._update_auto_rotation(LCD) return bool(self._read_byte(0x02) & 0x0F) def ClearPendingInput(self): @@ -174,6 +176,26 @@ def Set_Rotation(self, rotation): """Keep directional gestures intuitive at the selected mount angle.""" self.rotation = validate_rotation(rotation) + def Set_Auto_Rotation(self, controller): + """Attach the optional automatic-orientation controller.""" + self._auto_rotation = controller + + def _update_auto_rotation(self, LCD, redraw=True): + controller = getattr(self, "_auto_rotation", None) + if controller is None or LCD is None: + return False + return controller.update(redraw=redraw) + + def Wait(self, LCD, seconds, poll_interval_ms=100): + """Wait while continuing bounded automatic-orientation polling.""" + remaining_ms = max(0, int(float(seconds) * 1000)) + interval_ms = max(1, int(poll_interval_ms)) + while remaining_ms > 0: + self._update_auto_rotation(LCD) + delay_ms = min(interval_ms, remaining_ms) + _sleep_ms(self._clock, delay_ms) + remaining_ms -= delay_ms + def _gesture_name(self, gesture): direction = { G_UP: "up", @@ -197,6 +219,7 @@ def Timer_callback(self,t): self.l = 50 def BootScreen(self, LCD, sleep=4, version_number="0.0"): + self._update_auto_rotation(LCD, redraw=False) self.Set_Mode(self.Mode) splash_loaded = False @@ -269,6 +292,7 @@ def ControlScreen(self, LCD, text_array=None, back_colour=None, refresh=True): - back_colour: Optional background color for the screen. - refresh: Show immediately, or allow the caller to add graphics first. """ + self._update_auto_rotation(LCD, redraw=False) # Set the background color if provided if back_colour is not None: self.SetBackColour(LCD, backColour=back_colour) @@ -291,6 +315,7 @@ def ControlScreen(self, LCD, text_array=None, back_colour=None, refresh=True): def GoScreen(self, LCD, text='..GO!', subtitle=None): + self._update_auto_rotation(LCD, redraw=False) #self.mode = 0 #self.Set_Mode(self.Mode) LCD.fill(LCD.green) @@ -298,10 +323,11 @@ def GoScreen(self, LCD, text='..GO!', subtitle=None): if subtitle is not None: LCD.write_centered(subtitle,170,1,LCD.black) LCD.show() - time.sleep(1) + self.Wait(LCD, 1) def LiveScreen(self, LCD, textsize_rem=None, backColour=None, textColour=None, elapsed=None, remaining=None): + self._update_auto_rotation(LCD, redraw=False) if remaining is None: remaining = "blank!" if elapsed is None: @@ -323,6 +349,7 @@ def ClearGesture(self, LCD): """ Check for up gesture. """ + self._update_auto_rotation(LCD) if ( self._gesture_name(self.Gestures) == "up" or self.Gestures == G_DOUBLE_CLIC @@ -336,6 +363,7 @@ def StopGesture(self, LCD): """ Check for a double tap stop gesture. """ + self._update_auto_rotation(LCD) if self.Gestures == 0x0B: self.Gestures = 0 return True # double tap stop gesture, returns True @@ -345,6 +373,7 @@ def StopGesture(self, LCD): #Gesture def GetGesture(self, LCD, debounce_time=0.2): return_type = None + self._update_auto_rotation(LCD) self.Set_Mode(0) return_type = self._gesture_name(self.Gestures)