diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 168ff57..4d8f157 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -366,7 +366,7 @@ Whenever `hexdrive.py` is modified, you **must** perform all three steps: 2. **Bump `HEXDRIVE_APP_VERSION`** in `app.py` (and `linefollower.py` if present) to the **same** integer. This is how the app detects that the EEPROM firmware is out-of-date and prompts the user to reprogram. 3. **Rebuild the `.mpy`** by running from the BadgeBot directory: ```bash - mpy-cross -v hexdrive.py + mpy-cross -v EEPROM/hexdrive.py ``` This produces `hexdrive.mpy`, which is what actually gets written to the HexDrive EEPROM. diff --git a/.gitignore b/.gitignore index 80cda1a..67f618e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ BadgeBot.code-workspace .deploy_state/test_device_download_state.json .editorconfig .venv/ +.venv-wsl*/ diff --git a/EEPROM/gps.py b/EEPROM/gps.py new file mode 100644 index 0000000..ee09d4b --- /dev/null +++ b/EEPROM/gps.py @@ -0,0 +1,190 @@ +""" GPS App for Hexpansion """ +import app + +from app_components.tokens import label_font_size, button_labels +from events.input import Buttons, BUTTON_TYPES, ButtonDownEvent +from system.eventbus import eventbus +from system.hexpansion.config import HexpansionConfig +from system.patterndisplay.events import PatternDisable, PatternEnable +from system.scheduler.events import RequestForegroundPopEvent, RequestForegroundPushEvent, RequestStopAppEvent +from tildagonos import tildagonos +from machine import UART, Pin + +# Minimal length method names to make the mpy file as small as possible so it might fit in the 2k hexpansion EEPROM. +# Minimal functionality to get a GPS fix +# This version is NOT for the App Store + +VERSION = 1 + +# Hardware defintions: +TX_PIN = 1 # HS_G for TX +RX_PIN = 0 # HS_F for RX +RESET_PIN = 2 # HS_H for reset +PPS_PIN = 3 # HS_I for PPS + +###JUST FOR USE WITH MY PROTOTYPE BOARD +ENABLE_PIN = 0 # First LS pin used to enable the SMPSU +###JUST FOR USE WITH MY PROTOTYPE BOARD + +class GPSApp(app.App): # pylint: disable=no-member + """ App to get GPS data from a GPS module connected to the hexpansion and display it on the badge. """ + def __init__(self, config: HexpansionConfig | None = None): + super().__init__() + # If run from EEPROM on the hexpansion, the config will be passed in with the correct pin objects + self.config: HexpansionConfig | None = config + if config is None: + return + self.tx_pin = config.pin[TX_PIN] + self.rx_pin = config.pin[RX_PIN] + self.reset = config.pin[RESET_PIN] + self.pps = config.pin[PPS_PIN] + +###JUST FOR USE WITH MY PROTOTYPE BOARD + self.power_control = config.ls_pin[ENABLE_PIN] + self.power_control.init(mode=Pin.OUT) + self.power_control.value(1) +###JUST FOR USE WITH MY PROTOTYPE BOARD + + self.foreground = False + self.button_states = Buttons(self) + self.last_fix = None + + # Event handlers for gaining and losing focus and for stopping the app + eventbus.on_async(RequestStopAppEvent, self.s, self) + eventbus.on_async(RequestForegroundPushEvent, self.r, self) + eventbus.on_async(RequestForegroundPopEvent, self.p, self) + + self.uart = UART(1, baudrate=9600, tx=self.tx_pin, rx=self.rx_pin) + self.reset.init(mode=Pin.OUT) + self.pps.init(mode=Pin.IN) + self.reset.value(1) # set reset high here and release when 100ms has passed in foreground update. + self.ticks_since_start = 0 + self.ticks_since_last_fix = 0 + + + def deinit(self): + """ Deinitialise the app, releasing any resources (e.g. UART) """ + self.uart.deinit() + self.power_control.value(0) # Cut power to the GPS to save power when not in use + + + def get_version(self) -> int: + """ Get the version of the app - this is used to determine if an upgrade is required. """ + return VERSION + + + async def s(self, event: RequestStopAppEvent): + """ Handle the RequestStopAppEvent so that we can release resources """ + if event.app == self: + self.deinit() + + + async def r(self, event: RequestForegroundPushEvent): + """ Handle the RequestForegroundPushEvent to know when we gain focus """ + if event.app == self: + eventbus.emit(PatternDisable()) + eventbus.on(ButtonDownEvent, self.d, self) + self.foreground = True + + + async def p(self, event: RequestForegroundPopEvent): + """ Handle the RequestForegroundPopEvent to know when we lose focus """ + if event.app == self: + eventbus.emit(PatternEnable()) + eventbus.remove(ButtonDownEvent, self.d, self) + + + def d(self, event: ButtonDownEvent): + """ Handle button down events """ + if event.button == BUTTON_TYPES["CANCEL"]: + self.button_states.clear() + self.minimise() + + + def update(self, delta): + """ Update the app state - expire last_fix if it is too old """ + if self.reset.value(): + self.ticks_since_start += delta + if self.ticks_since_start > 100: + # Release reset after 100ms to allow the GPS to start up + self.reset.value(0) + if not self.foreground: + # This triggers the automatic foreground display + eventbus.emit(RequestForegroundPushEvent(self)) + self.foreground = True + if self.last_fix: + self.ticks_since_last_fix += delta + if self.ticks_since_last_fix > 10000: + # If it's been more than 10 seconds since the last fix, disccard it + self.last_fix = None + + + def background_update(self, _delta): + """ Update in the background - read from the UART and parse any GPS data """ + line = self.uart.readline() + if line: + try: + line = line.decode().strip() + result = n(line) + if result: + self.last_fix = result + self.ticks_since_last_fix = 0 + except (UnicodeError, ValueError, AttributeError): + pass + + + def draw(self, ctx): + """ Draw the app - display the last GPS fix or a searching message if no fix is available """ + ctx.rgb(0, 0.2, 0).rectangle(-120, -120, 240, 240).fill() + ctx.rgb(0, 1, 0) + ctx.font_size = label_font_size + ctx.text_align = ctx.LEFT + ctx.text_baseline = ctx.BOTTOM + if self.last_fix: + ctx.move_to(-100, -10).text("Lat: " + str(round(self.last_fix["lat"], 5))) + ctx.move_to(-100, 20).text("Lon: " + str(round(self.last_fix["lon"], 5))) + for i in range(1, 13): + tildagonos.leds[i] = (0,1,0) + tildagonos.leds.write() + else: + ctx.move_to(-100, 0).text("Searching...") + for i in range(1,13): + tildagonos.leds[i] = (0,0,0) + tildagonos.leds.write() + + # show labels for buttons + button_labels(ctx, cancel_label="Exit") + +def n(line: str) -> dict[str, float] | None: + """ Parse an NMEA RMC sentence and return a dictionary with the latitude and longitude if valid, or None if invalid. """ + parts = line.split(',') + + if parts[0] not in ("$GNRMC", "$GPRMC"): + return None + elif parts[2] != "A": # A = valid, V = invalid + return None + else: + lat_raw = parts[3] + lat_dir = parts[4] + lon_raw = parts[5] + lon_dir = parts[6] + + if not lat_raw or not lon_raw: + return None + + # Convert to decimal degrees + lat = float(lat_raw[:2]) + float(lat_raw[2:]) / 60 + lon = float(lon_raw[:3]) + float(lon_raw[3:]) / 60 + + if lat_dir == "S": + lat = -lat + if lon_dir == "W": + lon = -lon + + return { + "lat": lat, + "lon": lon + } + + +__app_export__ = GPSApp #pylint: disable=invalid-name diff --git a/hexdrive.py b/EEPROM/hexdrive.py similarity index 97% rename from hexdrive.py rename to EEPROM/hexdrive.py index 5baa844..5d48959 100644 --- a/hexdrive.py +++ b/EEPROM/hexdrive.py @@ -150,10 +150,9 @@ def deinitialise(self) -> bool: """ De-initialise the app - return True if successful, False if failed.""" # Turn off all PWM outputs & release resources self.set_power(False) - self._pwm_deinit() + self._pwm_deinit() for hs_pin in self.config.pin: - hs_pin.init(mode=Pin.OUT) - hs_pin.value(0) + hs_pin.init(mode=Pin.IN) return True @@ -224,9 +223,9 @@ def set_power(self, state: bool) -> bool: self._power_control.value(state) except Exception as e: # pylint: disable=broad-except print(f"D:{self.config.port}:power control failed {e}") - return False + return False self._power_state = state - return self._power_state + return self._power_state def get_power(self) -> bool: @@ -272,7 +271,7 @@ def set_servoposition(self, channel: int | None = None, position: int | None = N The pulse width for a specific servo output is position + the centre offset (in us) Based on standard RC servos with centre at 1500us and range of 1000-2000us. The position is a signed value from -1000 to 1000 which is scaled to 500-2500us. - This is a very wide range and may not be suitable for all servos, some will + This is a very wide range and may not be suitable for all servos, some will only be happy with 1000-2000us (i.e. position in the range -500 to 500). """ if not self._pwm_setup: return False @@ -301,8 +300,8 @@ def set_servoposition(self, channel: int | None = None, position: int | None = N print(self._pwm_log_string(channel) + f"Off failed {e}") return False # check if all channels are now off and set outputs_energised accordingly - self._check_outputs_energised() - elif channel is not None: + self._check_outputs_energised() + elif channel is not None: if channel < 0 or channel >= self._hexdrive_type.servos: return False if abs(position) > _MAX_SERVO_RANGE: @@ -328,7 +327,7 @@ def set_servoposition(self, channel: int | None = None, position: int | None = N self._freq[channel] = _DEFAULT_SERVO_FREQ self.PWMOutput[channel].freq(_DEFAULT_SERVO_FREQ) if self._logging: - print(self._pwm_log_string(channel) + f"{_DEFAULT_SERVO_FREQ}Hz for Servo") + print(self._pwm_log_string(channel) + f"{_DEFAULT_SERVO_FREQ}Hz for Servo") except Exception as e: # pylint: disable=broad-except print(self._pwm_log_string(channel) + f"set freq failed {e}") return False @@ -339,7 +338,7 @@ def set_servoposition(self, channel: int | None = None, position: int | None = N print(self._pwm_log_string(channel) + f"{pulse_width_in_ns}ns") self.PWMOutput[channel].duty_ns(pulse_width_in_ns) if self._logging: - print(self._pwm_log_string(channel) + f"{self.PWMOutput[channel]} duty") + print(self._pwm_log_string(channel) + f"{self.PWMOutput[channel]} duty") except Exception as e: # pylint: disable=broad-except print(self._pwm_log_string(channel) + f"set duty failed {e}") return False @@ -359,7 +358,7 @@ def set_servocentre(self, centre: int, channel: int | None = None) -> bool: return False if channel is not None and (channel < 0 or channel >= self._hexdrive_type.servos): return False - if centre < (_SERVO_CENTRE - _SERVO_MAX_TRIM ) or centre > (_SERVO_CENTRE + _SERVO_MAX_TRIM): + if centre < (_SERVO_CENTRE - _SERVO_MAX_TRIM ) or centre > (_SERVO_CENTRE + _SERVO_MAX_TRIM): return False if channel is None: self._servo_centre = [centre] * 4 @@ -380,6 +379,7 @@ def set_motors(self, outputs: tuple[int, ...]) -> bool: if abs(output) > 65535: return False if output == self._motor_output[motor]: + # no change in output for this motor so skip to the next one continue try: # if the output is changing direction then we need to switch which signal is being driven as the PWM output @@ -393,7 +393,7 @@ def set_motors(self, outputs: tuple[int, ...]) -> bool: self.PWMOutput[output_to_disable].deinit() self.PWMOutput[output_to_disable] = None self.config.pin[output_to_disable].value(0) - self._set_pwmoutput(output_to_enable, abs(output)) + self._set_pwmoutput(output_to_enable, abs(output)) except Exception as e: # pylint: disable=broad-except print(f"D:{self.config.port}:Motor{motor}:{output} set failed {e}") self._motor_output[motor] = output @@ -409,7 +409,7 @@ def set_pwm(self, duty_cycles: tuple[int, ...]) -> bool: if not self._pwm_setup: return False self._outputs_energised = any(duty_cycles) - for channel, duty_cycle in enumerate(duty_cycles): + for channel, duty_cycle in enumerate(duty_cycles): if not self._set_pwmoutput(channel, duty_cycle): return False self._time_since_last_update = 0 @@ -424,13 +424,13 @@ def set_pwm(self, duty_cycles: tuple[int, ...]) -> bool: def motor_step(self, phase: int) -> int | None: """ Step the motor to a specific phase in the stepping sequence. Returns None if failed (e.g. invalid phase or not configured for stepper), - otherwise returns the phase that was set. The phase is a value from 0 to _STEPPER_NUM_PHASES-1 which corresponds to the + otherwise returns the phase that was set. The phase is a value from 0 to _STEPPER_NUM_PHASES-1 which corresponds to the stepping sequence defined in _STEPPER_SEQUENCE.""" if phase >= _STEPPER_NUM_PHASES or self._hexdrive_type.steppers == 0: return None if not self._stepper: # not currently configured for stepper motor - configure - self._pwm_deinit() + self._pwm_deinit() self._stepper = True for channel, value in enumerate(_STEPPER_SEQUENCE[phase]): self.config.pin[channel].value(value) @@ -454,10 +454,10 @@ def motor_release(self): def _pwm_init(self) -> bool: self._pwm_setup = False # HS Pins - if self.config.pin is not None and len(self.config.pin) == 4: + if self.config.pin is not None and len(self.config.pin) == 4: # Allocate PWM generation to pins for channel, _ in enumerate(self.config.pin): - self._freq[channel] = 0 + self._freq[channel] = 0 if self._hexdrive_type is not None: if channel < (2 * self._hexdrive_type.motors): # First channels are for motors (can be 0, 1 or 2 motors) @@ -480,10 +480,10 @@ def _pwm_init(self) -> bool: if self._set_pwmoutput(channel, 0): self._stepper = False else: - return False + return False self._pwm_setup = True return self._pwm_setup - + # De-initialise all PWM outputs def _pwm_deinit(self): @@ -520,13 +520,13 @@ def _check_outputs_energised(self): # if the channel has not been setup yet then we initialise it from scratch, otherwise we just change the duty cycle def _set_pwmoutput(self, channel: int, duty_cycle: int) -> bool: if duty_cycle < 0 or duty_cycle > 65535: - return False + return False try: if self.PWMOutput[channel] is None: # Channel hasn't been setup yet so we need to initialise it from scratch self.PWMOutput[channel] = PWM(self.config.pin[channel], freq = self._freq[channel], duty_u16 = duty_cycle) if self._logging: - print(self._pwm_log_string(channel) + f"{self.PWMOutput[channel]} init") + print(self._pwm_log_string(channel) + f"{self.PWMOutput[channel]} init") elif duty_cycle != self.PWMOutput[channel].duty_u16(): self.PWMOutput[channel].duty_u16(duty_cycle) if self._logging: @@ -556,7 +556,7 @@ def _check_port_for_hexdrive(self, port: int) -> HexDriveType | None: return hexpansion_type # we are not interested in this type of hexpansion return None - + def _parse_version(self, version): """ Parse a version string, e.g. that of BadgeOS, into a list of components for comparison. Handles versions in the format v1.9.0-beta.1+build.123 diff --git a/README.md b/README.md index f33346b..8b6667e 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ This repo contains lots of files that you don't need on your badge to use a HexD + tildagon.toml + metadata.json + app.py or app.mpy -+ hexdrive.mpy ++ EEPROM/hexdrive.mpy + utils.mpy + hexpansion_mgr.mpy + motor_controller.mpy @@ -175,16 +175,24 @@ This is to help develop the BadgeBot application using the Badge simulator. Windows: ``` -git clone https://github.com/TeamRobotmad/badge-2024-software.git -cd badge-2024-software -git submodule update --init +git clone https://github.com/TeamRobotmad/BadgeBot.git +cd BadgeBot powershell -ExecutionPolicy Bypass -File .\dev\setup_dev_env.ps1 ``` +WSL (recommended for simulator tests): +``` +git clone https://github.com/TeamRobotmad/BadgeBot.git +cd BadgeBot +sh ./dev/setup_wsl_dev_env.sh +``` + +The WSL helper uses `uv` to provision Python 3.10 and installs both the local dev requirements and the simulator requirements. This is recommended because the published `wasmer` wheels used by the simulator currently load reliably there. + Linux/macOS: ``` -git clone https://github.com/hughrawlinson/tildagon-demo.git -cd tildagon-demo +git clone https://github.com/TeamRobotmad/BadgeBot.git +cd BadgeBot sh ./dev/setup_dev_env.sh ``` @@ -204,6 +212,12 @@ cd tests python -m pytest test_smoke.py test_autotune.py -v ``` +If BadgeBot is checked out inside the `badge-2024-software` repo, set `PYTHONPATH` to the parent repo root so `sim.run` can be imported. For the WSL helper's default environment this looks like: +``` +cd tests +PYTHONPATH=/path/to/badge-2024-software ../.venv-wsl310/bin/python -m pytest test_smoke.py test_autotune.py -v +``` + ### Best practise Run `isort` on in-app python files. Check `pylint` for linting errors. diff --git a/app.py b/app.py index cadfe17..f11e693 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,9 @@ """ Main Application File for BadgeBot.""" import asyncio import sys -from system.hexpansion.config import HexpansionConfig import time from math import cos, pi + import ota import settings from app_components.notification import Notification @@ -12,6 +12,7 @@ from events.input import BUTTON_TYPES, Button, Buttons, ButtonUpEvent from frontboards.twentyfour import BUTTONS from system.eventbus import eventbus +from system.hexpansion.config import HexpansionConfig from system.patterndisplay.events import PatternDisable, PatternEnable from system.scheduler.events import (RequestForegroundPopEvent, RequestForegroundPushEvent, @@ -27,7 +28,7 @@ #micropython.alloc_emergency_exception_buf(100) from .utils import draw_logo_animated, parse_version -from. hexdrive import VERSION as HEXDRIVE_APP_VERSION +from .EEPROM.hexdrive import VERSION as HEXDRIVE_APP_VERSION _SETTINGS_NAME_PREFIX = "badgebot." # Prefix for settings keys in EEPROM @@ -97,7 +98,7 @@ # App states where user can minimise app (Menu, Message, Logo) MINIMISE_VALID_STATES = [STATE_MENU, STATE_MESSAGE, STATE_LOGO] - + # App states where BadgeBot directly controls the badge LEDs (Motor Moves, Countdown, Message, Logo, Line Follower, AutoTune) _LED_CONTROL_STATES = [STATE_MOTOR_MOVES, STATE_COUNTDOWN, STATE_MESSAGE, STATE_LOGO, STATE_FOLLOWER, STATE_AUTOTUNE, STATE_AUTODRIVE, STATE_SENSOR] @@ -118,7 +119,7 @@ MENU_ITEM_SENSOR_TEST = 5 MENU_ITEM_AUTO_DRIVE = 6 MENU_ITEM_HEXPANSION = 7 -MENU_ITEM_SETTINGS = 8 +MENU_ITEM_SETTINGS = 8 MENU_ITEM_ABOUT = 9 MENU_ITEM_EXIT = 10 @@ -185,20 +186,20 @@ def __init__(self): # strings shown on the Logo screen self.b_msg: str = f"BadgeBot V{self.app_version}" self.t_msg: str = "RobotMad" - self.notification: Notification = None + self.notification: Notification | None = None self.message: list = [] self.message_colours: list = [] - self.message_type: str = None - self.current_menu: str = None - self.menu: Menu = None + self.message_type: str | None = None + self.current_menu: str | None = None + self.menu: Menu | None = None self.scroll_mode_enabled: bool = False # Whether pressing the "C" button can toggle scroll mode on/off, which allows the user to scroll through lines on the display. self.scroll_ignore_next_c_button: bool = False # Used to ignore the "C" button event that triggers scroll mode on, otherwise it would immediately toggle scroll mode off again - self.is_scroll: bool = False # Whether we are in scroll mode - this is displayed by a green border around the screen + self.is_scroll: bool = False # Whether we are in scroll mode - this is displayed by a green border around the screen self.scroll_offset: int = 0 # UI countdown self.run_countdown_elapsed_ms: int = 0 - self.countdown_next_state: int = None # which state to go to after countdown + self.countdown_next_state: int | None = None # which state to go to after countdown self._motor1_reversed: bool = False # 0 or 1 to control direction of motor 1, set based on settings self._motor2_reversed: bool = False # 0 or 1 to control direction of motor 2, set based on settings @@ -213,7 +214,7 @@ def __init__(self): self.settings['motor1_dir'] = MySetting(self.settings, _FWD_DIR_DEFAULT, 0, 1, labels=_MOTOR_DIRECTION_LABELS) self.settings['motor2_dir'] = MySetting(self.settings, _FWD_DIR_DEFAULT, 0, 1, labels=_MOTOR_DIRECTION_LABELS) self.settings['front_face'] = MySetting(self.settings, _FRONT_FACE_DEFAULT, 0, 11, labels=_FRONT_FACE_LABELS) - + # Module-specific settings - only initialise modules which are NOT dependent on specific Hexpansion hardware here, as we want to be able to access settings in the HexpansionMgr before we have detected what hardware is present. For Hexpansion-dependent modules, we will initialise their settings after we have scanned for hardware and know which modules we will be using. if _hexpansion_init_settings is not None: _hexpansion_init_settings(self.settings, MySetting) @@ -222,6 +223,7 @@ def __init__(self): self.fast_settings_update() # Check what version of the Badge s/w we are running on + ver: list[int | str] | None = None try: ver = parse_version(ota.get_version()) if ver is not None: @@ -229,11 +231,12 @@ def __init__(self): print(f"BadgeSW V{ver}") # Potential to do things differently based on badge s/w version # e.g. if ver < [1, 9, 0]: - except Exception: # pylint: disable=broad-exception-caught + except Exception: # pylint: disable=broad-exception-caught pass - + # make use of special characters if running on compatible badge s/w version - if ver is not None and ver > [1, 10, 0]: + version_triplet = tuple(part if isinstance(part, int) else 0 for part in (ver[:3] if ver is not None else [])) + if len(version_triplet) == 3 and version_triplet > (1, 10, 0): self.special_chars = { 'up': "\u25B2", # up arrow # 'down': "\u25BC", # down arrow - has always existed 'left': "\u25C0", # left arrow @@ -243,7 +246,8 @@ def __init__(self): # Hexpansion related - SEE ALSO hexpansion_mgr to update _SINGLE_PORT_HEXPANSION_REFS - # pid name vid eeprom total size eeprom page size app mpy name app mpy version app name motors servos sensors sub_type + # pid name vid eeprom total size eeprom page size app mpy name app mpy version app name motors servos sensors sub_type + assert HexpansionType is not None self.HEXPANSION_TYPES = [HexpansionType(0xCBCB, "HexDrive", app_mpy_name="hexdrive.mpy", app_mpy_version=HEXDRIVE_APP_VERSION, app_name="HexDriveApp", motors=2, servos=4, steppers=1, sub_type="Uncommitted" ), HexpansionType(0xCBCA, "HexDrive", app_mpy_name="hexdrive.mpy", app_mpy_version=HEXDRIVE_APP_VERSION, app_name="HexDriveApp", motors=2, sub_type="2 Motor" ), HexpansionType(0xCBCC, "HexDrive", app_mpy_name="hexdrive.mpy", app_mpy_version=HEXDRIVE_APP_VERSION, app_name="HexDriveApp", servos=4, sub_type="4 Servo" ), @@ -255,9 +259,9 @@ def __init__(self): HexpansionType(0x0202, "HexDriveV2", vid=0xCBCB, eeprom_total_size=32768, eeprom_page_size= 64, app_mpy_name="hexdrive.mpy", app_mpy_version=HEXDRIVE_APP_VERSION, app_name="HexDriveApp", servos=2, sub_type="2 Servo" ), HexpansionType(0x0300, "HexTest", vid=0xCBCB, eeprom_total_size=65536, eeprom_page_size=128), HexpansionType(0x0400, "HexDiag", vid=0xCBCB, eeprom_total_size=65536, eeprom_page_size=128), - HexpansionType(0x1234, "GPS", eeprom_total_size= 2048, eeprom_page_size= 16), - HexpansionType(0xD15C, "Flopagon", eeprom_total_size= 2048, eeprom_page_size= 16), # EEPROM too small for the app - HexpansionType(0xCAFF, "Club Mate", eeprom_total_size= 8192, eeprom_page_size= 32, app_mpy_name="caffeine.mpy", app_name="CaffeineJitter"), + #HexpansionType(0x1295, "GPS", app_mpy_name="gps.mpy", app_mpy_version=1, app_name="GPSApp"), # eeprom_total_size= 2048, eeprom_page_size= 16), + #HexpansionType(0xD15C, "Flopagon", eeprom_total_size= 2048, eeprom_page_size= 16), # EEPROM too small for the app + #HexpansionType(0xCAFF, "Club Mate", eeprom_total_size= 8192, eeprom_page_size= 32, app_mpy_name="caffeine.mpy", app_name="CaffeineJitter"), HexpansionType(0x0000, "Unknown", sub_type=""), # Virtual type to represent unrecognised hexpansions HexpansionType(0xFFFF, "Blank", sub_type="")] # Virtual type to represent blank EEPROMs @@ -267,7 +271,7 @@ def __init__(self): self.HEXSENSE_HEXPANSION_INDEX = 5 # Index in the HEXPANSION_TYPES list which corresponds to the HexSense type self.HEXTEST_HEXPANSION_INDEX = 9 # Index in the HEXPANSION_TYPES list which corresponds to the HexTest type self.HEXDIAG_HEXPANSION_INDEX = 10 # Index in the HEXPANSION_TYPES list which corresponds to the HexDiag type - self.HEXGPS_HEXPANSION_INDEX = 11 # Index in the HEXPANSION_TYPES list which corresponds to the HexGPS type + #self.HEXGPS_HEXPANSION_INDEX = 11 # Index in the HEXPANSION_TYPES list which corresponds to the HexGPS type self.UNRECOGNISED_HEXPANSION_INDEX = len(self.HEXPANSION_TYPES) - 2 # Index in the HEXPANSION_TYPES list which corresponds to unrecognised hexpansion types MUST BE LAST NON-BLANK ENTRY IN THE LIST self.BLANK_HEXPANSION_INDEX = len(self.HEXPANSION_TYPES) - 1 # Index in the HEXPANSION_TYPES list which corresponds to blank EEPROMs @@ -278,7 +282,7 @@ def __init__(self): # HexDrive hexpansion - has an app which we use to control the motors and servos self.hexdrive_ports = [] self.hexdrive_apps = [] - + # HexSense hexpansion - only a prototype at present self.hexsense_port = None # Store the HexpansionConfig of the HexSense that is providing the line sensors @@ -287,7 +291,7 @@ def __init__(self): self.hextest_port = None # GPS hexpansion - self.hexgps_port = None + #self.hexgps_port = None # Diagnostics hexpansion self.hexdiag_port = _DIAG_PORT @@ -329,7 +333,7 @@ def __init__(self): self.num_steppers: int = 0 # initialised to 0 until we detect a HexDrive Hexpansion and can set this based on the actual number of steppers it has # Line Sensors Hardware - self.num_line_sensors: int = 0 # initialised to 0 until we detect a HexSense Hexpansion and can set this based on the actual number of sensors it has + self.num_line_sensors: int = 0 # initialised to 0 until we detect a HexSense Hexpansion and can set this based on the actual number of sensors it has # Servo Hardware self.num_servos: int = 0 # initialised to 0 until we detect a HexDrive Hexpansion and can set this based on the actual number of servos it has @@ -352,20 +356,25 @@ def __init__(self): # We start with focus on launch, without an event emmited # This version is compatible with the simulator - asyncio.get_event_loop().create_task(self._gain_focus(RequestForegroundPushEvent(self))) + asyncio.get_event_loop().create_task(self._gain_focus(RequestForegroundPushEvent(self))) if self.logging: print(f"BadgeBot App V{self.app_version} Initialised") - def _register_state_functions(self, state: int, manager: object): - """Register the update, draw, and background update functions for each state in the dispatch tables.""" - if manager is not None and hasattr(manager, 'update'): - self._state_update_dispatch[state] = manager.update - if manager is not None and hasattr(manager, 'draw'): - self._state_draw_dispatch[state] = manager.draw - if manager is not None and hasattr(manager, 'background_update'): - self._state_background_dispatch[state] = manager.background_update + def _register_state_functions(self, state: int, manager: object | None): + """Register the update, draw, and background update functions for each state in the dispatch tables.""" + if manager is None: + return + update_fn = getattr(manager, "update", None) + draw_fn = getattr(manager, "draw", None) + background_fn = getattr(manager, "background_update", None) + if callable(update_fn): + self._state_update_dispatch[state] = update_fn + if callable(draw_fn): + self._state_draw_dispatch[state] = draw_fn + if callable(background_fn): + self._state_background_dispatch[state] = background_fn @property @@ -381,7 +390,7 @@ def front_face(self): """Convenience property to access front_face setting representing the forward direction for movement.""" if 'front_face' in self.settings: return self.settings['front_face'].v - return _FRONT_FACE_DEFAULT + return _FRONT_FACE_DEFAULT @property @@ -409,7 +418,7 @@ async def _lose_focus(self, event: RequestForegroundPopEvent): eventbus.emit(PatternEnable()) self.pattern_status = True if self.scroll_mode_enabled: - eventbus.remove(ButtonUpEvent, self._handle_button_up, self) + eventbus.remove(ButtonUpEvent, self._handle_button_up, self) async def _handle_button_up(self, event: ButtonUpEvent): @@ -422,7 +431,7 @@ async def _handle_button_up(self, event: ButtonUpEvent): async def background_task(self): - """Background task loop for handling time-based updates. This runs independently of the main update/draw loop + """Background task loop for handling time-based updates. This runs independently of the main update/draw loop and is suitable for tasks that need to run at a consistent interval regardless of the current state or drawing performance.""" last_time = time.ticks_ms() @@ -450,36 +459,44 @@ def background_update(self, delta: int): @property def enable_motor_moves(self): + """Whether the Motor Moves feature is enabled, based on whether we have detected motor hardware and have the manager available.""" return self.num_motors > 1 and self._motor_moves_mgr is not None - + @property def enable_servo_test(self): + """Whether the Servo Test feature is enabled, based on whether we have detected servo hardware and have the manager available.""" return self.num_servos > 0 and self._servo_test_mgr is not None - + @property def enable_stepper_test(self): + """Whether the Stepper Test feature is enabled, based on whether we have detected stepper hardware and have the manager available.""" return self.num_steppers > 0 and self._stepper_test_mgr is not None @property def enable_line_follow(self): + """Whether the Line Follow feature is enabled, based on whether we have detected line sensors and have the manager available.""" return self.num_motors > 1 and self.num_line_sensors > 0 and self._line_follow_mgr is not None - + @property def enable_sensor_test(self): + """Whether the Sensor Test feature is enabled, based on whether we have detected sensor hardware and have the manager available.""" + #print(f"Checking if Sensor Test is enabled: sensor_test_mgr={'present' if self._sensor_test_mgr is not None else 'absent'}") return self._sensor_test_mgr is not None @property def enable_autodrive(self): + """Whether the Autodrive feature is enabled, based on whether we have detected motor hardware and have the manager available.""" return self.num_motors > 1 and self._autodrive_mgr is not None @property def enable_hexpansion_mgr(self): + """Whether the Hexpansion Manager is enabled, based on whether the manager is available. Note that this does not necessarily mean that you have hexpansion hardware, as the manager can be enabled and used for managing settings related to hexpansions even if no hexpansion hardware is detected.""" return self._hexpansion_mgr is not None @@ -490,7 +507,7 @@ def initialise_settings(self): # Module-specific settings if self.enable_motor_moves and _motor_moves_init_settings is not None: _motor_moves_init_settings(self.settings, MySetting) - if self.enable_servo_test and _servo_test_init_settings is not None: + if self.enable_servo_test and _servo_test_init_settings is not None: _servo_test_init_settings(self.settings, MySetting) if self.enable_stepper_test and _stepper_test_init_settings is not None: _stepper_test_init_settings(self.settings, MySetting) @@ -502,7 +519,7 @@ def initialise_settings(self): _autodrive_init_settings(self.settings, MySetting) self.update_settings() # Load settings from EEPROM after initialisation self.fast_settings_update() # Update fast access settings - + def update_settings(self): """Update settings from EEPROM.""" @@ -515,17 +532,17 @@ def update_settings(self): def fast_settings_update(self): - # for fast access in background_update + """Update fast access settings from the main settings dictionary.""" self._motor1_reversed: bool = self.settings['motor1_dir'].v != 0 self._motor2_reversed: bool = self.settings['motor2_dir'].v != 0 def hexdiag_setup(self): - # Use HS pins on a spare Hexpansion to make diagnostic timing measurements + """ Use HS pins on a spare Hexpansion to make diagnostic timing measurements""" if self._diag_config is not None and self.hexdiag_port != self._diag_config.port: for i in range(4): self._diag_config.pin[i].init(mode=Pin.IN) - self._diag_config = None + self._diag_config = None if self.hexdiag_port is not None and self._diag_config is None: self._diag_config = HexpansionConfig(self.hexdiag_port) for i in range(4): @@ -533,11 +550,12 @@ def hexdiag_setup(self): def diagnostics_output(self, index: int, value: int): + """Output diagnostic values to the HS pins on the diagnostics hexpansion, for measurement with an oscilloscope""" if self._diag_config is not None and 0 <= index < 4: self._diag_config.pin[index].value(value) - def _pattern_management(self): + def _pattern_management(self): if self.current_state in _LED_CONTROL_STATES: if self.pattern_status: eventbus.emit(PatternDisable()) @@ -558,8 +576,8 @@ def update(self, delta: int): if self.notification: self.notification.update(delta) try: - # in case access to protected member _is_closed() is not allowed, we catch the exception and - # to prevent crashes - this means that in this case we won't be able to automatically clear + # in case access to protected member _is_closed() is not allowed, we catch the exception and + # to prevent crashes - this means that in this case we won't be able to automatically clear # notifications when they are closed, but at least the app won't crash. if self.notification._is_closed(): # pylint: disable=protected-access self.notification = None @@ -574,7 +592,7 @@ def update(self, delta: int): # we only do extra refresh cycles if the update period is long. if self.update_period >= DEFAULT_BACKGROUND_UPDATE_PERIOD: self.refresh = True - + # manage LED PatternEnable/Disable for all states #self._pattern_management() @@ -606,7 +624,12 @@ def update(self, delta: int): if self.settings['brightness'].v < 1.0: # Scale brightness for i in range(1,13): - tildagonos.leds[i] = tuple(int(j * self.settings['brightness'].v) for j in tildagonos.leds[i]) + colour = tildagonos.leds[i] + tildagonos.leds[i] = ( + int(colour[0] * self.settings['brightness'].v), + int(colour[1] * self.settings['brightness'].v), + int(colour[2] * self.settings['brightness'].v), + ) try: # saw this crash randomly - hence protected by try/except to prevent whole app crashing, and added logging to investigate further tildagonos.leds.write() @@ -623,8 +646,13 @@ def _update_main_application(self, delta: int): self.set_menu() self.refresh = True else: - self.menu.update(delta) - if self.menu.is_animating != "none": + menu = self.menu + if menu is None: + self.set_menu() + self.refresh = True + return + menu.update(delta) + if menu.is_animating != "none": if self.logging: print("Menu is animating") self.refresh = True @@ -695,29 +723,39 @@ def _update_state_message(self, delta: int): # pylint: disable=unused-argum elif self.current_state == STATE_LOGO: # LED management - to match rotating logo: for i in range(1,13): - colour = (255, 241, 0) # custom Robotmad shade of yellow + colour = (255, 241, 0) # custom Robotmad shade of yellow # raised cosine cubed wave - wave = self.settings['brightness'].v * pow((1.0 + cos(((i) * pi / 1.5) - (self.rpm * self.animation_counter * pi / 7500)))/2.0, 3) + wave = self.settings['brightness'].v * pow((1.0 + cos(((i) * pi / 1.5) - (self.rpm * self.animation_counter * pi / 7500)))/2.0, 3) # 4 sides each projecting a pattern of 3 LEDs (12 LEDs in total) - tildagonos.leds[i] = tuple(int(wave * j) for j in colour) + tildagonos.leds[i] = ( + int(wave * colour[0]), + int(wave * colour[1]), + int(wave * colour[2]), + ) self.refresh = True else: for i in range(1,13): tildagonos.leds[i] = (255,0,0) if self.message_type == "error" else (0,255,0) - - def _update_state_countdown(self, delta: int): + + def _update_state_countdown(self, delta: int): self.clear_leds() self.run_countdown_elapsed_ms += delta if self.run_countdown_elapsed_ms >= _RUN_COUNTDOWN_MS: if self.countdown_next_state == STATE_MOTOR_MOVES: # Motor Moves: delegate to begin_moves self.current_state = self.countdown_next_state - self._motor_moves_mgr.begin_moves() + if self._motor_moves_mgr is not None: + self._motor_moves_mgr.begin_moves() + else: + self.return_to_menu() elif self.countdown_next_state == STATE_AUTOTUNE: # PID AutoTune: start the tuner after countdown self.current_state = self.countdown_next_state - self._autotune_mgr.begin_tuning() + if self._autotune_mgr is not None: + self._autotune_mgr.begin_tuning() + else: + self.return_to_menu() else: # Generic fallback self.return_to_menu() @@ -733,7 +771,7 @@ def scroll_mode_enable(self, enable: bool): """Enable the potential for scroll mode to be toggled on and off by pressing the "C" button""" if enable: self.scroll_mode_enabled = True - self.scroll_ignore_next_c_button = True # we want to ignore the "C" button event that triggered this, otherwise it would immediately toggle scroll mode on + self.scroll_ignore_next_c_button = True # we want to ignore the "C" button event that triggered this, otherwise it would immediately toggle scroll mode on eventbus.on_async(ButtonUpEvent, self._handle_button_up, self) else: self.scroll_mode_enabled = False @@ -765,10 +803,10 @@ def draw(self, ctx): clear_background(ctx) #ctx.save() #if in a mode where rotated display is desirable: - # ctx.rotate(self.front_face * 2.0 * pi / _FRONT_FACE_NUM_ORIENTATIONS) # Rotate the entire display based on the front_face setting, so that "forward" is always at the top of the display regardless of how the badge is oriented + # ctx.rotate(self.front_face * 2.0 * pi / _FRONT_FACE_NUM_ORIENTATIONS) # Rotate the entire display based on the front_face setting, so that "forward" is always at the top of the display regardless of how the badge is oriented ctx.font_size = label_font_size if ctx.text_align != ctx.LEFT: - # See https://github.com/emfcamp/badge-2024-software/issues/181 + # See https://github.com/emfcamp/badge-2024-software/issues/181 ctx.text_align = ctx.LEFT ctx.text_baseline = ctx.BOTTOM @@ -799,14 +837,14 @@ def draw(self, ctx): draw_fn(ctx) #ctx.restore() - # Notifications are drawn on top of everything else, so that they are visible regardless of the current state. + # Notifications are drawn on top of everything else, so that they are visible regardless of the current state. # They also contain animations, so need to be drawn every frame when active. # As they 'withdraw' they reveal whatever is underneath them so this must be redrawn every frame while they are active to avoid leaving visual glitches on the screen. if self.notification: self.notification.draw(ctx) - + self.diagnostics_output(2, 0) - + @staticmethod @@ -820,6 +858,8 @@ def apply_motor_directions(self, output: tuple) -> tuple: """Negate individual motor outputs as per settings.""" output1, output2 = output output = (-output1 if self._motor1_reversed else output1, -output2 if self._motor2_reversed else output2) + if self.logging: + print(f"M:{output}") return output @@ -865,7 +905,7 @@ def draw_message(ctx, message, colours, size=label_font_size): colour = (1,1,1) # Font is not central in the height allocated to it due to space for descenders etc... # this is most obvious when there is only one line of text - # # position fine tuned to fit around button labels when showing 5 lines of text + # # position fine tuned to fit around button labels when showing 5 lines of text y_position = int(0.35 * ctx.font_size) if num_lines == 1 else int((i_num-((num_lines-2)/2)) * ctx.font_size - 2) ctx.rgb(*colour).move_to(-width//2, y_position).text(text_line) @@ -896,11 +936,11 @@ def show_message(self, msg_content, msg_colours, msg_type = None): # multi level auto repeat def auto_repeat_check(self, delta: int, speed_up: bool = True) -> bool: """Check if the auto-repeat threshold has been reached for a button hold, and update the auto-repeat level accordingly. - If speed_up is True, the auto-repeat interval decreases as the level increases, allowing for faster repeats the - longer the button is held. If speed_up is False, the interval remains constant, but the level can still increase - to allow for larger increments/decrements in settings adjustments. - Returns True if the auto-repeat action should be triggered, False otherwise. - """ + If speed_up is True, the auto-repeat interval decreases as the level increases, allowing for faster repeats the + longer the button is held. If speed_up is False, the interval remains constant, but the level can still increase + to allow for larger increments/decrements in settings adjustments. + Returns True if the auto-repeat action should be triggered, False otherwise. + """ self._auto_repeat += delta # multi stage auto repeat - the repeat gets faster the longer the button is held if self._auto_repeat > self._auto_repeat_intervals[self.auto_repeat_level if speed_up else 0]: @@ -919,10 +959,10 @@ def auto_repeat_check(self, delta: int, speed_up: bool = True) -> bool: def auto_repeat_clear(self): - """Reset the auto-repeat counters and level. This should be called when a button is released to ensure that the next button press starts with the initial auto-repeat interval and level.""" - self._auto_repeat = 1+ self._auto_repeat_intervals[0] # so that we trigger immediately on next press + """Reset the auto-repeat counters and level. This should be called when a button is released to ensure that the next button press starts with the initial auto-repeat interval and level.""" + self._auto_repeat = 1+ self._auto_repeat_intervals[0] # so that we trigger immediately on next press - self._auto_repeat_count = 0 + self._auto_repeat_count = 0 self.auto_repeat_level = 0 @@ -930,9 +970,9 @@ def auto_repeat_clear(self): ### MENU FUNCTIONALITY ### - def set_menu(self, menu_name = "main"): #: Literal["main"]): does it work without the type hint? - """Set the current menu to the specified menu name, and construct the menu if necessary. - If menu_name is None, it will clear the current menu and return to the previous state + def set_menu(self, menu_name: str | None = "main"): #: Literal["main"]): does it work without the type hint? + """Set the current menu to the specified menu name, and construct the menu if necessary. + If menu_name is None, it will clear the current menu and return to the previous state (e.g. from a submenu back to the main menu).""" if self.logging: print(f"B:Set Menu {menu_name}") @@ -1054,7 +1094,7 @@ def _main_menu_select_handler(self, item: str, idx: int): if self._hexpansion_mgr is not None: self._hexpansion_mgr.logging = self.logging # update logging setting in hexpansion manager based on current app setting, in case it was changed if self._hexpansion_mgr.start(): - self.current_state = STATE_HEXPANSION + self.current_state = STATE_HEXPANSION elif item == MAIN_MENU_ITEMS[MENU_ITEM_SETTINGS]: # Settings self.set_menu(MAIN_MENU_ITEMS[MENU_ITEM_SETTINGS]) elif item == MAIN_MENU_ITEMS[MENU_ITEM_ABOUT]: # About @@ -1062,7 +1102,7 @@ def _main_menu_select_handler(self, item: str, idx: int): self.button_states.clear() self.animation_counter = 0 self.current_state = STATE_LOGO - self.refresh = True + self.refresh = True elif item == MAIN_MENU_ITEMS[MENU_ITEM_EXIT]: # Exit if self._hexpansion_mgr is not None: self._hexpansion_mgr.unregister_events() @@ -1095,7 +1135,7 @@ def _settings_menu_select_handler(self, item: str, idx: int): def _menu_back_handler(self): if self.current_menu == "main": self.minimise() - # for submenus, just return to the main menu + # for submenus, just return to the main menu self.set_menu() diff --git a/copilot_instructions.md b/copilot_instructions.md index 6b079f1..5eeb81c 100644 --- a/copilot_instructions.md +++ b/copilot_instructions.md @@ -61,6 +61,7 @@ accelerometer distance estimation). The app runs directly on an ESP32-S3 badge | `tcs3472.py` | TCS3472 colour RGBC + CCT + lux sensor (I2C `0x29`) | | `tcs3430.py` | TCS3430 colour CIE XYZ + lux sensor (I2C `0x39`) | | `opt4048.py` | OPT4048 tristimulus XYZ colour sensor (I2C `0x44`) | +| `ina226.py` | INA226 current/voltage/power monitor (I2C `0x40`-`0x4F`, 100mΩ shunt default) | ### Configuration @@ -293,6 +294,14 @@ No dedicated settings currently; the `init_settings` hook exists for future use. - **Sensor driver pattern**: Extend `SensorBase`; set `I2C_ADDR` and `NAME` class attrs; implement `_init()`, `_measure()`, `_shutdown()`; add to `ALL_SENSOR_CLASSES` in `sensors/__init__.py`. +- **Alternative I2C addresses**: Drivers may also provide `I2C_ADDRS` for all + supported addresses. `SensorManager` will probe each address and may instantiate + multiple devices of the same driver on a single bus. +- **Power/current sensors**: Use integer fixed-point math only (no floats) and + report values in integer engineering units (`mA`, `mV`, etc.). +- **Typing expectations**: New sensor-manager/sensor-test changes should include + explicit type annotations that are compatible with both MicroPython runtime and + desktop linting/type-checking (Pylint/Pylance). - **State constants**: Defined in `app.py` and imported by sub-modules via `from .app import STATE_*`. - **Logging**: Use `if self._logging:` guard before `print()` statements. diff --git a/dev/build_release.py b/dev/build_release.py index 676b7a4..575fc92 100644 --- a/dev/build_release.py +++ b/dev/build_release.py @@ -8,7 +8,7 @@ RUNTIME_MODULES = { "app", - "hexdrive", + "EEPROM/hexdrive", "autotune", "autotune_mgr", "settings_mgr", @@ -33,6 +33,7 @@ "sensors/vl53l0x", "sensors/vl6180x", "sensors/opt4048", + "sensors/ina226", } files_to_mpy = {Path(f"{module}.py") for module in RUNTIME_MODULES} diff --git a/dev/download_to_device.py b/dev/download_to_device.py index 1213ebe..b77fdf6 100644 --- a/dev/download_to_device.py +++ b/dev/download_to_device.py @@ -37,7 +37,7 @@ class ModuleSpec: # Add new runtime modules here as the project grows. MODULES: tuple[ModuleSpec, ...] = ( - ModuleSpec(Path("hexdrive.py"), Path("hexdrive.mpy")), + ModuleSpec(Path("EEPROM/hexdrive.py"), Path("EEPROM/hexdrive.mpy")), ModuleSpec(Path("app.py"), Path("app.mpy")), ModuleSpec(Path("autotune.py"), Path("autotune.mpy")), ModuleSpec(Path("autotune_mgr.py"), Path("autotune_mgr.mpy")), @@ -59,13 +59,13 @@ class ModuleSpec: ModuleSpec(Path("sensors/vl53l0x.py"), Path("sensors/vl53l0x.mpy")), ModuleSpec(Path("sensors/vl6180x.py"), Path("sensors/vl6180x.mpy")), ModuleSpec(Path("sensors/opt4048.py"), Path("sensors/opt4048.mpy")), + ModuleSpec(Path("sensors/ina226.py"), Path("sensors/ina226.mpy")), ) # Files copied to the device as-is (no compilation). STATIC_FILES: tuple[Path, ...] = ( Path("metadata.json"), Path("tildagon.toml"), - #Path("caffeine.mpy"), # Club Mate hexpansion app ) diff --git a/dev/setup_wsl_dev_env.sh b/dev/setup_wsl_dev_env.sh new file mode 100644 index 0000000..4fc0b93 --- /dev/null +++ b/dev/setup_wsl_dev_env.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +cd "$REPO_ROOT" + +VENV_PATH="${1:-.venv-wsl310}" +PYTHON_VERSION="${PYTHON_VERSION:-3.10}" + +ensure_uv() { + if command -v uv >/dev/null 2>&1; then + command -v uv + return + fi + + if [ -x "$HOME/.local/bin/uv" ]; then + printf '%s\n' "$HOME/.local/bin/uv" + return + fi + + if command -v curl >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh + elif command -v wget >/dev/null 2>&1; then + wget -qO- https://astral.sh/uv/install.sh | sh + else + echo "Need curl or wget to install uv." >&2 + exit 1 + fi + + if [ -x "$HOME/.local/bin/uv" ]; then + printf '%s\n' "$HOME/.local/bin/uv" + return + fi + + echo "uv installation failed." >&2 + exit 1 +} + +UV_BIN="$(ensure_uv)" + +"$UV_BIN" python install "$PYTHON_VERSION" +"$UV_BIN" venv "$VENV_PATH" --python "$PYTHON_VERSION" +"$UV_BIN" pip install --python "$VENV_PATH/bin/python" -r "dev/dev_requirements.txt" +"$UV_BIN" pip install --python "$VENV_PATH/bin/python" -r "../../requirements.txt" + +echo "" +echo "WSL simulator environment ready." +echo "Activate with: source $VENV_PATH/bin/activate" +echo "If BadgeBot is checked out inside badge-2024-software, run tests with:" +echo " cd tests" +echo " PYTHONPATH=/path/to/badge-2024-software ../$VENV_PATH/bin/python -m pytest test_smoke.py test_autotune.py -v" \ No newline at end of file diff --git a/hexpansion_mgr.py b/hexpansion_mgr.py index 5409200..34171c0 100644 --- a/hexpansion_mgr.py +++ b/hexpansion_mgr.py @@ -1,4 +1,4 @@ -# Hexpansion & EEPROM Management Module for BadgeBot +""" Hexpansion & EEPROM Management Module for BadgeBot """ # # Handles detection, initialisation, programming, upgrading and erasure of # HexDrive / HexSense hexpansion EEPROMs. @@ -13,6 +13,7 @@ import os import sys import time + import vfs from app_components.notification import Notification from app_components.tokens import label_font_size, button_labels @@ -41,7 +42,7 @@ _SUB_ERASE = 4 # Hexpansion EEPROM erasing in progress _SUB_UPGRADE_CONFIRM = 5 # Hexpansion ready for App upgrade _SUB_PROGRAMMING = 6 # Hexpansion EEPROM programming (Initialsation or Upgrade) in progress -_SUB_PORT_SELECT = 7 # User selecting which hexpansion to erase (if multiple) in order to free up a slot for initialisation or upgrade +_SUB_PORT_SELECT = 7 # User selecting which hexpansion to erase (if multiple) in order to free up a slot for initialisation or upgrade _SUB_DONE = 8 # Final state after successful initialisation or upgrade, before returning to menu _SUB_EXIT = 9 # State for exiting from interactive mode back to menu) @@ -89,12 +90,12 @@ ("hexsense_port", "HexSense", "HEXSENSE_HEXPANSION_INDEX"), ("hextest_port", "HexTest", "HEXTEST_HEXPANSION_INDEX"), ("hexdiag_port", "HexDiag", "HEXDIAG_HEXPANSION_INDEX"), - ("hexgps_port", "HexGPS", "HEXGPS_HEXPANSION_INDEX"), + #("hexgps_port", "HexGPS", "HEXGPS_HEXPANSION_INDEX"), ) # ---- Settings initialisation ----------------------------------------------- -def init_settings(s, MySetting): # pylint: disable=unused-argument +def init_settings(s, MySetting): # pylint: disable=unused-argument, invalid-name """Register hexpansion-management-specific settings in the shared settings dict.""" return @@ -122,14 +123,14 @@ def __init__(self, app, logging: bool = False): self._sub_state: int = _SUB_INIT self._prev_state: int = _SUB_INIT self._port_selected: int = 0 - self._port_selected_header = None # HexpansionHeader for selected port + self._port_selected_header: HexpansionHeader | None = None # HexpansionHeader for selected port self._port_detail_page: int = 0 # 0=vid/pid, 1=eeprom, 2=details (conditional) self._port_detail_page_count: int = 2 # 2 or 3 depending on whether details page is available self._hexpansion_app_startup_timer: int = 0 - self._hexpansion_type_by_slot: list[HexpansionType | None] = [None]*_NUM_HEXPANSION_SLOTS + self._hexpansion_type_by_slot: list[int | None] = [None]*_NUM_HEXPANSION_SLOTS self._hexpansion_state_by_slot: list[int] = [_HEXPANSION_STATE_UNKNOWN]*_NUM_HEXPANSION_SLOTS - self._hexpansion_eeprom_addr_len: list[int] = [None]*_NUM_HEXPANSION_SLOTS - self._hexpansion_eeprom_addr: list[int] = [None]*_NUM_HEXPANSION_SLOTS + self._hexpansion_eeprom_addr_len: list[int | None] = [None]*_NUM_HEXPANSION_SLOTS + self._hexpansion_eeprom_addr: list[int | None] = [None]*_NUM_HEXPANSION_SLOTS self._hexpansion_init_type: int = 0 self._detected_port: int | None = None self._waiting_app_port: int | None = None @@ -168,10 +169,10 @@ def unregister_events(self): def logging(self) -> bool: """Get the current logging state.""" return self._logging - + @logging.setter def logging(self, value: bool): - """Set the logging state.""" + """Set the logging state.""" self._logging = value @@ -252,8 +253,10 @@ async def _handle_removal(self, event): async def _handle_insertion(self, event): if self._check_port_for_known_hexpansions(event.port) or event.port == self._port_selected: - # A known hexpansion type has been detected on the inserted port, so trigger an update of the hexpansion management state machine to handle it. - # Or the inserted port is the one currently being selected by the user in interactive mode, so we should also trigger an update to check if it's a valid hexpansion and update the UI accordingly. + # A known hexpansion type has been detected on the inserted port, so trigger an update of + # the hexpansion management state machine to handle it. Or the inserted port is the one + # currently being selected by the user in interactive mode, so we should also trigger an + # update to check if it's a valid hexpansion and update the UI accordingly. self._app.hexpansion_update_required = True if self._logging: print(f"H:Hexpansion inserted into port {event.port}") @@ -274,13 +277,13 @@ def start(self) -> bool: print("Entered Hexpansion Management mode") self._enter_port_select() return True - + def _enter_port_select(self): if self._port_selected == 0: self._port_selected = 1 if self._logging: - print(f"H:Entering port select mode, starting with port {self._port_selected}") + print(f"H:Entering port select mode, starting with port {self._port_selected}") self._read_port_header(self._port_selected) self._sub_state = _SUB_PORT_SELECT self._app.refresh = True @@ -366,7 +369,7 @@ def update(self, delta) -> bool: elif self._sub_state == _SUB_UPGRADE_CONFIRM: self._update_state_upgrade(delta) elif self._sub_state == _SUB_PROGRAMMING: - self._update_state_programming(delta) + self._update_state_programming(delta) elif self._sub_state == _SUB_PORT_SELECT: self._update_state_port_select(delta) elif self._sub_state == _SUB_CHECK: @@ -380,7 +383,7 @@ def update(self, delta) -> bool: if self._sub_state == _SUB_DONE: print("H:DONE") if self._mode == _MODE_INTERACTIVE: - self._enter_port_select() + self._enter_port_select() # Exit from _SUB_DONE to allow user to select another port for management if they wish else: if self._mode == _MODE_UPDATE: @@ -517,43 +520,53 @@ def _update_state_erase(self, delta): # pylint: disable=unused-argument Unresponsive to buttons during the erasure process.""" # not used in _MODE_INIT app = self._app + erase_port = self._erase_port + if erase_port is None: + self._sub_state = _SUB_PORT_SELECT if self._mode == _MODE_INTERACTIVE else _SUB_CHECK + return if self._logging: - print(f"H:Erasing EEPROM on port {self._erase_port}") + print(f"H:Erasing EEPROM on port {erase_port}") eeprom_page_size=app.HEXPANSION_TYPES[self._hexpansion_init_type].eeprom_page_size if self._hexpansion_init_type > 0 else _DEFAULT_EEPROM_PAGE_SIZE eeprom_total_size=app.HEXPANSION_TYPES[self._hexpansion_init_type].eeprom_total_size if self._hexpansion_init_type > 0 else _DEFAULT_EEPROM_TOTAL_SIZE + erase_addr_len = self._hexpansion_eeprom_addr_len[erase_port - 1] + erase_addr = self._hexpansion_eeprom_addr[erase_port - 1] + if erase_addr_len is None or erase_addr is None: + app.notification = Notification("Failed", port=erase_port) + self._sub_state = _SUB_PORT_SELECT if self._mode == _MODE_INTERACTIVE else _SUB_CHECK + return if self._logging: - print(f"H:Erase {self._hexpansion_init_type} page size: {eeprom_page_size} bytes, total size: {eeprom_total_size} bytes, addr_len: {self._hexpansion_eeprom_addr_len[self._erase_port-1]}, addr: {hex(self._hexpansion_eeprom_addr[self._erase_port-1])}") + print(f"H:Erase {self._hexpansion_init_type} page size: {eeprom_page_size} bytes, total size: {eeprom_total_size} bytes, addr_len: {erase_addr_len}, addr: {hex(erase_addr)}") - if self._erase_eeprom(self._erase_port, - self._hexpansion_eeprom_addr[self._erase_port-1], - self._hexpansion_eeprom_addr_len[self._erase_port-1], + if self._erase_eeprom(erase_port, + erase_addr, + erase_addr_len, eeprom_total_size, eeprom_page_size): - app.notification = Notification("Erased", port=self._erase_port) - self._hexpansion_type_by_slot[self._erase_port - 1] = app.BLANK_HEXPANSION_INDEX - self._hexpansion_state_by_slot[self._erase_port - 1] = _HEXPANSION_STATE_BLANK - hexpansion_type = self._type_name_for_port(self._erase_port) - app.show_message([hexpansion_type, f"in slot {self._erase_port}:", "Erased"], [(1,1,0), (1,1,1), (0,1,0)], "hexpansion") + app.notification = Notification("Erased", port=erase_port) + self._hexpansion_type_by_slot[erase_port - 1] = app.BLANK_HEXPANSION_INDEX + self._hexpansion_state_by_slot[erase_port - 1] = _HEXPANSION_STATE_BLANK + hexpansion_type = self._type_name_for_port(erase_port) + app.show_message([hexpansion_type, f"in slot {erase_port}:", "Erased"], [(1,1,0), (1,1,1), (0,1,0)], "hexpansion") self._sub_state = _SUB_DETECTED - self._detected_port = self._erase_port + self._detected_port = erase_port else: - app.notification = Notification("Failed", port=self._erase_port) + app.notification = Notification("Failed", port=erase_port) app.show_message(["EEPROM", "erasure", "failed", "Protected?"], [(1,0,0),(1,0,0),(1,0,0),(1,0,0)], "warning") self._message_being_shown = True self._sub_state = _SUB_PORT_SELECT if self._mode == _MODE_INTERACTIVE else _SUB_CHECK - + #self._reboop_required = True - - if self._erase_port is not None and self._erase_port in app.hexdrive_ports: - hexdrive_index = app.hexdrive_ports.index(self._erase_port) + + if erase_port in app.hexdrive_ports: + hexdrive_index = app.hexdrive_ports.index(erase_port) del app.hexdrive_ports[hexdrive_index] if hexdrive_index < len(app.hexdrive_apps): del app.hexdrive_apps[hexdrive_index] app.motor_controller = None if self._logging: - print(f"H:HexDrive on port {self._erase_port} erased!") + print(f"H:HexDrive on port {erase_port} erased!") - self._clear_single_port_hexpansion_refs(self._erase_port) + self._clear_single_port_hexpansion_refs(erase_port) self._erase_port = None @@ -561,15 +574,19 @@ def _update_state_erase(self, delta): # pylint: disable=unused-argument def _update_state_upgrade(self, delta): # pylint: disable=unused-argument """ Allow User to confirm or cancel App upgrade.""" app = self._app + upgrade_port = self._upgrade_port + if upgrade_port is None: + self._sub_state = _SUB_PORT_SELECT if self._mode == _MODE_INTERACTIVE else (_SUB_INIT if self._mode == _MODE_INIT else _SUB_CHECK) + return if app.button_states.get(BUTTON_TYPES["CONFIRM"]): app.button_states.clear() - app.notification = Notification("Upgrading", port=self._upgrade_port) + app.notification = Notification("Upgrading", port=upgrade_port) self._sub_state = _SUB_PROGRAMMING elif app.button_states.get(BUTTON_TYPES["CANCEL"]): if self._logging: print("H:Upgrade Cancelled") app.button_states.clear() - self._hexpansion_state_by_slot[self._upgrade_port - 1] = _HEXPANSION_STATE_RECOGNISED_OLD_APP + self._hexpansion_state_by_slot[upgrade_port - 1] = _HEXPANSION_STATE_RECOGNISED_OLD_APP self._upgrade_port = None self._sub_state = _SUB_PORT_SELECT if self._mode == _MODE_INTERACTIVE else (_SUB_INIT if self._mode == _MODE_INIT else _SUB_CHECK) @@ -592,12 +609,11 @@ def _report_hexpansion_states(self): type_name = app.HEXPANSION_TYPES[type_idx].name if type_idx is not None else "None" state_name = _HEXPANSION_STATE_NAMES[self._hexpansion_state_by_slot[port]] print(f"Port {port+1}: Type={type_name}, State={state_name}") - # _ports_to_initialise and _ports_to_check_app are also useful to report as they indicate hexpansions that have been detected but not yet fully processed, which can help with debugging issues around hexpansion detection and initialisation. print(f"Ports to initialise: {self._ports_to_initialise}") print(f"Ports to check app: {self._ports_to_check_app}") print(f"hexsense_port:{app.hexsense_port}") print(f"hextest_port:{app.hextest_port}") - print(f"hexgps_port:{app.hexgps_port}") + #print(f"hexgps_port:{app.hexgps_port}") print(f"hexdiag_port:{app.hexdiag_port}") print(f"hexdrive_ports:{app.hexdrive_ports}") print(f"hexpansion_update_required = {app.hexpansion_update_required}") @@ -611,6 +627,7 @@ def _check_hexpansion(self, port: int | None, type_index: int) -> tuple[int | No app = self._app hexpansion_app = None hexpansion_was_present = port is not None + old_port = port if hexpansion_was_present: if self._hexpansion_type_by_slot[port - 1] != type_index: old_port = port @@ -652,8 +669,7 @@ def _update_state_check(self, delta): # pylint: disable=unused-argument self._refresh_single_port_hexpansion_assignments() - # Build a new list of ports with HexDrives, based on the currently detected hexpansions and their types, and check if it differs from the current list of HexDrive ports (which may indicate that a HexDrive has been removed or added, or that a different type of hexpansion has been detected on the same port which may affect whether it's a valid HexDrive or not). - # loop over the ports + # Build a new list of ports with HexDrives: new_hexdrive_ports = [] for port in range(1, _NUM_HEXPANSION_SLOTS + 1): # check if there is a hexpansion of a type that can be a HexDrive on this port @@ -715,11 +731,11 @@ def _update_state_check(self, delta): # pylint: disable=unused-argument app.hexdrive_apps = hexdrive_apps if self._logging: print(f"H:Updated HexDrive apps: {app.hexdrive_apps}") - + # Create the high-level MotorController for IMU-aided driving # (only when the HexDrive has motors) if app.num_motors > 1 and len(app.hexdrive_apps) > 0 and app.motor_controller is None: - # App may still be loading - we can't initialise the MotorController until the HexDrive app is loaded, because we need to pass a reference to it in order to read the motor directions settings and apply them to the motors. + # App may still be loading - we can't initialise the MotorController until the HexDrive app is loaded try: from .motor_controller import MotorController app.motor_controller = MotorController( @@ -755,7 +771,7 @@ def _update_state_check(self, delta): # pylint: disable=unused-argument def _update_state_port_select(self, delta: int): # pylint: disable=unused-argument - app = self._app + app = self._app if app.button_states.get(BUTTON_TYPES["RIGHT"]): app.button_states.clear() self._port_selected = (self._port_selected % _NUM_HEXPANSION_SLOTS) + 1 @@ -817,42 +833,53 @@ def draw(self, ctx) -> bool: if self._sub_state == _SUB_DETECTED: hexpansion_type = app.HEXPANSION_TYPES[self._hexpansion_init_type].name hexpansion_sub_type = app.HEXPANSION_TYPES[self._hexpansion_init_type].sub_type - app.draw_message(ctx, ["Hexpansion", f"in slot {self._detected_port}:", "Init EEPROM as", hexpansion_type, f"{hexpansion_sub_type if hexpansion_sub_type else ''}?"], [(1, 1, 0), (1, 1, 0), (1, 1, 0), (1, 0, 1), (1, 0, 1)], label_font_size) - button_labels(ctx, confirm_label="Yes", up_label=app.special_chars['up'], down_label="\u25BC", left_label=app.HEXPANSION_TYPES[app.HEXDRIVE_HEXPANSION_INDEX].name, right_label=app.HEXPANSION_TYPES[app.HEXSENSE_HEXPANSION_INDEX].name, cancel_label="No") + app.draw_message(ctx, ["Hexpansion", f"in slot {self._detected_port}:", "Init EEPROM as", hexpansion_type, f"{hexpansion_sub_type if hexpansion_sub_type else ''}?"], \ + [(1, 1, 0), (1, 1, 0), (1, 1, 0), (1, 0, 1), (1, 0, 1)], label_font_size) + button_labels(ctx, confirm_label="Yes", up_label=app.special_chars['up'], down_label="\u25BC", \ + left_label=app.HEXPANSION_TYPES[app.HEXDRIVE_HEXPANSION_INDEX].name, \ + right_label=app.HEXPANSION_TYPES[app.HEXSENSE_HEXPANSION_INDEX].name, cancel_label="No") return True elif self._sub_state == _SUB_PORT_SELECT: self._draw_port_select(ctx) return True elif self._sub_state == _SUB_ERASE_CONFIRM: + if self._erase_port is None: + return False hexpansion_type_name = self._type_name_for_port(self._erase_port, self._hexpansion_init_type) - # TODO IF WE DON'T KNOW THE EEPROM TYPE then show what we propose and allow user to select from common options... + # If the EEPROM type is unknown, show the proposed type and later allow selecting from common options. app.draw_message(ctx, [hexpansion_type_name, f"in slot {self._erase_port}:", "Erase EEPROM?"], [(1, 0, 1), (1, 1, 0), (1, 0, 0)], label_font_size) button_labels(ctx, confirm_label="Yes", cancel_label="No") return True elif self._sub_state == _SUB_ERASE: + if self._erase_port is None: + return False hexpansion_type_name = self._type_name_for_port(self._erase_port, self._hexpansion_init_type) app.draw_message(ctx, [hexpansion_type_name, f"in slot {self._erase_port}:", "Erasing..."], [(1, 0, 1), (1, 1, 0), (1, 0, 0)], label_font_size) - return True + return True elif self._sub_state == _SUB_UPGRADE_CONFIRM: + if self._upgrade_port is None: + return False hexpansion_type_name = self._type_name_for_port(self._upgrade_port, self._hexpansion_init_type) app.draw_message(ctx, [hexpansion_type_name, f"in slot {self._upgrade_port}:", "Upgrade", f"{hexpansion_type_name} app?"], [(1, 0, 1), (1, 1, 0), (1, 1, 0), (1, 1, 0)], label_font_size) button_labels(ctx, confirm_label="Yes", cancel_label="No") return True elif self._sub_state == _SUB_PROGRAMMING: # During upgrade, show the already-detected type for the selected port. + if self._upgrade_port is None: + return False hexpansion_type_name = self._type_name_for_port(self._upgrade_port, self._hexpansion_init_type) app.draw_message(ctx, [f"{hexpansion_type_name}:", f"in slot {self._upgrade_port}:", "Programming", "Please wait..."], [(1, 0, 1), (1, 1, 0), (1, 1, 0), (1, 1, 0)], label_font_size) return True return False - - + + # Hexpansion/EEPROM information pages _PAGE_NAMES = ("VID/PID", "EEPROM", "Details") _PAGE_VID_PID = 0 _PAGE_EEPROM = 1 - _PAGE_DETAILS = 2 + _PAGE_DETAILS = 2 def _draw_port_select(self, ctx): """Draw the port-select screen with paged details.""" @@ -900,14 +927,17 @@ def _draw_port_select(self, ctx): running_app = self._find_hexpansion_app(self._port_selected) if running_app is not None: try: - ver = running_app.get_version() + get_version = getattr(running_app, "get_version", None) + if get_version is None: + raise AttributeError("get_version") + ver = get_version() lines.append(f"v{ver}") colours.append((0, 1, 1)) except Exception: # pylint: disable=broad-except pass else: lines.append(hexpansion_state) - colours.append((0, 1, 1)) + colours.append((0, 1, 1)) # Button labels: up/down show destination page names down_page = (page + 1) % self._port_detail_page_count up_page = (page - 1) % self._port_detail_page_count @@ -922,7 +952,8 @@ def _draw_port_select(self, ctx): colours.append((1, 1, 1)) app.draw_message(ctx, lines, colours, label_font_size) - confirm_label = "Init" if self._hexpansion_state_by_slot[self._port_selected - 1] == _HEXPANSION_STATE_BLANK else "Erase" if self._hexpansion_state_by_slot[self._port_selected - 1] >= _HEXPANSION_STATE_FAULTY else "" + confirm_label = "Init" if self._hexpansion_state_by_slot[self._port_selected - 1] == _HEXPANSION_STATE_BLANK else \ + "Erase" if self._hexpansion_state_by_slot[self._port_selected - 1] >= _HEXPANSION_STATE_FAULTY else "" button_labels(ctx, confirm_label=confirm_label, left_label=" HexpansionHeader | No if addr_len is None or eeprom_addr is None: # Autodetect eeprom addr eeprom_addr, addr_len = detect_eeprom_addr(i2c) - if eeprom_addr is None: + if eeprom_addr is None or addr_len is None: print(f"H:Failed to detect EEPROM address on port {port}") print(f"H:Scan:{i2c.scan()}") # debug - print all detected I2C addresses return None @@ -1022,7 +1053,7 @@ def _check_port_for_known_hexpansions(self, port) -> bool: # Unrecognised Hexpansion if self._logging: # report VID/PID in hexadecimal - print(f"H:Port {port} - VID/PID {hex(hexpansion_header.vid)}/{hex(hexpansion_header.pid)} not recognised") + print(f"H:Port {port} - VID/PID {hex(hexpansion_header.vid)}/{hex(hexpansion_header.pid)} not recognised") self._hexpansion_type_by_slot[port - 1] = app.UNRECOGNISED_HEXPANSION_INDEX self._hexpansion_state_by_slot[port - 1] = _HEXPANSION_STATE_UNRECOGNISED return False @@ -1036,16 +1067,20 @@ def _check_hexpansion_app_on_port(self, port: int, type_index: int) -> object | if hexpansion_app is not None: # get version number from app and compare to expected version for this hexpansion type try: - version = hexpansion_app.get_version() + get_version = getattr(hexpansion_app, "get_version", None) + if get_version is None: + raise AttributeError("get_version") + version = get_version() except Exception as e: # pylint: disable=broad-except try: - version = hexpansion_app.version + version = getattr(hexpansion_app, "version") except Exception as ee: # pylint: disable=broad-except print(f"H:Error getting app version for hexpansion on port {port}: {e}, {ee}") version = None if version != app.HEXPANSION_TYPES[type_index].app_mpy_version: if self._logging: - print(f"H:{app.HEXPANSION_TYPES[type_index].name} app on port {port} has version {hexpansion_app.version}, expected {app.HEXPANSION_TYPES[type_index].app_mpy_version}") + app_version = getattr(hexpansion_app, "version", version) + print(f"H:{app.HEXPANSION_TYPES[type_index].name} app on port {port} has version {app_version}, expected {app.HEXPANSION_TYPES[type_index].app_mpy_version}") self._hexpansion_state_by_slot[port - 1] = _HEXPANSION_STATE_RECOGNISED_OLD_APP # add to upgrade list if not already there if port not in self._ports_to_check_app: @@ -1074,7 +1109,7 @@ def _update_app_in_eeprom(self, port) -> int: if self._logging: print(f"H:Hexpansion type {app.HEXPANSION_TYPES[self._hexpansion_init_type].name} does not have an app to copy to EEPROM") return _APP_EEPROM_RESULT_FAILURE - source_file = app.HEXPANSION_TYPES[self._hexpansion_init_type].app_mpy_name + source_file = f"EEPROM/{app.HEXPANSION_TYPES[self._hexpansion_init_type].app_mpy_name}" if self._logging: print(f"H:Writing app.mpy on port {port} with {source_file}") try: @@ -1128,7 +1163,7 @@ def _update_app_in_eeprom(self, port) -> int: if len(e.args) > 0 and e.args[0] == 2: # File not found, which is fine since we just want to ensure it's not there before copying the new file. had_existing_app = False - else: + else: print(f"H:Error deleting {dest_path}: {e}") return _APP_EEPROM_RESULT_FAILURE @@ -1248,7 +1283,7 @@ def _erase_eeprom(port: int, addr: int, addr_len: int, eeprom_total_size: int, e for page in range(eeprom_total_size // eeprom_page_size): mem_addr = page * eeprom_page_size mem_addr_mask = (1 << (addr_len * 8)) - 1 - i2c.writeto_mem((addr | (mem_addr >> (8 * addr_len))), (mem_addr & mem_addr_mask), bytes([0xFF] * eeprom_page_size), addrsize=(8 * addr_len)) + i2c.writeto_mem((addr | (mem_addr >> (8 * addr_len))), (mem_addr & mem_addr_mask), bytes([0xFF] * eeprom_page_size), addrsize=8 * addr_len) while True: try: if i2c.writeto((addr | (mem_addr >> (8 * addr_len))), bytes([mem_addr & 0xFF]) if addr_len == 1 else bytes([mem_addr >> 8, mem_addr & 0xFF])): @@ -1284,7 +1319,7 @@ def _find_hexpansion_app(self, port: int) -> object | None: # ------------------------------------------------------------------ def _check_ports_to_initialise(self, delta) -> bool: # pylint: disable=unused-argument - """Check for hexpansion presence in any ports, and if a new hexpansion with a blank EEPROM is detected, prompt the user to initialise it. + """Check for hexpansion presence in any ports, and if a new hexpansion with a blank EEPROM is detected, prompt the user to initialise it. Returns True if we are now in the initialise confirmation state, False otherwise.""" app = self._app if 0 < len(self._ports_to_initialise) and self._detected_port is None: @@ -1301,14 +1336,19 @@ def _check_ports_to_upgrade(self, delta) -> bool: """Check any ports with Hexpansions which are expected to have apps have the latest app version and prompt the user to upgrade if not. Returns True if we are now in the upgrade confirmation state, False otherwise.""" app = self._app - if self._waiting_app_port is not None or (0 < len(self._ports_to_check_app)): - if self._waiting_app_port is None: - self._waiting_app_port = self._ports_to_check_app.pop() + port = self._waiting_app_port + if port is not None or (0 < len(self._ports_to_check_app)): + if port is None: + port = self._ports_to_check_app.pop() + self._waiting_app_port = port self._hexpansion_app_startup_timer = 0 - hexpansion_app = self._find_hexpansion_app(self._waiting_app_port) + hexpansion_app = self._find_hexpansion_app(port) if hexpansion_app is not None: try: - hexpansion_app_version = hexpansion_app.get_version() + get_version = getattr(hexpansion_app, "get_version", None) + if get_version is None: + raise AttributeError("get_version") + hexpansion_app_version = get_version() except Exception as e: # pylint: disable=broad-except hexpansion_app_version = 0 print(f"H:Error getting Hexpansion app version - assume old: {e}") @@ -1319,26 +1359,26 @@ def _check_ports_to_upgrade(self, delta) -> bool: else: if 0 == self._hexpansion_app_startup_timer: if self._logging: - print(f"H:No app found on port {self._waiting_app_port} - WAITING for app to appear in Scheduler") - app.notification = Notification("Checking...", port=self._waiting_app_port) + print(f"H:No app found on port {port} - WAITING for app to appear in Scheduler") + app.notification = Notification("Checking...", port=port) self._hexpansion_app_startup_timer += delta return True - if hexpansion_app_version == app.HEXPANSION_TYPES[self._hexpansion_type_by_slot[self._waiting_app_port - 1]].app_mpy_version: + if hexpansion_app_version == app.HEXPANSION_TYPES[self._hexpansion_type_by_slot[port - 1]].app_mpy_version: if self._logging: - print(f"H:Hexpansion on port {self._waiting_app_port} has latest App") - self._hexpansion_state_by_slot[self._waiting_app_port - 1] = _HEXPANSION_STATE_RECOGNISED_APP_OK + print(f"H:Hexpansion on port {port} has latest App") + self._hexpansion_state_by_slot[port - 1] = _HEXPANSION_STATE_RECOGNISED_APP_OK self._sub_state = _SUB_CHECK else: if self._logging: - print(f"H:Hexpansion app on port {self._waiting_app_port} needs upgrading from version {hexpansion_app_version} to {app.HEXPANSION_TYPES[self._hexpansion_type_by_slot[self._waiting_app_port - 1]].app_mpy_version}") - self._upgrade_port = self._waiting_app_port + print(f"H:Hexpansion [{port}] version {hexpansion_app_version} upgrade to {app.HEXPANSION_TYPES[self._hexpansion_type_by_slot[port - 1]].app_mpy_version}") + self._upgrade_port = port app.notification = Notification("Upgrade?", port=self._upgrade_port) self._sub_state = _SUB_UPGRADE_CONFIRM self._waiting_app_port = None - self._hexpansion_app_startup_timer = 0 + self._hexpansion_app_startup_timer = 0 return True return False - + # ---- Hexpansion type descriptor ------------------------------------------- @@ -1374,4 +1414,3 @@ def __init__(self, pid: int, name: str, vid: int =0xCAFE, self.app_mpy_name: str | None = app_mpy_name self.app_mpy_version: str | None = app_mpy_version self.app_name: str | None = app_name - diff --git a/line_follow.py b/line_follow.py index 3371197..0b96328 100644 --- a/line_follow.py +++ b/line_follow.py @@ -1,4 +1,4 @@ -# Line Follower Module for BadgeBot +""" Line Follower Module for BadgeBot """ # # Handles the line-following functionality. # Contains the LineSensors and LineSensor hardware driver classes @@ -71,7 +71,7 @@ def __init__(self, sensor_configs): for cfg in sensor_configs ] self._threshold = 0 - + # ------------------------------------------------------------------ @@ -79,7 +79,7 @@ def __init__(self, sensor_configs): def logging(self) -> bool: """Whether to print debug logs from the LineSensors class.""" return self._logging - + @logging.setter def logging(self, value: bool): """Set the logging state for the LineSensors class.""" @@ -87,34 +87,41 @@ def logging(self, value: bool): @property def num_sensors(self): + """Get the number of sensors managed by this LineSensors instance.""" return len(self._sensors) @property def threshold(self): + """Get the threshold value for the line sensors.""" return self._threshold @threshold.setter def threshold(self, value): + """Set the threshold value for the line sensors.""" self._threshold = value def enable(self): + """Enable all sensors.""" for sensor in self._sensors: sensor.enable() def disable(self): + """Disable all sensors.""" for sensor in self._sensors: sensor.disable() def raw_value(self, index): + """Get the raw discharge time value for the sensor at the specified index.""" return self._sensors[index].value def raw_values(self): + """Get the raw discharge time values for all sensors.""" return [sensor.value for sensor in self._sensors] def sample_count(self): """Get the total sample count across all sensors.""" return sum(sensor.sample_count for sensor in self._sensors) - + def sample_count_and_reset(self): """Atomically get the total sample count across all sensors and reset to zero.""" count = self.sample_count() @@ -123,6 +130,7 @@ def sample_count_and_reset(self): return count def read(self): + """Charge, release, and poll for all sensors using interrupts.""" for sensor in self._sensors: if sensor.start_time != 0: if time.ticks_diff(time.ticks_us(), sensor.start_time) <= _LINE_SENSOR_READ_TIMEOUT_US: @@ -144,27 +152,27 @@ def read(self): enable_irq(irq_state) return True - + #@micropython.native def read_blocking(self): """Charge, release, and poll for all sensors — no IRQ needed.""" gc.disable() irq_state = disable_irq() - + # Charge phase for sensor in self._sensors: sensor.pins["ctrl"].on() sensor.pins["sig"].init(mode=Pin.OUT) sensor.pins["sig"].on() - + time.sleep_us(_LINE_SENSOR_TRIGGER_DURATION_US) - + # Release all sig pins simultaneously start = time.ticks_us() for sensor in self._sensors: sensor.pins["sig"].init(mode=Pin.IN, pull=None) - + # Poll until both sensors have fallen or timeout done = [False] * len(self._sensors) while not all(done): @@ -178,10 +186,10 @@ def read_blocking(self): sensor.pins["ctrl"].off() sensor.sample_count += 1 done[i] = True - + enable_irq(irq_state) gc.enable() - + # Mark timed-out sensors for i, sensor in enumerate(self._sensors): if not done[i]: @@ -190,6 +198,7 @@ def read_blocking(self): # ---- LineSensor class ------------------------------------------------------ class LineSensor: + """Driver for a single QTRX reflectance sensor.""" def __init__(self, pins, name="LineSensor"): try: self._name = name @@ -200,12 +209,13 @@ def __init__(self, pins, name="LineSensor"): self.pins = pins self.pins["ctrl"].init(mode=Pin.OUT) self.pins["ctrl"].off() - self.pins["sig"].init(mode=Pin.IN, pull=Pin.PULL_UP) + self.pins["sig"].init(mode=Pin.IN) except Exception as e: # pylint: disable=broad-exception-caught print(f"{self._name} Init failed:{e}") @property def name(self) -> str: + """Get the name of this sensor (for logging/debugging).""" return self._name def disable(self): @@ -227,10 +237,11 @@ def handler(self, _): """Interrupt handler for the sensor signal pin. Measures the time since the sensor was triggered and updates the state.""" # Currently as the "irq" is not a hardware interrupt we are not guaranteed that the handler will be called immediately - # when the signal pin goes low, so instead of relying on the handler to capture the timing + # when the signal pin goes low, so instead of relying on the handler to capture the timing # it is recommended to do a blocking read in the background update loop. - # However the code here is the leanest way to capture the timing if the IRQ handler is called immediately when the signal pin goes low, so leaving it here for now in case that becomes possible in the future. - + # However the code here is the leanest way to capture the timing if the IRQ handler is called immediately when the signal + # pin goes low, so leaving it here for now in case that becomes possible in the future. + #if self.start_time == 0: # # spurious interrupt or handler called before read() set up the start_time; ignore # return @@ -254,7 +265,7 @@ def handler(self, _): # ---- Settings initialisation ----------------------------------------------- -def init_settings(s, MySetting: type): +def init_settings(s, MySetting: type): #pylint: disable=invalid-name """Register line-follower-specific settings in the shared settings dict.""" s['line_threshold'] = MySetting(s, _LINE_SENSOR_DEFAULT_THRESHOLD, 0, 65535) s['pid_kp'] = MySetting(s, _FOLLOWER_PID_KP_DEFAULT, 0, 65536) @@ -301,7 +312,7 @@ def __init__(self, app, logging: bool = False): self._app = app self._logging: bool = logging self._sensor_state = [False, False] - self.line_sensors = None # Will be a LineSensors instance when active + self.line_sensors: LineSensors | None = None # Will be a LineSensors instance when active self.sample_time: int = 0 self.sensor_rate: int = 0 # sample rate self.follower_mode: int = _FOLLOWER_MODE_DIFFERENTIAL # Default follower mode @@ -333,7 +344,7 @@ def start(self) -> bool: if self.line_sensors is None: # Line sensors are not available; inform the user and abort line follower. - Notification(app, "Line sensors not available") + app.notification = Notification("Line sensors not available") return False else: if len(app.hexdrive_apps) > 0: @@ -344,7 +355,7 @@ def start(self) -> bool: self.line_sensors.read_blocking() # initiate first sensor reading app.update_period = _LINE_SENSOR_UPDATE_PERIOD_MS app.set_menu(None) - app.button_states.clear() + app.button_states.clear() app.refresh = True app.auto_repeat_clear() self.motor_output = (0,0) @@ -356,14 +367,14 @@ def start(self) -> bool: if self.ki > 0: self.integral_limit = self.max_pwr // self.ki else: - self.integral_limit = 0 + self.integral_limit = 0 if self._logging: print("Entered Line Follower mode") - return True + return True if self._logging: print("HexDrive not available; Line Follower requires HexDrive to run") - Notification(app, "HexDrive Init Failed") - return False + app.notification = Notification("HexDrive Init Failed") + return False # ------------------------------------------------------------------ @@ -373,13 +384,14 @@ def start(self) -> bool: def update(self, delta) -> bool: """Handle Line Follower UI. Returns True if handled.""" app = self._app - + self.sample_time += delta if app.button_states.get(BUTTON_TYPES["CANCEL"]): app.button_states.clear() if len(app.hexdrive_apps) > 0: app.hexdrive_apps[0].set_power(False) - self.line_sensors.disable() + if self.line_sensors is not None: + self.line_sensors.disable() app.pid_integral = 0 app.pid_previous_error = 0 app.return_to_menu() @@ -387,17 +399,19 @@ def update(self, delta) -> bool: elif app.button_states.get(BUTTON_TYPES["UP"]): app.button_states.clear() app.settings['line_threshold'].v = app.settings['line_threshold'].inc(app.settings['line_threshold'].v) - self.line_sensors.threshold = app.settings['line_threshold'].v + if self.line_sensors is not None: + self.line_sensors.threshold = app.settings['line_threshold'].v app.refresh = True elif app.button_states.get(BUTTON_TYPES["DOWN"]): app.button_states.clear() app.settings['line_threshold'].v = app.settings['line_threshold'].dec(app.settings['line_threshold'].v) - self.line_sensors.threshold = app.settings['line_threshold'].v + if self.line_sensors is not None: + self.line_sensors.threshold = app.settings['line_threshold'].v app.refresh = True #if self.line_sensors.updated: # app.refresh = True # self.line_sensors.clear_updated() - if (self.sample_time > _LINE_SENSOR_SAMPLE_RATE_UPDATE_PERIOD_MS): + if self.sample_time > _LINE_SENSOR_SAMPLE_RATE_UPDATE_PERIOD_MS and self.line_sensors is not None: sample_count = self.line_sensors.sample_count_and_reset() self.sensor_rate = int(((self.sample_time / self.line_sensors.num_sensors) * sample_count) // self.sample_time) self.sample_time = 0 @@ -418,11 +432,13 @@ def background_update(self, delta) -> tuple[int, int] | None: # pylint: disable #if self.follower_mode == _FOLLOWER_MODE_DIFFERENTIAL: # PID control # Calculate the error as the normalised difference between the two sensor readings - self.line_sensors.read_blocking() # wait for sensor reading - error = self.compute_error(self.line_sensors.raw_value(0), self.line_sensors.raw_value(1)) - # self.line_sensors.read() # initiate next sensor reading (non-blocking, using IRQ handler to capture values when ready) - output = self.compute_differential_output(error) - #else: + if self.line_sensors is not None: + self.line_sensors.read_blocking() # wait for sensor reading + error = self.compute_error(self.line_sensors.raw_value(0), self.line_sensors.raw_value(1)) + # self.line_sensors.read() # initiate next sensor reading (non-blocking, using IRQ handler to capture values when ready) + output = self.compute_differential_output(error) + else: + output = (0, 0) # # Bang Bang control # if s != self._sensor_state: # self._sensor_state = s @@ -440,7 +456,7 @@ def background_update(self, delta) -> tuple[int, int] | None: # pylint: disable def compute_differential_output(self, error: int) -> tuple[int, int]: """Compute motor output using a full PID controller for differential line following. - + Uses the difference between left and right sensor readings as the error signal, and applies proportional, integral, and derivative terms to compute a steering correction. Returns a tuple of (left_motor, right_motor) power values, clamped to max_power. @@ -461,7 +477,7 @@ def compute_differential_output(self, error: int) -> tuple[int, int]: # Combined PID output # make correction value as integer to avoid issues with motor control expecting int values correction = p_term # + int(i_term) + int(d_term) - + # Combine correction with base forward power to get output for each motor output = (self.forward_power + correction, self.forward_power - correction) @@ -472,8 +488,8 @@ def compute_differential_output(self, error: int) -> tuple[int, int]: # print(f"PID: err={error} P={p_term} I={i_term} D={d_term} corr={correction} out={output}") return output - - + + @staticmethod def compute_error(left_raw: int, right_raw: int) -> int: """Compute a normalised error from raw sensor discharge times. @@ -509,7 +525,7 @@ def compute_error(left_raw: int, right_raw: int) -> int: def draw(self, ctx) -> bool: """Render Line Follower UI. Returns True if handled.""" app = self._app - + ctx.save() ctx.rgb(1, 1, 0).move_to(0, -1 * label_font_size).text(f"TH:{self.line_threshold}") ctx.rgb(0, 1, 1).move_to(-70, -1 * label_font_size).text(f"{self.sensor_rate} Hz") @@ -518,12 +534,13 @@ def draw(self, ctx) -> bool: for i in range(app.num_line_sensors): x = offset - i * spacing # make a simple visualization of the sensor reading as a filled circle, with colour indicating whether it's above or below the threshold - colour = (0, 1, 0) if self.line_sensors.raw_value(i) < self.line_threshold else (0, 0, 0) - ctx.rgb(*colour).arc(x, 0, 24, 0, 2 * pi, True).fill() - ctx.rgb(1, 1, 1).arc(x, 0, 25, 0, 2 * pi, True).stroke() - ctx.rgb(1, 1, 0).move_to(x - 20, 2 * label_font_size).text(f"{self.line_sensors.raw_value(i):4}") - # if self._logging: - # print(f"Sensor {i}: {self.line_sensors.value(i)} (raw: {self.line_sensors.raw_value(i)})") + if self.line_sensors is not None: + colour = (0, 1, 0) if self.line_sensors.raw_value(i) < self.line_threshold else (0, 0, 0) + ctx.rgb(*colour).arc(x, 0, 24, 0, 2 * pi, True).fill() + ctx.rgb(1, 1, 1).arc(x, 0, 25, 0, 2 * pi, True).stroke() + ctx.rgb(1, 1, 0).move_to(x - 20, 2 * label_font_size).text(f"{self.line_sensors.raw_value(i):4}") + # if self._logging: + # print(f"Sensor {i}: {self.line_sensors.value(i)} (raw: {self.line_sensors.raw_value(i)})") ctx.restore() button_labels(ctx, up_label="+", down_label="-", cancel_label="Cancel") return True diff --git a/motor_moves.py b/motor_moves.py index 66be288..4c46c49 100644 --- a/motor_moves.py +++ b/motor_moves.py @@ -1,4 +1,4 @@ -# Motor Moves Module for BadgeBot +""" Motor Moves Module for BadgeBot """ # # Handles the "turtle/Logo" style motor-move programming. # Internally manages its own sub-states (HELP, RECEIVE_INSTR, RUN, DONE). @@ -21,10 +21,10 @@ # init_settings(settings) – register motor-moves specific settings import asyncio + from events.input import BUTTON_TYPES, Button from app_components.tokens import label_font_size, button_labels from app_components.notification import Notification - from .utils import chain from .app import (STATE_COUNTDOWN, STATE_MOTOR_MOVES, STATE_LOGO, DEFAULT_BACKGROUND_UPDATE_PERIOD, MOTOR_PWM_FREQ) @@ -47,7 +47,7 @@ _MIN_USER_DRIVE_MS = 10 _MIN_USER_TURN_MS = 10 -_MAX_MAX_POWER = 100000 +_MAX_MAX_POWER = 65535 _MAX_ACCELERATION = 20000 _MAX_USER_DRIVE_MS = 10000 _MAX_USER_TURN_MS = 10000 @@ -70,28 +70,44 @@ # ---- Instruction class ----------------------------------------------------- class Instruction: + """Represents a single movement instruction, consisting of a direction (button press) and duration (number of ticks). + Also contains the power plan for this instruction, which is a list of (power_tuple)""" def __init__(self, press_type: Button) -> None: self._press_type = press_type self._duration = 1 - self.power_plan = [] + self.power_plan: list[tuple[tuple[int, int], int]] = [] @property def press_type(self) -> Button: + """The button press type (direction) for this instruction.""" return self._press_type @property def duration(self) -> int: + """The duration (number of ticks) for this instruction.""" return self._duration def inc(self): + """Increment the duration of this instruction by one tick.""" self._duration += 1 def __str__(self): - return f"{self.press_type.name} {self._duration}" + if self._press_type == BUTTON_TYPES["UP"]: + direction = "UP" + elif self._press_type == BUTTON_TYPES["DOWN"]: + direction = "DOWN" + elif self._press_type == BUTTON_TYPES["LEFT"]: + direction = "LEFT" + elif self._press_type == BUTTON_TYPES["RIGHT"]: + direction = "RIGHT" + else: + direction = str(self._press_type) + return f"{direction} {self._duration}" def directional_power_tuple(self, power) -> tuple[int, int]: + """Return the power tuple for this instruction based on its direction.""" if self._press_type == BUTTON_TYPES["UP"]: return (power, power) elif self._press_type == BUTTON_TYPES["DOWN"]: @@ -100,12 +116,15 @@ def directional_power_tuple(self, power) -> tuple[int, int]: return (-power, power) elif self._press_type == BUTTON_TYPES["RIGHT"]: return (power, -power) + return (0, 0) def directional_duration(self, mysettings) -> int: + """Return the base duration for this instruction based on its direction and the user-configured settings.""" if self._press_type == BUTTON_TYPES["UP"] or self._press_type == BUTTON_TYPES["DOWN"]: return (mysettings['drive_step_ms'].v if 'drive_step_ms' in mysettings else _DEFAULT_USER_DRIVE_MS) elif self._press_type == BUTTON_TYPES["LEFT"] or self._press_type == BUTTON_TYPES["RIGHT"]: return (mysettings['turn_step_ms'].v if 'turn_step_ms' in mysettings else _DEFAULT_USER_TURN_MS) + return _DEFAULT_USER_DRIVE_MS def make_power_plan(self, mysettings): @@ -113,7 +132,7 @@ def make_power_plan(self, mysettings): curr_power = 0 ramp_up = [] max_ramp_up_ticks = ((self.directional_duration(mysettings) * self._duration) // (2 * _TICK_MS)) - 1 - for i in range(max_ramp_up_ticks): + for _ in range(max_ramp_up_ticks): curr_power += mysettings['acceleration'].v if curr_power >= mysettings['max_power'].v: curr_power = mysettings['max_power'].v @@ -137,7 +156,7 @@ def make_power_plan(self, mysettings): # ---- Settings initialisation ----------------------------------------------- -def init_settings(s, MySetting: type): +def init_settings(s, MySetting: type): #pylint: disable=invalid-name """Register motor-moves-specific settings in the shared settings dict.""" s['acceleration'] = MySetting(s, _DEFAULT_ACCELERATION, _MIN_ACCELERATION, _MAX_ACCELERATION) s['max_power'] = MySetting(s, DEFAULT_MAX_POWER, _MIN_MAX_POWER, _MAX_MAX_POWER) @@ -163,12 +182,12 @@ def __init__(self, app, logging: bool = False): self._sub_state: int = _SUB_HELP self._prev_state: int = _SUB_HELP # Motor-moves instance variables - self.instructions: list = [] + self.instructions: list[Instruction] = [] self.current_instruction: Instruction | None = None - self.current_power_duration: tuple = ((0, 0), 0) - self.power_plan_iter: iter = iter([]) + self.current_power_duration: tuple[tuple[int, int], int] = ((0, 0), 0) + self.power_plan_iter = None self.long_press_delta: int = 0 - self._mc_task: any = None # asyncio task for MotorController-based execution + self._mc_task = None # asyncio task for MotorController-based execution if self.logging: print("MotorMovesMgr initialised") @@ -179,13 +198,14 @@ def __init__(self, app, logging: bool = False): def logging(self) -> bool: """Get or set logging state for this manager.""" return self._logging - + @logging.setter def logging(self, value: bool): self._logging = value @property def drive_mode(self): + """Get the current drive mode (time or distance) from settings, defaulting to distance if not set.""" return self._app.settings['drive_mode'].v if 'drive_mode' in self._app.settings else DRIVE_MODE_DISTANCE @@ -201,7 +221,7 @@ def start(self) -> bool: app.set_menu(None) app.button_states.clear() app.refresh = True - if self.current_instruction is None: + if self.current_instruction is None: self.reset_instructions() # initializes the instructions list and other related state self.reset_robot() # ensures robot is in a known reset state (motors off, etc) app.scroll_mode_enable(False) @@ -248,6 +268,7 @@ async def _run_instructions_async(self): await self._app.motor_controller.run_instructions(self.instructions) except asyncio.CancelledError: self._app.motor_controller.stop() + return except Exception as e: # pylint: disable=broad-exception-caught print(f"MotorController run error: {e}") self._app.motor_controller.stop() @@ -314,7 +335,7 @@ def _update_state_help(self, delta: int) -> None: app.return_to_menu() elif app.button_states.get(BUTTON_TYPES["CONFIRM"]): app.button_states.clear() - app.scroll(False) + app.scroll(False) app.scroll_mode_enable(True) self._sub_state = _SUB_RECEIVE_INSTR elif app.button_states.get(BUTTON_TYPES["DOWN"]): @@ -332,21 +353,21 @@ def _update_state_help(self, delta: int) -> None: app.current_state = STATE_LOGO - def _update_state_receive_instr(self, delta: int) -> None: + def _update_state_receive_instr(self, delta: int) -> None: app = self._app if app.button_states.get(BUTTON_TYPES["CONFIRM"]): app.long_press_delta += delta if app.long_press_delta >= _LONG_PRESS_MS: if self.power_plan_iter is None: - app.scroll_mode_enable(False) + app.scroll_mode_enable(False) app.animation_counter = 0 self._sub_state = _SUB_HELP else: # if there are No instructions then warn the user and return to help, otherwise start the countdown to run the instructions if len(self.instructions) == 0 and self.current_instruction is None: app.notification = Notification("No instructions entered") - app.scroll_mode_enable(False) + app.scroll_mode_enable(False) app.animation_counter = 0 self._sub_state = _SUB_HELP return @@ -392,7 +413,7 @@ def _update_state_run(self, delta: int) -> None: # pylint: disable=unused app = self._app app.clear_leds() # Run is primarily managed in the background update - but we allow CANCEL here as well to stop immediately - app.refresh = True # TODO not every cycle, just when we need to update the screen (e.g. for power level display) + app.refresh = True if app.button_states.get(BUTTON_TYPES["CANCEL"]): app.button_states.clear() self.reset_robot() @@ -409,7 +430,7 @@ def _update_state_done(self, delta: int) -> None: # pylint: disable=unuse elif app.button_states.get(BUTTON_TYPES["CONFIRM"]): app.button_states.clear() app.run_countdown_elapsed_ms = 1 - self.current_power_duration = ((0, 0, 0, 0), 0) + self.current_power_duration = ((0, 0), 0) app.countdown_next_state = STATE_MOTOR_MOVES app.current_state = STATE_COUNTDOWN # at end of countdown begin_moves will be called, which will start the sequence running again @@ -421,7 +442,7 @@ def _update_state_done(self, delta: int) -> None: # pylint: disable=unuse def _handle_instruction_press(self, press_type): app = self._app - if app.last_press == press_type: + if app.last_press == press_type and self.current_instruction is not None: self.current_instruction.inc() else: self.finalize_instruction() @@ -456,13 +477,14 @@ def reset_robot(self): if self.logging: print("Robot reset") if len(app.hexdrive_apps) > 0: - app.hexdrive_apps[0].set_power(False) + app.hexdrive_apps[0].set_power(False) def reset_instructions(self): + """Reset the instruction list and related state.""" self.instructions = [] self.current_instruction = None - self.power_plan_iter = iter([]) + self.power_plan_iter = None self._app.scroll(False) if self.logging: print("Instructions reset") @@ -482,12 +504,17 @@ def set_direction_leds(self, direction: Button): def _get_current_power_level(self, delta: int) -> tuple[int, int] | None: #if delta >= _TICK_MS: - # delta = _TICK_MS - 1 + # delta = _TICK_MS - 1 current_power, current_duration = self.current_power_duration updated_duration = current_duration - delta if updated_duration <= 0: + power_plan_iter = self.power_plan_iter + if power_plan_iter is None: + self.reset_robot() + self._sub_state = _SUB_DONE + return None try: - next_power, next_duration = next(self.power_plan_iter) + next_power, next_duration = next(power_plan_iter) except StopIteration: self.reset_robot() self._sub_state = _SUB_DONE diff --git a/pyproject.toml b/pyproject.toml index 0af2c3b..192ca53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,7 @@ # Set to 200 to match existing code style and reduce noisy line wrapping. [tool.pylint.format] max-line-length = 200 +max-module-lines = 2000 # General pylint behavior for this repository. [tool.pylint.master] @@ -31,6 +32,10 @@ ignored-modules = [ "frontboards.twentyfour", "display", "imu", + "egpio", +] +disable = [ + "import-outside-toplevel", # Allow imports inside functions for lazy imports and to avoid circular dependencies. ] # Type-checking exceptions for dynamic runtime members. @@ -49,4 +54,4 @@ generated-members = [ [tool.pytest.ini_options] testpaths = ["tests"] -python_files = ["test_*.py"] \ No newline at end of file +python_files = ["test_*.py"] diff --git a/sensor_manager.py b/sensor_manager.py index e96b7be..25f0488 100644 --- a/sensor_manager.py +++ b/sensor_manager.py @@ -12,8 +12,9 @@ """ from machine import I2C, Pin -from .sensors import ALL_SENSOR_CLASSES from system.hexpansion.config import HexpansionConfig +from .sensors import ALL_SENSOR_CLASSES +from .sensors.sensor_base import SensorBase #HexSense LED pin _LED_PIN = 1 # LED to illumiinate area under colour sensor to mmeasure reflected light from surface below. @@ -24,8 +25,8 @@ class SensorManager: def __init__(self, logging: bool = False): self._logging: bool = logging self._i2c = None - self._port: int = None - self._sensors = [] # list of initialised SensorBase instances + self._port: int | None = None + self._sensors: list[SensorBase] = [] # list of initialised SensorBase instances self._index: int = 0 # currently selected sensor self._last_data = {} self._read_interval_ms = 10 @@ -39,7 +40,7 @@ def __init__(self, logging: bool = False): @property def logging(self) -> bool: return self._logging - + @logging.setter def logging(self, value: bool): self._logging = value @@ -50,7 +51,7 @@ def read_interval(self) -> int: @property def type(self) -> str: - return self._type + return self._type # ------------------------------------------------------------------ @@ -81,15 +82,20 @@ def open(self, port: int) -> bool: print(f"SM:Port {port} scan: {[hex(a) for a in found_addrs]}") for cls in ALL_SENSOR_CLASSES: - if cls.I2C_ADDR in found_addrs: - sensor = cls() + addresses = getattr(cls, "I2C_ADDRS", (getattr(cls, "I2C_ADDR", 0),)) + for address in addresses: + if address not in found_addrs: + continue + try: + sensor = cls(i2c_addr=address) + except TypeError: + sensor = cls() if sensor.begin(self._i2c): self._sensors.append(sensor) if self.logging: - print(f"SM: + {cls.NAME} @ 0x{cls.I2C_ADDR:02X} {cls.TYPE}") - else: - if self.logging: - print(f"SM: - {cls.NAME} begin() failed") + print(f"SM: + {cls.NAME} @ 0x{sensor.i2c_addr:02X} {cls.TYPE}") + elif self.logging: + print(f"SM: - {cls.NAME} @ 0x{address:02X} begin() failed") self._index = 0 self._last_data = {} @@ -102,11 +108,12 @@ def open(self, port: int) -> bool: self._read_interval_ms = 250 self._type = "Generic" - # Enable LED if there is at least one sensor - if len(self._sensors) > 0: + # Enable LED only when at least one Colour sensor is present + # (avoids pin conflicts with non-colour hexpansions such as the motor-test board) + if len(self._sensors) > 0 and any(getattr(s, 'TYPE', '') == 'Colour' for s in self._sensors): if self.logging: print(f"SM:LED On port {port}") - config = HexpansionConfig(port) + config = HexpansionConfig(port) config.ls_pin[_LED_PIN].init(mode=Pin.OUT) config.ls_pin[_LED_PIN].value(1) config.ls_pin[_INTERRUPT_PIN].init(mode=Pin.IN) @@ -122,7 +129,7 @@ def report_interrupt(self) -> bool: v = config.ls_pin[_INTERRUPT_PIN].value() print(f"INT pin value: {v}") return v == 0 - + def close(self): """Shutdown all sensors and release the I2C bus.""" @@ -132,12 +139,13 @@ def close(self): except Exception: # pylint: disable=broad-exception-caught pass if self._port is not None: - if self.logging: - print(f"SM:LED Off port {self._port}") - config = HexpansionConfig(self._port) - if config is not None: - config.ls_pin[_LED_PIN].value(0) - config.ls_pin[_LED_PIN].init(mode=Pin.IN) + if len(self._sensors) > 0 and any(getattr(s, 'TYPE', '') == 'Colour' for s in self._sensors): + if self.logging: + print(f"SM:LED Off port {self._port}") + config = HexpansionConfig(self._port) + if config is not None: + config.ls_pin[_LED_PIN].value(0) + config.ls_pin[_LED_PIN].init(mode=Pin.IN) self._sensors = [] self._index = 0 self._last_data = {} @@ -163,8 +171,8 @@ def prev_sensor(self): self._last_data = {} self._read_interval_ms = getattr(self._sensors[self._index], 'READ_INTERVAL_MS', 250) self._type = getattr(self._sensors[self._index], 'TYPE', 'Generic') - - + + # ------------------------------------------------------------------ # Reading # ------------------------------------------------------------------ @@ -189,7 +197,9 @@ def num_sensors(self) -> int: def current_sensor_name(self) -> str: if not self._sensors: return "none" - return self._sensors[self._index].NAME + sensor = self._sensors[self._index] + return f"{sensor.NAME}" + #return f"{sensor.NAME}@0x{sensor.i2c_addr:02X}" @property def current_sensor_index(self) -> int: @@ -210,3 +220,10 @@ def is_open(self) -> bool: def sensor_list(self) -> list: """Return [(index, name), ...] for all found sensors.""" return [(i, s.NAME) for i, s in enumerate(self._sensors)] + + def get_sensor_by_name(self, name: str): + """Return the first sensor instance whose NAME matches, or None.""" + for s in self._sensors: + if s.NAME == name: + return s + return None diff --git a/sensor_test.py b/sensor_test.py index b67d5d6..733cdc8 100644 --- a/sensor_test.py +++ b/sensor_test.py @@ -14,50 +14,67 @@ from app_components.tokens import label_font_size, button_labels from app_components.notification import Notification from system.hexpansion.config import HexpansionConfig - +try: + from egpio import ePin +except ImportError: + class ePin: # pylint: disable=invalid-name + """Simulator stub for egpio.ePin – used only for ePin.PWM mode constant.""" + PWM = None +from .app import DEFAULT_BACKGROUND_UPDATE_PERIOD, MOTOR_PWM_FREQ try: from machine import Pin, mem32, disable_irq, enable_irq except ImportError: from machine import Pin + class _Mem32Shim: + def __getitem__(self, _addr: int) -> int: + return 0 + + def __setitem__(self, _addr: int, _value: int) -> None: + return None + # Simulator fallback: keep imports working even when direct register access # and IRQ controls are not exposed by the simulated machine module. - mem32 = None + mem32 = _Mem32Shim() - def disable_irq(): + def disable_irq() -> int: + """Disable interrupts and return previous state (if supported).""" # No-op in simulator fallback. return 0 - def enable_irq(_state): + def enable_irq(_state: int) -> None: + """Restore interrupts to the given state (if supported).""" # No-op in simulator fallback. + _ = _state return None + + try: from micropython import const except ImportError: # CPython / simulator fallback – const() is just an identity function # on MicroPython; replicate that so module-level const() calls work. - const = lambda x: x -from .app import DEFAULT_BACKGROUND_UPDATE_PERIOD, MOTOR_PWM_FREQ + const = lambda x: x #pylint: disable=unnecessary-lambda-assignment # Constants for rotation rate measurement and motor test mode. _ROTATION_RATE_MEASUREMENT_PERIOD_MS = 2500 # how often to update the displayed rotation rate measurement in ms (tradeoff between display responsiveness and stability of the reading) -_DEFAULT_ROTATION_RATE_EMITTER_DUTY = 64 # default duty cycle for the IR emitter when doing rate testing, 0-255 (0=off, 255=full on) +_DEFAULT_ROTATION_RATE_EMITTER_DUTY = 20 # default duty cycle for the IR emitter when doing rate testing, 0-255 (0=off, 255=full on) _DEFAULT_SPOKES_PER_ROTATION = 3 # number of times the photodiode will be triggered per full rotation of the wheel _MOTOR_TEST_BACKGROUND_UPDATE_PERIOD = 1000 # background update period in ms to use during motor test mode (tradeoff between display responsiveness and CPU load) -_ROTATION_RATE_EMITTER_PINS = [2, 4] # LS_C & LS_D pins used to drive the IR emitter for rotation rate testing +_ROTATION_RATE_EMITTER_PINS = [2, 4] # LS_C & LS_D pins used to drive the IR emitter for rotation rate testing _ROTATION_RATE_SENSOR_PINS = [0, 1] # HS_F & HS_G pins used to read the phottransistors for rotation rate testing _ROTATION_RATE_SENSOR_ENABLE_PINS = [3] # LS_D pins used to enable the phototransistors for rotation rate testing (set to output and high to enable, input to disable) - +_IR_EMITTER_PWM_STEP_SIZE = 2 # Step size for adjusting IR emitter brightness in manual mode, 0-255 (0=off, 255=full on) # Temporary - while there is no EEPROM on the Test Hexpansion -_ROTATION_RATE_PORT = 5 # Hexpansion slot used for rotation rate measurement +_ROTATION_RATE_PORT = 1 # Hexpansion slot used for rotation rate measurement # Local sub-states (internal to Sensor Test) _SUB_SELECT_PORT = 0 _SUB_READING = 1 _SUB_MOTOR_TEST = 2 -# Auto scan configuration +# Rotation Rate Auto scan configuration _AUTO_SCAN_STEPS = 50 # Number of power levels to test during auto scan _AUTO_SCAN_SETTLE_MS = 200 # ms to wait after setting power before discarding counter _AUTO_SCAN_MEASURE_MS = 2000 # ms measurement window per step @@ -89,7 +106,7 @@ def enable_irq(_state): # ---- Settings initialisation ----------------------------------------------- -def init_settings(s, MySetting: type): # pylint: disable=unused-argument +def init_settings(s, MySetting: type): # pylint: disable=unused-argument, invalid-name """Register sensor-test-specific settings in the shared settings dict. Currently no dedicated settings, but the hook exists for future use.""" # no sensor-test-specific settings at this time @@ -109,7 +126,7 @@ class SensorTestMgr: def __init__(self, app, hextest_port: int | None = _ROTATION_RATE_PORT, logging: bool = False): self._app = app self._sub_state = _SUB_SELECT_PORT - self._sensor_mgr = None # SensorManager instance (lazy-imported) + self._sensor_mgr = None # SensorManager instance (lazy-imported) self._port_selected: int = 1 self._sensor_data: dict = {} self._display_data: dict = {} @@ -133,12 +150,22 @@ def __init__(self, app, hextest_port: int | None = _ROTATION_RATE_PORT, logging: # Auto scan state self._auto_mode: bool = False # True = auto scanning, False = manual + self._auto_direction: int = 1 # 1 = forwards, -1 = reverse self._auto_step: int = 0 # current step index (0.._AUTO_SCAN_STEPS-1) self._auto_timer: int = 0 # elapsed ms within current phase self._auto_settling: bool = True # True = in settle phase, False = in measure phase - self._auto_results: list = [] # list of (power, rpm) tuples + self._auto_results: list[tuple[int, list[int], int | None]] = [] # list of (power, rpm list, current mA) self._auto_max_rpm: int = 0 # max rpm seen during scan + self._auto_max_current_ma: int = 0 # max current seen during scan + self._auto_last_current_ma: int = 0 # latest current sampled in auto mode self._auto_done: bool = False # True = scan complete + self._ina226 = None + self._ina226_sensor_mgr = None # SensorManager used exclusively for motor-test INA226 discovery + self._ina226_reading: dict[str, int] = {} + self._ina226_sum_current_ma: int = 0 + self._ina226_sum_bus_mv: int = 0 + self._ina226_sum_power_mw: int = 0 + self._ina226_sample_count: int = 0 # Use HS pins on a spare Hexpansion to measure rotation rate self._test_support_hexpansion_config: HexpansionConfig | None = None @@ -180,7 +207,7 @@ def hextest_setup(self, port: int | None): self._test_support_hexpansion_config.pin[i].init(mode=Pin.IN) if self._sub_state == _SUB_MOTOR_TEST: if self._logging: - print("Exiting Motor Test mode due to Hexpansion change") + print(f"Test Hexpansion {'removed' if port is None else 'changed'}") self._app.notification = Notification("Motor Test - aborted", port=self._test_support_hexpansion_config.port) self._stop_motor_test_mode() except AttributeError: @@ -205,20 +232,20 @@ def start(self) -> bool: self._sensor_data = {} self._display_data = {} app.refresh = True - self._ensure_sensor_mgr() + sensor_mgr = self._ensure_sensor_mgr() self.colour = (1.0, 1.0, 0.0) # reset to yellow when starting sensor test # If a HexDrive is present, try its port first if app.hexdrive_ports is not None: for port in app.hexdrive_ports: - if self._sensor_mgr.open(port): + if sensor_mgr.open(port): self._port_selected = port - app.update_period = self._sensor_mgr.read_interval + app.update_period = sensor_mgr.read_interval self._sub_state = _SUB_READING break # If no HexDrive, but a HexSense is present, try its port next - elif app.hexsense_port is not None and self._sensor_mgr.open(app.hexsense_port): + elif app.hexsense_port is not None and sensor_mgr.open(app.hexsense_port): self._port_selected = app.hexsense_port - app.update_period = self._sensor_mgr.read_interval + app.update_period = sensor_mgr.read_interval self._sub_state = _SUB_READING # Otherwise, start in port selection mode else: @@ -231,21 +258,22 @@ def start(self) -> bool: # Sensor Manager access # ------------------------------------------------------------------ - def _ensure_sensor_mgr(self): + def _ensure_sensor_mgr(self) -> "SensorManager": """Lazy-import and create SensorManager if needed.""" if self._sensor_mgr is None: from .sensor_manager import SensorManager self._sensor_mgr = SensorManager(logging=self._logging) else: self._sensor_mgr.close() + return self._sensor_mgr def open_sensor_port(self, port: int) -> bool: """Open a sensor port. Returns True if sensors found. Can be called by other modules (e.g. AutoDriveMgr) that need to reuse the SensorManager.""" - self._ensure_sensor_mgr() - return self._sensor_mgr.open(port) + sensor_mgr = self._ensure_sensor_mgr() + return sensor_mgr.open(port) @property @@ -288,7 +316,7 @@ def rotation_rate_emitter_duty(self, value: int): @staticmethod - def lookup_color_XYZ(x: int, y: int, z: int, brightness_threshold: int = 10) -> str: + def lookup_color_XYZ(x: int, y: int, z: int, brightness_threshold: int = 10) -> str: #pylint: disable=invalid-name """ Identifies a color name by searching through the COLOR_REGIONS table. Parameters: @@ -302,15 +330,15 @@ def lookup_color_XYZ(x: int, y: int, z: int, brightness_threshold: int = 10) -> total = x + y + z # 2. Calculate coordinates - x = x / total - y = y / total + x_coord = x / total + y_coord = y / total # 3. Search the lookup table for region in COLOR_REGIONS: x_min, x_max = region["x"] y_min, y_max = region["y"] - if x_min <= x <= x_max and y_min <= y <= y_max: + if x_min <= x_coord <= x_max and y_min <= y_coord <= y_max: return region["name"] return "Unknown" @@ -321,7 +349,7 @@ def lookup_color_XYZ(x: int, y: int, z: int, brightness_threshold: int = 10) -> @staticmethod - def lookup_colour_RGB(r: int, g: int, b: int, clear: int = 0) -> str: + def lookup_colour_RGB(r: int, g: int, b: int, clear: int = 0) -> str: #pylint: disable=invalid-name #pylint: disable=invalid-name """Identifies a color name from raw RGB channel readings using HSV colour space. HSV naturally separates chromatic colour (hue) from achromatic attributes @@ -389,6 +417,10 @@ def lookup_colour_RGB(r: int, g: int, b: int, clear: int = 0) -> str: def background_update(self, delta) -> tuple[int, int] | None: # pylint: disable=unused-argument """Perform background updates based on the current sub-state.""" if self._sub_state == _SUB_READING: + sensor_mgr = self._sensor_mgr + if sensor_mgr is None: + return None + # need per sensor read timing here to balance responsiveness with CPU load, since some sensors can be slow to read and we don't want to bog down the system by reading too frequently. We also want to update the displayed sample rate at a regular interval (e.g. every second) based on the number of samples read in that time. #self._read_timer += delta #if self._read_timer >= self._sensor_mgr.read_interval: #print(f"S:Reading sensor (S:read_timer={self._read_timer}ms, count_timer={self._count_timer}ms, sample_count={self.sample_count})") @@ -396,7 +428,7 @@ def background_update(self, delta) -> tuple[int, int] | None: # pylint: disable #self._read_timer = 0 # Read sensor data in the background and update sample count and rate calculation try: - self._sensor_data = self._sensor_mgr.read_current() + self._sensor_data = sensor_mgr.read_current() self.sample_count = self.sample_count + 1 except Exception as e: # pylint: disable=broad-exception-caught self._sensor_data = {"Error": str(e)} @@ -409,37 +441,7 @@ def background_update(self, delta) -> tuple[int, int] | None: # pylint: disable self.sample_count = 0 self._new_sample = True elif self._sub_state == _SUB_MOTOR_TEST: - if self._auto_mode and not self._auto_done: - self._auto_timer += delta - if self._auto_settling: - if self._auto_timer >= _AUTO_SCAN_SETTLE_MS: - # Settle phase done — discard counter and start measuring - count = 0 - for counter in self._rotation_rate_counters: - if counter is not None: - count += counter.value(0) # read-and-reset to discard - if count == 0: - # There has been no motion from any motors - so we can skip the measure phase and move straight to the next power level - self._auto_rotation_rate_step() - else: - self._auto_timer = 0 - self._auto_settling = False - else: - if self._auto_timer >= _AUTO_SCAN_MEASURE_MS: - # Measure phase done — read counter and record result - rounding = (_AUTO_SCAN_MEASURE_MS * self._rotation_rate_spokes) // 2 - rate = [0] * len(self._rotation_rate_counters) - for index, counter in enumerate(self._rotation_rate_counters): - if counter is not None: - count = counter.value(0) - rpm = ((60000 * count) + rounding) // (_AUTO_SCAN_MEASURE_MS * self._rotation_rate_spokes) - if rpm > self._auto_max_rpm: - self._auto_max_rpm = rpm - rate[index] = rpm - power = self._rotation_rate_motor_power - self._auto_results.append((power, rate)) - self._auto_rotation_rate_step() - + self._sample_ina226_in_background() return (self._rotation_rate_motor_power, self._rotation_rate_motor_power) return None @@ -451,9 +453,10 @@ def _auto_rotation_rate_step(self): # Scan complete — stop motors self._auto_done = True self._rotation_rate_motor_power = 0 + self._auto_direction *= -1 # reverse direction for next scan else: # Advance to next power level - self._rotation_rate_motor_power = (65535 * self._auto_step) // (_AUTO_SCAN_STEPS - 1) + self._rotation_rate_motor_power = self._auto_direction * (65535 * self._auto_step) // (_AUTO_SCAN_STEPS - 1) self._auto_timer = 0 self._auto_settling = True @@ -472,18 +475,22 @@ def update(self, delta: int): self._update_motor_test_mode(delta) - def _rotation_rate_enable(self, enable: bool = True): + def _rotation_rate_enable(self, enable: bool = True) -> bool: if self._test_support_hexpansion_config is None: - return + return False try: if enable: + if self._logging: + print("Enabling rotation rate emitter and sensors") for pin_num in _ROTATION_RATE_EMITTER_PINS: - self._test_support_hexpansion_config.ls_pin[pin_num].init(mode=Pin.OUT) # Set LS pins to output mode to turn on the IR emitters - self._test_support_hexpansion_config.ls_pin[pin_num].duty(self._rotation_rate_emitter_duty) # Set LS pins to the current duty cycle to drive the IR emitters for rotation rate measurement) + self._test_support_hexpansion_config.ls_pin[pin_num].init(mode=ePin.PWM) # Set LS pins to output mode to turn on the IR emitters + self._test_support_hexpansion_config.ls_pin[pin_num].duty(self.rotation_rate_emitter_duty) # Set LS pins to the current duty cycle to drive the IR emitters) for pin_num in _ROTATION_RATE_SENSOR_ENABLE_PINS: self._test_support_hexpansion_config.ls_pin[pin_num].init(mode=Pin.OUT) # Set LS pins to output mode to enable the phototransistors for rotation rate measurement self._test_support_hexpansion_config.ls_pin[pin_num].value(1) # Set LS enable pins high to turn on the phototransistors for rotation rate measurement else: + if self._logging: + print("Disabling rotation rate emitter and sensors") for pin_num in _ROTATION_RATE_EMITTER_PINS: self._test_support_hexpansion_config.ls_pin[pin_num].init(mode=Pin.IN) # Set LS pins to input mode to turn off the IR emitters for pin_num in _ROTATION_RATE_SENSOR_ENABLE_PINS: @@ -493,33 +500,103 @@ def _rotation_rate_enable(self, enable: bool = True): self._test_support_hexpansion_config.pin[pin_num].init(mode=Pin.IN) # Set HS pins to input mode to read the phototransistors for rotation rate measurement except AttributeError: pass # Simulator Pin stubs lack .init() + return True + + def _init_ina226_for_motor_test(self) -> bool: + self._ina226 = None + self._ina226_sensor_mgr = None + self._ina226_reading = {} + self._reset_ina226_accumulators() + if self._test_support_hexpansion_config is None: + return False + try: + from .sensor_manager import SensorManager + mgr = SensorManager(logging=self._logging) + port = self._test_support_hexpansion_config.port + if not mgr.open(port): + mgr.close() + if self._logging: + print(f"S:INA226 – no sensors found on port {port}") + return False + # Find the first INA226 sensor in the discovered list + sensor = mgr.get_sensor_by_name("INA226") + if sensor is not None: + self._ina226 = sensor + self._ina226_sensor_mgr = mgr + if self._logging: + print(f"S:INA226 found @ 0x{sensor.i2c_addr:02X}") + return True + # No INA226 found; close the manager + mgr.close() + except Exception as e: # pylint: disable=broad-exception-caught + if self._logging: + print(f"S:INA226 init failed: {e}") + return False + + def _reset_ina226_accumulators(self) -> None: + self._ina226_sum_current_ma = 0 + self._ina226_sum_bus_mv = 0 + self._ina226_sum_power_mw = 0 + self._ina226_sample_count = 0 + + def _sample_ina226_in_background(self) -> None: + sensor = self._ina226 + if sensor is None: + return + data = sensor.read_sample_if_ready() + if data is None: + return + try: + self._ina226_sum_current_ma += int(data.get("current_mA", 0)) + self._ina226_sum_bus_mv += int(data.get("bus_mV", 0)) + self._ina226_sum_power_mw += int(data.get("power_mW", 0)) + self._ina226_sample_count += 1 + except Exception as e: # pylint: disable=broad-exception-caught + if self._logging: + print(f"S:INA226 sample error: {e}") + return + + def _consume_ina226_average(self) -> int | None: + if self._ina226_sample_count <= 0: + self._ina226_reading = {} + return None + count = self._ina226_sample_count + current_ma = self._ina226_sum_current_ma // count + self._ina226_reading = { + "current_mA": current_ma, + "bus_mV": self._ina226_sum_bus_mv // count, + "power_mW": self._ina226_sum_power_mw // count, + } + self._reset_ina226_accumulators() + return current_ma def _update_motor_test_mode(self, delta: int): # pylint: disable=unused-argument app = self._app if self._test_support_hexpansion_config is None: - self._sub_state = _SUB_SELECT_PORT + self._stop_motor_test_mode() return + # CANCEL always exits motor test mode if app.button_states.get(BUTTON_TYPES["CANCEL"]): app.button_states.clear() - if self.logging: - print("Exiting Test mode") self._stop_motor_test_mode() return # CONFIRM toggles between manual and auto mode - if app.button_states.get(BUTTON_TYPES["CONFIRM"]): + elif app.button_states.get(BUTTON_TYPES["CONFIRM"]): app.button_states.clear() + self._rotation_rate_motor_power = 0 + self._auto_last_current_ma = 0 + self._rotation_rate_measurement_period_elapsed = 0 + self._reset_ina226_accumulators() + for counter in self._rotation_rate_counters: + if counter is not None: + counter.value(0) # reset counter if self._auto_mode: # Switch back to manual self._auto_mode = False self._auto_done = False - self._rotation_rate_motor_power = 0 - self._rotation_rate_measurement_period_elapsed = 0 - for counter in self._rotation_rate_counters: - if counter is not None: - counter.value(0) # reset counter else: # Start auto scan self._auto_mode = True @@ -529,28 +606,75 @@ def _update_motor_test_mode(self, delta: int): # pylint: disable=unused-argumen self._auto_settling = True self._auto_results = [] self._auto_max_rpm = 0 - self._rotation_rate_motor_power = 0 # first step is power 0 - for counter in self._rotation_rate_counters: - if counter is not None: - counter.value(0) # reset counter before starting + self._auto_max_current_ma = 0 app.refresh = True return if self._auto_mode: + if not self._auto_done: + self._auto_timer += delta + if self._auto_settling: + if self._auto_timer >= _AUTO_SCAN_SETTLE_MS: + # Settle phase done — discard counter and start measuring + count = 0 + for counter in self._rotation_rate_counters: + if counter is not None: + count += counter.value(0) # read-and-reset to discard + if count == 0: + # There has been no motion from any motors - so we can skip the measure phase and move straight to the next power level + self._auto_rotation_rate_step() + else: + self._auto_timer = 0 + self._auto_settling = False + self._reset_ina226_accumulators() + else: + if self._auto_timer >= _AUTO_SCAN_MEASURE_MS: + # Measure phase done — read counter and record result + rounding = (_AUTO_SCAN_MEASURE_MS * self._rotation_rate_spokes) // 2 + rate = [0] * len(self._rotation_rate_counters) + for index, counter in enumerate(self._rotation_rate_counters): + if counter is not None: + count = counter.value(0) + rpm = ((60000 * count) + rounding) // (_AUTO_SCAN_MEASURE_MS * self._rotation_rate_spokes) + if rpm > self._auto_max_rpm: + self._auto_max_rpm = rpm + rate[index] = rpm + current_ma = self._consume_ina226_average() + if current_ma is not None: + current_abs = abs(current_ma) + self._auto_last_current_ma = current_ma + if current_abs > self._auto_max_current_ma: + self._auto_max_current_ma = current_abs + power = self._rotation_rate_motor_power + self._auto_results.append((power, rate, current_ma)) + self._auto_rotation_rate_step() # In auto mode, no manual button control for power/IR return + else: + # manual measurement mode + self._rotation_rate_measurement_period_elapsed += delta + if self._rotation_rate_measurement_period_elapsed >= _ROTATION_RATE_MEASUREMENT_PERIOD_MS: + count = 0 + for index, counter in enumerate(self._rotation_rate_counters): + if counter is not None: + count = counter.value(0) # read-and-reset to get the count for the elapsed period + self._rotation_rate_rpms[index] = ((60000 * count) + self._rotation_rate_rounding) // (self._rotation_rate_measurement_period_elapsed * self._rotation_rate_spokes) + self._rotation_rate_measurement_period_elapsed = 0 + self._consume_ina226_average() + if self.logging: + print(f"S:Rotation Rates: {self._rotation_rate_rpms}") # Manual mode button handling if app.button_states.get(BUTTON_TYPES["UP"]): app.button_states.clear() - self.rotation_rate_emitter_duty = min(255, self.rotation_rate_emitter_duty + 8) + self.rotation_rate_emitter_duty = min(255, self.rotation_rate_emitter_duty + _IR_EMITTER_PWM_STEP_SIZE) if self.logging: - print(f"S:IR+Emitter Duty: {self._rotation_rate_emitter_duty}") + print(f"S:IR+Emitter Duty: {self.rotation_rate_emitter_duty}") elif app.button_states.get(BUTTON_TYPES["DOWN"]): app.button_states.clear() - self.rotation_rate_emitter_duty = max(0, self.rotation_rate_emitter_duty - 8) + self.rotation_rate_emitter_duty = max(0, self.rotation_rate_emitter_duty - _IR_EMITTER_PWM_STEP_SIZE) if self.logging: - print(f"S:IR-Emitter Duty: {self._rotation_rate_emitter_duty}") + print(f"S:IR-Emitter Duty: {self.rotation_rate_emitter_duty}") elif app.button_states.get(BUTTON_TYPES["RIGHT"]): app.button_states.clear() self._rotation_rate_motor_power = min(65535, self._rotation_rate_motor_power + 1000) @@ -558,19 +682,11 @@ def _update_motor_test_mode(self, delta: int): # pylint: disable=unused-argumen print(f"S:Motor+Power: {self._rotation_rate_motor_power}") elif app.button_states.get(BUTTON_TYPES["LEFT"]): app.button_states.clear() - self._rotation_rate_motor_power = max(0, self._rotation_rate_motor_power - 1000) + self._rotation_rate_motor_power = max(-65535, self._rotation_rate_motor_power - 1000) if self.logging: print(f"S:Motor-Power: {self._rotation_rate_motor_power}") - self._rotation_rate_measurement_period_elapsed += delta - if self._rotation_rate_measurement_period_elapsed >= _ROTATION_RATE_MEASUREMENT_PERIOD_MS: - for index, counter in enumerate(self._rotation_rate_counters): - if counter is not None: - count = counter.value(0) # read-and-reset to get the count for the elapsed period - self._rotation_rate_rpms[index] = ((60000 * count) + self._rotation_rate_rounding) // (self._rotation_rate_measurement_period_elapsed * self._rotation_rate_spokes) - self._rotation_rate_measurement_period_elapsed = 0 - if self.logging: - print(f"S:Count {count} = Rotation Rates: {self._rotation_rate_rpms}") + def _update_select_port(self, delta: int): # pylint: disable=unused-argument @@ -585,11 +701,7 @@ def _update_select_port(self, delta: int): # pylint: disable=unused-argument app.refresh = True elif app.button_states.get(BUTTON_TYPES["CONFIRM"]): app.button_states.clear() - motor_test_port = ( - self._test_support_hexpansion_config.port - if self._test_support_hexpansion_config is not None - else _ROTATION_RATE_PORT - ) + motor_test_port = self._test_support_hexpansion_config.port if self._test_support_hexpansion_config is not None else 0 if self._port_selected == motor_test_port and self._start_motor_test_mode(): app.notification = Notification("Motor Test", port=self._port_selected) if self.logging: @@ -597,7 +709,7 @@ def _update_select_port(self, delta: int): # pylint: disable=unused-argument self._sub_state = _SUB_MOTOR_TEST app.refresh = True else: - self._ensure_sensor_mgr() + sensor_mgr = self._ensure_sensor_mgr() self._sensor_data = {} self._display_data = {} self._read_timer = 0 @@ -606,10 +718,10 @@ def _update_select_port(self, delta: int): # pylint: disable=unused-argument self._sample_count = 0 self._new_sample = False app.refresh = True - if self._sensor_mgr.open(self._port_selected): - app.update_period = self._sensor_mgr.read_interval + if sensor_mgr.open(self._port_selected): + app.update_period = sensor_mgr.read_interval if self.logging: - print(f"Opened sensor port {self._port_selected} with read_interval {self._sensor_mgr.read_interval}ms") + print(f"Opened sensor port {self._port_selected} with read_interval {sensor_mgr.read_interval}ms") self._sub_state = _SUB_READING else: app.notification = Notification(" No Sensors", port=self._port_selected) @@ -757,7 +869,9 @@ def _update_reading(self, delta: int): # pylint: disable=unused-argument app.refresh = True elif app.button_states.get(BUTTON_TYPES["CANCEL"]): app.button_states.clear() - self._sensor_mgr.close() + sensor_mgr = self._sensor_mgr + if sensor_mgr is not None: + sensor_mgr.close() app.update_period = DEFAULT_BACKGROUND_UPDATE_PERIOD self._sub_state = _SUB_SELECT_PORT app.refresh = True @@ -769,6 +883,8 @@ def _start_motor_test_mode(self) -> bool: if len(app.hexdrive_apps) > 0 and self._test_support_hexpansion_config is not None: app.hexdrive_apps[0].set_logging(True) if app.hexdrive_apps[0].initialise() and app.hexdrive_apps[0].set_power(True) and app.hexdrive_apps[0].set_freq(MOTOR_PWM_FREQ): + app.hexdrive_apps[0].set_keep_alive(2000) # Updates can be quite slow as we are using the draw function + app.hexdrive_apps[0].set_motors((-1,-1)) # Try forcing PWM to be reinitialised by swapping direction. # Enable the IR emitter for measuring wheel rotation rate self._rotation_rate_enable(True) @@ -794,6 +910,7 @@ def _start_motor_test_mode(self) -> bool: print(f"S:Rate counter {self._rotation_rate_counters}") self._rotation_rate_measurement_period_elapsed = 0 self._rotation_rate_rpms = [0] * len(self._rotation_rate_counters) + self._init_ina226_for_motor_test() app.update_period = _MOTOR_TEST_BACKGROUND_UPDATE_PERIOD # update every 1000ms to give a responsive display without overwhelming the CPU with updates return True if self.logging: @@ -803,10 +920,23 @@ def _start_motor_test_mode(self) -> bool: def _stop_motor_test_mode(self): + if self._logging: + print("Stopping Motor Test mode and cleaning up") app = self._app self._auto_mode = False self._auto_done = False self._rotation_rate_motor_power = 0 + self._ina226_reading = {} + self._reset_ina226_accumulators() + if self._ina226 is not None: + if self._ina226_sensor_mgr is not None: + try: + self._ina226_sensor_mgr.close() + except Exception as exc: # pylint: disable=broad-exception-caught + if self._logging: + print("INA226 sensor manager close failed:", exc) + self._ina226_sensor_mgr = None + self._ina226 = None if len(app.hexdrive_apps) > 0: app.hexdrive_apps[0].set_pwm((0, 0, 0, 0)) @@ -857,6 +987,11 @@ def _draw_motor_test_mode(self, ctx): if rpm is not None: lines += [f"{index}: {rpm}rpm"] colours += [(1, 0, 1)] + if self._ina226_reading: + lines += [f"I:{self._ina226_reading.get('current_mA', 0)}mA"] + colours += [(1, 0.3, 0.3)] + lines += [f"V:{self._ina226_reading.get('bus_mV', 0)}mV"] + colours += [(0.3, 0.8, 1.0)] self._app.draw_message(ctx, lines, colours, label_font_size) button_labels(ctx, up_label="IR+", down_label="IR-", cancel_label="Back", left_label="Pwr-", right_label="Pwr+", confirm_label="Auto") @@ -882,6 +1017,7 @@ def _draw_auto_scan(self, ctx): n = len(self._auto_results) max_rpm = self._auto_max_rpm if self._auto_max_rpm > 0 else 1 + max_current_ma = self._auto_max_current_ma if self._auto_max_current_ma > 0 else 1 if n > 1: # Plot data points as small bars. @@ -890,8 +1026,8 @@ def _draw_auto_scan(self, ctx): # scalar for this chart by using the maximum measured RPM. bar_w = max(1, chart_w // _AUTO_SCAN_STEPS) for i in range(n): - power, rpms = self._auto_results[i] - x = chart_left + (power * chart_w) // 65535 + power, rpms, current_ma = self._auto_results[i] + x = chart_left + (abs(power) * chart_w) // 65535 for index, rpm in enumerate(rpms): h = (rpm * chart_h) // max_rpm if h > 0: @@ -901,6 +1037,11 @@ def _draw_auto_scan(self, ctx): else: ctx.rgb(1.0, 0.5, 0.0) ctx.rectangle(x, chart_bottom - h, bar_w, h).fill() + if current_ma is not None: + current_h = (abs(current_ma) * chart_h) // max_current_ma + marker_y = chart_bottom - current_h + ctx.rgb(1.0, 0.2, 0.2) + ctx.rectangle(x + bar_w, marker_y - 1, 2, 2).fill() # Title and max RPM label ctx.rgb(1, 1, 0) @@ -913,6 +1054,8 @@ def _draw_auto_scan(self, ctx): ctx.rgb(0, 1, 1) ctx.move_to(-60, chart_bottom + label_font_size + 2).text(f"Max:{max_rpm}rpm") + ctx.rgb(1.0, 0.2, 0.2) + ctx.move_to(15, chart_bottom + label_font_size + 2).text(f"Ipk:{max_current_ma}mA") button_labels(ctx, cancel_label="Back", confirm_label="Manual") @@ -928,10 +1071,12 @@ def _draw_select_port(self, ctx): def _draw_reading(self, ctx): up_label = down_label = "" - num_sensors = self._sensor_mgr.num_sensors if self._sensor_mgr else 1 - sensor_name = self._sensor_mgr.current_sensor_name if self._sensor_mgr else "Sensor" + sensor_mgr = self._sensor_mgr + num_sensors = sensor_mgr.num_sensors if sensor_mgr else 1 + sensor_name = sensor_mgr.current_sensor_name if sensor_mgr else "Sensor" if num_sensors > 1: - lines = [f"Slot {self._port_selected}-{self._sensor_mgr.current_sensor_index + 1}/{num_sensors}"] + current_sensor_index = sensor_mgr.current_sensor_index if sensor_mgr else 0 + lines = [f"Slot {self._port_selected}-{current_sensor_index + 1}/{num_sensors}"] else: lines = [f"Slot {self._port_selected}"] colours = [(1, 1, 0)] @@ -1103,7 +1248,7 @@ def _unit_in_use(self, unit: int) -> bool: ctrl = mem32[_PCNT_CTRL_REG] # Check register clock gate - if not (ctrl & _PCNT_CTRL_CLK_EN): + if not ctrl & _PCNT_CTRL_CLK_EN: if self.logging: print(f"PCNT: unit {unit} - register clock gate off, unit free") return False @@ -1147,6 +1292,8 @@ def init(self, src: int, filter_ns: int | None = None) -> bool: self.pin = src unit = self.unit + if unit is None: + return False conf0_addr = _PCNT_BASE + unit * 0x0C cnt_addr = _PCNT_BASE + 0x30 + unit * 4 rst_bit = 1 << (unit * 2) @@ -1212,7 +1359,11 @@ def value(self, value: int | None = None) -> int: DOES NOT SUPPORT SETTING THE COUNTER TO AN ARBITRARY VALUE, ONLY RESETTING TO ZERO.""" if not self._configured: return 0 + unit = self.unit + if unit is None: + return 0 + rst_bit = 1 << (unit * 2) cnt_addr = _PCNT_BASE + 0x30 + unit * 4 if value is not None and value == 0: diff --git a/sensors/__init__.py b/sensors/__init__.py index 16b2cdf..b9c70e9 100644 --- a/sensors/__init__.py +++ b/sensors/__init__.py @@ -33,3 +33,4 @@ def _try_add_sensor(import_name: str, class_name: str) -> None: _try_add_sensor("tcs3472", "TCS3472") _try_add_sensor("tcs3430", "TCS3430") _try_add_sensor("opt4048", "OPT4048") +_try_add_sensor("ina226", "INA226") diff --git a/sensors/ina226.py b/sensors/ina226.py new file mode 100644 index 0000000..d98520e --- /dev/null +++ b/sensors/ina226.py @@ -0,0 +1,190 @@ +""" +INA226 current/voltage/power monitor driver. + +Default I2C address range: 0x40-0x4F (A0/A1 address pins). +Measurements: + - bus_mV : bus voltage in millivolts + - current_mA : current in milliamps + - power_mW : power in milliwatts +""" + +import time + +from .sensor_base import SensorBase + +try: + _ticks_ms = time.ticks_ms + _ticks_add = time.ticks_add + _ticks_diff = time.ticks_diff + _sleep_ms = time.sleep_ms +except AttributeError: + def _ticks_ms() -> int: + return int(time.time() * 1000) + + def _ticks_add(base: int, delta: int) -> int: + return base + delta + + def _ticks_diff(a: int, b: int) -> int: + return a - b + + def _sleep_ms(delay_ms: int) -> None: + time.sleep(delay_ms / 1000) + + +# Register map +_REG_CONFIGURATION = 0x00 # Configuration register +_REG_SHUNT_VOLTAGE = 0x01 # Shunt voltage result (signed) +_REG_BUS_VOLTAGE = 0x02 # Bus voltage result (unsigned) +_REG_POWER = 0x03 # Power result (unsigned) +_REG_CURRENT = 0x04 # Current result (signed) +_REG_CALIBRATION = 0x05 # Calibration register +_REG_MASK_ENABLE = 0x06 # Alert mask/enable register +_REG_ALERT_LIMIT = 0x07 # Alert threshold register +_REG_MANUFACTURER_ID = 0xFE # Manufacturer ID register +_REG_DIE_ID = 0xFF # Die ID register + + +# Configuration register bits (0x00) +_CFG_RESET_BIT = 0x8000 # Software reset bit +_CFG_AVG_SHIFT = 12 # Averaging field shift (bits 14:12) +_CFG_VBUSCT_SHIFT = 9 # Bus voltage conversion time field shift (bits 11:9) +_CFG_VSHCT_SHIFT = 6 # Shunt voltage conversion time field shift (bits 8:6) +_CFG_MODE_SHIFT = 0 # Operating mode field shift (bits 2:0) + +# AVG field values (bits 14:12) +_CFG_AVG_1 = 0b000 # 1 sample average +_CFG_AVG_4 = 0b001 # 4 sample average +_CFG_AVG_16 = 0b010 # 16 sample average +_CFG_AVG_64 = 0b011 # 64 sample average +_CFG_AVG_128 = 0b100 # 128 sample average +_CFG_AVG_256 = 0b101 # 256 sample average +_CFG_AVG_512 = 0b110 # 512 sample average +_CFG_AVG_1024 = 0b111 # 1024 sample average + +# Conversion time field values for VBUSCT/VSHCT (bits 11:9 and 8:6) +_CFG_CT_140US = 0b000 # 140 us conversion time +_CFG_CT_204US = 0b001 # 204 us conversion time +_CFG_CT_332US = 0b010 # 332 us conversion time +_CFG_CT_588US = 0b011 # 588 us conversion time +_CFG_CT_1100US = 0b100 # 1.1 ms conversion time +_CFG_CT_2116US = 0b101 # 2.116 ms conversion time +_CFG_CT_4156US = 0b110 # 4.156 ms conversion time +_CFG_CT_8244US = 0b111 # 8.244 ms conversion time + +# Operating mode field values (bits 2:0) +_CFG_MODE_POWER_DOWN = 0b000 # Power-down mode +_CFG_MODE_SHUNT_TRIG = 0b001 # Shunt voltage, triggered +_CFG_MODE_BUS_TRIG = 0b010 # Bus voltage, triggered +_CFG_MODE_SHUNT_BUS_TRIG = 0b011 # Shunt and bus, triggered +_CFG_MODE_ADC_OFF = 0b100 # ADC off (disabled) +_CFG_MODE_SHUNT_CONT = 0b101 # Shunt voltage, continuous +_CFG_MODE_BUS_CONT = 0b110 # Bus voltage, continuous +_CFG_MODE_SHUNT_BUS_CONT = 0b111 # Shunt and bus, continuous + + +# Mask/Enable register bits (0x06) +_MASK_SOL = 0x8000 # Shunt over-voltage alert flag +_MASK_SUL = 0x4000 # Shunt under-voltage alert flag +_MASK_BOL = 0x2000 # Bus over-voltage alert flag +_MASK_BUL = 0x1000 # Bus under-voltage alert flag +_MASK_POL = 0x0800 # Power over-limit alert flag +_MASK_CNVR = 0x0400 # Conversion ready alert flag +_MASK_AFF = 0x0010 # Alert function flag +_MASK_CVRF = 0x0008 # Conversion ready flag +_MASK_OVF = 0x0004 # Math overflow flag +_MASK_APOL = 0x0002 # Alert pin polarity select +_MASK_LEN = 0x0001 # Alert latch enable + + +# Device identification +_MANUFACTURER_ID_TI = 0x5449 # Texas Instruments manufacturer ID + + +# Driver configuration constants (100 mΩ shunt) +_SHUNT_RESISTOR_MILLIOHM = 100 +_CALIBRATION_VALUE = 0x0200 # 512 => 0.1 mA current register LSB with 100 mΩ shunt +_CURRENT_LSB_UA = 100 # 0.1 mA current LSB in microamps +_POWER_LSB_UW = 2500 # 2.5 mW power LSB in microwatts +_READ_TIMEOUT_MS = 10 + +# Default operating configuration: +# - shunt conversion: 8.244 ms +# - bus conversion: 1.1 ms +# - averaging: 16 samples +_DEFAULT_CONFIGURATION = ( + (_CFG_AVG_16 << _CFG_AVG_SHIFT) + | (_CFG_CT_1100US << _CFG_VBUSCT_SHIFT) + | (_CFG_CT_8244US << _CFG_VSHCT_SHIFT) + | (_CFG_MODE_SHUNT_BUS_CONT << _CFG_MODE_SHIFT) +) + + +class INA226(SensorBase): + """INA226 sensor driver with integer fixed-point outputs.""" + + I2C_ADDR = 0x40 + I2C_ADDRS = tuple(range(0x40, 0x50)) + NAME = "INA226" + READ_INTERVAL_MS = 150 + TYPE = "Power" + + def _measure_from_registers(self) -> dict[str, int]: + bus_raw = self._read_u16_be(_REG_BUS_VOLTAGE) + current_raw = self._read_s16_be(_REG_CURRENT) + power_raw = self._read_u16_be(_REG_POWER) + + # Bus LSB = 1.25 mV + bus_mv = (bus_raw * 125) // 100 + # Current LSB from calibration = 100 uA (0.1 mA) + current_ma = (current_raw * _CURRENT_LSB_UA) // 1000 + # Power LSB from calibration = 2500 uW (2.5 mW) + power_mw = (power_raw * _POWER_LSB_UW) // 1000 + + return { + "bus_mV": bus_mv, + "current_mA": current_ma, + "power_mW": power_mw, + } + + def read_sample_if_ready(self) -> dict[str, int] | None: + """Return one sample in integer units when a new conversion is ready. + + This helper is intended for high-rate internal consumers (for example + background averaging in motor test mode). The public SensorBase `read()` + API still returns string values for UI rendering consistency. + """ + if not self._ready: + return None + status = self._read_u16_be(_REG_MASK_ENABLE) + if (status & _MASK_CVRF) == 0: + print(f"S:{self.NAME} sample not ready (status=0x{status:04X})") + return None + return self._measure_from_registers() + + def _init(self) -> bool: + manufacturer = self._read_u16_be(_REG_MANUFACTURER_ID) + if manufacturer != _MANUFACTURER_ID_TI: + return False + + self._write_u16_be(_REG_CONFIGURATION, _DEFAULT_CONFIGURATION) + self._write_u16_be(_REG_CALIBRATION, _CALIBRATION_VALUE) + self._write_u16_be(_REG_MASK_ENABLE, _MASK_CNVR | _MASK_LEN) # Enable conversion ready alert with latching + return True + + + def _measure(self) -> dict: + deadline = _ticks_add(_ticks_ms(), _READ_TIMEOUT_MS) + while True: + sample = self.read_sample_if_ready() + if sample is not None: + return { + "bus_mV": str(sample["bus_mV"]), + "current_mA": str(sample["current_mA"]), + "power_mW": str(sample["power_mW"]), + } + if _ticks_diff(deadline, _ticks_ms()) <= 0: + return {"Error": "timeout"} + _sleep_ms(1) + + def _shutdown(self) -> None: + self._write_u16_be(_REG_CONFIGURATION, _CFG_MODE_POWER_DOWN) diff --git a/sensors/opt4048.py b/sensors/opt4048.py index 11b44e6..961db21 100644 --- a/sensors/opt4048.py +++ b/sensors/opt4048.py @@ -110,12 +110,11 @@ def __init__(self): def _read_reg16(self, reg: int) -> int: """Read a 16-bit big-endian register.""" - d = self._i2c.readfrom_mem(self.I2C_ADDR, reg, 2) - return (d[0] << 8) | d[1] + return self._read_u16_be(reg) def _write_reg16(self, reg: int, value: int): """Write a 16-bit big-endian register.""" - self._i2c.writeto_mem(self.I2C_ADDR, reg, bytes([(value >> 8) & 0xFF, value & 0xFF])) + self._write_u16_be(reg, value) # ── Configuration helpers (public API) ─────────────────────────────────── diff --git a/sensors/sensor_base.py b/sensors/sensor_base.py index 113456f..557b1de 100644 --- a/sensors/sensor_base.py +++ b/sensors/sensor_base.py @@ -20,9 +20,10 @@ class SensorBase: READ_INTERVAL_MS = 250 TYPE = "Generic" - def __init__(self): + def __init__(self, i2c_addr: int | None = None): self._i2c = None self._ready = False + self._i2c_addr = self.I2C_ADDR if i2c_addr is None else i2c_addr # ------------------------------------------------------------------ # Public API (called by SensorManager / app.py) @@ -64,10 +65,23 @@ def reset(self): print(f"S:{self.NAME} reset error: {e}") self._ready = False + def shutdown(self): + """Put the sensor into a low-power state without changing ready state.""" + if self._i2c is None: + return + try: + self._shutdown() + except Exception as e: # pylint: disable=broad-exception-caught + print(f"S:{self.NAME} shutdown error: {e}") + @property def is_ready(self) -> bool: return self._ready + @property + def i2c_addr(self) -> int: + return self._i2c_addr + # ------------------------------------------------------------------ # Internal helpers - override in sub-classes # ------------------------------------------------------------------ @@ -81,7 +95,12 @@ def _measure(self) -> dict: raise NotImplementedError def _shutdown(self): - """Optional: power-down registers, etc.""" + """Optional power-down hook. + + Subclasses can implement register writes here when hardware supports + an explicit shutdown mode. Drivers without dedicated power management + can leave this as a no-op. + """ return # ------------------------------------------------------------------ @@ -89,10 +108,10 @@ def _shutdown(self): # ------------------------------------------------------------------ def _write_reg(self, reg: int, data: bytes): - self._i2c.writeto_mem(self.I2C_ADDR, reg, data) + self._i2c.writeto_mem(self._i2c_addr, reg, data) def _read_reg(self, reg: int, n: int = 1) -> bytes: - return self._i2c.readfrom_mem(self.I2C_ADDR, reg, n) + return self._i2c.readfrom_mem(self._i2c_addr, reg, n) def _read_u8(self, reg: int) -> int: return self._read_reg(reg, 1)[0] @@ -105,5 +124,14 @@ def _read_u16_be(self, reg: int) -> int: d = self._read_reg(reg, 2) return (d[0] << 8) | d[1] + def _read_s16_be(self, reg: int) -> int: + value = self._read_u16_be(reg) + if value & 0x8000: + value -= 0x10000 + return value + def _write_u8(self, reg: int, value: int): self._write_reg(reg, bytes([value & 0xFF])) + + def _write_u16_be(self, reg: int, value: int): + self._write_reg(reg, bytes([(value >> 8) & 0xFF, value & 0xFF])) diff --git a/servo_test.py b/servo_test.py index 872230e..12183db 100644 --- a/servo_test.py +++ b/servo_test.py @@ -1,4 +1,4 @@ -# Servo Tester Module for BadgeBot +""" Servo Tester Module for BadgeBot """ # # Handles the servo tester functionality. # Allows the user to control up to 4 servos with position, trim, @@ -29,7 +29,7 @@ # ---- Settings initialisation ----------------------------------------------- -def init_settings(s, MySetting): +def init_settings(s, MySetting): #pylint: disable=invalid-name """Register servo-test-specific settings in the shared settings dict.""" s['servo_step'] = MySetting(s, _SERVO_DEFAULT_STEP, 1, 100) s['servo_range'] = MySetting(s, _SERVO_DEFAULT_RANGE, 100, _MAX_SERVO_RANGE) @@ -37,6 +37,7 @@ def init_settings(s, MySetting): class ServoMode: + """Represents the mode of a servo in the tester.""" OFF = 0 TRIM = 1 POSITION = 2 @@ -45,16 +46,18 @@ class ServoMode: def __init__(self, mode=OFF): self._mode = mode - + @property def mode(self): + """ Get the current mode of the servo. One of OFF, TRIM, POSITION, SCANNING. """ return self._mode - + @mode.setter def mode(self, mode): self._mode = mode def inc(self): + """Cycle to the next mode.""" self._mode = (self._mode + 1) % 4 def __eq__(self, other): @@ -76,19 +79,19 @@ class ServoTestMgr: def __init__(self, app, logging: bool = False): self._app = app self._logging: bool = logging - self.servo = [None]*4 # Servo Positions - self.servo_centre = [_SERVO_DEFAULT_CENTRE]*4 # Trim Servo Centre - self.servo_range = [_SERVO_DEFAULT_RANGE]*4 # Limit Servo Range - self.servo_rate = [_SERVO_DEFAULT_RATE]*4 # Servo Rate of Change - self.servo_mode = [ServoMode() for _ in range(4)] # Servo Mode - self.servo_selected: int = 0 - self.timeout_period: int = 300000 # ms (5 minutes - without any user input) - self.keep_alive_period: int = 500 # ms (half the value used in hexdrive.py) + self.servo: list[int | None] = [None] * 4 # Servo Positions + self.servo_centre: list[int] = [_SERVO_DEFAULT_CENTRE] * 4 # Trim Servo Centre + self.servo_range: list[int] = [_SERVO_DEFAULT_RANGE] * 4 # Limit Servo Range + self.servo_rate: list[int] = [_SERVO_DEFAULT_RATE] * 4 # Servo Rate of Change + self.servo_mode: list[ServoMode] = [ServoMode() for _ in range(4)] # Servo Mode + self.servo_selected: int = 0 + self.timeout_period: int = 300000 # ms (5 minutes - without any user input) + self.keep_alive_period: int = 500 # ms (half the value used in hexdrive.py) self._time_since_last_input: int = 0 self._time_since_last_update: int = 0 if self._logging: print("ServoTestMgr initialised") - + # ------------------------------------------------------------------ @@ -96,7 +99,7 @@ def __init__(self, app, logging: bool = False): def logging(self) -> bool: """Whether to print debug logs to the console.""" return self._logging - + @logging.setter def logging(self, value: bool): self._logging = value @@ -105,19 +108,19 @@ def logging(self, value: bool): def step(self): """Get the current servo step size.""" return int(self._app.settings['servo_step'].v) if 'servo_step' in self._app.settings else _SERVO_DEFAULT_STEP - + @property def range(self): """Get the current servo range.""" return int(self._app.settings['servo_range'].v) if 'servo_range' in self._app.settings else _SERVO_DEFAULT_RANGE - + @property def period(self): """Get the current servo period.""" return int(self._app.settings['servo_period'].v) if 'servo_period' in self._app.settings else _SERVO_DEFAULT_PERIOD - + @property def available_servo_count(self) -> int: @@ -142,7 +145,7 @@ def start(self) -> bool: print("Entered Servo Test mode") return True return False - + # ------------------------------------------------------------------ # Per-tick update @@ -176,12 +179,15 @@ def update(self, delta: int) -> bool: else: self.servo_rate[self.servo_selected] = rate else: - if self.servo[self.servo_selected] is None: - self.servo[self.servo_selected] = 0 + current_servo = self.servo[self.servo_selected] + if current_servo is None: + current_servo = 0 self.servo_mode[self.servo_selected].mode = ServoMode.POSITION - self.servo[self.servo_selected] += self.step - if self.servo[self.servo_selected] is not None: - if self.servo_range[self.servo_selected] < (self.servo[self.servo_selected] + (self.servo_centre[self.servo_selected] - _SERVO_DEFAULT_CENTRE)): + current_servo += self.step + self.servo[self.servo_selected] = current_servo + current_servo = self.servo[self.servo_selected] + if current_servo is not None: + if self.servo_range[self.servo_selected] < (current_servo + (self.servo_centre[self.servo_selected] - _SERVO_DEFAULT_CENTRE)): self.servo[self.servo_selected] = self.servo_range[self.servo_selected] - (self.servo_centre[self.servo_selected] - _SERVO_DEFAULT_CENTRE) app.refresh = True elif app.button_states.get(BUTTON_TYPES["LEFT"]): @@ -208,12 +214,15 @@ def update(self, delta: int) -> bool: else: self.servo_rate[self.servo_selected] = rate else: - if self.servo[self.servo_selected] is None: - self.servo[self.servo_selected] = 0 + current_servo = self.servo[self.servo_selected] + if current_servo is None: + current_servo = 0 self.servo_mode[self.servo_selected].mode = ServoMode.POSITION - self.servo[self.servo_selected] -= self.step - if self.servo[self.servo_selected] is not None: - if -self.servo_range[self.servo_selected] > (self.servo[self.servo_selected] + (self.servo_centre[self.servo_selected] - _SERVO_DEFAULT_CENTRE)): + current_servo -= self.step + self.servo[self.servo_selected] = current_servo + current_servo = self.servo[self.servo_selected] + if current_servo is not None: + if -self.servo_range[self.servo_selected] > (current_servo + (self.servo_centre[self.servo_selected] - _SERVO_DEFAULT_CENTRE)): self.servo[self.servo_selected] = -self.servo_range[self.servo_selected] - (self.servo_centre[self.servo_selected] - _SERVO_DEFAULT_CENTRE) app.refresh = True else: @@ -241,7 +250,7 @@ def update(self, delta: int) -> bool: app.hexdrive_apps[0].set_servoposition(self.servo_selected, None) else: if self.servo[self.servo_selected] is None: - self.servo[self.servo_selected] = 0 + self.servo[self.servo_selected] = 0 app.refresh = True app.notification = Notification(f" Servo {self.servo_selected}:\n {self.servo_mode[self.servo_selected]}") @@ -264,26 +273,30 @@ def update(self, delta: int) -> bool: for i in range(self.available_servo_count): _refresh = app.refresh if self.servo_mode[i] == ServoMode.SCANNING: - if self.servo[i] is None: - self.servo[i] = 0 - self.servo[i] = self.servo[i] + ((self.servo_rate[i] * delta) // 1000) - if self.servo_range[i] < (self.servo[i] + (self.servo_centre[i] - _SERVO_DEFAULT_CENTRE)): + servo_value = self.servo[i] + if servo_value is None: + servo_value = 0 + servo_value += (self.servo_rate[i] * delta) // 1000 + if self.servo_range[i] < (servo_value + (self.servo_centre[i] - _SERVO_DEFAULT_CENTRE)): self.servo_rate[i] = -self.servo_rate[i] - self.servo[i] = self.servo_range[i] - (self.servo_centre[i] - _SERVO_DEFAULT_CENTRE) - elif -self.servo_range[i] > (self.servo[i] + (self.servo_centre[i] - _SERVO_DEFAULT_CENTRE)): + servo_value = self.servo_range[i] - (self.servo_centre[i] - _SERVO_DEFAULT_CENTRE) + elif -self.servo_range[i] > (servo_value + (self.servo_centre[i] - _SERVO_DEFAULT_CENTRE)): self.servo_rate[i] = -self.servo_rate[i] - self.servo[i] = -self.servo_range[i] - (self.servo_centre[i] - _SERVO_DEFAULT_CENTRE) + servo_value = -self.servo_range[i] - (self.servo_centre[i] - _SERVO_DEFAULT_CENTRE) + self.servo[i] = servo_value _refresh = True - if _refresh and len(app.hexdrive_apps) > 0 and self.servo_mode[i] != ServoMode.OFF and self.servo[i] is not None: - app.hexdrive_apps[0].set_servoposition(i, int(self.servo[i])) + servo_value = self.servo[i] + if _refresh and len(app.hexdrive_apps) > 0 and self.servo_mode[i] != ServoMode.OFF and servo_value is not None: + app.hexdrive_apps[0].set_servoposition(i, servo_value) return True def background_update(self, _delta: int): + """Handle any background updates for the servo tester.""" return None - + # ------------------------------------------------------------------ # Servo reset # ------------------------------------------------------------------ @@ -297,12 +310,14 @@ def reset_servo(self) -> bool: app.hexdrive_apps[0].set_freq(1000 // self.period, channel=i) app.hexdrive_apps[0].set_servocentre(self.servo_centre[i], i) self.servo_range[i] = self.range - if self.servo[i] is not None: - if self.servo[i] > self.servo_range[i]: - self.servo[i] = self.servo_range[i] - elif self.servo[i] < -self.servo_range[i]: - self.servo[i] = -self.servo_range[i] - if not app.hexdrive_apps[0].set_servoposition(i, int(self.servo[i])): + servo_value = self.servo[i] + if servo_value is not None: + if servo_value > self.servo_range[i]: + servo_value = self.servo_range[i] + elif servo_value < -self.servo_range[i]: + servo_value = -self.servo_range[i] + self.servo[i] = servo_value + if not app.hexdrive_apps[0].set_servoposition(i, servo_value): if self._logging: print("H:Failed to set servo position") self.servo_selected = 0 @@ -327,7 +342,7 @@ def draw(self, ctx) -> bool: servo_count = self.available_servo_count servo_text = ["S"] * (1 + servo_count) - servo_text_colours = [(0.4, 0.0, 0.0)] * (1 + servo_count) + servo_text_colours: list[tuple[float, float, float]] = [(0.4, 0.0, 0.0)] * (1 + servo_count) servo_text[0] = "Servo Test" servo_text_colours[0] = (1, 1, 0) for i in range(servo_count): @@ -348,8 +363,9 @@ def draw(self, ctx) -> bool: background_colour = (0.1, 0.1, 0.1) if i != self.servo_selected else (0.15, 0.15, 0.15) ctx.rgb(*background_colour).rectangle(-100, 1, 200, label_font_size - 2).fill() c = 100 * (self.servo_centre[i] - _SERVO_DEFAULT_CENTRE) / self.servo_range[i] - if self.servo[i] is not None: - x = 100 * (self.servo[i] + self.servo_centre[i] - _SERVO_DEFAULT_CENTRE) / self.servo_range[i] + servo_value = self.servo[i] + if servo_value is not None: + x = 100 * (servo_value + self.servo_centre[i] - _SERVO_DEFAULT_CENTRE) / self.servo_range[i] ctx.rgb(*bar_colour).rectangle(x - 2, 1, 5, label_font_size - 2).fill() ctx.rgb(*body_colour) if x > (c + 4): @@ -361,8 +377,13 @@ def draw(self, ctx) -> bool: if self.servo_mode[i] == ServoMode.SCANNING: servo_text[i + 1] = f"{int(abs(self.servo_rate[i])):4}\u00B5s/s" else: - servo_text[i + 1] = "Off" if (self.servo[i] is None or self.servo_mode[i] == ServoMode.OFF) else f"{int(self.servo[i]):+5}\u00B5s" - servo_text_colours[1 + self.servo_selected] = tuple(int(j * 2.5) for j in servo_text_colours[1 + self.servo_selected]) + servo_text[i + 1] = "Off" if (servo_value is None or self.servo_mode[i] == ServoMode.OFF) else f"{servo_value:+5}\u00B5s" + selected_colour = servo_text_colours[1 + self.servo_selected] + servo_text_colours[1 + self.servo_selected] = ( + selected_colour[0] * 2.5, + selected_colour[1] * 2.5, + selected_colour[2] * 2.5, + ) app.draw_message(ctx, servo_text, servo_text_colours, label_font_size) if self.servo_mode[self.servo_selected] == ServoMode.SCANNING: button_labels(ctx, up_label=app.special_chars["up"], down_label="\u25BC", confirm_label="Mode", cancel_label="Back", left_label="Slower", right_label="Faster") diff --git a/tests/conftest.py b/tests/conftest.py index 3f7fa01..4b41deb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -162,7 +162,7 @@ def install_fake_hexpansion(vid: int, pid: int, port: int, if app_class is None: app_class = HexDriveApp if app_version is None: - from sim.apps.BadgeBot.hexdrive import VERSION + from sim.apps.BadgeBot.EEPROM.hexdrive import VERSION app_version = VERSION fake_app = app_class(port, app_version) diff --git a/tests/test_ina226.py b/tests/test_ina226.py new file mode 100644 index 0000000..6a09e50 --- /dev/null +++ b/tests/test_ina226.py @@ -0,0 +1,72 @@ +from pathlib import Path +import sys + +_APP_DIR = Path(__file__).resolve().parents[1] +if str(_APP_DIR) not in sys.path: + sys.path.insert(0, str(_APP_DIR)) + +from sensors.ina226 import ( + INA226, + _MASK_CVRF, + _REG_BUS_VOLTAGE, + _REG_CALIBRATION, + _REG_CONFIGURATION, + _REG_CURRENT, + _REG_MANUFACTURER_ID, + _REG_MASK_ENABLE, + _REG_POWER, + _DEFAULT_CONFIGURATION, +) + + +def _u16_be(value: int) -> bytes: + return bytes([(value >> 8) & 0xFF, value & 0xFF]) + + +class _FakeI2C: + def __init__(self): + self.reads = {} + self.writes = [] + + def readfrom_mem(self, addr, reg, n): + value = self.reads[(addr, reg)] + return value[:n] + + def writeto_mem(self, addr, reg, data): + self.writes.append((addr, reg, bytes(data))) + + +def test_ina226_supports_alternative_i2c_addresses(): + assert INA226.I2C_ADDR == 0x40 + assert INA226.I2C_ADDRS[0] == 0x40 + assert INA226.I2C_ADDRS[-1] == 0x4F + assert len(INA226.I2C_ADDRS) == 16 + + +def test_ina226_init_and_measure_integer_units(): + fake_i2c = _FakeI2C() + sensor = INA226(i2c_addr=0x45) + fake_i2c.reads[(0x45, _REG_MANUFACTURER_ID)] = _u16_be(0x5449) + assert sensor.begin(fake_i2c) is True + + assert (0x45, _REG_CONFIGURATION, _u16_be(_DEFAULT_CONFIGURATION)) in fake_i2c.writes + assert (0x45, _REG_CALIBRATION, _u16_be(0x0200)) in fake_i2c.writes + + fake_i2c.reads[(0x45, _REG_MASK_ENABLE)] = _u16_be(_MASK_CVRF) + fake_i2c.reads[(0x45, _REG_BUS_VOLTAGE)] = _u16_be(4000) + fake_i2c.reads[(0x45, _REG_CURRENT)] = _u16_be(1234) + fake_i2c.reads[(0x45, _REG_POWER)] = _u16_be(320) + + result = sensor.read() + assert result["bus_mV"] == "5000" + assert result["current_mA"] == "123" + assert result["power_mW"] == "800" + + +def test_ina226_read_sample_if_ready_none_when_not_ready(): + fake_i2c = _FakeI2C() + sensor = INA226(i2c_addr=0x40) + fake_i2c.reads[(0x40, _REG_MANUFACTURER_ID)] = _u16_be(0x5449) + fake_i2c.reads[(0x40, _REG_MASK_ENABLE)] = _u16_be(0x0000) + assert sensor.begin(fake_i2c) is True + assert sensor.read_sample_if_ready() is None diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 3f921d9..2ec3df4 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -15,8 +15,8 @@ def test_import_badgebot_app_and_app_export(): assert BadgeBot.__app_export__ == BadgeBotApp def test_import_hexdrive_app_and_app_export(): - import sim.apps.BadgeBot.hexdrive as HexDrive - from sim.apps.BadgeBot.hexdrive import HexDriveApp + import sim.apps.BadgeBot.EEPROM.hexdrive as HexDrive + from sim.apps.BadgeBot.EEPROM.hexdrive import HexDriveApp assert HexDrive.__app_export__ == HexDriveApp def test_badgebot_app_init(): @@ -24,13 +24,13 @@ def test_badgebot_app_init(): BadgeBotApp() def test_hexdrive_app_init(port): - from sim.apps.BadgeBot.hexdrive import HexDriveApp + from sim.apps.BadgeBot.EEPROM.hexdrive import HexDriveApp config = HexpansionConfig(port) HexDriveApp(config) def test_app_versions_match(): import sim.apps.BadgeBot.app as BadgeBot - import sim.apps.BadgeBot.hexdrive as HexDrive + import sim.apps.BadgeBot.EEPROM.hexdrive as HexDrive assert BadgeBot.HEXDRIVE_APP_VERSION == HexDrive.VERSION # above test should always pass since BadgeBot.HEXDRIVE_APP_VERSION is imported from HexDrive.VERSION, but this test will at least catch if someone accidentally changes one without the other. @@ -43,7 +43,7 @@ def test_hexdrive_type_pids_consistent(): the motor/servo/stepper capability counts must agree. """ from sim.apps.BadgeBot import BadgeBotApp - from sim.apps.BadgeBot.hexdrive import _HEXDRIVE_TYPES + from sim.apps.BadgeBot.EEPROM.hexdrive import _HEXDRIVE_TYPES app_instance = BadgeBotApp() hexdrive_hexpansion_types = [ diff --git a/typings/app.pyi b/typings/app.pyi new file mode 100644 index 0000000..582abcc --- /dev/null +++ b/typings/app.pyi @@ -0,0 +1,3 @@ +class App: + def __init__(self) -> None: ... + def minimise(self) -> None: ... diff --git a/typings/app_components/__init__.pyi b/typings/app_components/__init__.pyi index b731025..9ef99dd 100644 --- a/typings/app_components/__init__.pyi +++ b/typings/app_components/__init__.pyi @@ -3,6 +3,7 @@ from typing import Any class Menu: is_animating: str - def __init__(self, *args: Any, **kwargs: Any) -> None: ... - def update(self, delta: int) -> None: ... - def draw(self, ctx: Any) -> None: ... + def __init__(self, *_args: Any, **_kwargs: Any) -> None: ... + def update(self, _delta: int) -> None: ... + def draw(self, _ctx: Any) -> None: ... + def _cleanup(self) -> None: ... diff --git a/typings/app_components/notification.pyi b/typings/app_components/notification.pyi index 831423a..1ce7be1 100644 --- a/typings/app_components/notification.pyi +++ b/typings/app_components/notification.pyi @@ -1,6 +1,7 @@ from typing import Any class Notification: - def __init__(self, text: str) -> None: ... - def update(self, delta: int) -> None: ... - def draw(self, ctx: Any) -> None: ... + def __init__(self, _text: str, **_kwargs: Any) -> None: ... + def update(self, _delta: int) -> None: ... + def draw(self, _ctx: Any) -> None: ... + def _is_closed(self) -> bool: ... diff --git a/typings/egpio.pyi b/typings/egpio.pyi new file mode 100644 index 0000000..fe69768 --- /dev/null +++ b/typings/egpio.pyi @@ -0,0 +1,4 @@ +class ePin: + IN: int + OUT: int + PWM: int diff --git a/typings/events/input.pyi b/typings/events/input.pyi index 7f8616b..9a6ddd0 100644 --- a/typings/events/input.pyi +++ b/typings/events/input.pyi @@ -6,11 +6,16 @@ class Button: class ButtonUpEvent: button: Button - def __init__(self, button: Button | None = ...) -> None: ... + def __init__(self, _button: Button | None = ...) -> None: ... + +class ButtonDownEvent: + button: Button + + def __init__(self, _button: Button | None = ...) -> None: ... class Buttons: - def __init__(self, app: Any) -> None: ... - def get(self, button: Button) -> bool: ... + def __init__(self, _app: Any) -> None: ... + def get(self, _button: Button) -> bool: ... def clear(self) -> None: ... BUTTON_TYPES: dict[str, Button] diff --git a/typings/machine.pyi b/typings/machine.pyi index 38b869b..298c390 100644 --- a/typings/machine.pyi +++ b/typings/machine.pyi @@ -3,31 +3,39 @@ from typing import Any class Pin: IN: int OUT: int + PWM: int + IRQ_FALLING: int - def __init__(self, *args: Any, **kwargs: Any) -> None: ... - def init(self, *args: Any, **kwargs: Any) -> None: ... - def value(self, *args: Any) -> int: ... + def __init__(self, *_args: Any, **_kwargs: Any) -> None: ... + def init(self, *_args: Any, **_kwargs: Any) -> None: ... + def value(self, *_args: Any) -> int: ... class PWM: - def __init__(self, pin: Pin, *args: Any, **kwargs: Any) -> None: ... - def freq(self, *args: Any) -> int: ... - def duty_u16(self, *args: Any) -> int: ... - def duty_ns(self, *args: Any) -> int: ... + def __init__(self, _pin: Pin, *_args: Any, **_kwargs: Any) -> None: ... + def freq(self, *_args: Any) -> int: ... + def duty_u16(self, *_args: Any) -> int: ... + def duty_ns(self, *_args: Any) -> int: ... def deinit(self) -> None: ... class I2C: - def __init__(self, *args: Any, **kwargs: Any) -> None: ... - def readfrom_mem(self, addr: int, memaddr: int, nbytes: int, *args: Any, **kwargs: Any) -> bytes: ... - def writeto_mem(self, addr: int, memaddr: int, buf: bytes, *args: Any, **kwargs: Any) -> None: ... - def writeto(self, addr: int, buf: bytes, stop: bool = True) -> int: ... - def readfrom(self, addr: int, nbytes: int, stop: bool = True) -> bytes: ... + def __init__(self, *_args: Any, **_kwargs: Any) -> None: ... + def scan(self) -> list[int]: ... + def readfrom_mem(self, _addr: int, _memaddr: int, _nbytes: int, *_args: Any, **_kwargs: Any) -> bytes: ... + def writeto_mem(self, _addr: int, _memaddr: int, _buf: bytes, *_args: Any, **_kwargs: Any) -> None: ... + def writeto(self, _addr: int, _buf: bytes, _stop: bool = True) -> int: ... + def readfrom(self, _addr: int, _nbytes: int, _stop: bool = True) -> bytes: ... + +class UART: + def __init__(self, *_args: Any, **_kwargs: Any) -> None: ... + def readline(self) -> bytes | None: ... + def deinit(self) -> None: ... class Timer: PERIODIC: int ONE_SHOT: int - def __init__(self, id: int = -1, *args: Any, **kwargs: Any) -> None: ... - def init(self, *args: Any, **kwargs: Any) -> None: ... + def __init__(self, _id: int = -1, /, *_args: Any, **_kwargs: Any) -> None: ... + def init(self, *_args: Any, **_kwargs: Any) -> None: ... def deinit(self) -> None: ... class _Mem32: @@ -37,4 +45,4 @@ class _Mem32: mem32: _Mem32 def disable_irq() -> int: ... -def enable_irq(state: int) -> None: ... +def enable_irq(_state: int) -> None: ... diff --git a/typings/system/eventbus.pyi b/typings/system/eventbus.pyi index ef42555..8dd6e45 100644 --- a/typings/system/eventbus.pyi +++ b/typings/system/eventbus.pyi @@ -1,8 +1,9 @@ from typing import Any class _EventBus: - def on_async(self, event_type: Any, handler: Any, owner: Any) -> None: ... - def remove(self, event_type: Any, handler: Any, owner: Any) -> None: ... - def emit(self, event: Any) -> None: ... + def on(self, _event_type: Any, _handler: Any, _owner: Any) -> None: ... + def on_async(self, _event_type: Any, _handler: Any, _owner: Any) -> None: ... + def remove(self, _event_type: Any, _handler: Any, _owner: Any) -> None: ... + def emit(self, _event: Any) -> None: ... eventbus: _EventBus diff --git a/typings/system/hexpansion/header.pyi b/typings/system/hexpansion/header.pyi index a476014..c362e17 100644 --- a/typings/system/hexpansion/header.pyi +++ b/typings/system/hexpansion/header.pyi @@ -1,7 +1,18 @@ +from __future__ import annotations + from typing import Any class HexpansionHeader: - def __init__(self, *args: Any, **kwargs: Any) -> None: ... + vid: int + pid: int + unique_id: int + friendly_name: str + eeprom_page_size: int + eeprom_total_size: int + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: ... + @classmethod + def from_bytes(cls, _data: bytes) -> HexpansionHeader: ... -def write_header(i2c: Any, addr: int, header: HexpansionHeader, *args: Any, **kwargs: Any) -> None: ... -def read_header(i2c: Any, addr: int, *args: Any, **kwargs: Any) -> HexpansionHeader: ... +def write_header(*_args: Any, **_kwargs: Any) -> None: ... +def read_header(_i2c: Any, _addr: int, *_args: Any, **_kwargs: Any) -> HexpansionHeader: ... diff --git a/typings/system/hexpansion/util.pyi b/typings/system/hexpansion/util.pyi index cd5d216..2263968 100644 --- a/typings/system/hexpansion/util.pyi +++ b/typings/system/hexpansion/util.pyi @@ -1,3 +1,4 @@ from typing import Any -def get_hexpansion_block_devices(i2c: Any, header: Any, addr: int, *args: Any, **kwargs: Any) -> tuple[Any, Any]: ... +def get_hexpansion_block_devices(_i2c: Any, _header: Any, _addr: int, *_args: Any, **_kwargs: Any) -> tuple[Any, Any]: ... +def detect_eeprom_addr(_i2c: Any, *_args: Any, **_kwargs: Any) -> tuple[int | None, int | None]: ...