From 938ea1a607837dd29c46334c6eb45044396b4429 Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Thu, 6 Nov 2025 16:07:49 +0100 Subject: [PATCH 01/10] Add constrain function --- src/modulino/__init__.py | 2 +- src/modulino/helpers.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/modulino/__init__.py b/src/modulino/__init__.py index c9ac73c..949a57c 100644 --- a/src/modulino/__init__.py +++ b/src/modulino/__init__.py @@ -4,7 +4,7 @@ __maintainer__ = "Arduino" # Import core classes and/or functions to expose them at the package level -from .helpers import map_value, map_value_int +from .helpers import map_value, map_value_int, constrain from .modulino import Modulino from .pixels import ModulinoPixels, ModulinoColor from .thermo import ModulinoThermo diff --git a/src/modulino/helpers.py b/src/modulino/helpers.py index ef5a068..e896a21 100644 --- a/src/modulino/helpers.py +++ b/src/modulino/helpers.py @@ -29,3 +29,17 @@ def map_value_int(x: float | int, in_min: float | int, in_max: float | int, out_ The mapped value as an integer. """ return int(map_value(x, in_min, in_max, out_min, out_max)) + +def constrain(value: float | int, min_value: float | int, max_value: float | int) -> float | int: + """ + Constrains a value to be within a specified range. + + Args: + value: The value to constrain. + min_value: The minimum allowable value. + max_value: The maximum allowable value. + + Returns: + The constrained value. + """ + return max(min_value, min(value, max_value)) \ No newline at end of file From 36548340daad70da06309743373e8f7cc302d66a Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Wed, 19 Nov 2025 16:40:10 +0100 Subject: [PATCH 02/10] Rename the movement properties --- examples/movement.py | 9 +++++---- src/modulino/movement.py | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/examples/movement.py b/examples/movement.py index 5f10213..fcb2502 100644 --- a/examples/movement.py +++ b/examples/movement.py @@ -11,10 +11,11 @@ movement = ModulinoMovement() while True: - acc = movement.accelerometer - gyro = movement.gyro + acc = movement.acceleration + gyro = movement.angular_velocity - print(f"🏃 Accelerometer: x:{acc.x:>8.3f} y:{acc.y:>8.3f} z:{acc.z:>8.3f}") - print(f"🌐 Gyroscope: x:{gyro.x:>8.3f} y:{gyro.y:>8.3f} z:{gyro.z:>8.3f}") + print(f"🏃 Acceleration: x:{acc.x:>8.3f} y:{acc.y:>8.3f} z:{acc.z:>8.3f}") + print(f"💪 Acceleration Magnitude: {movement.acceleration_magnitude:>8.3f} g") + print(f"🌐 Angular Velocity: x:{gyro.x:>8.3f} y:{gyro.y:>8.3f} z:{gyro.z:>8.3f}") print("") sleep_ms(100) diff --git a/src/modulino/movement.py b/src/modulino/movement.py index 3b1c88a..827b929 100644 --- a/src/modulino/movement.py +++ b/src/modulino/movement.py @@ -27,7 +27,7 @@ def __init__(self, i2c_bus = None, address: int | None = None) -> None: self.sensor = LSM6DSOX(self.i2c_bus, address=self.address) @property - def accelerometer(self) -> MovementValues: + def acceleration(self) -> MovementValues: """ Returns: MovementValues: The acceleration values in the x, y, and z axes. @@ -38,7 +38,17 @@ def accelerometer(self) -> MovementValues: return MovementValues(sensor_values[0], sensor_values[1], sensor_values[2]) @property - def gyro(self) -> MovementValues: + def acceleration_magnitude(self) -> float: + """ + Returns: + float: The magnitude of the acceleration vector in g. + When the Modulino is at rest (on planet earth), this value should be approximately 1.0g due to gravity. + """ + x, y, z = self.accelerometer + return (x**2 + y**2 + z**2) ** 0.5 + + @property + def angular_velocity(self) -> MovementValues: """ Returns: MovementValues: The gyroscope values in the x, y, and z axes. From 7a997288c537aac6d2b70e84652a5fcbb7ba9655 Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Thu, 27 Nov 2025 15:30:37 +0100 Subject: [PATCH 03/10] Forbid BL address for custom address --- examples/change_address.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/change_address.py b/examples/change_address.py index ec49216..d289e26 100644 --- a/examples/change_address.py +++ b/examples/change_address.py @@ -37,6 +37,10 @@ print("Invalid address. Address must be between 0 and 127") exit(1) +if new_address == 100: + print("The address 0x64 is reserved for bootloader mode. Please choose a different address.") + exit(1) + print(f"Changing address of device at {hex(selected_device.address)} to {hex(new_address)}...") selected_device.change_address(new_address) sleep(1) # Give the device time to reset From 09ecd3598e16de4d56513247a456167701ee12ef Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Thu, 27 Nov 2025 15:31:04 +0100 Subject: [PATCH 04/10] Skip devices in BL mode during scan --- src/modulino/modulino.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/modulino/modulino.py b/src/modulino/modulino.py index 42601fd..4c9f532 100644 --- a/src/modulino/modulino.py +++ b/src/modulino/modulino.py @@ -25,6 +25,8 @@ 0x4: "Latch Relay" } +_BOOTLOADER_ADDRESS = const(0x64) + class _I2CHelper: """ A helper class for interacting with I2C devices on supported boards. @@ -267,7 +269,7 @@ def enter_bootloader(self): sleep(0.25) # Wait for the device to reset return True except OSError as e: - # ENODEV (e.errno == 19) can be thrown if either the device reset while writing out the buffer + # ENODEV (e.errno == 19) can be thrown if the device resets while writing out the buffer return False def read(self, amount_of_bytes: int) -> bytes | None: @@ -328,6 +330,9 @@ def available_devices(bus: I2C = None) -> list[Modulino]: device_addresses = bus.scan() devices = [] for address in device_addresses: + if address == _BOOTLOADER_ADDRESS: + # Skip bootloader address + continue device = Modulino(i2c_bus=bus, address=address) devices.append(device) return devices From 1485714b890c70d2d721d87e2206c3937b984a28 Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Thu, 27 Nov 2025 15:31:58 +0100 Subject: [PATCH 05/10] Use custom scan function to work around address range limitation --- src/modulino/modulino.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/modulino/modulino.py b/src/modulino/modulino.py index 4c9f532..1ceae7f 100644 --- a/src/modulino/modulino.py +++ b/src/modulino/modulino.py @@ -183,7 +183,7 @@ def discover(self, default_addresses: list[int]) -> int | None: if len(default_addresses) == 0: return None - devices_on_bus = self.i2c_bus.scan() + devices_on_bus = Modulino.scan(self.i2c_bus) for addr in default_addresses: if addr in devices_on_bus: return addr @@ -207,7 +207,7 @@ def connected(self) -> bool: """ if not bool(self): return False - return self.address in self.i2c_bus.scan() + return self.address in Modulino.scan(self.i2c_bus) @property def pin_strap_address(self) -> int | None: @@ -314,6 +314,18 @@ def has_default_address(self) -> bool: """ return self.address in self.default_addresses + @staticmethod + def scan(bus: I2C) -> list[int]: + addresses = bytearray() # Use 8bit data type + # Skip general call address (0x00) + for address in range(1,128): + try: + bus.writeto(address, b'') + addresses.append(address) + except OSError: + pass + return list(addresses) + @staticmethod def available_devices(bus: I2C = None) -> list[Modulino]: """ @@ -327,7 +339,7 @@ def available_devices(bus: I2C = None) -> list[Modulino]: """ if bus is None: bus = _I2CHelper.get_interface() - device_addresses = bus.scan() + device_addresses = Modulino.scan(bus) devices = [] for address in device_addresses: if address == _BOOTLOADER_ADDRESS: From e298ebb5eb85cb5a61dbd19657df660dc99d541f Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Thu, 27 Nov 2025 15:33:23 +0100 Subject: [PATCH 06/10] Add alias for angular velocity --- src/modulino/movement.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/modulino/movement.py b/src/modulino/movement.py index 827b929..2086234 100644 --- a/src/modulino/movement.py +++ b/src/modulino/movement.py @@ -56,4 +56,16 @@ def angular_velocity(self) -> MovementValues: or by using the index operator for tuple unpacking. """ sensor_values = self.sensor.gyro() - return MovementValues(sensor_values[0], sensor_values[1], sensor_values[2]) \ No newline at end of file + return MovementValues(sensor_values[0], sensor_values[1], sensor_values[2]) + + @property + def gyro(self) -> MovementValues: + """ + Alias for angular_velocity property. + + Returns: + MovementValues: The gyroscope values in the x, y, and z axes. + These values can be accessed as .x, .y, and .z properties + or by using the index operator for tuple unpacking. + """ + return self.angular_velocity \ No newline at end of file From c0e29d36ed409505ed4af568a06feb436632fbe7 Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Thu, 27 Nov 2025 15:57:44 +0100 Subject: [PATCH 07/10] Improve efficiency of I2C scanning --- src/modulino/modulino.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/modulino/modulino.py b/src/modulino/modulino.py index 1ceae7f..c37ba78 100644 --- a/src/modulino/modulino.py +++ b/src/modulino/modulino.py @@ -133,7 +133,7 @@ class Modulino: This class variable needs to be overridden in derived classes. """ - def __init__(self, i2c_bus: I2C = None, address: int = None, name: str = None): + def __init__(self, i2c_bus: I2C = None, address: int = None, name: str = None, check_connection: bool = True) -> None: """ Initializes the Modulino object with the given i2c bus and address. If the address is not provided, the device will try to auto discover it. @@ -145,6 +145,7 @@ def __init__(self, i2c_bus: I2C = None, address: int = None, name: str = None): i2c_bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. address (int): The address of the device. If not provided, the device will try to auto discover it. name (str): The name of the device. + check_connection (bool): Whether to check if the device is connected to the bus. """ if i2c_bus is None: @@ -168,7 +169,7 @@ def __init__(self, i2c_bus: I2C = None, address: int = None, name: str = None): if self.address is None: raise RuntimeError(f"Couldn't find the {self.name} device on the bus. Try resetting the board.") - elif not self.connected: + elif check_connection and not self.connected: raise RuntimeError(f"Couldn't find a {self.name} device with address {hex(self.address)} on the bus. Try resetting the board.") def discover(self, default_addresses: list[int]) -> int | None: @@ -183,11 +184,9 @@ def discover(self, default_addresses: list[int]) -> int | None: if len(default_addresses) == 0: return None - devices_on_bus = Modulino.scan(self.i2c_bus) - for addr in default_addresses: - if addr in devices_on_bus: - return addr - + devices_on_bus = Modulino.scan(self.i2c_bus, default_addresses) + if len(devices_on_bus) > 0: + return devices_on_bus[0] return None def __bool__(self) -> bool: @@ -207,7 +206,12 @@ def connected(self) -> bool: """ if not bool(self): return False - return self.address in Modulino.scan(self.i2c_bus) + + try: + self.i2c_bus.writeto(self.address, b'') + return True + except OSError: + return False @property def pin_strap_address(self) -> int | None: @@ -315,10 +319,12 @@ def has_default_address(self) -> bool: return self.address in self.default_addresses @staticmethod - def scan(bus: I2C) -> list[int]: + def scan(bus: I2C, target_addresses = None) -> list[int]: addresses = bytearray() # Use 8bit data type - # Skip general call address (0x00) - for address in range(1,128): + # General call address (0x00) is skipped in default range + candidates = target_addresses if target_addresses is not None else range(1,128) + + for address in candidates: try: bus.writeto(address, b'') addresses.append(address) @@ -345,7 +351,7 @@ def available_devices(bus: I2C = None) -> list[Modulino]: if address == _BOOTLOADER_ADDRESS: # Skip bootloader address continue - device = Modulino(i2c_bus=bus, address=address) + device = Modulino(i2c_bus=bus, address=address, check_connection=False) devices.append(device) return devices From 2f043f30140e20013cc244f7c4d7bb5d7e902259 Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Thu, 27 Nov 2025 16:22:47 +0100 Subject: [PATCH 08/10] Improve robustness of address changer script --- examples/change_address.py | 79 ++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/examples/change_address.py b/examples/change_address.py index d289e26..54b9e5c 100644 --- a/examples/change_address.py +++ b/examples/change_address.py @@ -11,42 +11,63 @@ from time import sleep from modulino import Modulino -print() -bus = None # Change this to the I2C bus you are using on 3rd party host boards -devices = Modulino.available_devices(bus) +def main(): + print() + bus = None # Change this to the I2C bus you are using on 3rd party host boards + devices = Modulino.available_devices(bus) -if len(devices) == 0: - print("No devices found on the bus. Try resetting the board.") - exit(1) + if len(devices) == 0: + print("No devices found on the bus. Try resetting the board.") + return -print("The following devices were found on the bus:") + print("The following devices were found on the bus:") -for index, device in enumerate(devices): - print(f"{index + 1}) {device.device_type} at {hex(device.address)}") + for index, device in enumerate(devices): + print(f"{index + 1}) {device.device_type} at {hex(device.address)}") -choice = int(input("\nEnter the device number for which you want to change the address: ")) + choice_is_valid = False + while not choice_is_valid: + try: + choice = int(input("\nEnter the device number for which you want to change the address: ")) + except ValueError: + print("Invalid input. Please enter a valid device number.") + continue + + if choice < 1 or choice > len(devices): + print("Invalid choice. Please select a valid device number.") + else: + choice_is_valid = True -if choice < 1 or choice > len(devices): - print("Invalid choice. Please select a valid device number.") - exit(1) + selected_device = devices[choice - 1] -selected_device = devices[choice - 1] -new_address = int(input("Enter the new address (hexadecimal or decimal): "), 0) -if new_address < 0 or new_address > 127: - print("Invalid address. Address must be between 0 and 127") - exit(1) + new_address_is_valid = False + while not new_address_is_valid: + try: + new_address = int(input("Enter the new address (hexadecimal or decimal): "), 0) + except ValueError: + print("Invalid input. Please enter a valid hexadecimal (e.g., 0x2A) or decimal (e.g., 42) address.") + continue -if new_address == 100: - print("The address 0x64 is reserved for bootloader mode. Please choose a different address.") - exit(1) + if new_address < 1 or new_address > 127: + print("Invalid address. Address must be between 1 and 127") + elif new_address == 100: + print("The address 0x64 (100) is reserved for bootloader mode. Please choose a different address.") + else: + new_address_is_valid = True -print(f"Changing address of device at {hex(selected_device.address)} to {hex(new_address)}...") -selected_device.change_address(new_address) -sleep(1) # Give the device time to reset + print(f"Changing address of device at {hex(selected_device.address)} to {hex(new_address)}...") + selected_device.change_address(new_address) + sleep(1) # Give the device time to reset -# Check if the address was successfully changed -if selected_device.connected: - print(f"✅ Address changed successfully to {hex(new_address)}") -else: - print("❌ Failed to change address. Please try again.") \ No newline at end of file + # Check if the address was successfully changed + if selected_device.connected: + print(f"✅ Address changed successfully to {hex(new_address)}") + else: + print("❌ Failed to change address. Please try again.") + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\Aborted by user") \ No newline at end of file From 9452baf8da274fad29a30e503ac667bbfccd55b1 Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Thu, 27 Nov 2025 16:33:52 +0100 Subject: [PATCH 09/10] Add interpreter to examples script --- examples/change_address.py | 3 ++- run_examples.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) mode change 100644 => 100755 run_examples.py diff --git a/examples/change_address.py b/examples/change_address.py index 54b9e5c..3f422b0 100644 --- a/examples/change_address.py +++ b/examples/change_address.py @@ -23,7 +23,8 @@ def main(): print("The following devices were found on the bus:") for index, device in enumerate(devices): - print(f"{index + 1}) {device.device_type} at {hex(device.address)}") + dev_type = device.device_type if device.device_type is not None else "Unknown Device" + print(f"{index + 1}) {dev_type} at {hex(device.address)}") choice_is_valid = False while not choice_is_valid: diff --git a/run_examples.py b/run_examples.py old mode 100644 new mode 100755 index e479c7a..17eacef --- a/run_examples.py +++ b/run_examples.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 # This script will list the examples in the examples folder and run the selected example using mpremote. # The user can select the example using the arrow keys. # To run the script, use the following command: From 36e3a211e93254947b1ec28e2559d234396f1f63 Mon Sep 17 00:00:00 2001 From: Sebastian Romero Date: Wed, 3 Dec 2025 16:13:24 +0100 Subject: [PATCH 10/10] Add timeout to distance sensor reading --- src/modulino/distance.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/modulino/distance.py b/src/modulino/distance.py index 673f303..5d4e414 100644 --- a/src/modulino/distance.py +++ b/src/modulino/distance.py @@ -1,3 +1,4 @@ +from time import sleep_ms, ticks_ms, ticks_diff from .modulino import Modulino from .lib.vl53l4cd import VL53L4CD @@ -25,16 +26,19 @@ def __init__(self, i2c_bus = None, address: int | None = None) -> None: self.sensor.start_ranging() @property - def _distance_raw(self) -> int | None: + def _distance_raw(self, timeout = 1000) -> int | None: """ Reads the raw distance value from the sensor and clears the interrupt. Returns: int: The distance in centimeters. """ - try: + try: + start = ticks_ms() while not self.sensor.data_ready: - pass + if ticks_diff(ticks_ms(), start) > timeout: + raise OSError("Timeout waiting for sensor data") + sleep_ms(1) self.sensor.clear_interrupt() sensor_value = self.sensor.distance return sensor_value