diff --git a/README.md b/README.md index db311e9..96aabf8 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,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 configuration.py font_data.py font_renderer.py launch.py lcd_1inch28.py live_display.py params.json qmi8658.py settings.py splash.py timing.py touch_drive.py font_data*.bin startup_splash.rgb565 : +mpremote connect auto fs cp configuration.py font_data.py font_renderer.py hardware.py launch.py lcd_1inch28.py live_display.py params.json qmi8658.py settings.py splash.py timing.py touch_drive.py font_data*.bin startup_splash.rgb565 : mpremote connect auto fs cp main.py : mpremote connect auto reset ``` @@ -136,7 +136,14 @@ If first boot fails: * The original text-only splash means `startup_splash.rgb565` is missing or has the wrong size; repeat the application upload command. * 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 missing touchscreen or IMU error indicates unsupported hardware or a board-level connection problem. +* 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 for the current run. Swipe down to use the normal timer, which remains available without the IMU. + +### 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 is selected. It is not initialized when Launch Mode is off. Transient initialization failures receive the same three attempts, while an unexpected chip ID fails immediately. If initialization or a launch-time sample fails, Launch Mode is disabled for the current run and the standard swipe-down timer remains available. The saved sensitivity is retained so the firmware can retry after a restart or the next Launch Mode configuration change. ## Configuration files diff --git a/hardware.py b/hardware.py new file mode 100644 index 0000000..13ab350 --- /dev/null +++ b/hardware.py @@ -0,0 +1,99 @@ +"""Peripheral startup policy shared by the firmware and host-side tests.""" + +import time + + +class PeripheralError(Exception): + """Base error for a peripheral that cannot be used safely.""" + + def __init__(self, peripheral, operation, detail, retryable=False): + self.peripheral = peripheral + self.operation = operation + self.detail = str(detail) + self.retryable = retryable + message = "{} {} failed: {}".format(peripheral, operation, self.detail) + super().__init__(message) + + +class PeripheralIOError(PeripheralError): + """A potentially transient bus or register access failure.""" + + def __init__(self, peripheral, operation, detail): + super().__init__(peripheral, operation, detail, retryable=True) + + +class PeripheralIdentityError(PeripheralError): + """A responding device does not have the expected chip identity.""" + + def __init__(self, peripheral, expected, actual): + self.expected = expected + self.actual = actual + detail = "expected chip ID 0x{:02X}, received 0x{:02X}".format( + expected, actual + ) + super().__init__(peripheral, "detection", detail, retryable=False) + + +def _sleep_ms(clock, milliseconds): + sleep_ms = getattr(clock, "sleep_ms", None) + if sleep_ms is not None: + sleep_ms(milliseconds) + else: + clock.sleep(milliseconds / 1000) + + +def initialize_with_retry( + factory, + peripheral, + attempts=3, + retry_delay_ms=100, + clock=time, + logger=print, +): + """Build a complete peripheral, retrying only transient I/O failures.""" + if attempts < 1: + raise ValueError("attempts must be positive") + + for attempt in range(1, attempts + 1): + try: + return factory() + except OSError as error: + failure = PeripheralIOError(peripheral, "initialization", error) + except PeripheralError as error: + failure = error + + if not failure.retryable or attempt == attempts: + logger("Hardware error: {}".format(failure)) + raise failure + + logger( + "Hardware retry {}/{}: {}".format(attempt, attempts, failure) + ) + _sleep_ms(clock, retry_delay_ms) + + +def initialize_optional_imu(sensitivity, factory, **retry_options): + """Initialize the IMU only for Launch Mode and degrade on failure.""" + if float(sensitivity) <= 0: + return None, None + + logger = retry_options.get("logger", print) + try: + sensor = initialize_with_retry(factory, "QMI8658", **retry_options) + return sensor, None + except PeripheralError as error: + logger("Launch Mode disabled: {}".format(error)) + return None, error + + +def show_hardware_message(lcd, title, lines, background=None): + """Display a short, actionable hardware status message.""" + if background is None: + background = lcd.red + lcd.fill(background) + lcd.write_centered(title, 48, 2, lcd.white) + y_position = 105 + for line in lines: + lcd.write_centered(line, y_position, 1, lcd.white) + y_position += 30 + lcd.show() diff --git a/main.py b/main.py index 835c8da..fe19efe 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,12 @@ import time from configuration import set_sensitivity, set_session +from hardware import ( + PeripheralError, + initialize_optional_imu, + initialize_with_retry, + show_hardware_message, +) from launch import accel_launch from lcd_1inch28 import LCD_1inch28 from live_display import ( @@ -31,6 +37,29 @@ PLINE2 = [PIT_SESSION_MSG[1], None, 145, 2, "red"] +def _show_touch_failure(lcd, error): + print("Timer stopped: {}".format(error)) + show_hardware_message( + lcd, + "Touch error", + ["Touch not detected", "Check board / I2C", "Restart timer"], + ) + + +def _show_imu_degraded(lcd, error): + print("Normal timing remains available: {}".format(error)) + show_hardware_message( + lcd, + "Launch disabled", + ["IMU not available", "Normal timer works", "Check board / I2C"], + background=lcd.brown, + ) + + +def _initialize_imu(sensitivity): + return initialize_optional_imu(sensitivity, QMI8658) + + def main(): system_params, user_params = load_configuration(PARAMS_FILE, USER_FILE) duration_values = system_params["DURATION_VALUES"] @@ -42,19 +71,29 @@ def main(): print("User Parameters: " + str(user_params)) - # Gyro and accelerometer - qmi8658 = QMI8658() - # Display and touchscreen lcd = LCD_1inch28() lcd.set_bl_pwm(65535) - touch = Touch_CST816T(mode=1, LCD=lcd) + try: + touch = initialize_with_retry( + lambda: Touch_CST816T(mode=1, LCD=lcd), + "CST816T", + ) + touch.BootScreen(lcd, version_number=version) + except PeripheralError as error: + _show_touch_failure(lcd, error) + return False - touch.BootScreen(lcd, version_number=version) time.sleep(boot_delay_sec) + qmi8658, imu_error = _initialize_imu(user_params["SENSITIVITY"]) + if imu_error is not None: + _show_imu_degraded(lcd, imu_error) + time.sleep(2) + while True: - sensitivity = user_params["SENSITIVITY"] + configured_sensitivity = user_params["SENSITIVITY"] + sensitivity = configured_sensitivity if qmi8658 is not None else 0 race_length = user_params["RACE_LENGTH"] rest_length = user_params["REST_LENGTH"] @@ -88,15 +127,28 @@ def main(): ) user_params, _ = persist_setting(USER_FILE, user_params, "REST_LENGTH", rest_length) elif gesture == "up": - sensitivity = set_sensitivity( + configured_sensitivity = set_sensitivity( LCD=lcd, Touch=touch, sensitivity_values=launch_sense_values, - sensitivity=sensitivity, + sensitivity=configured_sensitivity, operation="Config", back_colour="palegreen", ) - user_params, _ = persist_setting(USER_FILE, user_params, "SENSITIVITY", sensitivity) + user_params, _ = persist_setting( + USER_FILE, + user_params, + "SENSITIVITY", + configured_sensitivity, + ) + if configured_sensitivity > 0 and qmi8658 is None: + qmi8658, imu_error = _initialize_imu(configured_sensitivity) + if imu_error is not None: + _show_imu_degraded(lcd, imu_error) + time.sleep(2) + sensitivity = ( + configured_sensitivity if qmi8658 is not None else 0 + ) elif gesture == "down": print("Timer go!") launch = True @@ -112,11 +164,17 @@ def main(): else: touch.GoScreen(lcd) - launch_detected = accel_launch( - qmi8658, - sensitivity=sensitivity, - cancel_check=lambda: touch.StopGesture(lcd), - ) + try: + launch_detected = accel_launch( + qmi8658, + sensitivity=sensitivity, + cancel_check=lambda: touch.StopGesture(lcd), + ) + except PeripheralError as error: + qmi8658 = None + _show_imu_degraded(lcd, error) + time.sleep(2) + continue if not launch_detected: print("Launch mode cancelled or timed out.") continue diff --git a/qmi8658.py b/qmi8658.py index 30c6afa..681e699 100644 --- a/qmi8658.py +++ b/qmi8658.py @@ -1,7 +1,6 @@ -from machine import Pin,I2C,SPI,PWM,Timer,ADC -import framebuf -import time -Vbat_Pin = 29 +from machine import I2C, Pin + +from hardware import PeripheralIOError, PeripheralIdentityError #Pin definition I2C_SDA = 6 @@ -20,18 +19,37 @@ class QMI8658(object): - def __init__(self,address=0X6B): + def __init__(self,address=0X6B, bus=None): self._address = address - self._bus = I2C(id=1,scl=Pin(I2C_SDL),sda=Pin(I2C_SDA),freq=100_000) - bRet=self.WhoAmI() - if bRet : - self.Read_Revision() - else : - return NULL - self.Config_apply() + try: + self._bus = bus + if self._bus is None: + self._bus = I2C( + id=1, + scl=Pin(I2C_SDL), + sda=Pin(I2C_SDA), + freq=100_000, + ) + except OSError as error: + raise PeripheralIOError("QMI8658", "I2C setup", error) + + try: + chip_id = self._read_byte(0x00) + except OSError as error: + raise PeripheralIOError("QMI8658", "chip ID read", error) + if chip_id != 0x05: + raise PeripheralIdentityError("QMI8658", 0x05, chip_id) + + try: + self.revision = self.Read_Revision() + self.Config_apply() + except OSError as error: + raise PeripheralIOError("QMI8658", "configuration", error) def _read_byte(self,cmd): rec=self._bus.readfrom_mem(int(self._address),int(cmd),1) + if len(rec) != 1: + raise OSError("short I2C read") return rec[0] def _read_block(self, reg, length=1): rec=self._bus.readfrom_mem(int(self._address),int(reg),length) @@ -68,14 +86,13 @@ def Config_apply(self): def Read_Raw_XYZ(self): xyz=[0,0,0,0,0,0] - raw_timestamp = self._read_block(0x30,3) - raw_acc_xyz=self._read_block(0x35,6) - raw_gyro_xyz=self._read_block(0x3b,6) - raw_xyz=self._read_block(0x35,12) - timestamp = (raw_timestamp[2]<<16)|(raw_timestamp[1]<<8)|(raw_timestamp[0]) + try: + raw_xyz=self._read_block(0x35,12) + except OSError as error: + raise PeripheralIOError("QMI8658", "sample read", error) + if len(raw_xyz) != 12: + raise PeripheralIOError("QMI8658", "sample read", "short I2C read") for i in range(6): - # xyz[i]=(raw_acc_xyz[(i*2)+1]<<8)|(raw_acc_xyz[i*2]) - # xyz[i+3]=(raw_gyro_xyz[((i+3)*2)+1]<<8)|(raw_gyro_xyz[(i+3)*2]) xyz[i] = (raw_xyz[(i*2)+1]<<8)|(raw_xyz[i*2]) if xyz[i] >= 32767: xyz[i] = xyz[i]-65535 diff --git a/tests/test_hardware.py b/tests/test_hardware.py new file mode 100644 index 0000000..7f5b7e6 --- /dev/null +++ b/tests/test_hardware.py @@ -0,0 +1,120 @@ +import unittest + +from hardware import ( + PeripheralIOError, + PeripheralIdentityError, + initialize_optional_imu, + initialize_with_retry, + show_hardware_message, +) + + +class FakeClock: + def __init__(self): + self.sleeps = [] + + def sleep_ms(self, milliseconds): + self.sleeps.append(milliseconds) + + +class FakeLCD: + red = 1 + white = 2 + + def __init__(self): + self.calls = [] + + def fill(self, colour): + self.calls.append(("fill", colour)) + + def write_centered(self, text, y_position, size, colour): + self.calls.append(("text", text, y_position, size, colour)) + + def show(self): + self.calls.append(("show",)) + + +class HardwarePolicyTests(unittest.TestCase): + def test_transient_io_failures_are_retried_then_succeed(self): + attempts = [] + clock = FakeClock() + logs = [] + + def factory(): + attempts.append(True) + if len(attempts) < 3: + raise PeripheralIOError("QMI8658", "chip ID read", "busy") + return "sensor" + + result = initialize_with_retry( + factory, + "QMI8658", + clock=clock, + logger=logs.append, + ) + + self.assertEqual("sensor", result) + self.assertEqual(3, len(attempts)) + self.assertEqual([100, 100], clock.sleeps) + self.assertEqual(2, len(logs)) + + def test_wrong_identity_is_not_retried(self): + attempts = [] + + def factory(): + attempts.append(True) + raise PeripheralIdentityError("CST816T", 0xB5, 0x00) + + with self.assertRaises(PeripheralIdentityError): + initialize_with_retry(factory, "CST816T", logger=lambda message: None) + + self.assertEqual(1, len(attempts)) + + def test_disabled_launch_mode_does_not_initialize_imu(self): + calls = [] + + sensor, error = initialize_optional_imu( + 0, + lambda: calls.append(True), + ) + + self.assertIsNone(sensor) + self.assertIsNone(error) + self.assertEqual([], calls) + + def test_optional_imu_failure_returns_degraded_mode(self): + failure = PeripheralIOError("QMI8658", "chip ID read", "no device") + + def factory(): + raise failure + + sensor, error = initialize_optional_imu( + 0.5, + factory, + attempts=1, + logger=lambda message: None, + ) + + self.assertIsNone(sensor) + self.assertIs(error, failure) + + def test_hardware_message_is_visible_and_actionable(self): + lcd = FakeLCD() + + show_hardware_message( + lcd, + "Touch error", + ["Touch not detected", "Restart timer"], + ) + + self.assertEqual(("fill", lcd.red), lcd.calls[0]) + displayed_text = [call[1] for call in lcd.calls if call[0] == "text"] + self.assertEqual( + ["Touch error", "Touch not detected", "Restart timer"], + displayed_text, + ) + self.assertEqual(("show",), lcd.calls[-1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_peripherals.py b/tests/test_peripherals.py new file mode 100644 index 0000000..98a83e6 --- /dev/null +++ b/tests/test_peripherals.py @@ -0,0 +1,159 @@ +import importlib +import sys +import types +import unittest + +from hardware import ( + PeripheralIOError, + PeripheralIdentityError, + initialize_with_retry, +) + + +class FakePin: + IN = 0 + OUT = 1 + PULL_UP = 2 + IRQ_FALLING = 4 + + def __init__(self, number, mode=None, pull=None): + self.number = number + self.mode = mode + self.pull = pull + self.value = None + self.irq_args = None + + def __call__(self, value=None): + if value is None: + return self.value + self.value = value + + def irq(self, **kwargs): + self.irq_args = kwargs + + +class FakeTimer: + pass + + +class FakeClock: + def __init__(self): + self.sleeps = [] + + def sleep_ms(self, milliseconds): + self.sleeps.append(milliseconds) + + +class FakeBus: + def __init__(self, registers=None, read_error=None): + self.registers = registers or {} + self.read_error = read_error + self.writes = [] + + def readfrom_mem(self, address, register, length): + if self.read_error is not None: + raise self.read_error + value = self.registers.get(register, bytes(length)) + if isinstance(value, int): + value = bytes([value]) + return value[:length] + + def writeto_mem(self, address, register, value): + self.writes.append((address, register, value)) + + +def import_drivers(): + machine = types.ModuleType("machine") + machine.Pin = FakePin + machine.I2C = object + machine.Timer = FakeTimer + + previous_machine = sys.modules.get("machine") + sys.modules["machine"] = machine + try: + return ( + importlib.import_module("qmi8658"), + importlib.import_module("touch_drive"), + ) + finally: + if previous_machine is None: + del sys.modules["machine"] + else: + sys.modules["machine"] = previous_machine + + +qmi8658, touch_drive = import_drivers() + + +class PeripheralDriverTests(unittest.TestCase): + def make_touch(self, bus): + return touch_drive.Touch_CST816T( + bus=bus, + pin_factory=FakePin, + timer_factory=FakeTimer, + clock=FakeClock(), + ) + + def test_qmi_wrong_chip_id_is_incompatible_hardware(self): + with self.assertRaises(PeripheralIdentityError) as raised: + qmi8658.QMI8658(bus=FakeBus({0x00: 0xFF})) + + self.assertEqual(0x05, raised.exception.expected) + self.assertEqual(0xFF, raised.exception.actual) + + def test_touch_wrong_chip_id_is_incompatible_hardware(self): + with self.assertRaises(PeripheralIdentityError) as raised: + self.make_touch(FakeBus({0xA7: 0x00})) + + self.assertEqual(0xB5, raised.exception.expected) + self.assertEqual(0x00, raised.exception.actual) + + def test_absent_qmi_is_a_retryable_io_failure(self): + with self.assertRaises(PeripheralIOError) as raised: + qmi8658.QMI8658(bus=FakeBus(read_error=OSError("no device"))) + + self.assertTrue(raised.exception.retryable) + self.assertEqual("chip ID read", raised.exception.operation) + + def test_absent_touch_is_a_retryable_io_failure(self): + with self.assertRaises(PeripheralIOError) as raised: + self.make_touch(FakeBus(read_error=OSError("no device"))) + + self.assertTrue(raised.exception.retryable) + self.assertEqual("chip ID read", raised.exception.operation) + + def test_transient_qmi_detection_error_recovers_on_retry(self): + attempts = [] + clock = FakeClock() + + def factory(): + attempts.append(True) + if len(attempts) < 3: + bus = FakeBus(read_error=OSError("bus busy")) + else: + bus = FakeBus({0x00: 0x05, 0x01: 0x42}) + return qmi8658.QMI8658(bus=bus) + + sensor = initialize_with_retry( + factory, + "QMI8658", + clock=clock, + logger=lambda message: None, + ) + + self.assertIsInstance(sensor, qmi8658.QMI8658) + self.assertEqual(3, len(attempts)) + + def test_runtime_qmi_read_error_is_classified(self): + bus = FakeBus({0x00: 0x05, 0x01: 0x42}) + sensor = qmi8658.QMI8658(bus=bus) + bus.read_error = OSError("bus disconnected") + + with self.assertRaises(PeripheralIOError) as raised: + sensor.Read_XYZ() + + self.assertEqual("sample read", raised.exception.operation) + + +if __name__ == "__main__": + unittest.main() diff --git a/touch_drive.py b/touch_drive.py index 4083ea3..a2bc2f8 100644 --- a/touch_drive.py +++ b/touch_drive.py @@ -1,9 +1,9 @@ # Touch drive # v3.3 -from machine import Pin,I2C,SPI,PWM,Timer,ADC -import framebuf +from machine import I2C, Pin, Timer import time -Vbat_Pin = 29 + +from hardware import PeripheralIOError, PeripheralIdentityError #Guesture Hex values G_UP = 0x01 @@ -14,41 +14,98 @@ G_DOUBLE_CLIC = 0x0B +def _sleep_ms(clock, milliseconds): + sleep_ms = getattr(clock, "sleep_ms", None) + if sleep_ms is not None: + sleep_ms(milliseconds) + else: + clock.sleep(milliseconds / 1000) + + class Touch_CST816T(object): #Initialize the touch chip - def __init__(self,address=0x15,mode=0,i2c_num=1,i2c_sda=6,i2c_scl=7,int_pin=21,rst_pin=22,LCD=None): - self._bus = I2C(id=i2c_num,scl=Pin(i2c_scl),sda=Pin(i2c_sda),freq=400_000) #Initialize I2C + def __init__( + self, + address=0x15, + mode=0, + i2c_num=1, + i2c_sda=6, + i2c_scl=7, + int_pin=21, + rst_pin=22, + LCD=None, + bus=None, + pin_factory=Pin, + timer_factory=Timer, + clock=time, + ): self._address = address #Set slave address - self.int=Pin(int_pin,Pin.IN, Pin.PULL_UP) - self.tim = Timer() - self.rst=Pin(rst_pin,Pin.OUT) + self._clock = clock self._configured_mode = None - self.Reset() - bRet=self.WhoAmI() - if bRet : - print("Success:Detected CST816T.") - Rev= self.Read_Revision() - print("CST816T Revision = {}".format(Rev)) + try: + self._bus = bus + if self._bus is None: + self._bus = I2C( + id=i2c_num, + scl=pin_factory(i2c_scl), + sda=pin_factory(i2c_sda), + freq=400_000, + ) + self.int=pin_factory(int_pin,pin_factory.IN, pin_factory.PULL_UP) + self.tim = timer_factory() + self.rst=pin_factory(rst_pin,pin_factory.OUT) + self.Reset() + chip_id = self._read_byte(0xA7) + except PeripheralIOError as error: + raise PeripheralIOError("CST816T", "chip ID read", error.detail) + except OSError as error: + raise PeripheralIOError("CST816T", "chip ID read", error) + + if chip_id != 0xB5: + raise PeripheralIdentityError("CST816T", 0xB5, chip_id) + + try: + self.revision = self.Read_Revision() self.Stop_Sleep() - else : - print("Error: Not Detected CST816T.") - return None - self.Mode = mode - self.Gestures="None" - self.Flag = self.Flgh =self.l = 0 - self.X_point = self.Y_point = 0 - self.int.irq(handler=self.Int_Callback,trigger=Pin.IRQ_FALLING) + self.Mode = mode + self.Gestures = 0 + self.Flag = self.Flgh =self.l = 0 + self.X_point = self.Y_point = 0 + self.int.irq( + handler=self.Int_Callback, + trigger=pin_factory.IRQ_FALLING, + ) + except PeripheralIOError as error: + raise PeripheralIOError("CST816T", "configuration", error.detail) + except OSError as error: + raise PeripheralIOError("CST816T", "configuration", error) + + print("Success: Detected CST816T.") + print("CST816T Revision = {}".format(self.revision)) def _read_byte(self,cmd): - rec=self._bus.readfrom_mem(int(self._address),int(cmd),1) + try: + rec=self._bus.readfrom_mem(int(self._address),int(cmd),1) + except OSError as error: + raise PeripheralIOError("CST816T", "register read", error) + if len(rec) != 1: + raise PeripheralIOError("CST816T", "register read", "short I2C read") return rec[0] def _read_block(self, reg, length=1): - rec=self._bus.readfrom_mem(int(self._address),int(reg),length) + try: + rec=self._bus.readfrom_mem(int(self._address),int(reg),length) + except OSError as error: + raise PeripheralIOError("CST816T", "register read", error) + if len(rec) != length: + raise PeripheralIOError("CST816T", "register read", "short I2C read") return rec def _write_byte(self,cmd,val): - self._bus.writeto_mem(int(self._address),int(cmd),bytes([int(val)])) + try: + self._bus.writeto_mem(int(self._address),int(cmd),bytes([int(val)])) + except OSError as error: + raise PeripheralIOError("CST816T", "register write", error) def WhoAmI(self): if (0xB5) != self._read_byte(0xA7): @@ -65,9 +122,9 @@ def Stop_Sleep(self): #Reset def Reset(self): self.rst(0) - time.sleep_ms(1) + _sleep_ms(self._clock, 1) self.rst(1) - time.sleep_ms(50) + _sleep_ms(self._clock, 50) self._configured_mode = None #Set mode