diff --git a/README.md b/README.md index 986464a..07dae5e 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,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. * **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. Swipe up saves; swipe down cancels and restores the previous brightness. **Restore defaults** requires confirmation, then restores Timer Mode, 100% brightness, 20-minute track/rest sessions, and disabled Launch Mode. +* **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. If the IMU is unavailable in G Mode, the firmware shows an actionable message and safely returns to Timer Mode. The timer remains usable. @@ -160,7 +160,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 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 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 ``` @@ -171,7 +171,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 its saved track duration, rest duration, and launch sensitivity are preserved. +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`. ### 4. Verify first boot @@ -197,11 +197,11 @@ The QMI8658 IMU is optional unless a non-zero Launch Mode sensitivity is selecte Version 4.0.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`), and `BRIGHTNESS_PERCENT`. +* `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`). 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. -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 and 100% brightness 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, and 0° rotation defaults. ## Host-side tests @@ -211,4 +211,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, 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, 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. diff --git a/User Guide.md b/User Guide.md index ea259f6..3a3d435 100644 --- a/User Guide.md +++ b/User Guide.md @@ -16,7 +16,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, and brightness 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, 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. ### Operating Mode Menu @@ -44,7 +44,8 @@ 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. -* ``Restore defaults`` requires an explicit confirmation. Confirming restores Timer Mode, 100% brightness, 20-minute track and rest sessions, and disabled Launch Mode. Cancelling changes nothing. +* ``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. +* ``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. ### Session Duration diff --git a/application.py b/application.py index 6ddf330..0bbdeef 100644 --- a/application.py +++ b/application.py @@ -108,11 +108,16 @@ def run_application(lcd): system_params, user_params = load_configuration(PARAMS_FILE, USER_FILE) print("User Parameters: " + str(user_params)) + lcd.set_rotation(user_params["DISPLAY_ROTATION_DEG"]) _apply_brightness(lcd, user_params["BRIGHTNESS_PERCENT"]) try: touch = initialize_with_retry( - lambda: Touch_CST816T(mode=1, LCD=lcd), + lambda: Touch_CST816T( + mode=1, + LCD=lcd, + rotation=user_params["DISPLAY_ROTATION_DEG"], + ), "CST816T", ) run_startup_screens( diff --git a/lcd_1inch28.py b/lcd_1inch28.py index 8c7c762..8141464 100644 --- a/lcd_1inch28.py +++ b/lcd_1inch28.py @@ -25,6 +25,7 @@ class LCD_1inch28(framebuf.FrameBuffer): def __init__(self): #SPI initialization SPI self.width = 240 self.height = 240 + self.rotation = 0 self.cs = Pin(CS,Pin.OUT) self.rst = Pin(RST,Pin.OUT) @@ -85,6 +86,20 @@ def write_data(self, buf): #Write data def set_bl_pwm(self,duty): #Set screen brightness self.pwm.duty_u16(duty)#max 65535 + + def set_rotation(self, rotation): + """Counter-rotate output for the selected clockwise mount angle.""" + # Import after framebuffer construction to protect the RP2040's largest + # contiguous allocation during startup. + from orientation import madctl_for_rotation + + value = madctl_for_rotation(rotation) + # MADCTL changes are ignored while tearing output is active on GC9A01. + self.write_cmd(0x34) + self.write_cmd(0x36) + self.write_data(value) + self.write_cmd(0x35) + self.rotation = rotation def init_display(self): #LCD initialization LCD """Initialize display""" diff --git a/operating_modes.py b/operating_modes.py index a368d23..b697b34 100644 --- a/operating_modes.py +++ b/operating_modes.py @@ -3,6 +3,7 @@ from settings import ( BRIGHTNESS_VALUES, DEFAULT_USER_PARAMS, + DISPLAY_ROTATION_VALUES, OPERATING_MODES, persist_setting, restore_user_defaults, @@ -21,6 +22,7 @@ SETTINGS_CHOICES = ( ("Brightness", "brightness"), + ("Rotation", "rotation"), ("Restore defaults", "restore"), ("Back", "back"), ) @@ -153,6 +155,56 @@ def draw(): return original, False +def apply_rotation(lcd, touch, degrees): + """Apply one mount angle to rendering and directional gestures.""" + lcd.set_rotation(degrees) + touch.Set_Rotation(degrees) + + +def rotation_lines(degrees): + return [ + ["Mount rotation", None, 35, 2, "white"], + ["{} deg".format(degrees), None, 88, 4, "white"], + ["Device clockwise", 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): + """Preview mount rotations and return ``(value, should_save)``.""" + values = list(DISPLAY_ROTATION_VALUES) + try: + index = values.index(current) + except ValueError: + index = values.index(DEFAULT_USER_PARAMS["DISPLAY_ROTATION_DEG"]) + original = values[index] + + def draw(): + apply_rotation(lcd, touch, values[index]) + touch.ControlScreen( + lcd, + text_array=rotation_lines(values[index]), + back_colour="black", + ) + + draw() + while True: + gesture = touch.GetGesture(lcd) + if gesture == "left": + index = (index - 1) % len(values) + draw() + elif gesture == "right": + index = (index + 1) % len(values) + draw() + elif gesture == "up": + return values[index], True + elif gesture == "down": + apply_rotation(lcd, touch, original) + return original, False + + def restore_confirmation_lines(selected): return [ ["Restore defaults?", None, 35, 2, "white"], @@ -209,10 +261,31 @@ def _run_settings(touch, lcd, user_params, user_file): else: apply_brightness(lcd, previous) + elif action == "rotation": + previous = user_params["DISPLAY_ROTATION_DEG"] + selected, should_save = select_rotation(touch, lcd, previous) + if not should_save: + continue + updated, saved = persist_setting( + user_file, + user_params, + "DISPLAY_ROTATION_DEG", + selected, + ) + if saved: + user_params = updated + else: + apply_rotation(lcd, touch, previous) + elif action == "restore" and confirm_restore_defaults(touch, lcd): defaults, saved = restore_user_defaults(user_file) if saved: apply_brightness(lcd, defaults["BRIGHTNESS_PERCENT"]) + apply_rotation( + lcd, + touch, + defaults["DISPLAY_ROTATION_DEG"], + ) return defaults, True diff --git a/orientation.py b/orientation.py new file mode 100644 index 0000000..cb8ad41 --- /dev/null +++ b/orientation.py @@ -0,0 +1,38 @@ +"""Shared display and touch orientation helpers.""" + + +ROTATION_VALUES = (0, 90, 180, 270) + +# The native Waveshare initialization uses MY, ML, and BGR (0x98). The +# remaining values counter-rotate the display for a device mounted clockwise +# by the corresponding number of degrees while preserving ML and BGR. +MADCTL_BY_MOUNT_ROTATION = { + 0: 0x98, + 90: 0xF8, + 180: 0x58, + 270: 0x38, +} + +_DIRECTIONS_CLOCKWISE = ("up", "right", "down", "left") + + +def validate_rotation(rotation): + """Return a supported clockwise mount rotation or raise ``ValueError``.""" + if isinstance(rotation, bool) or rotation not in ROTATION_VALUES: + raise ValueError("Rotation must be 0, 90, 180, or 270 degrees") + return rotation + + +def madctl_for_rotation(rotation): + """Return the GC9A01 MADCTL value for a clockwise mount rotation.""" + return MADCTL_BY_MOUNT_ROTATION[validate_rotation(rotation)] + + +def map_gesture_direction(direction, rotation): + """Map a board-relative gesture to the viewer at ``rotation`` degrees.""" + validate_rotation(rotation) + if direction not in _DIRECTIONS_CLOCKWISE: + return direction + index = _DIRECTIONS_CLOCKWISE.index(direction) + steps = rotation // 90 + return _DIRECTIONS_CLOCKWISE[(index + steps) % 4] diff --git a/settings.py b/settings.py index a2bd098..a13ec5e 100644 --- a/settings.py +++ b/settings.py @@ -25,10 +25,12 @@ "REST_LENGTH": 20, "OPERATING_MODE": "timer", "BRIGHTNESS_PERCENT": 100, + "DISPLAY_ROTATION_DEG": 0, } OPERATING_MODES = ("timer", "g") BRIGHTNESS_VALUES = (25, 50, 75, 100) +DISPLAY_ROTATION_VALUES = (0, 90, 180, 270) LEGACY_USER_KEYS = { "TRACK_LENGTH": "RACE_LENGTH", @@ -160,6 +162,10 @@ def normalize_user_params(data, system_params=None): "BRIGHTNESS_PERCENT", DEFAULT_USER_PARAMS["BRIGHTNESS_PERCENT"], ) + display_rotation = migrated.get( + "DISPLAY_ROTATION_DEG", + DEFAULT_USER_PARAMS["DISPLAY_ROTATION_DEG"], + ) if ( not isinstance(race_length, int) @@ -183,6 +189,12 @@ def normalize_user_params(data, system_params=None): or brightness_percent not in BRIGHTNESS_VALUES ): brightness_percent = DEFAULT_USER_PARAMS["BRIGHTNESS_PERCENT"] + if ( + not isinstance(display_rotation, int) + or isinstance(display_rotation, bool) + or display_rotation not in DISPLAY_ROTATION_VALUES + ): + display_rotation = DEFAULT_USER_PARAMS["DISPLAY_ROTATION_DEG"] normalized = { "SENSITIVITY": sensitivity, @@ -190,6 +202,7 @@ def normalize_user_params(data, system_params=None): "REST_LENGTH": rest_length, "OPERATING_MODE": operating_mode, "BRIGHTNESS_PERCENT": brightness_percent, + "DISPLAY_ROTATION_DEG": display_rotation, } return normalized, normalized != source diff --git a/tests/test_display_rotation.py b/tests/test_display_rotation.py new file mode 100644 index 0000000..bfc173a --- /dev/null +++ b/tests/test_display_rotation.py @@ -0,0 +1,86 @@ +import importlib +import sys +import types +import unittest + +from orientation import madctl_for_rotation, map_gesture_direction + + +def import_lcd_driver(): + machine = types.ModuleType("machine") + for name in ("Pin", "I2C", "SPI", "PWM", "Timer", "ADC"): + setattr(machine, name, object) + framebuf = types.ModuleType("framebuf") + framebuf.FrameBuffer = object + framebuf.RGB565 = 1 + + original_machine = sys.modules.get("machine") + original_framebuf = sys.modules.get("framebuf") + sys.modules["machine"] = machine + sys.modules["framebuf"] = framebuf + try: + return importlib.import_module("lcd_1inch28") + finally: + if original_machine is None: + del sys.modules["machine"] + else: + sys.modules["machine"] = original_machine + if original_framebuf is None: + del sys.modules["framebuf"] + else: + sys.modules["framebuf"] = original_framebuf + + +lcd_driver = import_lcd_driver() + + +class DisplayRotationTests(unittest.TestCase): + def test_all_mount_angles_have_expected_controller_values(self): + self.assertEqual( + {0: 0x98, 90: 0xF8, 180: 0x58, 270: 0x38}, + { + rotation: madctl_for_rotation(rotation) + for rotation in (0, 90, 180, 270) + }, + ) + + def test_controller_rotation_safely_toggles_tearing_output(self): + for rotation in (0, 90, 180, 270): + with self.subTest(rotation=rotation): + lcd = lcd_driver.LCD_1inch28.__new__( + lcd_driver.LCD_1inch28 + ) + lcd.commands = [] + lcd.data = [] + lcd.write_cmd = lcd.commands.append + lcd.write_data = lcd.data.append + + lcd.set_rotation(rotation) + + self.assertEqual([0x34, 0x36, 0x35], lcd.commands) + self.assertEqual([madctl_for_rotation(rotation)], lcd.data) + self.assertEqual(rotation, lcd.rotation) + + def test_invalid_rotation_is_rejected_before_hardware_write(self): + lcd = lcd_driver.LCD_1inch28.__new__(lcd_driver.LCD_1inch28) + lcd.commands = [] + lcd.write_cmd = lcd.commands.append + lcd.write_data = lambda _value: None + + with self.assertRaises(ValueError): + lcd.set_rotation(45) + + self.assertEqual([], lcd.commands) + + def test_unknown_gestures_remain_unmapped_at_every_rotation(self): + for rotation in (0, 90, 180, 270): + with self.subTest(rotation=rotation): + self.assertIsNone(map_gesture_direction(None, rotation)) + self.assertEqual( + "double", + map_gesture_direction("double", rotation), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_operating_modes.py b/tests/test_operating_modes.py index 4fe296a..5126413 100644 --- a/tests/test_operating_modes.py +++ b/tests/test_operating_modes.py @@ -6,6 +6,7 @@ from font_renderer import measure_text, pixel_height from hold_detector import HoldDetector from operating_modes import ( + SETTINGS_CHOICES, apply_brightness, brightness_duty, brightness_lines, @@ -13,8 +14,10 @@ configure_operating_mode, mode_menu_lines, restore_confirmation_lines, + rotation_lines, select_brightness, select_operating_mode, + select_rotation, settings_menu_lines, ) from settings import DEFAULT_USER_PARAMS, file_in @@ -34,15 +37,20 @@ def ticks_diff(self, current, previous): class FakeLCD: def __init__(self): self.duties = [] + self.rotations = [] def set_bl_pwm(self, duty): self.duties.append(duty) + def set_rotation(self, degrees): + self.rotations.append(degrees) + class FakeTouch: def __init__(self, gestures): self.gestures = iter(gestures) self.screens = [] + self.rotations = [] def ControlScreen(self, lcd, text_array=None, back_colour=None): self.screens.append((text_array, back_colour)) @@ -50,6 +58,9 @@ def ControlScreen(self, lcd, text_array=None, back_colour=None): def GetGesture(self, lcd): return next(self.gestures) + def Set_Rotation(self, degrees): + self.rotations.append(degrees) + class OperatingModeTests(unittest.TestCase): def assert_round_fit(self, lines): @@ -118,14 +129,17 @@ def test_mode_navigation_selects_and_cancels(self): self.assertEqual("g", selected) self.assertIsNone(cancelled) - def test_all_menu_text_fits_round_display(self): - for index in range(3): - self.assert_round_fit(mode_menu_lines(index)) - self.assert_round_fit(settings_menu_lines(index)) - for brightness in (25, 50, 75, 100): - self.assert_round_fit(brightness_lines(brightness)) - for selected in ("Cancel", "RESTORE"): - self.assert_round_fit(restore_confirmation_lines(selected)) + def test_all_menu_text_fits_round_display_at_every_rotation(self): + for rotation in (0, 90, 180, 270): + for index in range(len(SETTINGS_CHOICES)): + self.assert_round_fit(settings_menu_lines(index)) + for index in range(3): + self.assert_round_fit(mode_menu_lines(index)) + for brightness in (25, 50, 75, 100): + self.assert_round_fit(brightness_lines(brightness)) + self.assert_round_fit(rotation_lines(rotation)) + for selected in ("Cancel", "RESTORE"): + self.assert_round_fit(restore_confirmation_lines(selected)) def test_brightness_cancel_restores_previous_preview(self): lcd = FakeLCD() @@ -140,6 +154,47 @@ 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_rotation_cancel_restores_display_and_touch_preview(self): + lcd = FakeLCD() + touch = FakeTouch(["right", "down"]) + + selected, should_save = select_rotation(touch, lcd, 0) + + self.assertEqual(0, selected) + self.assertFalse(should_save) + self.assertEqual([0, 90, 0], lcd.rotations) + self.assertEqual([0, 90, 0], touch.rotations) + + def test_rotation_setting_is_previewed_and_persisted(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + lcd = FakeLCD() + touch = FakeTouch( + [ + "left", "up", # Settings + "right", "up", # Rotation + "right", "up", # 90 degrees, save + "down", # leave Settings + "down", # cancel mode menu + ] + ) + + updated, mode = configure_operating_mode( + touch, + lcd, + dict(DEFAULT_USER_PARAMS), + path, + ) + + self.assertEqual("timer", mode) + self.assertEqual(90, updated["DISPLAY_ROTATION_DEG"]) + self.assertEqual( + 90, + file_in(path, debug=False)["DISPLAY_ROTATION_DEG"], + ) + self.assertEqual(90, lcd.rotations[-1]) + self.assertEqual(90, touch.rotations[-1]) + def test_selected_mode_is_persisted(self): with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "user.json") @@ -187,17 +242,20 @@ def test_restore_defaults_requires_confirmation_and_returns_timer(self): changed = dict(DEFAULT_USER_PARAMS) changed["OPERATING_MODE"] = "g" changed["BRIGHTNESS_PERCENT"] = 25 + changed["DISPLAY_ROTATION_DEG"] = 180 changed["RACE_LENGTH"] = 60 + lcd = FakeLCD() + touch = FakeTouch( + [ + "right", "up", # Settings from G Mode + "right", "right", "up", # Restore defaults + "right", "up", # Confirm RESTORE + ] + ) updated, mode = configure_operating_mode( - FakeTouch( - [ - "right", "up", # Settings from G Mode - "right", "up", # Restore defaults - "right", "up", # Confirm RESTORE - ] - ), - FakeLCD(), + touch, + lcd, changed, path, ) @@ -205,6 +263,8 @@ def test_restore_defaults_requires_confirmation_and_returns_timer(self): self.assertEqual("timer", mode) self.assertEqual(DEFAULT_USER_PARAMS, updated) self.assertEqual(DEFAULT_USER_PARAMS, file_in(path, debug=False)) + self.assertEqual(0, lcd.rotations[-1]) + self.assertEqual(0, touch.rotations[-1]) def test_restore_defaults_can_be_cancelled_after_viewing_restore(self): confirmed = confirm_restore_defaults( diff --git a/tests/test_settings.py b/tests/test_settings.py index c58267a..cfcb7a4 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -181,11 +181,12 @@ def test_legacy_user_keys_are_migrated_and_removed(self): "REST_LENGTH": 15, "OPERATING_MODE": "timer", "BRIGHTNESS_PERCENT": 100, + "DISPLAY_ROTATION_DEG": 0, }, normalized, ) - def test_existing_user_file_gains_mode_and_brightness_defaults(self): + def test_existing_user_file_gains_mode_brightness_and_rotation_defaults(self): normalized, changed = normalize_user_params( {"SENSITIVITY": 0, "RACE_LENGTH": 10, "REST_LENGTH": 15}, DEFAULT_SYSTEM_PARAMS, @@ -194,6 +195,7 @@ def test_existing_user_file_gains_mode_and_brightness_defaults(self): self.assertTrue(changed) self.assertEqual("timer", normalized["OPERATING_MODE"]) self.assertEqual(100, normalized["BRIGHTNESS_PERCENT"]) + self.assertEqual(0, normalized["DISPLAY_ROTATION_DEG"]) def test_invalid_mode_and_brightness_use_defaults(self): invalid = dict(DEFAULT_USER_PARAMS) @@ -209,6 +211,29 @@ 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): + with self.subTest(rotation=rotation): + user = dict(DEFAULT_USER_PARAMS) + user["DISPLAY_ROTATION_DEG"] = rotation + normalized, changed = normalize_user_params( + user, + DEFAULT_SYSTEM_PARAMS, + ) + self.assertFalse(changed) + self.assertEqual(rotation, normalized["DISPLAY_ROTATION_DEG"]) + + for invalid in (True, 45, 360, "90"): + with self.subTest(invalid=invalid): + user = dict(DEFAULT_USER_PARAMS) + user["DISPLAY_ROTATION_DEG"] = invalid + normalized, changed = normalize_user_params( + user, + DEFAULT_SYSTEM_PARAMS, + ) + self.assertTrue(changed) + self.assertEqual(0, normalized["DISPLAY_ROTATION_DEG"]) + def test_restore_user_defaults_replaces_complete_file(self): with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "user.json") diff --git a/tests/test_touch_mode.py b/tests/test_touch_mode.py index a22e014..f94a5bc 100644 --- a/tests/test_touch_mode.py +++ b/tests/test_touch_mode.py @@ -34,6 +34,7 @@ class TouchModeTests(unittest.TestCase): def make_touch(self): touch = touch_drive.Touch_CST816T.__new__(touch_drive.Touch_CST816T) touch.Mode = 1 + touch.rotation = 0 touch._configured_mode = None touch.writes = [] touch._write_byte = lambda command, value: touch.writes.append( @@ -61,6 +62,38 @@ def test_changed_mode_is_written_once(self): self.assertEqual((0xFA, 0x41), touch.writes[-1]) self.assertEqual(3, len(touch.writes)) + def test_directional_gestures_follow_each_mount_rotation(self): + touch = self.make_touch() + gestures = ( + touch_drive.G_UP, + touch_drive.G_RIGHT, + touch_drive.G_DOWN, + touch_drive.G_LEFT, + ) + expected = { + 0: ("up", "right", "down", "left"), + 90: ("right", "down", "left", "up"), + 180: ("down", "left", "up", "right"), + 270: ("left", "up", "right", "down"), + } + + for rotation, directions in expected.items(): + with self.subTest(rotation=rotation): + touch.Set_Rotation(rotation) + self.assertEqual( + directions, + tuple(touch._gesture_name(value) for value in gestures), + ) + + def test_clear_gesture_uses_viewer_relative_up(self): + touch = self.make_touch() + touch.Set_Rotation(90) + touch.Gestures = touch_drive.G_LEFT + self.assertTrue(touch.ClearGesture(None)) + + touch.Gestures = touch_drive.G_UP + self.assertFalse(touch.ClearGesture(None)) + if __name__ == "__main__": unittest.main() diff --git a/touch_drive.py b/touch_drive.py index 894dfeb..8759780 100644 --- a/touch_drive.py +++ b/touch_drive.py @@ -4,6 +4,7 @@ import time from hardware import PeripheralIOError, PeripheralIdentityError +from orientation import map_gesture_direction, validate_rotation #Guesture Hex values G_UP = 0x01 @@ -38,10 +39,12 @@ def __init__( pin_factory=Pin, timer_factory=Timer, clock=time, + rotation=0, ): self._address = address #Set slave address self._clock = clock self._configured_mode = None + self.Set_Rotation(rotation) try: self._bus = bus if self._bus is None: @@ -166,6 +169,19 @@ def ClearPendingInput(self): """Discard input that completed a hold before opening another screen.""" self.Gestures = 0 self.Flag = 0 + + def Set_Rotation(self, rotation): + """Keep directional gestures intuitive at the selected mount angle.""" + self.rotation = validate_rotation(rotation) + + def _gesture_name(self, gesture): + direction = { + G_UP: "up", + G_DOWN: "down", + G_LEFT: "left", + G_RIGHT: "right", + }.get(gesture) + return map_gesture_direction(direction, self.rotation) def Int_Callback(self,pin): if self.Mode == 0 : @@ -307,7 +323,10 @@ def ClearGesture(self, LCD): """ Check for up gesture. """ - if self.Gestures == G_UP or self.Gestures == G_DOUBLE_CLIC: + if ( + self._gesture_name(self.Gestures) == "up" + or self.Gestures == G_DOUBLE_CLIC + ): self.Gestures = 0 return True # up gesture, returns True else: @@ -328,18 +347,7 @@ def GetGesture(self, LCD, debounce_time=0.2): return_type = None self.Set_Mode(0) - if self.Gestures == G_UP: - return_type = "up" - - - if self.Gestures == G_DOWN: - return_type = "down" - - if self.Gestures == G_LEFT: - return_type = "left" - - if self.Gestures == G_RIGHT: - return_type = "right" + return_type = self._gesture_name(self.Gestures) self.Gestures = 0 # clear for next gesture, very important time.sleep(debounce_time) @@ -364,15 +372,16 @@ def SetDurationGesture(self, LCD, duration, duration_values=None): while got_input is False: - if self.Gestures == G_UP: + gesture = self._gesture_name(self.Gestures) + if gesture == "up": got_input = True return_type = "up" - if self.Gestures == G_DOWN: + if gesture == "down": got_input = True return_type = "down" - if self.Gestures == G_LEFT: + if gesture == "left": index_value += 1 if index_value == len(duration_values): index_value = 0 diff --git a/user.json b/user.json index 3215743..fe41ee9 100644 --- a/user.json +++ b/user.json @@ -3,5 +3,6 @@ "RACE_LENGTH": 10, "REST_LENGTH": 10, "OPERATING_MODE": "timer", - "BRIGHTNESS_PERCENT": 100 + "BRIGHTNESS_PERCENT": 100, + "DISPLAY_ROTATION_DEG": 0 }