From f1758b961809dd596e47cfbf62ef3fd489fa131c Mon Sep 17 00:00:00 2001 From: mrsqr Date: Wed, 12 Aug 2026 17:42:13 +0100 Subject: [PATCH] Add Ready-screen Auto-Dim --- README.md | 23 ++-- User Guide.md | 7 +- application.py | 46 ++++++- auto_dim.py | 240 ++++++++++++++++++++++++++++++++++ operating_modes.py | 51 ++++++++ params.json | 3 +- settings.py | 19 ++- tests/test_application.py | 28 ++++ tests/test_auto_dim.py | 239 +++++++++++++++++++++++++++++++++ tests/test_operating_modes.py | 46 ++++++- tests/test_settings.py | 53 +++++++- timer_mode.py | 51 ++++++++ user.json | 3 +- 13 files changed, 785 insertions(+), 24 deletions(-) create mode 100644 auto_dim.py create mode 100644 tests/test_application.py create mode 100644 tests/test_auto_dim.py diff --git a/README.md b/README.md index b05965a..3d509d2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,10 @@ Trackday or race session timer. * Added left/right review navigation and prevented cool-down from starting until every result page has been reached and the final page is advanced. * Kept summary data in bounded RAM only; no session history is written to flash in this increment. * Retained captured peaks as explicitly partial data if the IMU fails during a session, while normal timing continues. -* Expanded hardware-independent regression coverage to 165 tests. +* Added a persistent Auto-Dim setting that reduces the Ready screen to 25% brightness after 10 seconds without motion and restores the saved brightness immediately when motion resumes or Ready is left. +* Kept Auto-Dim disabled by default and preserved normal brightness if motion sensing is disabled or unavailable. +* Moved the Ready-screen dim level into `params.json` as the validated `AUTO_DIM_PERCENT` system setting, defaulting to 25%. +* Expanded hardware-independent regression coverage to 185 tests. ## Version 4.2 ### v4.2.0 @@ -110,7 +113,7 @@ Press and continuously hold the touchscreen for five seconds from the Timer Read * **Timer Mode** retains the existing track, rest, and Launch Mode workflow. During a track session, a baseline-corrected value such as `MAX 1.23 g` appears in a compact, clearly spaced line above the countdown. The peak resets for each track session and remains visible through overrun. `MAX --` indicates that acceleration data is unavailable; timing and the stop gesture continue normally. After a double-tap stop, an interactive review gives actual duration, overrun, total maximum G, acceleration, braking, left G, right G, and the completion reason their own high-visibility screens. Swipe left to advance and right to go back; cool-down begins only after swiping left from the eighth and final page. The review is held in RAM only and is not retained after leaving it. Rest sessions do not show maximum G. * **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. 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. +* **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. **Auto-Dim** is an independent On/Off choice: when enabled, the Ready screen reduces to 25% brightness after 10 seconds without motion and returns immediately to the saved brightness when motion is detected. It never dims menus, configuration, Launch Mode, active timing, review, rest, or G Mode. 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, disabled Auto-Dim, 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. @@ -187,7 +190,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 auto_rotation.py battery.py configuration.py font_data.py font_renderer.py g_force.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 session_summary.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_dim.py auto_rotation.py battery.py configuration.py font_data.py font_renderer.py g_force.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 session_summary.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 ``` @@ -198,7 +201,7 @@ On a fresh installation, the firmware creates `user.json` with safe defaults. To mpremote connect auto fs cp user.json : ``` -When upgrading an existing device, omit that command so all of its saved user settings are preserved. The firmware adds a safe 0° rotation automatically when upgrading an older `user.json`. +When upgrading an existing device, omit that command so all of its saved user settings are preserved. The firmware adds safe fixed 0° rotation and disabled Auto-Dim defaults automatically when upgrading an older `user.json`. ### 4. Verify first boot @@ -211,26 +214,26 @@ If first boot fails: * An import error generally means a `.py` support module was omitted; repeat the upload command and keep `main.py` last. * No serial device after flashing usually indicates a charge-only USB cable, an incorrect UF2, or a board still in BOOT mode. * A touchscreen hardware error is a controlled stop: check that this is the supported integrated board, then restart it. The serial message includes the failed operation or unexpected chip ID. -* An IMU hardware error disables Launch Mode and the session maximum-G reading for the current run. Swipe down to use the normal timer; `MAX --` confirms that timing remains available without the IMU. +* An IMU hardware error disables Launch Mode, Ready-screen Auto-Dim, and the session maximum-G reading for the current run. Swipe down to use the normal timer; `MAX --` confirms that timing remains available without the IMU. Auto-Dim safely leaves the display at its saved brightness. ### Peripheral failure policy 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, 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. +The QMI8658 IMU is optional unless a non-zero Launch Mode sensitivity, G Mode, Auto rotation, or Ready-screen Auto-Dim 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 rotation retains its last reliable orientation, Auto-Dim restores the saved brightness, and manual rotation choices remain usable. Saved choices are retained so the firmware can retry after a restart. ## Configuration files Version 4.2.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` (`auto` or the fixed clockwise device mounting angle `0`, `90`, `180`, or `270`). +* `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`, `MODE_MENU_HOLD_SEC`, and `AUTO_DIM_PERCENT` (an integer from 1 to 100, default 25). +* `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`, `DISPLAY_ROTATION_DEG` (`auto` or the fixed clockwise device mounting angle `0`, `90`, `180`, or `270`), and `AUTO_DIM_ENABLED` (`true` or `false`). 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. Directional summary labels use a dashboard mounting convention: the screen faces the driver, the screen-normal axis represents acceleration/braking, and the viewer-horizontal axis represents left/right. Fixed and automatic quarter-turn display rotations are applied to the lateral mapping. Mounting the board with its screen facing away from the driver reverses the longitudinal labels. -The firmware has built-in system and user defaults. Missing, malformed, or unsupported user values are replaced with safe defaults and saved using the canonical keys above. Existing `TRACK_LENGTH`, `TRACK_SESSION_LENGTH`, and `REST_SESSION_LENGTH` user keys are migrated automatically, while older files gain Timer Mode, 100% brightness, and 0° rotation defaults. +The firmware has built-in system and user defaults. Missing, malformed, or unsupported user values are replaced with safe defaults and saved using the canonical keys above. Existing `TRACK_LENGTH`, `TRACK_SESSION_LENGTH`, and `REST_SESSION_LENGTH` user keys are migrated automatically, while older files gain Timer Mode, 100% brightness, 0° rotation, and disabled Auto-Dim defaults. ## Host-side tests @@ -240,4 +243,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, 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. +The suite uses fakes for time, continuous holds, touch gestures, automatic and fixed display rotation, Ready-screen inactivity dimming and motion wake-up, 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 0545c33..6ad36ae 100644 --- a/User Guide.md +++ b/User Guide.md @@ -4,7 +4,7 @@ The following describes general operation of both the ``Track Session`` and ``Rest in Pits Session`` timer. * Upon startup, the Caterham boot artwork is followed by a hardware-information screen showing the board, processor, firmware, operating system, and platform. Each screen is shown for two seconds by default. Maintainers can tune the waits independently with `STARTUP_SPLASH_DURATION_SEC` and `HARDWARE_SPLASH_DURATION_SEC` in `params.json`. -* The ``Primary Screen`` will then show ``Ready`` together with the saved track duration, rest duration, and effective Launch Mode state. A battery icon above `Ready` fills from left to right with estimated remaining charge. A lightning bolt through the battery means USB/external power is present. When the timer starts while connected to USB, the initial full fill represents powered status because this board cannot read the isolated battery cell until it runs from battery. ``Launch unavailable`` means the saved non-zero sensitivity could not be used because the IMU is unavailable; normal swipe-down timing still works. To start the ``Track Session`` or race, ``Swipe Down``. Hold the screen continuously for five seconds to open the operating-mode menu. +* The ``Primary Screen`` will then show ``Ready`` together with the saved track duration, rest duration, and effective Launch Mode state. A battery icon above `Ready` fills from left to right with estimated remaining charge. A lightning bolt through the battery means USB/external power is present. When the timer starts while connected to USB, the initial full fill represents powered status because this board cannot read the isolated battery cell until it runs from battery. If Auto-Dim is enabled, the display reduces to 25% brightness after 10 seconds without motion on this screen and wakes immediately at the next detected movement. ``Launch unavailable`` means the saved non-zero sensitivity could not be used because the IMU is unavailable; normal swipe-down timing still works. To start the ``Track Session`` or race, ``Swipe Down``. Hold the screen continuously for five seconds to open the operating-mode menu. * After swiping down, keep the device still for the brief ``SESSION G - Calibrating`` screen. This establishes the acceleration baseline for both the session peak and Launch Mode. ``Go`` will then display briefly. If ``Launch Mode`` has been activated, ``Lights`` will be displayed while the timer waits for sufficient acceleration. * While waiting in ``Launch Mode``, double-tap to cancel and return to the ``Primary Screen``. The wait also cancels automatically after 30 seconds. * Upon starting, the ``Track Session`` timer count down will be displayed, and immediately commence. @@ -18,7 +18,7 @@ The following describes general operation of both the ``Track Session`` and ``Re * Once the ``Rest in Pits Session`` is complete, the timer will return to the ``Primary screen``. The ``Rest in Pits Session`` can be terminated with a ``Double Tap``. ## Configuration / Setup -Track duration, rest duration, launch sensitivity, operating mode, brightness, and display rotation are saved to `user.json` when changed. These settings persist across restarts and power loss. If the file is missing, damaged, or contains unsupported values, the timer restores safe defaults and rewrites the file using the canonical setting names. +Track duration, rest duration, launch sensitivity, operating mode, brightness, display rotation, and Auto-Dim state are saved to `user.json` when changed. These settings persist across restarts and power loss. If the file is missing, damaged, or contains unsupported values, the timer restores safe defaults and rewrites the file using the canonical setting names. ### Operating Mode Menu @@ -47,7 +47,8 @@ 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`` 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. +* ``Auto-Dim`` offers ``Off`` and ``On``. When On, 10 continuous seconds without motion reduces only the Timer Ready screen to the level configured by `AUTO_DIM_PERCENT` in `params.json` (25% by default; valid values are integer percentages from 1 to 100). Moving the device immediately restores the saved brightness and restarts the inactivity interval. Leaving Ready for a menu, configuration, Launch Mode, an active session, review, rest, or G Mode always restores normal saved brightness. The setting defaults to Off and does not change the saved Brightness percentage. If the IMU is unavailable, the timer shows a warning and safely remains at normal brightness. +* ``Restore defaults`` requires an explicit confirmation. Confirming restores Timer Mode, 100% brightness, 0° rotation, disabled Auto-Dim, 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. diff --git a/application.py b/application.py index 9da98d5..4f78669 100644 --- a/application.py +++ b/application.py @@ -11,6 +11,7 @@ TIMER_MODULES = ( "timer_mode", + "auto_dim", "configuration", "hold_detector", "launch", @@ -69,6 +70,21 @@ def _show_auto_rotation_degraded(lcd, error, launch_disabled=False): ) +def _show_auto_dim_degraded(lcd, error, launch_disabled=False): + from hardware import show_hardware_message + + print("Ready Auto-Dim unavailable: {}".format(error)) + middle_line = ( + "Launch also disabled" if launch_disabled else "Normal timer works" + ) + show_hardware_message( + lcd, + "Auto-Dim paused", + ["IMU not available", middle_line, "Normal brightness"], + background=lcd.brown, + ) + + def _show_g_mode_unavailable(lcd, error): from hardware import show_hardware_message @@ -88,6 +104,18 @@ def _initialize_imu(sensitivity): return initialize_optional_imu(sensitivity, QMI8658) +def required_imu_sensitivity(user_params, active_mode, rotation_setting): + """Return effective initialization demand for all IMU-backed features.""" + requirement = user_params["SENSITIVITY"] + if ( + active_mode == MODE_G + or rotation_setting == "auto" + or user_params["AUTO_DIM_ENABLED"] + ) and requirement <= 0: + return 1 + return requirement + + def _open_mode_menu(touch, lcd, user_params, auto_rotation): from operating_modes import configure_operating_mode @@ -146,12 +174,11 @@ def run_application(lcd): ) 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 + imu_requirement = required_imu_sensitivity( + user_params, + active_mode, + rotation_setting, + ) qmi8658, imu_error = _initialize_imu(imu_requirement) auto_rotation = AutoRotationController( @@ -208,6 +235,12 @@ def run_application(lcd): imu_error, launch_disabled=user_params["SENSITIVITY"] > 0, ) + elif user_params["AUTO_DIM_ENABLED"]: + _show_auto_dim_degraded( + lcd, + imu_error, + launch_disabled=user_params["SENSITIVITY"] > 0, + ) else: _show_imu_degraded(lcd, imu_error) touch.Wait(lcd, 2) @@ -229,6 +262,7 @@ def run_application(lcd): qmi8658, _initialize_imu, _show_imu_degraded, + _show_auto_dim_degraded, auto_rotation, ) except PeripheralError as error: diff --git a/auto_dim.py b/auto_dim.py new file mode 100644 index 0000000..63750de --- /dev/null +++ b/auto_dim.py @@ -0,0 +1,240 @@ +"""Ready-screen-only motion inactivity dimming.""" + +import math +import time + + +DEFAULT_AUTO_DIM_PERCENT = 25 +INACTIVITY_TIMEOUT_SEC = 10 +SAMPLE_INTERVAL_MS = 100 +ACCELERATION_MOTION_THRESHOLD_G = 0.06 +GYRO_MOTION_THRESHOLD_DPS = 3.0 +FILTER_ALPHA = 0.10 + + +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 _axes(sample): + if len(sample) < 3: + raise ValueError("Motion sample must contain x, y, and z acceleration") + acceleration = tuple(float(value) for value in sample[:3]) + gyroscope = ( + tuple(float(value) for value in sample[3:6]) + if len(sample) >= 6 + else (0.0, 0.0, 0.0) + ) + return acceleration, gyroscope + + +def brightness_duty(percent): + """Convert a bounded percentage to the display PWM duty range.""" + value = max(0, min(100, int(percent))) + return int(round(65535 * value / 100)) + + +class ReadyAutoDim: + """Track motion and brightness while, and only while, Ready is active.""" + + def __init__( + self, + lcd, + sensor=None, + enabled=False, + normal_percent=100, + dim_percent=DEFAULT_AUTO_DIM_PERCENT, + clock=time, + inactivity_timeout_sec=INACTIVITY_TIMEOUT_SEC, + sample_interval_ms=SAMPLE_INTERVAL_MS, + acceleration_threshold_g=ACCELERATION_MOTION_THRESHOLD_G, + gyro_threshold_dps=GYRO_MOTION_THRESHOLD_DPS, + filter_alpha=FILTER_ALPHA, + ): + if float(inactivity_timeout_sec) <= 0: + raise ValueError("Auto-Dim inactivity timeout must be positive") + if int(sample_interval_ms) <= 0: + raise ValueError("Auto-Dim sample interval must be positive") + if float(acceleration_threshold_g) <= 0: + raise ValueError("Acceleration motion threshold must be positive") + if float(gyro_threshold_dps) <= 0: + raise ValueError("Gyroscope motion threshold must be positive") + if float(filter_alpha) <= 0 or float(filter_alpha) > 1: + raise ValueError("Filter alpha must be greater than 0 and at most 1") + if ( + not isinstance(dim_percent, int) + or isinstance(dim_percent, bool) + or not 1 <= dim_percent <= 100 + ): + raise ValueError("Auto-Dim percentage must be an integer from 1 to 100") + + self.lcd = lcd + self.sensor = sensor + self.enabled = bool(enabled) + self.normal_percent = max(0, min(100, int(normal_percent))) + self.dim_percent = dim_percent + self.clock = clock + self.inactivity_timeout_ms = int(float(inactivity_timeout_sec) * 1000) + self.sample_interval_ms = int(sample_interval_ms) + self.acceleration_threshold_g = float(acceleration_threshold_g) + self.gyro_threshold_dps = float(gyro_threshold_dps) + self.filter_alpha = float(filter_alpha) + self.active = False + self.dimmed = False + self.filtered_acceleration = None + self.filtered_gyroscope = None + self.last_sample_ms = None + self.last_motion_ms = None + self._applied_percent = None + + @property + def available(self): + return self.sensor is not None + + def _apply_brightness(self, percent, force=False): + value = max(0, min(100, int(percent))) + if force or value != self._applied_percent: + self.lcd.set_bl_pwm(brightness_duty(value)) + self._applied_percent = value + + def _reset_motion_state(self, now_ms=None): + if now_ms is None: + now_ms = _ticks_ms(self.clock) + self.filtered_acceleration = None + self.filtered_gyroscope = None + self.last_sample_ms = None + self.last_motion_ms = now_ms + + def set_sensor(self, sensor): + """Replace the shared IMU and restart the inactivity interval.""" + self.sensor = sensor + if self.active: + self._reset_motion_state() + + def enter_ready(self): + """Wake at saved brightness and begin a fresh Ready interval.""" + self.active = True + self.dimmed = False + self._apply_brightness(self.normal_percent, force=True) + self._reset_motion_state() + + def leave_ready(self): + """Restore saved brightness before any non-Ready screen is drawn.""" + self.active = False + self.dimmed = False + self._apply_brightness(self.normal_percent, force=True) + self.filtered_acceleration = None + self.filtered_gyroscope = None + self.last_sample_ms = None + self.last_motion_ms = None + + def disable_sensor(self): + """Degrade safely to normal brightness after an IMU failure.""" + self.sensor = None + self.dimmed = False + self._apply_brightness(self.normal_percent, force=True) + self._reset_motion_state() + + def _motion_detected(self, sample): + acceleration, gyroscope = _axes(sample) + if self.filtered_acceleration is None: + self.filtered_acceleration = acceleration + acceleration_delta = 0.0 + else: + acceleration_delta = math.sqrt( + sum( + (value - reference) * (value - reference) + for value, reference in zip( + acceleration, + self.filtered_acceleration, + ) + ) + ) + alpha = self.filter_alpha + if acceleration_delta >= self.acceleration_threshold_g: + # A real movement becomes the new reference immediately so + # the inactivity interval starts when that movement stops, + # rather than waiting for a slow filter to catch up. + self.filtered_acceleration = acceleration + else: + self.filtered_acceleration = tuple( + reference + alpha * (value - reference) + for value, reference in zip( + acceleration, + self.filtered_acceleration, + ) + ) + if self.filtered_gyroscope is None: + self.filtered_gyroscope = gyroscope + gyro_delta = 0.0 + else: + gyro_delta = math.sqrt( + sum( + (value - reference) * (value - reference) + for value, reference in zip( + gyroscope, + self.filtered_gyroscope, + ) + ) + ) + if gyro_delta < self.gyro_threshold_dps: + alpha = self.filter_alpha + self.filtered_gyroscope = tuple( + reference + alpha * (value - reference) + for value, reference in zip( + gyroscope, + self.filtered_gyroscope, + ) + ) + return ( + acceleration_delta >= self.acceleration_threshold_g + or gyro_delta >= self.gyro_threshold_dps + ) + + def update(self, sample=None, now_ms=None, force=False): + """Poll at a bounded rate and return whether brightness changed.""" + if not self.active or not self.enabled or not self.available: + return False + if now_ms is None: + 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 + + if sample is None: + sample = self.sensor.Read_XYZ() + if self._motion_detected(sample): + self.last_motion_ms = now_ms + if self.dimmed: + self.dimmed = False + self._apply_brightness(self.normal_percent) + return True + return False + + if ( + not self.dimmed + and _ticks_diff(self.clock, now_ms, self.last_motion_ms) + >= self.inactivity_timeout_ms + ): + self.dimmed = True + self._apply_brightness(self.dim_percent) + return True + return False diff --git a/operating_modes.py b/operating_modes.py index f238f7b..b090afe 100644 --- a/operating_modes.py +++ b/operating_modes.py @@ -24,6 +24,7 @@ SETTINGS_CHOICES = ( ("Brightness", "brightness"), ("Rotation", "rotation"), + ("Auto-Dim", "auto_dim"), ("Restore defaults", "restore"), ("Back", "back"), ) @@ -156,6 +157,42 @@ def draw(): return original, False +def auto_dim_lines(enabled): + return [ + ["Auto-Dim", None, 35, 2, "white"], + ["ON" if enabled else "OFF", None, 88, 5, "white"], + ["Ready screen only", None, 153, 1, "white"], + ["L/R: change", None, 180, 1, "white"], + ["UP: save", None, 202, 1, "white"], + ["DOWN: cancel", None, 220, 1, "white"], + ] + + +def select_auto_dim(touch, lcd, current): + """Select persistent Ready-screen Auto-Dim state.""" + values = (False, True) + index = values.index(current) if isinstance(current, bool) else 0 + original = values[index] + + def draw(): + touch.ControlScreen( + lcd, + text_array=auto_dim_lines(values[index]), + back_colour="black", + ) + + draw() + while True: + gesture = touch.GetGesture(lcd) + if gesture in ("left", "right"): + index = 1 - index + draw() + elif gesture == "up": + return values[index], True + elif gesture == "down": + return original, False + + def apply_rotation(lcd, touch, degrees, auto_rotation=None): """Apply one mount angle to rendering and directional gestures.""" if degrees == AUTO_ROTATION: @@ -335,6 +372,20 @@ def _run_settings( auto_rotation=auto_rotation, ) + elif action == "auto_dim": + previous = user_params["AUTO_DIM_ENABLED"] + selected, should_save = select_auto_dim(touch, lcd, previous) + if not should_save: + continue + updated, saved = persist_setting( + user_file, + user_params, + "AUTO_DIM_ENABLED", + selected, + ) + if saved: + user_params = updated + elif action == "restore" and confirm_restore_defaults(touch, lcd): defaults, saved = restore_user_defaults(user_file) if saved: diff --git a/params.json b/params.json index 7277500..b1f2244 100644 --- a/params.json +++ b/params.json @@ -6,5 +6,6 @@ "DISPLAY_DELAY_REST_COLOUR": "blue", "STARTUP_SPLASH_DURATION_SEC": 2, "HARDWARE_SPLASH_DURATION_SEC": 2, - "MODE_MENU_HOLD_SEC": 5 + "MODE_MENU_HOLD_SEC": 5, + "AUTO_DIM_PERCENT": 25 } diff --git a/settings.py b/settings.py index 9ca798c..ca75469 100644 --- a/settings.py +++ b/settings.py @@ -17,6 +17,7 @@ "STARTUP_SPLASH_DURATION_SEC": 2, "HARDWARE_SPLASH_DURATION_SEC": 2, "MODE_MENU_HOLD_SEC": 5, + "AUTO_DIM_PERCENT": 25, } DEFAULT_USER_PARAMS = { @@ -26,6 +27,7 @@ "OPERATING_MODE": "timer", "BRIGHTNESS_PERCENT": 100, "DISPLAY_ROTATION_DEG": 0, + "AUTO_DIM_ENABLED": False, } OPERATING_MODES = ("timer", "g") @@ -101,6 +103,10 @@ def validate_system_params(data): source["MODE_MENU_HOLD_SEC"] = DEFAULT_SYSTEM_PARAMS[ "MODE_MENU_HOLD_SEC" ] + if "AUTO_DIM_PERCENT" not in source: + source["AUTO_DIM_PERCENT"] = DEFAULT_SYSTEM_PARAMS[ + "AUTO_DIM_PERCENT" + ] valid = ( _positive_int_list(source.get("DURATION_VALUES")) @@ -113,6 +119,9 @@ def validate_system_params(data): and source.get("HARDWARE_SPLASH_DURATION_SEC") >= 0 and _is_number(source.get("MODE_MENU_HOLD_SEC")) and source.get("MODE_MENU_HOLD_SEC") > 0 + and isinstance(source.get("AUTO_DIM_PERCENT"), int) + and not isinstance(source.get("AUTO_DIM_PERCENT"), bool) + and 1 <= source.get("AUTO_DIM_PERCENT") <= 100 and isinstance(source.get("VERSION"), str) and len(source.get("VERSION")) > 0 and source.get("DISPLAY_DELAY_REST_COLOUR") in DISPLAY_COLOURS @@ -166,6 +175,11 @@ def normalize_user_params(data, system_params=None): "DISPLAY_ROTATION_DEG", DEFAULT_USER_PARAMS["DISPLAY_ROTATION_DEG"], ) + auto_dim_enabled = migrated.get( + "AUTO_DIM_ENABLED", + DEFAULT_USER_PARAMS["AUTO_DIM_ENABLED"], + ) + auto_dim_invalid = not isinstance(auto_dim_enabled, bool) if ( not isinstance(race_length, int) @@ -194,6 +208,8 @@ def normalize_user_params(data, system_params=None): or display_rotation not in DISPLAY_ROTATION_VALUES ): display_rotation = DEFAULT_USER_PARAMS["DISPLAY_ROTATION_DEG"] + if auto_dim_invalid: + auto_dim_enabled = DEFAULT_USER_PARAMS["AUTO_DIM_ENABLED"] normalized = { "SENSITIVITY": sensitivity, @@ -202,8 +218,9 @@ def normalize_user_params(data, system_params=None): "OPERATING_MODE": operating_mode, "BRIGHTNESS_PERCENT": brightness_percent, "DISPLAY_ROTATION_DEG": display_rotation, + "AUTO_DIM_ENABLED": auto_dim_enabled, } - return normalized, normalized != source + return normalized, auto_dim_invalid or normalized != source def _remove_if_exists(path): diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 0000000..abc05fc --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,28 @@ +import unittest + +from application import required_imu_sensitivity +from settings import DEFAULT_USER_PARAMS + + +class ApplicationTests(unittest.TestCase): + def test_auto_dim_requests_imu_when_launch_and_rotation_do_not(self): + user = dict(DEFAULT_USER_PARAMS) + user["AUTO_DIM_ENABLED"] = True + + self.assertEqual(1, required_imu_sensitivity(user, "timer", 0)) + + def test_disabled_sensor_features_do_not_request_imu(self): + user = dict(DEFAULT_USER_PARAMS) + + self.assertEqual(0, required_imu_sensitivity(user, "timer", 0)) + + def test_existing_imu_features_keep_their_initialization_policy(self): + user = dict(DEFAULT_USER_PARAMS) + self.assertEqual(1, required_imu_sensitivity(user, "g", 0)) + self.assertEqual(1, required_imu_sensitivity(user, "timer", "auto")) + user["SENSITIVITY"] = 1.75 + self.assertEqual(1.75, required_imu_sensitivity(user, "timer", 0)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_auto_dim.py b/tests/test_auto_dim.py new file mode 100644 index 0000000..6bc84cd --- /dev/null +++ b/tests/test_auto_dim.py @@ -0,0 +1,239 @@ +import unittest + +from auto_dim import DEFAULT_AUTO_DIM_PERCENT, ReadyAutoDim, brightness_duty +from hardware import PeripheralIOError + + +class FakeClock: + def __init__(self): + self.now = 0 + + def ticks_ms(self): + return self.now + + def ticks_diff(self, current, previous): + return current - previous + + +class FakeLCD: + def __init__(self): + self.duties = [] + + def set_bl_pwm(self, duty): + self.duties.append(duty) + + +class FakeSensor: + def __init__(self, samples=None, error=None): + self.samples = iter(samples or []) + self.error = error + self.read_count = 0 + + def Read_XYZ(self): + self.read_count += 1 + if self.error is not None: + raise self.error + return next(self.samples) + + +class AutoDimTests(unittest.TestCase): + def make_monitor(self, enabled=True, normal_percent=100, sensor=None): + clock = FakeClock() + lcd = FakeLCD() + monitor = ReadyAutoDim( + lcd, + sensor=sensor, + enabled=enabled, + normal_percent=normal_percent, + clock=clock, + filter_alpha=1, + ) + return monitor, lcd, clock + + def test_enabled_monitor_dims_after_ten_continuous_stationary_seconds(self): + monitor, lcd, clock = self.make_monitor(sensor=object()) + monitor.enter_ready() + + monitor.update(sample=(0, 0, 1, 0, 0, 0), now_ms=0, force=True) + clock.now = 9_999 + monitor.update( + sample=(0.01, -0.01, 1, 0.2, 0, 0), + now_ms=clock.now, + force=True, + ) + self.assertFalse(monitor.dimmed) + + clock.now = 10_000 + changed = monitor.update( + sample=(0, 0, 1, 0, 0, 0), + now_ms=clock.now, + force=True, + ) + + self.assertTrue(changed) + self.assertTrue(monitor.dimmed) + self.assertEqual( + brightness_duty(DEFAULT_AUTO_DIM_PERCENT), + lcd.duties[-1], + ) + + def test_configured_dim_percentage_controls_backlight_duty(self): + clock = FakeClock() + lcd = FakeLCD() + monitor = ReadyAutoDim( + lcd, + sensor=object(), + enabled=True, + normal_percent=100, + dim_percent=40, + clock=clock, + filter_alpha=1, + ) + monitor.enter_ready() + monitor.update(sample=(0, 0, 1), now_ms=0, force=True) + + monitor.update(sample=(0, 0, 1), now_ms=10_000, force=True) + + self.assertTrue(monitor.dimmed) + self.assertEqual(brightness_duty(40), lcd.duties[-1]) + + def test_motion_wakes_immediately_and_restarts_inactivity_interval(self): + monitor, lcd, clock = self.make_monitor(sensor=object()) + monitor.enter_ready() + monitor.update(sample=(0, 0, 1), now_ms=0, force=True) + monitor.update(sample=(0, 0, 1), now_ms=10_000, force=True) + self.assertTrue(monitor.dimmed) + + changed = monitor.update(sample=(0.5, 0, 1), now_ms=10_100, force=True) + + self.assertTrue(changed) + self.assertFalse(monitor.dimmed) + self.assertEqual(brightness_duty(100), lcd.duties[-1]) + monitor.update(sample=(0.5, 0, 1), now_ms=20_099, force=True) + self.assertFalse(monitor.dimmed) + monitor.update(sample=(0.5, 0, 1), now_ms=20_100, force=True) + self.assertTrue(monitor.dimmed) + + def test_inactivity_starts_when_a_large_orientation_change_stops(self): + monitor, _lcd, _clock = self.make_monitor(sensor=object()) + monitor.filter_alpha = 0.1 + monitor.enter_ready() + monitor.update(sample=(0, 0, 1), now_ms=0, force=True) + + monitor.update(sample=(1, 0, 0), now_ms=5_000, force=True) + monitor.update(sample=(1, 0, 0), now_ms=14_999, force=True) + self.assertFalse(monitor.dimmed) + monitor.update(sample=(1, 0, 0), now_ms=15_000, force=True) + + self.assertTrue(monitor.dimmed) + + def test_gyroscope_motion_prevents_dimming_without_axis_change(self): + monitor, _lcd, _clock = self.make_monitor(sensor=object()) + monitor.enter_ready() + monitor.update(sample=(0, 0, 1, 0, 0, 0), now_ms=0, force=True) + + monitor.update(sample=(0, 0, 1, 0, 4, 0), now_ms=9_000, force=True) + monitor.update(sample=(0, 0, 1, 0, 0, 0), now_ms=18_999, force=True) + + self.assertFalse(monitor.dimmed) + + def test_stationary_gyroscope_bias_does_not_prevent_dimming(self): + monitor, _lcd, _clock = self.make_monitor(sensor=object()) + biased_stationary = (0, 0, 1, 3.2, 4.6, 0.25) + monitor.enter_ready() + monitor.update(sample=biased_stationary, now_ms=0, force=True) + + monitor.update(sample=biased_stationary, now_ms=10_000, force=True) + + self.assertTrue(monitor.dimmed) + + def test_stationary_noise_does_not_reset_inactivity(self): + monitor, _lcd, _clock = self.make_monitor(sensor=object()) + monitor.enter_ready() + monitor.update(sample=(0, 0, 1), now_ms=0, force=True) + for now_ms, sample in ( + (2_000, (0.01, -0.01, 1.01)), + (4_000, (-0.01, 0.01, 0.99)), + (6_000, (0.01, 0, 1.01)), + (8_000, (0, -0.01, 0.99)), + (10_000, (0, 0, 1)), + ): + monitor.update(sample=sample, now_ms=now_ms, force=True) + + self.assertTrue(monitor.dimmed) + + def test_disabled_or_unavailable_monitor_never_dims(self): + disabled, disabled_lcd, _ = self.make_monitor(enabled=False, sensor=object()) + disabled.enter_ready() + disabled.update(sample=(0, 0, 1), now_ms=20_000, force=True) + + unavailable, unavailable_lcd, _ = self.make_monitor(sensor=None) + unavailable.enter_ready() + unavailable.update(sample=(0, 0, 1), now_ms=20_000, force=True) + + self.assertFalse(disabled.dimmed) + self.assertFalse(unavailable.dimmed) + self.assertEqual([brightness_duty(100)], disabled_lcd.duties) + self.assertEqual([brightness_duty(100)], unavailable_lcd.duties) + + def test_sampling_is_bounded_to_ten_hz(self): + sensor = FakeSensor([(0, 0, 1)] * 2) + monitor, _lcd, clock = self.make_monitor(sensor=sensor) + monitor.enter_ready() + + monitor.update() + clock.now = 99 + monitor.update() + clock.now = 100 + monitor.update() + + self.assertEqual(2, sensor.read_count) + + def test_leaving_ready_always_restores_saved_brightness(self): + monitor, lcd, _clock = self.make_monitor(normal_percent=75, sensor=object()) + monitor.enter_ready() + monitor.update(sample=(0, 0, 1), now_ms=0, force=True) + monitor.update(sample=(0, 0, 1), now_ms=10_000, force=True) + self.assertTrue(monitor.dimmed) + + monitor.leave_ready() + + self.assertFalse(monitor.active) + self.assertFalse(monitor.dimmed) + self.assertEqual(brightness_duty(75), lcd.duties[-1]) + + def test_sensor_failure_can_degrade_to_normal_brightness(self): + failure = PeripheralIOError("QMI8658", "sample read", "disconnected") + sensor = FakeSensor(error=failure) + monitor, lcd, _clock = self.make_monitor(normal_percent=75, sensor=sensor) + monitor.enter_ready() + + with self.assertRaises(PeripheralIOError): + monitor.update(force=True) + monitor.disable_sensor() + + self.assertFalse(monitor.available) + self.assertFalse(monitor.dimmed) + self.assertEqual(brightness_duty(75), lcd.duties[-1]) + + def test_invalid_timing_and_filter_configuration_is_rejected(self): + invalid_options = ( + {"inactivity_timeout_sec": 0}, + {"sample_interval_ms": 0}, + {"acceleration_threshold_g": 0}, + {"gyro_threshold_dps": 0}, + {"filter_alpha": 0}, + {"filter_alpha": 1.1}, + {"dim_percent": 0}, + {"dim_percent": 101}, + {"dim_percent": 25.0}, + {"dim_percent": True}, + ) + for options in invalid_options: + with self.subTest(options=options): + with self.assertRaises(ValueError): + ReadyAutoDim(FakeLCD(), **options) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_operating_modes.py b/tests/test_operating_modes.py index 29e4ae2..507b9fe 100644 --- a/tests/test_operating_modes.py +++ b/tests/test_operating_modes.py @@ -8,6 +8,7 @@ from operating_modes import ( SETTINGS_CHOICES, apply_brightness, + auto_dim_lines, brightness_duty, brightness_lines, confirm_restore_defaults, @@ -16,6 +17,7 @@ restore_confirmation_lines, rotation_lines, select_brightness, + select_auto_dim, select_operating_mode, select_rotation, settings_menu_lines, @@ -170,6 +172,8 @@ def test_all_menu_text_fits_round_display_at_every_rotation(self): self.assert_round_fit(mode_menu_lines(index)) for brightness in (25, 50, 75, 100): self.assert_round_fit(brightness_lines(brightness)) + for enabled in (False, True): + self.assert_round_fit(auto_dim_lines(enabled)) self.assert_round_fit(rotation_lines(rotation)) self.assert_round_fit( rotation_lines("auto", FakeAutoRotation(rotation)) @@ -190,6 +194,23 @@ def test_brightness_cancel_restores_previous_preview(self): self.assertIn(brightness_duty(75), lcd.duties) self.assertEqual(brightness_duty(100), lcd.duties[-1]) + def test_auto_dim_selection_saves_and_cancels_boolean_values(self): + selected, should_save = select_auto_dim( + FakeTouch(["right", "up"]), + object(), + False, + ) + cancelled, cancel_save = select_auto_dim( + FakeTouch(["left", "down"]), + object(), + False, + ) + + self.assertTrue(selected) + self.assertTrue(should_save) + self.assertFalse(cancelled) + self.assertFalse(cancel_save) + def test_rotation_cancel_restores_display_and_touch_preview(self): lcd = FakeLCD() touch = FakeTouch(["right", "down"]) @@ -317,6 +338,29 @@ def test_brightness_setting_is_previewed_and_persisted(self): self.assertEqual(75, file_in(path, debug=False)["BRIGHTNESS_PERCENT"]) self.assertIn(brightness_duty(75), lcd.duties) + def test_auto_dim_setting_is_persisted(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + + updated, mode = configure_operating_mode( + FakeTouch( + [ + "left", "up", # Settings + "right", "right", "up", # Auto-Dim + "right", "up", # On, save + "down", # leave Settings + "down", # cancel mode menu + ] + ), + FakeLCD(), + dict(DEFAULT_USER_PARAMS), + path, + ) + + self.assertEqual("timer", mode) + self.assertTrue(updated["AUTO_DIM_ENABLED"]) + self.assertTrue(file_in(path, debug=False)["AUTO_DIM_ENABLED"]) + def test_restore_defaults_requires_confirmation_and_returns_timer(self): with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "user.json") @@ -329,7 +373,7 @@ def test_restore_defaults_requires_confirmation_and_returns_timer(self): touch = FakeTouch( [ "right", "up", # Settings from G Mode - "right", "right", "up", # Restore defaults + "right", "right", "right", "up", # Restore defaults "right", "up", # Confirm RESTORE ] ) diff --git a/tests/test_settings.py b/tests/test_settings.py index 2c3a1a0..6b19fc2 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -136,6 +136,32 @@ def test_existing_system_file_gains_default_mode_hold_duration(self): self.assertTrue(valid) self.assertEqual(5, params["MODE_MENU_HOLD_SEC"]) + def test_existing_system_file_gains_default_auto_dim_percentage(self): + legacy = dict(DEFAULT_SYSTEM_PARAMS) + del legacy["AUTO_DIM_PERCENT"] + + params, valid = validate_system_params(legacy) + + self.assertTrue(valid) + self.assertEqual(25, params["AUTO_DIM_PERCENT"]) + + def test_auto_dim_percentage_must_be_an_integer_from_one_to_one_hundred(self): + for percentage in (1, 25, 100): + with self.subTest(percentage=percentage): + configured = dict(DEFAULT_SYSTEM_PARAMS) + configured["AUTO_DIM_PERCENT"] = percentage + params, valid = validate_system_params(configured) + self.assertTrue(valid) + self.assertEqual(percentage, params["AUTO_DIM_PERCENT"]) + + for invalid in (0, 101, 25.0, True, "25", None): + with self.subTest(invalid=invalid): + configured = dict(DEFAULT_SYSTEM_PARAMS) + configured["AUTO_DIM_PERCENT"] = invalid + params, valid = validate_system_params(configured) + self.assertFalse(valid) + self.assertEqual(DEFAULT_SYSTEM_PARAMS, params) + def test_legacy_boot_delay_is_migrated(self): legacy = dict(DEFAULT_SYSTEM_PARAMS) del legacy["STARTUP_SPLASH_DURATION_SEC"] @@ -182,11 +208,12 @@ def test_legacy_user_keys_are_migrated_and_removed(self): "OPERATING_MODE": "timer", "BRIGHTNESS_PERCENT": 100, "DISPLAY_ROTATION_DEG": 0, + "AUTO_DIM_ENABLED": False, }, normalized, ) - def test_existing_user_file_gains_mode_brightness_and_rotation_defaults(self): + def test_existing_user_file_gains_all_new_setting_defaults(self): normalized, changed = normalize_user_params( {"SENSITIVITY": 0, "RACE_LENGTH": 10, "REST_LENGTH": 15}, DEFAULT_SYSTEM_PARAMS, @@ -196,6 +223,7 @@ def test_existing_user_file_gains_mode_brightness_and_rotation_defaults(self): self.assertEqual("timer", normalized["OPERATING_MODE"]) self.assertEqual(100, normalized["BRIGHTNESS_PERCENT"]) self.assertEqual(0, normalized["DISPLAY_ROTATION_DEG"]) + self.assertFalse(normalized["AUTO_DIM_ENABLED"]) def test_invalid_mode_and_brightness_use_defaults(self): invalid = dict(DEFAULT_USER_PARAMS) @@ -211,6 +239,29 @@ def test_invalid_mode_and_brightness_use_defaults(self): self.assertEqual("timer", normalized["OPERATING_MODE"]) self.assertEqual(100, normalized["BRIGHTNESS_PERCENT"]) + def test_auto_dim_accepts_only_boolean_values(self): + for enabled in (False, True): + with self.subTest(enabled=enabled): + user = dict(DEFAULT_USER_PARAMS) + user["AUTO_DIM_ENABLED"] = enabled + normalized, changed = normalize_user_params( + user, + DEFAULT_SYSTEM_PARAMS, + ) + self.assertFalse(changed) + self.assertIs(enabled, normalized["AUTO_DIM_ENABLED"]) + + for invalid in (0, 1, "on", None): + with self.subTest(invalid=invalid): + user = dict(DEFAULT_USER_PARAMS) + user["AUTO_DIM_ENABLED"] = invalid + normalized, changed = normalize_user_params( + user, + DEFAULT_SYSTEM_PARAMS, + ) + self.assertTrue(changed) + self.assertFalse(normalized["AUTO_DIM_ENABLED"]) + def test_rotation_accepts_auto_and_four_angles(self): for rotation in (0, 90, 180, 270, "auto"): with self.subTest(rotation=rotation): diff --git a/timer_mode.py b/timer_mode.py index dc2bc6c..ac27e49 100644 --- a/timer_mode.py +++ b/timer_mode.py @@ -2,6 +2,7 @@ import time +from auto_dim import ReadyAutoDim from battery import BatteryMonitor from configuration import set_sensitivity, set_session from g_force import SessionGPeak, calibrate_baseline @@ -34,6 +35,7 @@ def run_timer_mode( qmi8658, initialize_imu, show_imu_degraded, + show_auto_dim_degraded, auto_rotation=None, ): """Run complete timer sessions until a safe Ready-screen hold requests menu.""" @@ -43,6 +45,27 @@ def run_timer_mode( display_delay_rest = system_params["DISPLAY_DELAY_REST"] display_delay_rest_colour = system_params["DISPLAY_DELAY_REST_COLOUR"] battery_monitor = BatteryMonitor() + auto_dim_enabled = user_params["AUTO_DIM_ENABLED"] + + if auto_dim_enabled and qmi8658 is None: + qmi8658, imu_error = initialize_imu(1) + if auto_rotation is not None: + auto_rotation.set_sensor(qmi8658, imu_error) + if imu_error is not None: + show_auto_dim_degraded( + lcd, + imu_error, + launch_disabled=user_params["SENSITIVITY"] > 0, + ) + touch.Wait(lcd, 2) + + ready_auto_dim = ReadyAutoDim( + lcd, + sensor=qmi8658, + enabled=auto_dim_enabled, + normal_percent=user_params["BRIGHTNESS_PERCENT"], + dim_percent=system_params["AUTO_DIM_PERCENT"], + ) while True: configured_sensitivity = user_params["SENSITIVITY"] @@ -60,6 +83,8 @@ def run_timer_mode( debug=True, ) + ready_auto_dim.set_sensor(qmi8658) + ready_auto_dim.enter_ready() while not launch: if ready_screen_dirty: draw_ready_screen( @@ -74,13 +99,32 @@ def run_timer_mode( ) ready_screen_dirty = False + try: + ready_auto_dim.update() + except PeripheralError as error: + print("Ready Auto-Dim paused: {}".format(error)) + ready_auto_dim.disable_sensor() + qmi8658 = None + sensitivity = 0 + if auto_rotation is not None: + auto_rotation.set_sensor(None, error) + show_auto_dim_degraded( + lcd, + error, + launch_disabled=configured_sensitivity > 0, + ) + touch.Wait(lcd, 2) + ready_screen_dirty = True + if hold_detector.update(touch.IsPressed(lcd)): + ready_auto_dim.leave_ready() touch.ClearPendingInput() return user_params, qmi8658 gesture = touch.GetGesture(lcd, debounce_time=0.05) if gesture == "left": + ready_auto_dim.leave_ready() race_length = set_session( LCD=lcd, Touch=touch, @@ -96,8 +140,10 @@ def run_timer_mode( race_length, ) ready_screen_dirty = True + ready_auto_dim.enter_ready() hold_detector.reset() elif gesture == "right": + ready_auto_dim.leave_ready() rest_length = set_session( LCD=lcd, Touch=touch, @@ -113,8 +159,10 @@ def run_timer_mode( rest_length, ) ready_screen_dirty = True + ready_auto_dim.enter_ready() hold_detector.reset() elif gesture == "up": + ready_auto_dim.leave_ready() configured_sensitivity = set_sensitivity( LCD=lcd, Touch=touch, @@ -139,9 +187,12 @@ def run_timer_mode( sensitivity = ( configured_sensitivity if qmi8658 is not None else 0 ) + ready_auto_dim.set_sensor(qmi8658) ready_screen_dirty = True + ready_auto_dim.enter_ready() hold_detector.reset() elif gesture == "down": + ready_auto_dim.leave_ready() print("Timer go!") launch = True diff --git a/user.json b/user.json index fe41ee9..fbe4f60 100644 --- a/user.json +++ b/user.json @@ -4,5 +4,6 @@ "REST_LENGTH": 10, "OPERATING_MODE": "timer", "BRIGHTNESS_PERCENT": 100, - "DISPLAY_ROTATION_DEG": 0 + "DISPLAY_ROTATION_DEG": 0, + "AUTO_DIM_ENABLED": false }