From 79ae52243ee595cc4b27ed58f3def40bf37a3226 Mon Sep 17 00:00:00 2001 From: mrsqr Date: Sun, 9 Aug 2026 19:00:49 +0100 Subject: [PATCH] Fix configuration persistence and add host tests --- .github/workflows/tests.yml | 18 ++ .gitignore | 2 + README.md | 18 ++ User Guide.md | 9 +- configuration.py | 67 ++++++ launch.py | 18 ++ main.py | 463 ++++++++++-------------------------- params.json | 2 - settings.py | 270 +++++++++++++++++++++ tests/__init__.py | 1 + tests/test_configuration.py | 131 ++++++++++ tests/test_launch.py | 33 +++ tests/test_settings.py | 150 ++++++++++++ tests/test_timing.py | 46 ++++ timing.py | 63 +++-- 15 files changed, 926 insertions(+), 365 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 configuration.py create mode 100644 launch.py create mode 100644 settings.py create mode 100644 tests/__init__.py create mode 100644 tests/test_configuration.py create mode 100644 tests/test_launch.py create mode 100644 tests/test_settings.py create mode 100644 tests/test_timing.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a9ea6df --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,18 @@ +name: Host tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Check Python syntax + run: python -m compileall -q . + - name: Run host-side tests + run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43ae0e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index 7f8e7de..b12f03b 100644 --- a/README.md +++ b/README.md @@ -61,4 +61,22 @@ The instructions enable the software on the required hardware. These instructi * Copy the .py files * Copy the uf2 file, the Pico should restart, and the timer automatically starts. +## Configuration files + +Version 3.2 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`, and `BOOT_DELAY_SEC`. +* `user.json` contains the current user selections: `RACE_LENGTH` (track-session minutes), `REST_LENGTH` (pit-rest minutes), and `SENSITIVITY` (launch threshold; `0` disables Launch Mode). + +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. + +## Host-side tests + +Run the hardware-independent regression suite with: + +```sh +python -m unittest discover -s tests -v +``` + +The suite uses fakes for time, touch gestures, display calls, filesystem operations, and accelerometer samples. It does not validate physical SPI/I2C wiring, LCD rendering, touchscreen recognition, QMI8658 readings, or real-world launch thresholds; those behaviors still require the selected target hardware. diff --git a/User Guide.md b/User Guide.md index 7ade688..ac59f7e 100644 --- a/User Guide.md +++ b/User Guide.md @@ -1,4 +1,4 @@ -# User Guide -v3.1 +# User Guide - v3.2 ## General / Sessions Use The following describes general operation of both the ``Track Session`` and ``Rest in Pits Session`` timer. @@ -14,8 +14,8 @@ The following describes general operation of both the ``Track Session`` and ``Re * Following termination, a ``Rest in Pits`` splash will display, followed by commencement of the ``Rest in Pits Session`` timer. * 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 -All settings are stored from session to session while the timer has power. Settings are wiped if power is lost. +## Configuration / Setup +Track duration, rest duration, and launch sensitivity 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 v3.2 setting names. ### Session Duration It is possible to change the duration of both the ``Track Session`` and the ``Rest in Pits``. @@ -29,4 +29,5 @@ It is possible to change the duration of both the ``Track Session`` and the ``Re ``Launch mode`` can be enabled by defining a ``Launch Sensitivity`` value above zero. * From the ``Primary Screen``, ``Swipe Up`` to edit ``Launch Sensitivity``. * Swipe ``Left`` or ``Right`` to select an appropriate value greater than zero. -* Swip ``UP`` to save and enable ``Launch mode`` +* Swipe ``UP`` to save and enable ``Launch mode``. +* Select `0` and swipe ``UP`` to save and disable ``Launch mode``. diff --git a/configuration.py b/configuration.py new file mode 100644 index 0000000..f3582a3 --- /dev/null +++ b/configuration.py @@ -0,0 +1,67 @@ +"""Touch-driven configuration editors with injected display dependencies.""" + + +def _selected_index(values, current): + if not values: + raise ValueError("At least one selectable value is required") + try: + return values.index(current) + except ValueError: + return 0 + + +def set_sensitivity(LCD=None, Touch=None, sensitivity_values=None, sensitivity=0, + operation="Config", back_colour="palegreen"): + """Select launch sensitivity, starting from the current saved value.""" + values = list(sensitivity_values or []) + index = _selected_index(values, sensitivity) + + def draw(): + text_array = [ + [str(values[index]), 60, 80, 5, "white"], + ["Launch", 75, 150, 2, "black"], + ["Sensitivity", 20, 180, 2, "black"], + [operation, 75, 35, 2, "black"], + ] + Touch.ControlScreen(LCD, text_array=text_array, back_colour=back_colour) + + draw() + while True: + gesture = Touch.GetGesture(LCD) + if gesture == "up": + return values[index] + if gesture == "left": + index = (index - 1) % len(values) + draw() + elif gesture == "right": + index = (index + 1) % len(values) + draw() + + +def set_session(LCD=None, Touch=None, session=None, session_values=None, + session_name=None, operation="Config", back_colour="palegreen"): + """Select a session duration, starting from the session's current value.""" + values = list(session_values or []) + current = getattr(session, "duration_mins", None) + index = _selected_index(values, current) + + def draw(): + text_array = [ + [str(values[index]), 70, 96, 5, "white"], + [session_name, 75, 195, 2, "black"], + [operation, 75, 35, 2, "black"], + ] + Touch.ControlScreen(LCD, text_array=text_array, back_colour=back_colour) + + draw() + while True: + gesture = Touch.GetGesture(LCD) + if gesture == "up": + session.duration_mins = values[index] + return session.duration_mins + if gesture == "left": + index = (index - 1) % len(values) + draw() + elif gesture == "right": + index = (index + 1) % len(values) + draw() diff --git a/launch.py b/launch.py new file mode 100644 index 0000000..1167e79 --- /dev/null +++ b/launch.py @@ -0,0 +1,18 @@ +"""Hardware-independent launch wait loop.""" + + +def accel_launch(qmi8658, sensitivity=0): + """Wait for the existing all-axis threshold condition. + + The launch algorithm itself remains tracked by issue #3. Keeping the loop in + this importable module allows deterministic regression tests around it. + """ + ac_x = 0 + ac_y = 0 + ac_z = 0 + while ac_x < sensitivity or ac_y < sensitivity or ac_z < sensitivity: + xyz = qmi8658.Read_XYZ() + ac_x = xyz[0] + ac_y = xyz[1] + ac_z = xyz[2] + return True diff --git a/main.py b/main.py index 367049f..e56d011 100644 --- a/main.py +++ b/main.py @@ -1,368 +1,163 @@ # Mark Rodman # Session Timer for track days and racing. -# V3.2- -# 1) Accelerometer enable launch, -# 2) User params -# 3) System params -# 4) Persistence between reboots -# ---------------------------------------------------- -# Description. -# This is an aid to monitoring the duration of a given -# session, not a lap counter! -# ---------------------------------------------------- -# See readme for instruction -# ---------------------------------------------------- +# V3.2 -from machine import Pin,I2C,SPI,PWM,Timer -import framebuf import time -import sys -import json -from timing import * -from lcd_1inch28 import * -from touch_drive import * -from qmi8658 import * + +from configuration import set_sensitivity, set_session +from launch import accel_launch +from lcd_1inch28 import LCD_1inch28 +from qmi8658 import QMI8658 +from settings import load_configuration, persist_setting +from timing import SessionTracker, secs_to_mins_secs +from touch_drive import Touch_CST816T + PARAMS_FILE = "params.json" USER_FILE = "user.json" -PIT_SESSION_MSG = ['Cool down!', 'Rest in pits'] -TRACK_SESSION_MSG = ['Ready', 'Swipe DOWN to start'] -CLINE1 = [TRACK_SESSION_MSG[0], 20, 96, 5, "white" ] -CLINE2 = [TRACK_SESSION_MSG[1], 44, 195, 1, "black"] -CLINE3 = ["message", 50, 35, 3, "black"] +PIT_SESSION_MSG = ["Cool down!", "Rest in pits"] +TRACK_SESSION_MSG = ["Ready", "Swipe DOWN to start"] +CLINE1 = [TRACK_SESSION_MSG[0], 20, 96, 5, "white"] +CLINE2 = [TRACK_SESSION_MSG[1], 44, 195, 1, "black"] +CLINE3 = ["message", 50, 35, 3, "black"] PLINE1 = [PIT_SESSION_MSG[0], 4, 96, 3, "white"] PLINE2 = [PIT_SESSION_MSG[1], 23, 150, 2, "red"] -def secs_to_mins_secs(seconds): - """ - Converts seconds to minutes and remaining seconds. - :param seconds: the number of seconds to convert - :return: a formatted string representing the minutes and remaining seconds - """ - minutes = seconds // 60 - remaining_seconds = seconds % 60 - return f"{minutes:02}:{remaining_seconds:02}" - - -def set_sensitivity(LCD=None, Touch=None, sensitivity_values=None, sensitivity=0, operation='Config', back_colour='palegreen'): - """ - Function sets the sensitivity level of the accelerometer used to detect launch - Operation is completed through the supplied LCD and Touch objects. - :param LCD: The LCD object for controlling the display. - :param Touch: The Touch object for interacting with the touch screen. - :param sensitivity_values: A list of possible sensitivity values for the touch screen. - :param sensitivity: The current sensitivity level of the touch screen. - :param operation: The current operation being performed. - :param back_colour: The background color of the display. - :return: The updated sensitivity level of the touch screen. - """ - index = 0 - exit_cmd = False - CL_X = 60 - CLINE1 = [str(sensitivity_values[index]), CL_X, 80, 5, "white"] - CLINE2 = ["Launch", 75, 150, 2, "black"] - CLINE2A = ["Sensitivitiy", 20, 180, 2, "black"] - CLINE3 = [operation, 75, 35, 2, "black"] - c1 = [CLINE1, CLINE2, CLINE3] - Touch.ControlScreen(LCD, text_array=c1, back_colour=back_colour) - while not exit_cmd: - gesture = Touch.GetGesture(LCD) - if gesture == 'up': - exit_cmd = True - if gesture == 'left': - if index == 0: - index = len(sensitivity_values) - 1 - else: - index -= 1 - if gesture == 'right': - index += 1 - if index == len(sensitivity_values): - index = 0 - - CLINE1 = [str(sensitivity_values[index]), CL_X, 80, 5, "white"] - c1 = [CLINE1, CLINE2, CLINE3, CLINE2A] - Touch.ControlScreen(LCD, text_array=c1, back_colour=back_colour) - sensitivity = sensitivity_values[index] - return sensitivity - - -def set_session(LCD=None, Touch=None, session=None, session_values=None, session_name=None, operation='Config', back_colour='palegreen'): - """ - Function sets the session duration for the declared session instance. - The function updates the session object with the selected value from the session_values list. - It displays the session information on the LCD screen using the Touch.ControlScreen() method. - It allows the user to navigate through the session_values list using touch gestures. - The user can exit the operation by swiping up, go to the previous value by swiping left, and go to the next - value by swiping right. - :param LCD: A reference to the LCD object. - :param Touch: A reference to the Touch object. - :param session: The session object to be updated. - :param session_values: A list of values for the session. - :param session_name: The name of the session. - :param operation: The operation to be performed. - :param back_colour: The background color for the display. - :return: None - """ - exit_cmd = False - index = 0 - CLINE1 = [str(session_values[index]), 70, 96, 5, "white"] - CLINE2 = [session_name, 75, 195, 2, "black"] - CLINE3 = [operation, 75, 35, 2, "black"] - c1 = [CLINE1, CLINE2, CLINE3] - Touch.ControlScreen(LCD, text_array=c1, back_colour=back_colour) - - while not exit_cmd: - gesture = Touch.GetGesture(LCD) - if gesture == 'up': - exit_cmd = True - if gesture == 'left': - if index == 0: - index = len(session_values) - 1 - else: - index -= 1 - if gesture == 'right': - index += 1 - if index == len(session_values): - index = 0 - CLINE1 = [str(session_values[index]), 70, 96, 5, "white"] - c1 = [CLINE1, CLINE2, CLINE3] - Touch.ControlScreen(LCD, text_array=c1, back_colour=back_colour) - session.duration_mins = session_values[index] - return session.duration_mins - - -def accel_launch(qmi8658, sensitivity=0): - """ - Continuously reads the accelerometer values from the qmi8658 sensor until the acceleration values exceed the - specified sensitivity. - :param qmi8658: an object representing the qmi8658 sensor - :param sensitivity: the minimum acceleration threshold in each axis (default is 0) - :return: True if the acceleration values exceed the sensitivity, False otherwise - """ - ac_x = 0 - ac_y = 0 - ac_z = 0 - - while ac_x < sensitivity or ac_y < sensitivity or ac_z < sensitivity: - xyz=qmi8658.Read_XYZ() - ac_x = xyz[0] - ac_y = xyz[1] - ac_z = xyz[2] - #print("sensitivity", sensitivity, "x", ac_x, "y", ac_y, "z", ac_z) - return True - - -def file_out(file=None, data=None, mode='w', debug=True): - """ - Write JSON data to a file. - :param file: The path to the file where the JSON data will be written. Default is None. - :param data: The data to write to the file in JSON format. Default is None. - :param mode: The opening mode for the file. Default mode is 'w'. - :param debug: If True, prints the error messages. Default is True. - :return: True if write was successful, otherwise False. - """ - if file is None: - if debug: - print("No file specified.") - return False - - if data is None: - if debug: - print("No data provided to write.") - return False - - try: - with open(file, mode) as target_file: - json.dump(data, target_file) - return True - except Exception as e: - if debug: - print(f"Error occurred: {e}") - return False +def main(): + system_params, user_params = load_configuration(PARAMS_FILE, USER_FILE) + duration_values = system_params["DURATION_VALUES"] + launch_sense_values = system_params["LAUNCH_SENSE_VALUES"] + version = system_params["VERSION"] + boot_delay_sec = system_params["BOOT_DELAY_SEC"] + display_delay_rest = system_params["DISPLAY_DELAY_REST"] + display_delay_rest_colour = system_params["DISPLAY_DELAY_REST_COLOUR"] + print("User Parameters: " + str(user_params)) + # Gyro and accelerometer + qmi8658 = QMI8658() -def file_in(file=None, mode='r+', debug=True): - """ - Open and read a JSON file. - :param file: The path to the JSON file to be read. Default is None. - :param mode: The opening mode for the file. Default mode is 'r+'. - :return: The JSON data from the file, or None if an error occurs. - """ - if file is None: - if debug: - print("No file specified.") - return None - - try: - with open(file, mode) as target_file: - return json.load(target_file) - except Exception as e: - if debug: - print(f"Error occurred: {e}") - return None - - -def update_json(json_data=None, key=None, value=None): - """ - Update the given JSON data with a new key-value pair or update an existing key-value pair. - :param json_data: The JSON data to be updated. (type: dict) - :param key: The key of the key-value pair to be updated. (type: str) - :param value: The value to be associated with the key. (type: Any) - :return: The updated JSON data if successful, None otherwise. (type: dict or None) - """ - if json_data and key and value: - # Update the key-value pair - if key in json_data: - json_data.update({key:value}) - else: - json_data[key] = value - else: - return None - return json_data - - -def main(): - system_params = file_in(file=PARAMS_FILE) # load system params file - user_params = file_in(file=USER_FILE) # load user params file - if system_params: - for key, value in system_params.items(): # declare globals from - globals()[key] = value # from the file - else: - sys.exit() - - if user_params: - for key, value in user_params.items(): # declare globals from - globals()[key] = value # from the file - else: - # load some defaults! - user_params = {"SENSITIVITY": 0, "TRACK_LENGTH": 20, "REST_LENGTH": 20 } - print("Default User Params loaded") - + # Display and touchscreen + lcd = LCD_1inch28() + lcd.set_bl_pwm(65535) + touch = Touch_CST816T(mode=1, LCD=lcd) - print("User Parameters: " + str(user_params)) - # Gyro and Accel - qmi8658=QMI8658() - Vbat= ADC(Pin(Vbat_Pin)) - - # Init screen - LCD = LCD_1inch28() - LCD.set_bl_pwm(65535) - # Init touchscreen - Touch = Touch_CST816T(mode=1, LCD=LCD) - - # Bootscreen - Touch.BootScreen(LCD, version_number=VERSION) - time.sleep(BOOT_DELAY_SEC) + touch.BootScreen(lcd, version_number=version) + time.sleep(boot_delay_sec) while True: - - # Load accelerometer sensitivity - if "SENSITIVITY" in user_params: sensitivity = user_params["SENSITIVITY"] - else: sensitivity = 0 # fall back value - - if "RACE_LENGTH" in user_params: race_length = user_params["RACE_LENGTH"] - else: race_length = 20 # fall back value - - if "REST_LENGTH" in user_params: rest_length = user_params["REST_LENGTH"] - else: rest_length = 20 # fall back value - + sensitivity = user_params["SENSITIVITY"] + race_length = user_params["RACE_LENGTH"] + rest_length = user_params["REST_LENGTH"] + launch = False - return_type = "up" - - # Setup the session timers. - ts = SessionTracker(duration_mins=race_length, stype="track") # on track session timer - rest = SessionTracker(duration_mins=rest_length, stype="rest", debug=True) # rest session timer + track_session = SessionTracker(duration_mins=race_length, stype="track") + rest_session = SessionTracker(duration_mins=rest_length, stype="rest", debug=True) while not launch: - gesture = None - CLINE3[0] = (str(ts.duration_mins) + "mins") - c1 = [CLINE1, CLINE2, CLINE3] - Touch.ControlScreen(LCD, text_array=c1, back_colour="green") - - gesture = Touch.GetGesture(LCD) - - if gesture: - if gesture == 'left': # Race/Track Session Timer change - race_length = set_session(LCD=LCD, Touch=Touch, session=ts, session_values=DURATION_VALUES, session_name='Track', back_colour='palegreen') - # Update track_length within JSON - user_params = update_json(json_data=user_params, key="RACE_LENGTH", value=race_length) - # Write out JSON to file, for next boot - write_response = file_out(file=USER_FILE, data=user_params) - if gesture == 'right': # Pit Session Timer Timer change - rest_length = set_session(LCD=LCD, Touch=Touch, session=rest, session_values=DURATION_VALUES, session_name='Rest', back_colour='paleblue') - # Update rest_length within JSON - user_params = update_json(json_data=user_params, key="REST_LENGTH", value=rest_length) - # Write out JSON to file, for next boot - write_response = file_out(file=USER_FILE, data=user_params) - if gesture == 'up': - sensitivity = set_sensitivity(LCD=LCD, Touch=Touch, sensitivity_values=LAUNCH_SENSE_VALUES, sensitivity=sensitivity, operation='Config', back_colour='palegreen') - # Update sensitivity within JSON - user_params = update_json(json_data=user_params, key="SENSITIVITY", value=sensitivity) - # Write out JSON to file, for next boot - write_response = file_out(file=USER_FILE, data=user_params) - if gesture == 'down': - print("Timer go!") - launch = True - + CLINE3[0] = str(track_session.duration_mins) + "mins" + touch.ControlScreen(lcd, text_array=[CLINE1, CLINE2, CLINE3], back_colour="green") + gesture = touch.GetGesture(lcd) + + if gesture == "left": + race_length = set_session( + LCD=lcd, + Touch=touch, + session=track_session, + session_values=duration_values, + session_name="Track", + back_colour="palegreen", + ) + user_params, _ = persist_setting(USER_FILE, user_params, "RACE_LENGTH", race_length) + elif gesture == "right": + rest_length = set_session( + LCD=lcd, + Touch=touch, + session=rest_session, + session_values=duration_values, + session_name="Rest", + back_colour="paleblue", + ) + user_params, _ = persist_setting(USER_FILE, user_params, "REST_LENGTH", rest_length) + elif gesture == "up": + sensitivity = set_sensitivity( + LCD=lcd, + Touch=touch, + sensitivity_values=launch_sense_values, + sensitivity=sensitivity, + operation="Config", + back_colour="palegreen", + ) + user_params, _ = persist_setting(USER_FILE, user_params, "SENSITIVITY", sensitivity) + elif gesture == "down": + print("Timer go!") + launch = True + time.sleep(0.5) - # Go Screen if sensitivity > 0: - Touch.GoScreen(LCD, text='lights!') + touch.GoScreen(lcd, text="lights!") else: - Touch.GoScreen(LCD) + touch.GoScreen(lcd) - # Detect launch, if not required sensitivity should be 0 accel_launch(qmi8658, sensitivity=sensitivity) + track_session.start_session() - # Start session, using set duration - ts.start_session() - ts_duration = ts.duration_mins - - while ts.live is True: + while track_session.live is True: now = time.time() - remaining = secs_to_mins_secs(int(ts.end_time - now)) - elapsed = secs_to_mins_secs((now - ts.start_time)) - - # Capture input for cancel / pause here - if Touch.StopGesture(LCD): - ts.live = False - # This is the running session - if now < ts.end_time: - if now < ts.last_15: - Touch.LiveScreen(LCD, textsize_rem=6, backColour=None, textColour=None, elapsed=elapsed, remaining=remaining) - elif ts.last_15 <= now < ts.last_5: - Touch.LiveScreen(LCD, textsize_rem=6, backColour=LCD.salmon, textColour=LCD.black, elapsed=elapsed, remaining=remaining) - else: - Touch.LiveScreen(LCD, textsize_rem=6, backColour=LCD.lilac, textColour=None, elapsed=elapsed, remaining=remaining) + remaining = secs_to_mins_secs(track_session.end_time - now) + elapsed = secs_to_mins_secs(now - track_session.start_time) + + if touch.StopGesture(lcd): + track_session.live = False + + if now < track_session.end_time: + if now < track_session.last_15: + touch.LiveScreen( + lcd, textsize_rem=6, backColour=None, textColour=None, + elapsed=elapsed, remaining=remaining, + ) + elif now < track_session.last_5: + touch.LiveScreen( + lcd, textsize_rem=6, backColour=lcd.salmon, textColour=lcd.black, + elapsed=elapsed, remaining=remaining, + ) + else: + touch.LiveScreen( + lcd, textsize_rem=6, backColour=lcd.lilac, textColour=None, + elapsed=elapsed, remaining=remaining, + ) else: - Touch.LiveScreen(LCD, textsize_rem=6, backColour=LCD.red, textColour=LCD.black, elapsed=elapsed, remaining="00:00") - - # Ready rest in pits - p1 = [PLINE1, PLINE2] - Touch.ControlScreen(LCD, text_array=p1, back_colour=DISPLAY_DELAY_REST_COLOUR) - time.sleep(DISPLAY_DELAY_REST) # Splash rest in pits - - # Start rest session - rest.start_session(debug=True) - rest_duration = rest.duration_mins - - while rest.live is True: + touch.LiveScreen( + lcd, textsize_rem=6, backColour=lcd.red, textColour=lcd.black, + elapsed=elapsed, remaining="00:00", + ) + + touch.ControlScreen( + lcd, + text_array=[PLINE1, PLINE2], + back_colour=display_delay_rest_colour, + ) + time.sleep(display_delay_rest) + + rest_session.start_session(debug=True) + while rest_session.live is True: now = time.time() - remaining = secs_to_mins_secs(int(rest.end_time - now)) - elapsed = secs_to_mins_secs((now - rest.start_time)) - # Capture input for clear timer - if Touch.ClearGesture(LCD): - rest.live = False - if now < rest.end_time: - Touch.LiveScreen(LCD, textsize_rem=6, backColour=LCD.blue, textColour=None, elapsed=elapsed, remaining=remaining) + remaining = secs_to_mins_secs(rest_session.end_time - now) + elapsed = secs_to_mins_secs(now - rest_session.start_time) + + if touch.ClearGesture(lcd): + rest_session.live = False + if now < rest_session.end_time: + touch.LiveScreen( + lcd, textsize_rem=6, backColour=lcd.blue, textColour=None, + elapsed=elapsed, remaining=remaining, + ) else: - rest.live = False - + rest_session.live = False + -if __name__=='__main__': +if __name__ == "__main__": main() - diff --git a/params.json b/params.json index 3862b24..bc2096c 100644 --- a/params.json +++ b/params.json @@ -1,10 +1,8 @@ { "DURATION_VALUES": [1, 5, 10, 15, 20, 25, 30, 40, 50, 60], - "REST_SESSION_LENGTH": 20, "DISPLAY_DELAY_REST": 5, "LAUNCH_SENSE_VALUES": [0, 0.5, 1, 1.25, 1.5, 1.75, 2, 2.5, 3.5, 4], "VERSION": "3.2", "DISPLAY_DELAY_REST_COLOUR": "blue", - "TRACK_SESSION_LENGTH": 20, "BOOT_DELAY_SEC": 2 } diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..54f489d --- /dev/null +++ b/settings.py @@ -0,0 +1,270 @@ +"""Configuration loading, validation, migration, and persistence helpers.""" + +import json + +try: + import uos as os +except ImportError: # CPython + import os + + +DEFAULT_SYSTEM_PARAMS = { + "DURATION_VALUES": [1, 5, 10, 15, 20, 25, 30, 40, 50, 60], + "DISPLAY_DELAY_REST": 5, + "LAUNCH_SENSE_VALUES": [0, 0.5, 1, 1.25, 1.5, 1.75, 2, 2.5, 3.5, 4], + "VERSION": "3.2", + "DISPLAY_DELAY_REST_COLOUR": "blue", + "BOOT_DELAY_SEC": 2, +} + +DEFAULT_USER_PARAMS = { + "SENSITIVITY": 0, + "RACE_LENGTH": 20, + "REST_LENGTH": 20, +} + +LEGACY_USER_KEYS = { + "TRACK_LENGTH": "RACE_LENGTH", + "TRACK_SESSION_LENGTH": "RACE_LENGTH", + "REST_SESSION_LENGTH": "REST_LENGTH", +} + +DISPLAY_COLOURS = ( + "green", + "palegreen", + "blue", + "paleblue", + "red", + "white", + "brown", + "black", + "lilac", + "testcolour", +) + + +def _copy_params(params): + copied = {} + for key, value in params.items(): + copied[key] = list(value) if isinstance(value, list) else value + return copied + + +def _is_number(value): + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _positive_int_list(value): + return ( + isinstance(value, list) + and len(value) > 0 + and all( + isinstance(item, int) and not isinstance(item, bool) and item > 0 + for item in value + ) + ) + + +def _non_negative_number_list(value): + return ( + isinstance(value, list) + and len(value) > 0 + and all(_is_number(item) and item >= 0 for item in value) + ) + + +def validate_system_params(data): + """Return ``(params, valid)`` using built-in defaults for invalid input.""" + if not isinstance(data, dict): + return _copy_params(DEFAULT_SYSTEM_PARAMS), False + + valid = ( + _positive_int_list(data.get("DURATION_VALUES")) + and _non_negative_number_list(data.get("LAUNCH_SENSE_VALUES")) + and _is_number(data.get("DISPLAY_DELAY_REST")) + and data.get("DISPLAY_DELAY_REST") >= 0 + and _is_number(data.get("BOOT_DELAY_SEC")) + and data.get("BOOT_DELAY_SEC") >= 0 + and isinstance(data.get("VERSION"), str) + and len(data.get("VERSION")) > 0 + and data.get("DISPLAY_DELAY_REST_COLOUR") in DISPLAY_COLOURS + ) + if not valid: + return _copy_params(DEFAULT_SYSTEM_PARAMS), False + + params = {} + for key in DEFAULT_SYSTEM_PARAMS: + value = data[key] + params[key] = list(value) if isinstance(value, list) else value + return params, True + + +def normalize_user_params(data, system_params=None): + """Migrate and validate user data, returning canonical keys and a changed flag.""" + if system_params is None: + system_params = _copy_params(DEFAULT_SYSTEM_PARAMS) + + source = dict(data) if isinstance(data, dict) else {} + migrated = dict(source) + for legacy_key, canonical_key in LEGACY_USER_KEYS.items(): + if canonical_key not in migrated and legacy_key in migrated: + migrated[canonical_key] = migrated[legacy_key] + + duration_values = system_params["DURATION_VALUES"] + sensitivity_values = system_params["LAUNCH_SENSE_VALUES"] + default_race = DEFAULT_USER_PARAMS["RACE_LENGTH"] + default_rest = DEFAULT_USER_PARAMS["REST_LENGTH"] + default_sensitivity = DEFAULT_USER_PARAMS["SENSITIVITY"] + + if default_race not in duration_values: + default_race = duration_values[0] + if default_rest not in duration_values: + default_rest = duration_values[0] + if default_sensitivity not in sensitivity_values: + default_sensitivity = sensitivity_values[0] + + race_length = migrated.get("RACE_LENGTH", default_race) + rest_length = migrated.get("REST_LENGTH", default_rest) + sensitivity = migrated.get("SENSITIVITY", default_sensitivity) + + if ( + not isinstance(race_length, int) + or isinstance(race_length, bool) + or race_length not in duration_values + ): + race_length = default_race + if ( + not isinstance(rest_length, int) + or isinstance(rest_length, bool) + or rest_length not in duration_values + ): + rest_length = default_rest + if not _is_number(sensitivity) or sensitivity not in sensitivity_values: + sensitivity = default_sensitivity + + normalized = { + "SENSITIVITY": sensitivity, + "RACE_LENGTH": race_length, + "REST_LENGTH": rest_length, + } + return normalized, normalized != source + + +def _remove_if_exists(path): + try: + os.remove(path) + except OSError: + pass + + +def _replace_file(source, target): + replace = getattr(os, "replace", None) + if replace is not None: + replace(source, target) + return + + backup = target + ".bak" + _remove_if_exists(backup) + backed_up = False + try: + os.rename(target, backup) + backed_up = True + except OSError: + pass + + try: + os.rename(source, target) + except Exception: + if backed_up: + os.rename(backup, target) + raise + else: + if backed_up: + _remove_if_exists(backup) + + +def file_out(file=None, data=None, mode="w", debug=True): + """Serialize JSON to a temporary file and replace the target safely.""" + if file is None: + if debug: + print("No file specified.") + return False + if data is None: + if debug: + print("No data provided to write.") + return False + + temporary = file + ".tmp" + try: + with open(temporary, mode) as target_file: + json.dump(data, target_file) + target_file.flush() + _replace_file(temporary, file) + return True + except Exception as error: + _remove_if_exists(temporary) + if debug: + print("Error occurred: " + str(error)) + return False + + +def _read_json(path, mode): + with open(path, mode) as target_file: + return json.load(target_file) + + +def file_in(file=None, mode="r", debug=True): + """Read JSON in read-only mode, falling back to a recoverable backup.""" + if file is None: + if debug: + print("No file specified.") + return None + + try: + return _read_json(file, mode) + except Exception as error: + backup = file + ".bak" + try: + data = _read_json(backup, "r") + file_out(file, data, debug=False) + if debug: + print("Recovered configuration from " + backup) + return data + except Exception: + if debug: + print("Error occurred: " + str(error)) + return None + + +def update_json(json_data=None, key=None, value=None): + """Return an updated copy of a settings dictionary.""" + if not isinstance(json_data, dict) or key is None or key == "" or value is None: + return None + updated = dict(json_data) + updated[key] = value + return updated + + +def persist_setting(file, json_data, key, value, debug=True): + """Persist one setting and retain the known-good dictionary on failure.""" + updated = update_json(json_data, key, value) + if updated is None or not file_out(file, updated, debug=debug): + return json_data, False + return updated, True + + +def load_configuration(params_file, user_file, debug=True): + """Load validated system and canonical user settings with safe defaults.""" + raw_system = file_in(params_file, debug=False) + system_params, system_valid = validate_system_params(raw_system) + if debug and not system_valid: + print("Invalid or missing system parameters; using built-in defaults.") + + raw_user = file_in(user_file, debug=False) + user_params, user_changed = normalize_user_params(raw_user, system_params) + if user_changed: + if debug: + print("User parameters were missing, invalid, or migrated; saving canonical values.") + file_out(user_file, user_params, debug=debug) + + return system_params, user_params diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..5312d8b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Host-side test suite for hardware-independent timer behavior.""" diff --git a/tests/test_configuration.py b/tests/test_configuration.py new file mode 100644 index 0000000..4f3b84e --- /dev/null +++ b/tests/test_configuration.py @@ -0,0 +1,131 @@ +import unittest +from types import SimpleNamespace + +from configuration import set_sensitivity, set_session + + +class FakeTouch: + def __init__(self, gestures): + self._gestures = iter(gestures) + self.screens = [] + + def ControlScreen(self, lcd, text_array=None, back_colour=None): + self.screens.append((lcd, text_array, back_colour)) + + def GetGesture(self, lcd): + return next(self._gestures) + + +class ConfigurationEditorTests(unittest.TestCase): + duration_values = [1, 5, 10, 15, 20, 25, 30, 40, 50, 60] + sensitivity_values = [0, 0.5, 1, 1.25, 1.5, 1.75, 2, 2.5, 3.5, 4] + + def test_session_immediate_save_preserves_every_allowed_value(self): + for current in self.duration_values: + with self.subTest(current=current): + session = SimpleNamespace(duration_mins=current) + touch = FakeTouch(["up"]) + selected = set_session( + LCD=object(), + Touch=touch, + session=session, + session_values=self.duration_values, + session_name="Track", + ) + self.assertEqual(current, selected) + self.assertEqual(current, session.duration_mins) + self.assertEqual(str(current), touch.screens[0][1][0][0]) + + def test_sensitivity_immediate_save_preserves_every_allowed_value(self): + for current in self.sensitivity_values: + with self.subTest(current=current): + touch = FakeTouch(["up"]) + selected = set_sensitivity( + LCD=object(), + Touch=touch, + sensitivity_values=self.sensitivity_values, + sensitivity=current, + ) + self.assertEqual(current, selected) + self.assertEqual(str(current), touch.screens[0][1][0][0]) + + def test_session_navigation_wraps_in_both_directions(self): + session = SimpleNamespace(duration_mins=self.duration_values[0]) + selected = set_session( + LCD=object(), + Touch=FakeTouch(["left", "up"]), + session=session, + session_values=self.duration_values, + session_name="Track", + ) + self.assertEqual(self.duration_values[-1], selected) + + session.duration_mins = self.duration_values[-1] + selected = set_session( + LCD=object(), + Touch=FakeTouch(["right", "up"]), + session=session, + session_values=self.duration_values, + session_name="Track", + ) + self.assertEqual(self.duration_values[0], selected) + + def test_sensitivity_navigation_wraps_in_both_directions(self): + selected = set_sensitivity( + LCD=object(), + Touch=FakeTouch(["left", "up"]), + sensitivity_values=self.sensitivity_values, + sensitivity=self.sensitivity_values[0], + ) + self.assertEqual(self.sensitivity_values[-1], selected) + + selected = set_sensitivity( + LCD=object(), + Touch=FakeTouch(["right", "up"]), + sensitivity_values=self.sensitivity_values, + sensitivity=self.sensitivity_values[-1], + ) + self.assertEqual(self.sensitivity_values[0], selected) + + def test_invalid_stored_values_fall_back_to_first_option(self): + session = SimpleNamespace(duration_mins=999) + self.assertEqual( + self.duration_values[0], + set_session( + LCD=object(), + Touch=FakeTouch(["up"]), + session=session, + session_values=self.duration_values, + session_name="Track", + ), + ) + self.assertEqual( + self.sensitivity_values[0], + set_sensitivity( + LCD=object(), + Touch=FakeTouch(["up"]), + sensitivity_values=self.sensitivity_values, + sensitivity=999, + ), + ) + + def test_empty_allowed_values_are_rejected(self): + with self.assertRaises(ValueError): + set_session( + LCD=object(), + Touch=FakeTouch(["up"]), + session=SimpleNamespace(duration_mins=20), + session_values=[], + session_name="Track", + ) + with self.assertRaises(ValueError): + set_sensitivity( + LCD=object(), + Touch=FakeTouch(["up"]), + sensitivity_values=[], + sensitivity=0, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_launch.py b/tests/test_launch.py new file mode 100644 index 0000000..41c9bb7 --- /dev/null +++ b/tests/test_launch.py @@ -0,0 +1,33 @@ +import unittest + +from launch import accel_launch + + +class FakeAccelerometer: + def __init__(self, samples): + self._samples = iter(samples) + self.read_count = 0 + + def Read_XYZ(self): + self.read_count += 1 + return next(self._samples) + + +class LaunchLoopTests(unittest.TestCase): + def test_zero_sensitivity_bypasses_sensor_reads(self): + sensor = FakeAccelerometer([]) + self.assertTrue(accel_launch(sensor, sensitivity=0)) + self.assertEqual(0, sensor.read_count) + + def test_existing_all_axis_condition_is_deterministic(self): + # The threshold algorithm is intentionally unchanged here; see issue #3. + sensor = FakeAccelerometer([ + [2, 0, 0, 0, 0, 0], + [2, 2, 2, 0, 0, 0], + ]) + self.assertTrue(accel_launch(sensor, sensitivity=1)) + self.assertEqual(2, sensor.read_count) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..a7dd293 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,150 @@ +import json +import os +import tempfile +import unittest +from unittest.mock import patch + +import settings as settings_module +from settings import ( + DEFAULT_SYSTEM_PARAMS, + DEFAULT_USER_PARAMS, + file_in, + file_out, + load_configuration, + normalize_user_params, + persist_setting, + update_json, + validate_system_params, +) + + +class SettingsTests(unittest.TestCase): + def test_update_json_accepts_zero_without_mutating_source(self): + original = {"SENSITIVITY": 2, "RACE_LENGTH": 20, "REST_LENGTH": 20} + updated = update_json(original, "SENSITIVITY", 0) + self.assertEqual(0, updated["SENSITIVITY"]) + self.assertEqual(2, original["SENSITIVITY"]) + self.assertEqual({"SENSITIVITY": 0}, update_json({}, "SENSITIVITY", 0)) + + def test_enable_disable_and_reload_round_trip(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + settings = dict(DEFAULT_USER_PARAMS) + settings, saved = persist_setting(path, settings, "SENSITIVITY", 2, debug=False) + self.assertTrue(saved) + settings, saved = persist_setting(path, settings, "SENSITIVITY", 0, debug=False) + self.assertTrue(saved) + self.assertEqual(0, file_in(path, debug=False)["SENSITIVITY"]) + + def test_failed_persist_retains_known_good_dictionary(self): + settings = dict(DEFAULT_USER_PARAMS) + updated, saved = persist_setting( + "/path/that/does/not/exist/user.json", + settings, + "SENSITIVITY", + 2, + debug=False, + ) + self.assertFalse(saved) + self.assertIs(settings, updated) + + def test_serialization_failure_preserves_existing_file(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + self.assertTrue(file_out(path, DEFAULT_USER_PARAMS, debug=False)) + self.assertFalse(file_out(path, {"invalid": object()}, debug=False)) + self.assertEqual(DEFAULT_USER_PARAMS, file_in(path, debug=False)) + self.assertFalse(os.path.exists(path + ".tmp")) + + def test_backup_rename_fallback_replaces_existing_file(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + self.assertTrue(file_out(path, DEFAULT_USER_PARAMS, debug=False)) + updated = dict(DEFAULT_USER_PARAMS) + updated["RACE_LENGTH"] = 10 + with patch.object(settings_module.os, "replace", None): + self.assertTrue(file_out(path, updated, debug=False)) + self.assertEqual(updated, file_in(path, debug=False)) + self.assertFalse(os.path.exists(path + ".bak")) + + def test_malformed_primary_recovers_from_backup(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + with open(path, "w", encoding="utf-8") as target: + target.write("{") + with open(path + ".bak", "w", encoding="utf-8") as target: + json.dump(DEFAULT_USER_PARAMS, target) + self.assertEqual(DEFAULT_USER_PARAMS, file_in(path, debug=False)) + os.remove(path + ".bak") + self.assertEqual(DEFAULT_USER_PARAMS, file_in(path, debug=False)) + + def test_missing_and_truncated_user_files_are_rewritten(self): + with tempfile.TemporaryDirectory() as directory: + params_path = os.path.join(directory, "params.json") + user_path = os.path.join(directory, "user.json") + self.assertTrue(file_out(params_path, DEFAULT_SYSTEM_PARAMS, debug=False)) + + _, user = load_configuration(params_path, user_path, debug=False) + self.assertEqual(DEFAULT_USER_PARAMS, user) + self.assertEqual(DEFAULT_USER_PARAMS, file_in(user_path, debug=False)) + + with open(user_path, "w", encoding="utf-8") as target: + target.write("{") + _, user = load_configuration(params_path, user_path, debug=False) + self.assertEqual(DEFAULT_USER_PARAMS, user) + self.assertEqual(DEFAULT_USER_PARAMS, file_in(user_path, debug=False)) + + def test_invalid_system_params_use_known_defaults(self): + params, valid = validate_system_params({"DURATION_VALUES": []}) + self.assertFalse(valid) + self.assertEqual(DEFAULT_SYSTEM_PARAMS, params) + + invalid_colour = dict(DEFAULT_SYSTEM_PARAMS) + invalid_colour["DISPLAY_DELAY_REST_COLOUR"] = "not-a-colour" + params, valid = validate_system_params(invalid_colour) + self.assertFalse(valid) + self.assertEqual(DEFAULT_SYSTEM_PARAMS, params) + + def test_missing_and_out_of_range_user_values_use_defaults(self): + normalized, changed = normalize_user_params(None, DEFAULT_SYSTEM_PARAMS) + self.assertTrue(changed) + self.assertEqual(DEFAULT_USER_PARAMS, normalized) + + normalized, changed = normalize_user_params( + {"SENSITIVITY": False, "RACE_LENGTH": True, "REST_LENGTH": 20.0}, + DEFAULT_SYSTEM_PARAMS, + ) + self.assertTrue(changed) + self.assertEqual(DEFAULT_USER_PARAMS, normalized) + + normalized, changed = normalize_user_params( + {"SENSITIVITY": 99, "RACE_LENGTH": -1, "REST_LENGTH": "20"}, + DEFAULT_SYSTEM_PARAMS, + ) + self.assertTrue(changed) + self.assertEqual(DEFAULT_USER_PARAMS, normalized) + + def test_legacy_user_keys_are_migrated_and_removed(self): + normalized, changed = normalize_user_params( + {"SENSITIVITY": 0.5, "TRACK_LENGTH": 10, "REST_SESSION_LENGTH": 15}, + DEFAULT_SYSTEM_PARAMS, + ) + self.assertTrue(changed) + self.assertEqual( + {"SENSITIVITY": 0.5, "RACE_LENGTH": 10, "REST_LENGTH": 15}, + normalized, + ) + + def test_documented_configuration_loads_and_round_trips(self): + with tempfile.TemporaryDirectory() as directory: + params_path = os.path.join(directory, "params.json") + user_path = os.path.join(directory, "user.json") + self.assertTrue(file_out(params_path, DEFAULT_SYSTEM_PARAMS, debug=False)) + self.assertTrue(file_out(user_path, DEFAULT_USER_PARAMS, debug=False)) + system, user = load_configuration(params_path, user_path, debug=False) + self.assertEqual(DEFAULT_SYSTEM_PARAMS, system) + self.assertEqual(DEFAULT_USER_PARAMS, user) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_timing.py b/tests/test_timing.py new file mode 100644 index 0000000..0cd8cd3 --- /dev/null +++ b/tests/test_timing.py @@ -0,0 +1,46 @@ +import unittest + +from timing import SessionTracker, secs_to_mins_secs + + +class FakeClock: + def __init__(self, now): + self.now = now + + def __call__(self): + return self.now + + +class TimingTests(unittest.TestCase): + def test_session_thresholds_and_overrun_phase(self): + clock = FakeClock(100) + session = SessionTracker(duration_mins=10, stype="track", clock=clock) + session.start_session() + + self.assertEqual(100, session.start_time) + self.assertEqual(700, session.end_time) + self.assertEqual(610, session.last_15) + self.assertEqual(670, session.last_5) + self.assertEqual("running", session.phase(609)) + self.assertEqual("last_15", session.phase(610)) + self.assertEqual("last_5", session.phase(670)) + self.assertEqual("overrun", session.phase(700)) + self.assertTrue(session.live) + + def test_ready_phase_before_session_start(self): + self.assertEqual("ready", SessionTracker(duration_mins=10).phase()) + + def test_time_formatting_uses_whole_seconds(self): + self.assertEqual("00:00", secs_to_mins_secs(0)) + self.assertEqual("01:01", secs_to_mins_secs(61)) + self.assertEqual("01:01", secs_to_mins_secs(61.9)) + + def test_string_representation_is_complete(self): + self.assertEqual( + "SessionTracker(stype=rest, duration_mins=5)", + str(SessionTracker(duration_mins=5, stype="rest")), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/timing.py b/timing.py index 56a3d6e..6d94034 100644 --- a/timing.py +++ b/timing.py @@ -1,14 +1,14 @@ import time + class SessionTracker(): """ A class to track session details including duration, start, and end times. """ - #def __init__(self): - def __init__(self, duration_mins = None, stype: Optional[str] = None, debug: bool = False, live: Optional[bool] = None): - self.duration_mins: Optional[int] = duration_mins - self.duration_secs: Optional[int] = None - self.stype: Optional[str] = stype + def __init__(self, duration_mins=None, stype=None, debug=False, live=None, clock=None): + self.duration_mins = duration_mins + self.duration_secs = None + self.stype = stype # stores actual start and end targets in seconds. self.start_time = None self.end_time = None @@ -16,9 +16,10 @@ def __init__(self, duration_mins = None, stype: Optional[str] = None, debug: boo self.last_5 = None # %5 before end time self.last_15 = None # %15 before end time # misc values - self.live: Optional[bool] = live - self.debug: bool = debug - self.alarm: Optional[str] = None + self.live = live + self.debug = debug + self.alarm = None + self._clock = clock or time.time if self.duration_mins is None: self.duration_mins = 20 @@ -30,11 +31,13 @@ def update_duration(self, mins): self.duration_secs = self.duration_mins * 60 return - def start_session(self, mins=None, debug=True): + def start_session(self, mins=None, debug=None): """ Start a new session """ - if mins: + if debug is None: + debug = self.debug + if mins is not None: self.duration_mins = mins if self.duration_mins is None: if debug: @@ -43,7 +46,7 @@ def start_session(self, mins=None, debug=True): self.duration_secs = self.duration_mins * 60 - self.start_time = time.time() + self.start_time = self._clock() self.end_time = self.start_time + self.duration_secs self.last_15 = self.start_time + int(self.duration_secs * 0.85) @@ -67,18 +70,28 @@ def start_session(self, mins=None, debug=True): return - def __str__(self) -> str: - """ - Return a string representation of the session details. - """ - #print("____Session prepared_____") - #print("Session type: " + str(self.stype)) - #print("Duration minutes: " + str(self.duration_mins)) - #print("Duration seconds: " + str(self.duration_secs)) - #print("Start Time:" + str(self.start_time)) - #print("End Time:" + str(self.end_time)) - # print("Last 15%: " + str(self.last_15)) - # print("Last 5%:" + str(self.last_5)) - return (f"SessionTracker(stype={self.stype}, duration_mins={self.duration_mins}") + def phase(self, now=None): + """Return the current display phase for deterministic host-side tests.""" + if self.start_time is None: + return "ready" + if now is None: + now = self._clock() + if now >= self.end_time: + return "overrun" + if now >= self.last_5: + return "last_5" + if now >= self.last_15: + return "last_15" + return "running" + + def __str__(self): + """Return a string representation of the session details.""" + return f"SessionTracker(stype={self.stype}, duration_mins={self.duration_mins})" - + +def secs_to_mins_secs(seconds): + """Format an integer second count as MM:SS.""" + seconds = int(seconds) + minutes = seconds // 60 + remaining_seconds = seconds % 60 + return f"{minutes:02}:{remaining_seconds:02}"