├── .gitignore ├── examples ├── distance.py ├── joystick.py ├── movement.py ├── thermo.py ├── vibro.py ├── knob_pixels.py ├── latch_relay.py ├── third_party_board.py ├── knob.py ├── knob_buzzer.py ├── knob_async.py ├── pixels_thermo.py ├── buttons.py ├── pixels.py ├── buzzer.py ├── change_address.py └── firmware_update.py ├── src └── modulino │ ├── __init__.py │ ├── latch_relay.py │ ├── helpers.py │ ├── vibro.py │ ├── thermo.py │ ├── distance.py │ ├── movement.py │ ├── buzzer.py │ ├── joystick.py │ ├── knob.py │ ├── pixels.py │ ├── buttons.py │ ├── modulino.py │ └── lib │ └── vl53l4cd.py ├── pyproject.toml ├── .github └── workflows │ └── render-documentation.yml ├── package.json ├── run_examples.py ├── README.md ├── docs ├── README.md └── assets │ └── library-banner.svg └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /examples/distance.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to use the ModulinoDistance class to read the distance from the Time of Flight sensor of the Modulino. 3 | 4 | The sensor works by sending out short pulses of light and then measure the time it takes for some of the emitted light to come back 5 | when it hits an object. The time it takes for the light to come back is directly proportional to the distance between the sensor and the object. 6 | 7 | Initial author: Sebastian Romero (s.romero@arduino.cc) 8 | """ 9 | 10 | from modulino import ModulinoDistance 11 | from time import sleep_ms 12 | 13 | distance = ModulinoDistance() 14 | 15 | while True: 16 | print(f"📏 Distance: {distance.distance} cm") 17 | sleep_ms(50) -------------------------------------------------------------------------------- /examples/joystick.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example demonstrates how to use the ModulinoJoystick class to read 3 | joystick coordinates and handle button events. 4 | 5 | Initial author: Sebastian Romero (s.romero@arduino.cc) 6 | """ 7 | 8 | from modulino import ModulinoJoystick 9 | 10 | joystick = ModulinoJoystick() 11 | joystick.on_button_press = lambda: print("Button pressed") 12 | joystick.on_button_release = lambda: print("Button released") 13 | joystick.on_button_long_press = lambda: print("Button long pressed") 14 | 15 | while True: 16 | state_changed = joystick.update() 17 | if state_changed: 18 | print(f"Joystick position: x={joystick.x}, y={joystick.y}") 19 | # print("Button pressed:", joystick.button_pressed) -------------------------------------------------------------------------------- /examples/movement.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to use the ModulinoMovement class to read the accelerometer 3 | and gyroscope values from the Modulino. 4 | 5 | Initial author: Sebastian Romero (s.romero@arduino.cc) 6 | """ 7 | 8 | from modulino import ModulinoMovement 9 | from time import sleep_ms 10 | 11 | movement = ModulinoMovement() 12 | 13 | while True: 14 | acc = movement.acceleration 15 | gyro = movement.angular_velocity 16 | 17 | print(f"🏃 Acceleration: x:{acc.x:>8.3f} y:{acc.y:>8.3f} z:{acc.z:>8.3f}") 18 | print(f"💪 Acceleration Magnitude: {movement.acceleration_magnitude:>8.3f} g") 19 | print(f"🌐 Angular Velocity: x:{gyro.x:>8.3f} y:{gyro.y:>8.3f} z:{gyro.z:>8.3f}") 20 | print("") 21 | sleep_ms(100) 22 | -------------------------------------------------------------------------------- /examples/thermo.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to use the ModulinoThermo class to read the temperature and humidity from the Modulino. 3 | 4 | If those values are temporarily unavailable, they will be set to None. 5 | 6 | Initial author: Sebastian Romero (s.romero@arduino.cc) 7 | """ 8 | 9 | from modulino import ModulinoThermo 10 | from time import sleep 11 | 12 | thermo_module = ModulinoThermo() 13 | 14 | while True: 15 | temperature = thermo_module.temperature 16 | humidity = thermo_module.relative_humidity 17 | 18 | if temperature != None and humidity != None: 19 | print(f"🌡️ Temperature: {temperature:.1f} °C") 20 | print(f"💧 Humidity: {humidity:.1f} %") 21 | print() 22 | 23 | sleep(2) -------------------------------------------------------------------------------- /src/modulino/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.0.0' 2 | __author__ = "Sebastian Romero" 3 | __license__ = "MPL 2.0" 4 | __maintainer__ = "Arduino" 5 | 6 | # Import core classes and/or functions to expose them at the package level 7 | from .helpers import map_value, map_value_int, constrain 8 | from .modulino import Modulino 9 | from .pixels import ModulinoPixels, ModulinoColor 10 | from .thermo import ModulinoThermo 11 | from .buzzer import ModulinoBuzzer 12 | from .buttons import ModulinoButtons 13 | from .knob import ModulinoKnob 14 | from .movement import ModulinoMovement 15 | from .distance import ModulinoDistance 16 | from .joystick import ModulinoJoystick 17 | from .latch_relay import ModulinoLatchRelay 18 | from .vibro import ModulinoVibro, PowerLevel 19 | -------------------------------------------------------------------------------- /examples/vibro.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example demonstrates how to use the ModulinoVibro class to control a vibration motor. 3 | It cycles through different vibration patterns with varying power levels and durations. 4 | 5 | Initial author: Sebastian Romero (s.romero@arduino.cc) 6 | """ 7 | 8 | from modulino import ModulinoVibro, PowerLevel 9 | from time import sleep 10 | 11 | vibro = ModulinoVibro() 12 | 13 | pattern = [ 14 | (500, PowerLevel.GENTLE), 15 | (1000, PowerLevel.MODERATE), 16 | (1500, PowerLevel.MEDIUM), 17 | (2000, PowerLevel.INTENSE), 18 | (2500, PowerLevel.POWERFUL), 19 | (3000, PowerLevel.MAXIMUM) 20 | ] 21 | 22 | for duration, power in pattern: 23 | vibro.on(duration, power, blocking=True) 24 | sleep(0.5) # Pause between vibrations 25 | -------------------------------------------------------------------------------- /examples/knob_pixels.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to use the ModulinoKnob and ModulinoPixels classes to control a set of pixels with a knob. 3 | 4 | The knob is used to set the number of pixels to turn on. 5 | The range property of the knob is used to map the knob values to the number of pixels to turn on. 6 | 7 | Initial author: Sebastian Romero (s.romero@arduino.cc) 8 | """ 9 | 10 | from modulino import ModulinoKnob, ModulinoPixels, ModulinoColor 11 | 12 | knob = ModulinoKnob() 13 | pixels = ModulinoPixels() 14 | 15 | # 8 pixels, index is 0-based. -1 means all pixels are off. 16 | knob.value = -1 17 | knob.range = (-1, 7) 18 | 19 | def update_pixels(): 20 | pixels.clear_all() 21 | if knob.value >= 0: 22 | pixels.set_range_color(0, knob.value, ModulinoColor.GREEN) 23 | pixels.show() 24 | 25 | while True: 26 | if(knob.update()): 27 | print(f"New level: {knob.value}") 28 | update_pixels() -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [[tool.pydoc-markdown.loaders]] 2 | type = "python" 3 | search_path = [ "./src" ] 4 | packages = ["modulino"] 5 | 6 | [[tool.pydoc-markdown.processors]] 7 | type = "filter" 8 | skip_empty_modules = true 9 | do_not_filter_modules = false 10 | # Private classes need to be excluded explicitly since this is not supported yet by the filter processor 11 | expression = "not 'modulino.lib' in name and not (name.startswith('_') and not name.endswith('_')) and default()" 12 | 13 | [[tool.pydoc-markdown.processors]] 14 | type = "google" 15 | 16 | [tool.pydoc-markdown.renderer] 17 | type = "markdown" 18 | filename = "docs/api.md" 19 | code_headers = true 20 | descriptive_class_title = "class " 21 | descriptive_module_title = true 22 | add_module_prefix = false 23 | render_toc = true 24 | render_toc_title = "Summary" 25 | render_module_header = false 26 | 27 | [tool.pydoc-markdown.renderer.header_level_by_type] 28 | Module = 1 29 | Class = 2 30 | Method = 3 31 | Function = 3 32 | Variable = 3 -------------------------------------------------------------------------------- /examples/latch_relay.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example demonstrates how to use the Modulino Latch Relay module 3 | to turn a relay on and off repeatedly. 4 | 5 | Initial author: Sebastian Romero (s.romero@arduino.cc) 6 | """ 7 | 8 | from modulino import ModulinoLatchRelay 9 | from time import sleep_ms 10 | 11 | relay = ModulinoLatchRelay() 12 | initial_state = relay.is_on 13 | 14 | if initial_state is None: 15 | print("Relay state is unknown (last state before poweroff is maintained)") 16 | else: 17 | print(f"Relay is currently {'on' if initial_state else 'off'}") 18 | 19 | while True: 20 | print("Turning relay on") 21 | relay.on() 22 | sleep_ms(150) # Wait for the relay to settle 23 | print(f"Relay is currently {'on' if relay.is_on else 'off'}") 24 | sleep_ms(1000) # Keep the relay on for 1 second 25 | print("Turning relay off") 26 | relay.off() 27 | sleep_ms(150) # Wait for the relay to settle 28 | print(f"Relay is currently {'on' if relay.is_on else 'off'}") 29 | sleep_ms(1000) # Keep the relay off for 1 second 30 | -------------------------------------------------------------------------------- /examples/third_party_board.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to use the Modulino library with a third party board. 3 | When running on a non-Arduino board, the I2C bus must be initialized manually. 4 | Usually the available I2C buses are predefined and can be accessed by their number, e.g. I2C(0). 5 | If not, the pins for SDA and SCL must be specified. 6 | 7 | Please note that the Modulinos are designed to work with a bus frequency of 100kHz. 8 | 9 | Initial author: Sebastian Romero (s.romero@arduino.cc) 10 | """ 11 | 12 | from modulino import Modulino 13 | from modulino import ModulinoPixels 14 | from machine import I2C, Pin 15 | 16 | # The modulinos use a frequency of 100kHz by default. 17 | bus = I2C(0, freq=100000) 18 | # bus = I2C(0, scl=Pin(18), sda=Pin(19), freq=100000) # If you need to specify the pins 19 | 20 | # In case the board was reset during a previous operation the modulinos might 21 | # end up with a stuck bus. To get them unstuck we need to reset the bus. 22 | bus = Modulino.reset_bus(bus) 23 | 24 | # Do something with your modulino... 25 | # For example controlling the pixels: 26 | pixels = ModulinoPixels(bus) 27 | pixels.set_all_rgb(0, 255, 0, 100) 28 | pixels.show() 29 | -------------------------------------------------------------------------------- /.github/workflows/render-documentation.yml: -------------------------------------------------------------------------------- 1 | # Alternative: 2 | # https://github.com/ml-tooling/lazydocs 3 | 4 | name: Render Documentation 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | - docs 11 | paths: 12 | - ".github/workflows/render-documentation.ya?ml" 13 | - "src/**" 14 | workflow_dispatch: 15 | 16 | jobs: 17 | render-docs: 18 | permissions: 19 | contents: write 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | if: github.event_name != 'pull_request' 24 | - uses: actions/checkout@v4 25 | if: github.event_name == 'pull_request' 26 | with: 27 | repository: ${{ github.event.pull_request.head.repo.full_name }} 28 | ref: ${{ github.event.pull_request.head.ref }} 29 | - name: Install dependencies 30 | run: pip3 install pydoc-markdown 31 | - name: Render documentation 32 | run: pydoc-markdown 33 | - name: Commit changes 34 | uses: stefanzweifel/git-auto-commit-action@v4 35 | with: 36 | commit_message: "Update documentation" 37 | file_pattern: "docs/**" 38 | commit_user_name: "GitHub Action" 39 | commit_user_email: "action@github.com" -------------------------------------------------------------------------------- /src/modulino/latch_relay.py: -------------------------------------------------------------------------------- 1 | from .modulino import Modulino 2 | 3 | class ModulinoLatchRelay(Modulino): 4 | """ 5 | Class to control the relay module of the Modulino. 6 | """ 7 | 8 | default_addresses = [0x4] 9 | 10 | def __init__(self, i2c_bus=None, address=None): 11 | """ 12 | Initializes the Modulino Buzzer. 13 | 14 | Parameters: 15 | i2c_bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. 16 | address (int): The I2C address of the module. If not provided, the default address will be used. 17 | """ 18 | super().__init__(i2c_bus, address, "Latch Relay") 19 | self.data = bytearray(3) 20 | 21 | def on(self) -> None: 22 | """ 23 | Turns on the relay. 24 | """ 25 | self.data[0] = 1 26 | self.write(self.data) 27 | 28 | def off(self) -> None: 29 | """ 30 | Turns off the relay. 31 | """ 32 | self.data[0] = 0 33 | self.write(self.data) 34 | 35 | @property 36 | def is_on(self) -> bool: 37 | """ 38 | Checks if the relay is currently on. 39 | """ 40 | status = self.read(3) 41 | if status[0] == 0 and status[1] == 0: 42 | return None # last status before poweroff is maintained 43 | 44 | if status[0] == 1: 45 | return False 46 | 47 | return True 48 | -------------------------------------------------------------------------------- /examples/knob.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to use the ModulinoKnob class to read the value of a rotary encoder knob. 3 | 4 | The knob is used to increase or decrease a value. The knob is rotated clockwise to increase the value and counter-clockwise to decrease it. 5 | 6 | You can register callbacks for the following events: 7 | - Press: The knob is pressed. 8 | - Release: The knob is released. 9 | - Rotate clockwise: The knob is rotated clockwise. 10 | - Rotate counter clockwise: The knob is rotated counter clockwise. 11 | 12 | Use reset() to reset the knob value to 0. 13 | 14 | Initial author: Sebastian Romero (s.romero@arduino.cc) 15 | """ 16 | 17 | from modulino import ModulinoKnob 18 | from time import sleep 19 | 20 | knob = ModulinoKnob() 21 | knob.value = 5 # (Optional) Set an initial value 22 | knob.range = (-10, 10) # (Optional) Set a value range 23 | 24 | def on_release(): 25 | knob.reset() 26 | print("🔘 Released! Knob's value was reset.") 27 | 28 | knob.on_press = lambda: print("🔘 Pressed!") 29 | knob.on_release = on_release 30 | knob.on_rotate_clockwise = lambda steps, value: print(f"🎛️ Rotated {steps} steps clockwise! Value: {value}") 31 | knob.on_rotate_counter_clockwise = lambda steps, value: print(f"🎛️ Rotated {steps} steps counter clockwise! Value: {value}") 32 | 33 | while True: 34 | if(knob.update()): 35 | print("👀 Knob value or state changed!") 36 | 37 | sleep(0.1) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "urls": [ 3 | ["modulino/__init__.py", "github:arduino/modulino-mpy/src/modulino/__init__.py"], 4 | ["modulino/helpers.py", "github:arduino/modulino-mpy/src/modulino/helpers.py"], 5 | ["modulino/buttons.py", "github:arduino/modulino-mpy/src/modulino/buttons.py"], 6 | ["modulino/buzzer.py", "github:arduino/modulino-mpy/src/modulino/buzzer.py"], 7 | ["modulino/distance.py", "github:arduino/modulino-mpy/src/modulino/distance.py"], 8 | ["modulino/lib/vl53l4cd.py", "github:arduino/modulino-mpy/src/modulino/lib/vl53l4cd.py"], 9 | ["modulino/knob.py", "github:arduino/modulino-mpy/src/modulino/knob.py"], 10 | ["modulino/modulino.py", "github:arduino/modulino-mpy/src/modulino/modulino.py"], 11 | ["modulino/movement.py", "github:arduino/modulino-mpy/src/modulino/movement.py"], 12 | ["modulino/pixels.py", "github:arduino/modulino-mpy/src/modulino/pixels.py"], 13 | ["modulino/thermo.py", "github:arduino/modulino-mpy/src/modulino/thermo.py"], 14 | ["modulino/joystick.py", "github:arduino/modulino-mpy/src/modulino/joystick.py"], 15 | ["modulino/latch_relay.py", "github:arduino/modulino-mpy/src/modulino/latch_relay.py"], 16 | ["modulino/vibro.py", "github:arduino/modulino-mpy/src/modulino/vibro.py"] 17 | ], 18 | "deps": [ 19 | ["lsm6dsox", "latest"], 20 | ["github:jposada202020/MicroPython_HS3003", "master"] 21 | ], 22 | "version": "1.0.0" 23 | } 24 | -------------------------------------------------------------------------------- /examples/knob_buzzer.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example demonstrates how to use the ModulinoKnob and ModulinoBuzzer classes to play different notes using a buzzer. 3 | 4 | The knob is used to select the note to play. The knob is rotated clockwise to increase the frequency of the note and counter-clockwise to decrease it. 5 | Once the knob is pressed, the buzzer stops playing the note. 6 | Only the notes between 400 and 2000 Hz from the predefined list are played in this example. 7 | You can run print(ModulinoBuzzer.NOTES) to see the full list of available notes. 8 | 9 | Initial author: Sebastian Romero (s.romero@arduino.cc) 10 | """ 11 | 12 | from modulino import ModulinoKnob, ModulinoBuzzer 13 | 14 | knob = ModulinoKnob() 15 | buzzer = ModulinoBuzzer() 16 | 17 | # Select notes between 400 and 2000 Hz 18 | notes = sorted(list(filter(lambda note: note >= 400 and note <= 2000, ModulinoBuzzer.NOTES.values()))) 19 | 20 | knob.range = (0, len(notes) - 1) 21 | knob.on_press = lambda : buzzer.no_tone() 22 | 23 | def on_knob_rotate_clockwise(_, value): 24 | frequency = notes[value] 25 | print(f"🎵 Frequency: {frequency} Hz") 26 | buzzer.tone(frequency) 27 | 28 | def on_knob_rotate_counter_clockwise(_, value): 29 | frequency = notes[value] 30 | print(f"🎵 Frequency: {frequency} Hz") 31 | buzzer.tone(frequency) 32 | 33 | knob.on_rotate_clockwise = on_knob_rotate_clockwise 34 | knob.on_rotate_counter_clockwise = on_knob_rotate_counter_clockwise 35 | 36 | while True: 37 | knob.update() 38 | -------------------------------------------------------------------------------- /examples/knob_async.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to use the ModulinoKnob class to read the value of a rotary encoder knob asynchronously. 3 | 4 | Asyncio is used to read the knob value and to blink the built-in LED at the same time. 5 | The knob is used to increase or decrease a value. The knob is rotated clockwise to increase the value and counter-clockwise to decrease it. 6 | 7 | You can register callbacks for the following events: 8 | - Press: The knob is pressed. 9 | - Release: The knob is released. 10 | - Rotate clockwise: The knob is rotated clockwise. 11 | - Rotate counter clockwise: The knob is rotated counter clockwise. 12 | 13 | Initial author: Sebastian Romero (s.romero@arduino.cc) 14 | """ 15 | 16 | from modulino import ModulinoKnob 17 | from machine import Pin 18 | import asyncio 19 | 20 | led = Pin("LED_BUILTIN", Pin.OUT) 21 | 22 | knob = ModulinoKnob() 23 | 24 | knob.on_press = lambda: print("🔘 Pressed!") 25 | knob.on_release = lambda: print("🔘 Released!") 26 | knob.on_rotate_clockwise = lambda steps, value: print(f"🎛️ Rotated {steps} steps clockwise! Value: {value}") 27 | knob.on_rotate_counter_clockwise = lambda steps, value: print(f"🎛️ Rotated {steps} steps counter clockwise! Value: {value}") 28 | 29 | async def blink_led(): 30 | while True: 31 | led.value(0) 32 | await asyncio.sleep(0.5) 33 | led.value(1) 34 | await asyncio.sleep(0.5) 35 | 36 | async def read_knob(): 37 | while True: 38 | if(knob.update()): 39 | print("👀 Knob value or state changed!") 40 | await asyncio.sleep_ms(20) 41 | 42 | async def main(): 43 | await asyncio.gather( 44 | blink_led(), 45 | read_knob() 46 | ) 47 | 48 | asyncio.run(main()) 49 | -------------------------------------------------------------------------------- /src/modulino/helpers.py: -------------------------------------------------------------------------------- 1 | def map_value(x: float | int, in_min: float | int, in_max: float | int, out_min: float | int, out_max: float | int) -> float | int: 2 | """ 3 | Maps a value from one range to another. 4 | 5 | Args: 6 | x: The value to map. 7 | in_min: The minimum value of the input range. 8 | in_max: The maximum value of the input range. 9 | out_min: The minimum value of the output range. 10 | out_max: The maximum value of the output range. 11 | 12 | Returns: 13 | The mapped value as a float or int depending on the input. 14 | """ 15 | return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min 16 | 17 | def map_value_int(x: float | int, in_min: float | int, in_max: float | int, out_min: float | int, out_max: float | int) -> int: 18 | """ 19 | Maps a value from one range to another and returns an integer. 20 | 21 | Args: 22 | x: The value to map. 23 | in_min: The minimum value of the input range. 24 | in_max: The maximum value of the input range. 25 | out_min: The minimum value of the output range. 26 | out_max: The maximum value of the output range. 27 | 28 | Returns: 29 | The mapped value as an integer. 30 | """ 31 | return int(map_value(x, in_min, in_max, out_min, out_max)) 32 | 33 | def constrain(value: float | int, min_value: float | int, max_value: float | int) -> float | int: 34 | """ 35 | Constrains a value to be within a specified range. 36 | 37 | Args: 38 | value: The value to constrain. 39 | min_value: The minimum allowable value. 40 | max_value: The maximum allowable value. 41 | 42 | Returns: 43 | The constrained value. 44 | """ 45 | return max(min_value, min(value, max_value)) -------------------------------------------------------------------------------- /src/modulino/vibro.py: -------------------------------------------------------------------------------- 1 | from .modulino import Modulino 2 | from time import sleep_ms 3 | 4 | class PowerLevel: 5 | STOP = 0 6 | GENTLE = 25 7 | MODERATE = 35 8 | MEDIUM = 45 9 | INTENSE = 55 10 | POWERFUL = 65 11 | MAXIMUM = 75 12 | 13 | class ModulinoVibro(Modulino): 14 | """ 15 | Class to operate the vibration motor of the Modulino Vibro. 16 | """ 17 | 18 | default_addresses = [0x70] 19 | 20 | def __init__(self, i2c_bus=None, address=None): 21 | """ 22 | Initializes the Modulino Vibro. 23 | 24 | Parameters: 25 | i2c_bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. 26 | address (int): The I2C address of the module. If not provided, the default address will be used. 27 | """ 28 | super().__init__(i2c_bus, address, "Vibro") 29 | self.data = bytearray(12) 30 | self.frequency = 1000 # Default frequency in Hz 31 | self.off() 32 | 33 | def on(self, lenght_ms: int = 0xFFFF, power = PowerLevel.MEDIUM, blocking: bool = False) -> None: 34 | """ 35 | Vibrates the motor for the specified duration and power level. 36 | 37 | Parameters: 38 | lenght_ms: The duration of the vibration in milliseconds. If omitted, it defaults to 65535 ms (maximum duration). 39 | blocking: If set to True, the function will wait until the vibration is finished. 40 | """ 41 | self.data[0:4] = self.frequency.to_bytes(4, 'little') 42 | self.data[4:8] = lenght_ms.to_bytes(4, 'little') 43 | self.data[8:12] = power.to_bytes(4, 'little') 44 | self.write(self.data) 45 | 46 | if blocking: 47 | # Subtract 5ms to accommodate for the time it takes to send the data 48 | sleep_ms(lenght_ms - 5) 49 | 50 | def off(self) -> None: 51 | """ 52 | Stops the motor from vibrating. 53 | """ 54 | self.data = bytearray(12) 55 | self.write(self.data) -------------------------------------------------------------------------------- /examples/pixels_thermo.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to use the ModulinoPixels and ModulinoThermo classes to display the temperature on a pixel strip. 3 | 4 | A high temperature is represented by red color and a lower temperature with a yellow color. 5 | 6 | The pixels will map to the temperature range so that the first pixel represents 7 | the lowest temperature and the last pixel the highest temperature of the predefined range. 8 | You can change the temperature range to accommodate the temperature range of your environment. 9 | 10 | Initial author: Sebastian Romero (s.romero@arduino.cc) 11 | """ 12 | 13 | from modulino import ModulinoPixels, ModulinoThermo 14 | from time import sleep 15 | 16 | pixels = ModulinoPixels() 17 | thermo_module = ModulinoThermo() 18 | 19 | # Yellow to red scale with 8 steps 20 | colors = [ 21 | (255, 255, 0), 22 | (255, 204, 0), 23 | (255, 153, 0), 24 | (255, 102, 0), 25 | (255, 51, 0), 26 | (255, 0, 0), 27 | (204, 0, 0), 28 | (153, 0, 0) 29 | ] 30 | 31 | # Define the range of temperatures (°C) to map to the pixel strip 32 | temperature_range = (20, 30) 33 | 34 | while True: 35 | temperature = thermo_module.temperature 36 | print(f"🌡️ Temperature: {temperature:.1f} °C") 37 | 38 | # Constrain temperature to the given range 39 | if temperature < temperature_range[0]: 40 | temperature = temperature_range[0] 41 | elif temperature > temperature_range[1]: 42 | temperature = temperature_range[1] 43 | 44 | # Map temperature to the pixel strip 45 | # temperature_range[0]°C : 0 index -> first pixel 46 | # temperature_range[1]°C : 7 index -> last pixel 47 | temperature_index = int((temperature - temperature_range[0]) * 7 / (temperature_range[1] - temperature_range[0])) 48 | 49 | pixels.clear_all() 50 | 51 | for index in range(0, temperature_index + 1): 52 | pixels.set_rgb(index, *colors[index], 100) 53 | 54 | pixels.show() 55 | sleep(1) -------------------------------------------------------------------------------- /examples/buttons.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to use the ModulinoButtons class to interact with the buttons of the Modulino. 3 | 4 | The ModulinoButtons class allows you to read the state of the buttons, set the state of the LEDs, and define callbacks for the different button events. 5 | It's necessary to call the `update()` method in each iteration of the loop to read the state of the buttons and execute the callbacks. 6 | Use the `long_press_duration` property to set the duration in milliseconds that the button must be pressed to trigger the long press event. 7 | 8 | Initial author: Sebastian Romero (s.romero@arduino.cc) 9 | """ 10 | 11 | from modulino import ModulinoButtons 12 | from time import sleep 13 | 14 | buttons = ModulinoButtons() 15 | 16 | buttons.on_button_a_press = lambda : print("Button A pressed") 17 | buttons.on_button_a_long_press = lambda : print("Button A long press") 18 | buttons.on_button_a_release = lambda : print("Button A released") 19 | 20 | buttons.on_button_b_press = lambda : print("Button B pressed") 21 | buttons.on_button_b_long_press = lambda : print("Button B long press") 22 | buttons.on_button_b_release = lambda : print("Button B released") 23 | 24 | buttons.on_button_c_press = lambda : print("Button C pressed") 25 | buttons.on_button_c_long_press = lambda : print("Button C long press") 26 | buttons.on_button_c_release = lambda : print("Button C released") 27 | 28 | buttons.led_a.on() 29 | sleep(0.5) 30 | buttons.led_b.on() 31 | sleep(0.5) 32 | buttons.led_c.on() 33 | sleep(0.5) 34 | buttons.set_led_status(False, False, False) # Turn off all LEDs 35 | 36 | while True: 37 | buttons_state_changed = buttons.update() 38 | 39 | if(buttons_state_changed): 40 | led_a_status = buttons.is_pressed(0) # Turn LED A on if button A is pressed 41 | led_b_status = buttons.is_pressed(1) # Turn LED B on if button B is pressed 42 | led_c_status = buttons.is_pressed(2) # Turn LED C on if button C is pressed 43 | buttons.set_led_status(led_a_status, led_b_status, led_c_status) -------------------------------------------------------------------------------- /src/modulino/thermo.py: -------------------------------------------------------------------------------- 1 | from .modulino import Modulino 2 | from micropython import const 3 | from collections import namedtuple 4 | 5 | # Driver from github.com/jposada202020/MicroPython_HS3003 6 | from micropython_hs3003 import hs3003 7 | 8 | Measurement = namedtuple('Measurement', ['temperature', 'relative_humidity']) 9 | """A named tuple to store the temperature and relative humidity measurements.""" 10 | 11 | class ModulinoThermo(Modulino): 12 | """ 13 | Class to interact with the temperature and humidity sensor of the Modulino Thermo. 14 | """ 15 | 16 | # The default I2C address of the HS3003 sensor cannot be changed by the user 17 | # so we can define it as a constant and avoid discovery overhead. 18 | DEFAULT_ADDRESS = const(0x44) 19 | 20 | def __init__(self, i2c_bus: I2C = None, address: int = DEFAULT_ADDRESS) -> None: 21 | """ 22 | Initializes the Modulino Thermo. 23 | 24 | Parameters: 25 | i2c_bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. 26 | address (int): The I2C address of the module. If not provided, the default address will be used. 27 | """ 28 | super().__init__(i2c_bus, address, "Thermo") 29 | self.sensor: hs3003.HS3003 = hs3003.HS3003(self.i2c_bus) 30 | 31 | @property 32 | def measurements(self) -> Measurement: 33 | """ 34 | Return Temperature and Relative Humidity or None if the data is stalled 35 | """ 36 | (temperature, humidity) = self.sensor.measurements 37 | 38 | if self.sensor._status_bit == 1: 39 | return Measurement(None, None) 40 | 41 | return Measurement(temperature, humidity) 42 | 43 | @property 44 | def relative_humidity(self) -> float: 45 | """The current relative humidity in % rH""" 46 | return self.measurements.relative_humidity 47 | 48 | @property 49 | def temperature(self) -> float: 50 | """The current temperature in Celsius""" 51 | return self.measurements.temperature 52 | -------------------------------------------------------------------------------- /examples/pixels.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to use the ModulinoPixels class to control a set of pixels. 3 | 4 | The pixels are set to different colors and animations. 5 | You can use the ModulinoColor class to set predefined colors: 6 | - RED, GREEN, BLUE, YELLOW, CYAN, VIOLET, WHITE 7 | 8 | Initial author: Sebastian Romero (s.romero@arduino.cc) 9 | """ 10 | 11 | from modulino import ModulinoPixels, ModulinoColor 12 | from time import sleep 13 | 14 | pixels = ModulinoPixels() 15 | 16 | for index in range(0, 8): 17 | color_wheel_colors = [ 18 | (255, 0, 0), 19 | (255, 85, 0), 20 | (255, 255, 0), 21 | (0, 255, 0), 22 | (0, 255, 255), 23 | (0, 0, 255), 24 | (255, 0, 255), 25 | (255, 0, 0) 26 | ] 27 | pixels.set_rgb(index, *color_wheel_colors[index], 100) 28 | pixels.show() 29 | sleep(1) 30 | 31 | pixels.set_all_rgb(255, 0, 0, 100) 32 | pixels.show() 33 | sleep(1) 34 | 35 | pixels.set_all_color(ModulinoColor.GREEN, 100) 36 | pixels.show() 37 | sleep(1) 38 | 39 | pixels.set_all_color(ModulinoColor.BLUE, 100) 40 | pixels.show() 41 | sleep(1) 42 | 43 | 44 | # Night Rider animation 45 | 46 | def set_glowing_led(index, r, g, b, brightness): 47 | """ 48 | Set the color of the LED at the given index with its 49 | neighboring LEDs slightly dimmed to create a glowing effect. 50 | """ 51 | pixels.clear_all() 52 | pixels.set_rgb(index, r, g, b, brightness) 53 | 54 | if index > 0: 55 | pixels.set_rgb(index - 1, r, g, b, brightness // 8) # LED to the left 56 | if index < 7: 57 | pixels.set_rgb(index + 1, r, g, b, brightness // 8) # LED to the right 58 | 59 | pixels.show() 60 | 61 | for j in range(0, 3): 62 | for i in range(0, 8): 63 | set_glowing_led(i, 255, 0, 0, 100) 64 | sleep(0.05) 65 | 66 | for i in range(7, -1, -1): 67 | set_glowing_led(i, 255, 0, 0, 100) 68 | sleep(0.05) 69 | 70 | # Turn off all LEDs 71 | # Daisy chain the show() method to send the data to the LEDs 72 | # This works for all the methods that modify the LEDs' appearance. 73 | pixels.clear_all().show() -------------------------------------------------------------------------------- /src/modulino/distance.py: -------------------------------------------------------------------------------- 1 | from time import sleep_ms, ticks_ms, ticks_diff 2 | from .modulino import Modulino 3 | from .lib.vl53l4cd import VL53L4CD 4 | 5 | class ModulinoDistance(Modulino): 6 | """ 7 | Class to interact with the distance sensor of the Modulino Distance. 8 | """ 9 | 10 | default_addresses = [0x29] 11 | convert_default_addresses = False 12 | 13 | def __init__(self, i2c_bus = None, address: int | None = None) -> None: 14 | """ 15 | Initializes the Modulino Distance. 16 | 17 | Parameters: 18 | i2c_bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. 19 | address (int): The I2C address of the module. If not provided, the default address will be used. 20 | """ 21 | 22 | super().__init__(i2c_bus, address, "Distance") 23 | self.sensor = VL53L4CD(self.i2c_bus, self.address) 24 | self.sensor.timing_budget = 20 25 | self.sensor.inter_measurement = 0 26 | self.sensor.start_ranging() 27 | 28 | @property 29 | def _distance_raw(self, timeout = 1000) -> int | None: 30 | """ 31 | Reads the raw distance value from the sensor and clears the interrupt. 32 | 33 | Returns: 34 | int: The distance in centimeters. 35 | """ 36 | try: 37 | start = ticks_ms() 38 | while not self.sensor.data_ready: 39 | if ticks_diff(ticks_ms(), start) > timeout: 40 | raise OSError("Timeout waiting for sensor data") 41 | sleep_ms(1) 42 | self.sensor.clear_interrupt() 43 | sensor_value = self.sensor.distance 44 | return sensor_value 45 | except OSError: 46 | # Catch timeout errors 47 | return None 48 | 49 | @property 50 | def distance(self) -> int: 51 | """ 52 | Returns: 53 | int: The distance in centimeters. 54 | """ 55 | while True: 56 | raw_distance = self._distance_raw 57 | # Filter out invalid readings 58 | if not raw_distance is None and raw_distance > 0: 59 | return raw_distance -------------------------------------------------------------------------------- /examples/buzzer.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to use the ModulinoBuzzer class to play a melody using the buzzer of the Modulino. 3 | 4 | You can print the available notes with `print(ModulinoBuzzer.NOTES)` 5 | Use the blocking parameter to add a delay between the notes which effectively makes 6 | the tones play for the specified duration. 7 | If you set blocking to False, following tones will overwrite the previous ones unless you 8 | manually add a delay between them. 9 | You can always use no_tone() to stop the current tone no matter the duration set. 10 | 11 | Initial author: Sebastian Romero (s.romero@arduino.cc) 12 | """ 13 | 14 | from modulino import ModulinoBuzzer 15 | from time import sleep 16 | 17 | buzzer = ModulinoBuzzer() 18 | 19 | # Super Mario Bros theme intro 20 | melody = [ 21 | (ModulinoBuzzer.NOTES["E5"], 125), 22 | (ModulinoBuzzer.NOTES["REST"], 25), 23 | (ModulinoBuzzer.NOTES["E5"], 125), 24 | (ModulinoBuzzer.NOTES["REST"], 125), 25 | (ModulinoBuzzer.NOTES["E5"], 125), 26 | (ModulinoBuzzer.NOTES["REST"], 125), 27 | (ModulinoBuzzer.NOTES["C5"], 125), 28 | (ModulinoBuzzer.NOTES["E5"], 125), 29 | (ModulinoBuzzer.NOTES["REST"], 125), 30 | (ModulinoBuzzer.NOTES["G5"], 125), 31 | (ModulinoBuzzer.NOTES["REST"], 375), 32 | (ModulinoBuzzer.NOTES["G4"], 250) 33 | ] 34 | 35 | for note, duration in melody: 36 | buzzer.tone(note, duration, blocking=True) 37 | 38 | # Wait 2 seconds before playing the next melody 39 | sleep(2) 40 | 41 | # Police siren sound effect 42 | def generate_siren(frequency_start, frequency_end, total_duration, steps, iterations): 43 | siren = [] 44 | mid_point = steps // 2 45 | duration_rise = total_duration // 2 46 | duration_fall = total_duration // 2 47 | 48 | for _ in range(iterations): 49 | for i in range(steps): 50 | if i < mid_point: 51 | # Easing in rising part 52 | step_duration = duration_rise // mid_point + (duration_rise // mid_point * (mid_point - i) // mid_point) 53 | frequency = int(frequency_start + (frequency_end - frequency_start) * (i / mid_point)) 54 | else: 55 | # Easing in falling part 56 | step_duration = duration_fall // mid_point + (duration_fall // mid_point * (i - mid_point) // mid_point) 57 | frequency = int(frequency_end - (frequency_end - frequency_start) * ((i - mid_point) / mid_point)) 58 | 59 | siren.append((frequency, step_duration)) 60 | 61 | return siren 62 | 63 | # 4 seconds up and down siren, with 200 steps and 2 iterations 64 | siren_melody = generate_siren(440, 880, 4000, 200, 2) 65 | 66 | for note, duration in siren_melody: 67 | buzzer.tone(note, duration, blocking=True) -------------------------------------------------------------------------------- /examples/change_address.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example shows how to change the I2C address of a Modulino device. 3 | After changing the address, the device will be reset and the new address will be verified. 4 | From then on, when creating a Modulino object, you should use the new address. 5 | e.g. ModulinoBuzzer(address=0x2A) 6 | 7 | Initial author: Sebastian Romero (s.romero@arduino.cc) 8 | """ 9 | 10 | from sys import exit 11 | from time import sleep 12 | from modulino import Modulino 13 | 14 | def main(): 15 | print() 16 | bus = None # Change this to the I2C bus you are using on 3rd party host boards 17 | devices = Modulino.available_devices(bus) 18 | 19 | if len(devices) == 0: 20 | print("No devices found on the bus. Try resetting the board.") 21 | return 22 | 23 | print("The following devices were found on the bus:") 24 | 25 | for index, device in enumerate(devices): 26 | dev_type = device.device_type if device.device_type is not None else "Unknown Device" 27 | print(f"{index + 1}) {dev_type} at {hex(device.address)}") 28 | 29 | choice_is_valid = False 30 | while not choice_is_valid: 31 | try: 32 | choice = int(input("\nEnter the device number for which you want to change the address: ")) 33 | except ValueError: 34 | print("Invalid input. Please enter a valid device number.") 35 | continue 36 | 37 | if choice < 1 or choice > len(devices): 38 | print("Invalid choice. Please select a valid device number.") 39 | else: 40 | choice_is_valid = True 41 | 42 | selected_device = devices[choice - 1] 43 | 44 | 45 | new_address_is_valid = False 46 | while not new_address_is_valid: 47 | try: 48 | new_address = int(input("Enter the new address (hexadecimal or decimal): "), 0) 49 | except ValueError: 50 | print("Invalid input. Please enter a valid hexadecimal (e.g., 0x2A) or decimal (e.g., 42) address.") 51 | continue 52 | 53 | if new_address < 1 or new_address > 127: 54 | print("Invalid address. Address must be between 1 and 127") 55 | elif new_address == 100: 56 | print("The address 0x64 (100) is reserved for bootloader mode. Please choose a different address.") 57 | else: 58 | new_address_is_valid = True 59 | 60 | print(f"Changing address of device at {hex(selected_device.address)} to {hex(new_address)}...") 61 | selected_device.change_address(new_address) 62 | sleep(1) # Give the device time to reset 63 | 64 | # Check if the address was successfully changed 65 | if selected_device.connected: 66 | print(f"✅ Address changed successfully to {hex(new_address)}") 67 | else: 68 | print("❌ Failed to change address. Please try again.") 69 | 70 | if __name__ == "__main__": 71 | try: 72 | main() 73 | except KeyboardInterrupt: 74 | print("\Aborted by user") -------------------------------------------------------------------------------- /src/modulino/movement.py: -------------------------------------------------------------------------------- 1 | from .modulino import Modulino 2 | from lsm6dsox import LSM6DSOX 3 | from collections import namedtuple 4 | 5 | MovementValues = namedtuple('MovementValues', ['x', 'y', 'z']) 6 | """A named tuple to store the x, y, and z values of the movement sensors.""" 7 | 8 | class ModulinoMovement(Modulino): 9 | """ 10 | Class to interact with the movement sensor (IMU) of the Modulino Movement. 11 | """ 12 | 13 | # Module can have one of two default addresses 14 | # based on the solder jumper configuration on the board 15 | default_addresses = [0x6A, 0x6B] 16 | convert_default_addresses = False 17 | 18 | def __init__(self, i2c_bus = None, address: int | None = None) -> None: 19 | """ 20 | Initializes the Modulino Movement. 21 | 22 | Parameters: 23 | i2c_bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. 24 | address (int): The I2C address of the module. If not provided, the default address will be used. 25 | """ 26 | super().__init__(i2c_bus, address, "Movement") 27 | self.sensor = LSM6DSOX(self.i2c_bus, address=self.address) 28 | 29 | @property 30 | def acceleration(self) -> MovementValues: 31 | """ 32 | Returns: 33 | MovementValues: The acceleration values in the x, y, and z axes. 34 | These values can be accessed as .x, .y, and .z properties 35 | or by using the index operator for tuple unpacking. 36 | """ 37 | sensor_values = self.sensor.accel() 38 | return MovementValues(sensor_values[0], sensor_values[1], sensor_values[2]) 39 | 40 | @property 41 | def acceleration_magnitude(self) -> float: 42 | """ 43 | Returns: 44 | float: The magnitude of the acceleration vector in g. 45 | When the Modulino is at rest (on planet earth), this value should be approximately 1.0g due to gravity. 46 | """ 47 | x, y, z = self.accelerometer 48 | return (x**2 + y**2 + z**2) ** 0.5 49 | 50 | @property 51 | def angular_velocity(self) -> MovementValues: 52 | """ 53 | Returns: 54 | MovementValues: The gyroscope values in the x, y, and z axes. 55 | These values can be accessed as .x, .y, and .z properties 56 | or by using the index operator for tuple unpacking. 57 | """ 58 | sensor_values = self.sensor.gyro() 59 | return MovementValues(sensor_values[0], sensor_values[1], sensor_values[2]) 60 | 61 | @property 62 | def gyro(self) -> MovementValues: 63 | """ 64 | Alias for angular_velocity property. 65 | 66 | Returns: 67 | MovementValues: The gyroscope values in the x, y, and z axes. 68 | These values can be accessed as .x, .y, and .z properties 69 | or by using the index operator for tuple unpacking. 70 | """ 71 | return self.angular_velocity -------------------------------------------------------------------------------- /run_examples.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # This script will list the examples in the examples folder and run the selected example using mpremote. 3 | # The user can select the example using the arrow keys. 4 | # To run the script, use the following command: 5 | # python run_examples.py 6 | 7 | import os 8 | 9 | def get_examples(): 10 | # Reads the examples from the examples folder 11 | examples = [] 12 | for filename in os.listdir('examples'): 13 | if filename.endswith('.py'): 14 | examples.append(filename) 15 | return examples 16 | 17 | def run_example(example): 18 | # Run the example using mpremote: mpremote mount src run ./examples/{example} 19 | os.system(f'mpremote mount src run ./examples/{example}') 20 | 21 | if __name__ == '__main__': 22 | examples = get_examples() 23 | 24 | # Ask the user which example to run using the arrow keys 25 | import curses 26 | from curses import wrapper 27 | from curses.textpad import Textbox, rectangle 28 | 29 | def main(stdscr): 30 | global examples 31 | curses.curs_set(0) 32 | main_text_start_row = 2 33 | selected_row = 0 34 | 35 | while True: 36 | try: 37 | # Refresh curses.LINES to get the latest terminal size 38 | curses.update_lines_cols() 39 | 40 | # Check if the terminal size is large enough to display the examples 41 | if curses.LINES < len(examples) + main_text_start_row: 42 | stdscr.clear() 43 | stdscr.addstr(0, 0, "Increase the terminal size to display the examples.") 44 | stdscr.refresh() 45 | continue 46 | 47 | stdscr.clear() # Clear the screen before repainting 48 | stdscr.addstr(0, 0, "Select an example to run:") 49 | 50 | for i, example in enumerate(examples): 51 | x = 0 if i == selected_row else 2 52 | y = main_text_start_row + i 53 | 54 | if i == selected_row: 55 | stdscr.addstr(y, x, f"> {example}", curses.A_BOLD) 56 | stdscr.addstr(y, x + len(example) + 2, ' ') 57 | else: 58 | stdscr.addstr(y, x, example) 59 | 60 | stdscr.refresh() 61 | key = stdscr.getch() 62 | 63 | if key == curses.KEY_UP: 64 | selected_row = max(0, selected_row - 1) 65 | elif key == curses.KEY_DOWN: 66 | selected_row = min(len(examples) - 1, selected_row + 1) 67 | elif key == ord('\n'): 68 | # Ensure the screen is cleared before running the example 69 | stdscr.clear() 70 | stdscr.refresh() 71 | run_example(examples[selected_row]) 72 | # break # Uncomment this line to exit the loop after running one example 73 | except KeyboardInterrupt: 74 | break 75 | 76 | wrapper(main) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](./docs/assets/library-banner.svg) 2 | 3 | # 📦 Modulino MicroPython Package 4 | 5 | This package contains an API to connect to Arduino Modulinos, read their data and control them. 6 | 7 | ## ✨ Features 8 | 9 | Supports the following Modulinos: 10 | 11 | - 🔘 **Modulino Buttons**: A three-button Modulino. 12 | - 🎵 **Modulino Buzzer**: A piezo speaker. 13 | - 🌈 **Modulino Pixels**: Control RGB LEDs on the Modulino Pixels. 14 | - 📏 **Modulino Distance**: Measure distance to an object. 15 | - 🏃‍♂️ **Modulino Movement**: Measure acceleration and positioning. 16 | - 🎛️ **Modulino Knob**: A rotating knob with a button. 17 | - 🌡️ **Modulino Thermo**: Read surrounding temperature and humidity. 18 | - ⚡️ **Modulino Latch Relay**: Control a latching relay to switch devices on and off. 19 | - 🕹️ **Modulino Joystick**: Read X/Y axis and button state from a joystick Modulino. 20 | - 📳 **Modulino Vibro**: Control a vibration motor. 21 | 22 | ## 📖 Documentation 23 | For more information on the features of this library and how to use them please read the documentation [here](./docs/). 24 | 25 | ## ✅ Supported Boards 26 | 27 | Any board that has I2C and can run a modern version of MicroPython is supported. On non-Arduino boards you will have to specify the I2C interface to be used. e.g. `pixels = ModulinoPixels(I2C(0))`. On Arduino boards the correct I2C interface will be detected automatically. 28 | On boards that don't have a Qwiic connector you will need to buy a Qwiic to Dupont cable or make your own. 29 | 30 | ## ⚙️ Installation 31 | 32 | The easiest way is to use [mpremote and mip](https://docs.micropython.org/en/latest/reference/packages.html#packages): 33 | 34 | ```bash 35 | mpremote mip install github:arduino/arduino-modulino-mpy 36 | ``` 37 | 38 | ## 🧑‍💻 Developer Installation 39 | 40 | The easiest way is to clone the repository and then run any example using `mpremote`. 41 | The recommended way is to mount the root directory remotely on the board and then running an example script. e.g. 42 | 43 | ``` 44 | mpremote connect mount src run ./examples/pixels.py 45 | ``` 46 | 47 | If your board cannot be detected automatically you can try to explicitely specify the board's serial number. For example: 48 | 49 | ``` 50 | mpremote connect id:387784598440 mount src run ./examples/board_control.py 51 | ``` 52 | 53 | The specified serial number passed to the `id` attribute can be retrieved using `mpremote connect list`. 54 | The serial number is the value in the second column. 55 | 56 | To select and run the desired example you can run: 57 | 58 | ``` 59 | python run_examples.py 60 | ``` 61 | 62 | ## 🐛 Reporting Issues 63 | 64 | If you encounter any issue, please open a bug report [here](https://github.com/arduino/arduino-modulino-mpy/issues). 65 | 66 | ## 📕 Further Reading 67 | 68 | - Take a look at the documentation of the [Arduino Plug and Make Kit](https://docs.arduino.cc/hardware/plug-and-make-kit/) which includes 7 selected Modulinos. 69 | 70 | ## 💪 Contributing 71 | 72 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 73 | 74 | ## 🤙 Contact 75 | 76 | For questions, comments, or feedback on this package, please create an issue on this repository. -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # 📖 Documentation 2 | 3 | ## 💻 Usage 4 | 5 | To use this library you can import the `modulino` module along with the desired classes which give you access to the different modulinos. For example: 6 | 7 | ```python 8 | from modulino import ModulinoPixels 9 | 10 | pixels = ModulinoPixels() 11 | ``` 12 | Once the desired object is obtained you can call functions and query properties on these objects such as `pixels.set_all_rgb(255, 0, 0)`. 13 | 14 | ## ℹ️ Using 3rd Party Boards 15 | 16 | When using this library on a non-Arduino board, the I2C bus must be initialized manually. 17 | Usually the available I2C buses are predefined and can be accessed by their number, e.g.: 18 | 19 | ```python 20 | from machine import I2C 21 | from modulino import ModulinoPixels 22 | 23 | pixels = ModulinoPixels(I2C(0)) 24 | ``` 25 | 26 | If not, the pins for SDA and SCL must be specified. An example on how to do this can be found [here](../examples/third_party_board.py). 27 | 28 | ## 🕹️🕹️ Using multiple Modulinos of the same type 29 | 30 | When using multiple Modulinos of the same type, you can create separate instances for each one by specifying different I2C addresses. For that to work, make sure to change their I2C address to a unique one by running the `change_address.py` script which can be found [here](../examples/change_address.py). This only works for Modulino models that support changing the I2C address (e.g., ModulinoButtons, ModulinoBuzzer, ModulinoKnob, ModulinoPixels). 31 | 32 | ```python 33 | from modulino import ModulinoButtons 34 | 35 | buttons1 = ModulinoButtons(address=0x10) 36 | buttons2 = ModulinoButtons(address=0x11) 37 | 38 | print("Button A on Modulino 1 is pressed:", buttons1.button_a_pressed) 39 | print("Button A on Modulino 2 is pressed:", buttons2.button_a_pressed) 40 | ``` 41 | 42 | ## 👀 Examples 43 | 44 | The following scripts are examples of how to use the Modulinos with Python: 45 | 46 | - [buttons.py](../examples/buttons.py): This example shows how to use the ModulinoButtons class to interact with the buttons of the Modulino. 47 | - [buzzer.py](../examples/buzzer.py): This example shows how to use the ModulinoBuzzer class to play a melody using the buzzer of the Modulino. 48 | - [distance.py](../examples/distance.py): This example shows how to use the ModulinoDistance class to read the distance from the Time of Flight sensor of the Modulino. 49 | - [knob.py](../examples/knob.py): This example shows how to use the ModulinoKnob class to read the value of a rotary encoder knob. 50 | - [knob_buzzer.py](../examples/knob_buzzer.py): This example demonstrates how to use the ModulinoKnob and ModulinoBuzzer classes to play different notes using a buzzer. 51 | - [knob_pixels.py](../examples/knob_pixels.py): This example shows how to use the ModulinoKnob and ModulinoPixels classes to control a set of pixels with a knob. 52 | - [movement.py](../examples/movement.py): This example shows how to use the ModulinoMovement class to read the accelerometer 53 | and gyroscope values from the Modulino. 54 | - [pixels.py](../examples/pixels.py): This example shows how to use the ModulinoPixels class to control a set of pixels. 55 | - [pixels_thermo.py](../examples/pixels_thermo.py): This example shows how to use the ModulinoPixels and ModulinoThermo classes to display the temperature on a pixel strip. 56 | temperature and altitude from the Modulino. 57 | - [thermo.py](../examples/thermo.py): This example shows how to use the ModulinoThermo class to read the temperature and humidity from the Modulino. -------------------------------------------------------------------------------- /src/modulino/buzzer.py: -------------------------------------------------------------------------------- 1 | from .modulino import Modulino 2 | from time import sleep_ms 3 | 4 | class ModulinoBuzzer(Modulino): 5 | """ 6 | Class to play tones on the piezo element of the Modulino Buzzer. 7 | Predefined notes are available in the NOTES dictionary e.g. ModulinoBuzzer.NOTES["C4"] 8 | """ 9 | 10 | NOTES: dict[str, int] = { 11 | "FS3": 185, 12 | "G3": 196, 13 | "GS3": 208, 14 | "A3": 220, 15 | "AS3": 233, 16 | "B3": 247, 17 | "C4": 262, 18 | "CS4": 277, 19 | "D4": 294, 20 | "DS4": 311, 21 | "E4": 330, 22 | "F4": 349, 23 | "FS4": 370, 24 | "G4": 392, 25 | "GS4": 415, 26 | "A4": 440, 27 | "AS4": 466, 28 | "B4": 494, 29 | "C5": 523, 30 | "CS5": 554, 31 | "D5": 587, 32 | "DS5": 622, 33 | "E5": 659, 34 | "F5": 698, 35 | "FS5": 740, 36 | "G5": 784, 37 | "GS5": 831, 38 | "A5": 880, 39 | "AS5": 932, 40 | "B5": 988, 41 | "C6": 1047, 42 | "CS6": 1109, 43 | "D6": 1175, 44 | "DS6": 1245, 45 | "E6": 1319, 46 | "F6": 1397, 47 | "FS6": 1480, 48 | "G6": 1568, 49 | "GS6": 1661, 50 | "A6": 1760, 51 | "AS6": 1865, 52 | "B6": 1976, 53 | "C7": 2093, 54 | "CS7": 2217, 55 | "D7": 2349, 56 | "DS7": 2489, 57 | "E7": 2637, 58 | "F7": 2794, 59 | "FS7": 2960, 60 | "G7": 3136, 61 | "GS7": 3322, 62 | "A7": 3520, 63 | "AS7": 3729, 64 | "B7": 3951, 65 | "C8": 4186, 66 | "CS8": 4435, 67 | "D8": 4699, 68 | "DS8": 4978, 69 | "REST": 0 70 | } 71 | """ 72 | Dictionary with the notes and their corresponding frequencies. 73 | The supported notes are defined as follows: 74 | - FS3, G3, GS3, A3, AS3, B3 75 | - C4, CS4, D4, DS4, E4, F4, FS4, G4, GS4, A4, AS4, B4 76 | - C5, CS5, D5, DS5, E5, F5, FS5, G5, GS5, A5, AS5, B5 77 | - C6, CS6, D6, DS6, E6, F6, FS6, G6, GS6, A6, AS6, B6 78 | - C7, CS7, D7, DS7, E7, F7, FS7, G7, GS7, A7, AS7, B7 79 | - C8, CS8, D8, DS8 80 | - REST (Silence) 81 | """ 82 | 83 | default_addresses = [0x3C] 84 | 85 | def __init__(self, i2c_bus=None, address=None): 86 | """ 87 | Initializes the Modulino Buzzer. 88 | 89 | Parameters: 90 | i2c_bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. 91 | address (int): The I2C address of the module. If not provided, the default address will be used. 92 | """ 93 | super().__init__(i2c_bus, address, "Buzzer") 94 | self.data = bytearray(8) 95 | self.no_tone() 96 | 97 | def tone(self, frequency: int, lenght_ms: int = 0xFFFF, blocking: bool = False) -> None: 98 | """ 99 | Plays a tone with the given frequency and duration. 100 | If blocking is set to True, the function will wait until the tone is finished. 101 | 102 | Parameters: 103 | frequency: The frequency of the tone in Hz (freuqencies below 180 Hz are not supported) 104 | lenght_ms: The duration of the tone in milliseconds. If omitted, the tone will play indefinitely 105 | blocking: If set to True, the function will wait until the tone is finished 106 | """ 107 | if frequency < 180 and frequency != 0: 108 | raise ValueError("Frequency must be greater than 180 Hz") 109 | 110 | self.data[0:4] = frequency.to_bytes(4, 'little') 111 | self.data[4:8] = lenght_ms.to_bytes(4, 'little') 112 | self.write(self.data) 113 | 114 | if blocking: 115 | # Subtract 5ms to avoid unwanted pauses between tones 116 | # Those pauses are caused by the time it takes to send the data to the buzzer 117 | sleep_ms(lenght_ms - 5) 118 | 119 | def no_tone(self) -> None: 120 | """ 121 | Stops the current tone from playing. 122 | """ 123 | self.data = bytearray(8) 124 | self.write(self.data) -------------------------------------------------------------------------------- /src/modulino/joystick.py: -------------------------------------------------------------------------------- 1 | from .modulino import Modulino 2 | from time import ticks_ms 3 | from micropython import const 4 | 5 | class ModulinoJoystick(Modulino): 6 | """ 7 | Class to operate the Modulino Joystick module. 8 | """ 9 | 10 | default_addresses = [0x58] 11 | default_long_press_duration = const(1000) # milliseconds 12 | 13 | def __init__(self, i2c_bus=None, address=None): 14 | """ 15 | Initializes the Modulino Joystick module. 16 | 17 | Parameters: 18 | i2c_bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. 19 | address (int): The I2C address of the module. If not provided, the default address will be used. 20 | """ 21 | super().__init__(i2c_bus, address, "Joystick") 22 | self._state = [0, 0, 0] # x, y, button state 23 | self._x = 0 24 | self._y = 0 25 | self._deadzone_threshold = 10 26 | self._last_press_timestamp = 0 27 | self._button_pressed = False 28 | self._on_button_press = None 29 | self._on_button_release = None 30 | self._on_button_long_press = None 31 | self._long_press_duration = self.default_long_press_duration # milliseconds 32 | 33 | def _values_changed(self, x_old, x_new, y_old, y_new, threshold=2): 34 | """ 35 | Checks if the joystick state has changed significantly. 36 | 37 | Parameters: 38 | x_old (int): Old x-coordinate. 39 | x_new (int): New x-coordinate. 40 | y_old (int): Old y-coordinate. 41 | y_new (int): New y-coordinate. 42 | threshold (int): The minimum change in position to consider it a state change. 43 | """ 44 | return abs(x_new - x_old) > threshold or abs(y_new - y_old) > threshold 45 | 46 | def _normalize_coordinates(self, x, y): 47 | """ 48 | Applies deadzone logic to joystick coordinates and maps them to a range centered around 0. 49 | 50 | Parameters: 51 | x (int): The x-coordinate of the joystick. 52 | y (int): The y-coordinate of the joystick. 53 | 54 | Returns: 55 | tuple: Adjusted x and y coordinates after applying deadzone. 56 | """ 57 | if abs(x - 128) < self._deadzone_threshold: 58 | x = 128 59 | if abs(y - 128) < self._deadzone_threshold: 60 | y = 128 61 | return x - 128, y - 128 62 | 63 | def update(self): 64 | """ 65 | Updates the joystick state by reading the current position and button state. 66 | """ 67 | new_state = self.read(3) 68 | previous_state = self._state 69 | self._state = new_state 70 | current_timestamp = ticks_ms() 71 | button_state_changed = new_state[2] != previous_state[2] 72 | 73 | x = new_state[0] 74 | y = new_state[1] 75 | x, y = self._normalize_coordinates(x, y) 76 | x_y_changed = self._values_changed(x, self._x, y, self._y) 77 | 78 | if x_y_changed: 79 | self._x = x 80 | self._y = y 81 | 82 | # Check for long press 83 | if(new_state[2] == 1 and previous_state[2] == 1 and self._last_press_timestamp and current_timestamp - self._last_press_timestamp > self.long_press_duration): 84 | self._last_press_timestamp = None 85 | if self._on_button_long_press: 86 | self._on_button_long_press() 87 | 88 | if button_state_changed: 89 | self._button_pressed = bool(new_state[2] & 0x01) 90 | 91 | # Handle button press and release events 92 | if new_state[2] == 1 and previous_state[2] == 0: 93 | self._last_press_timestamp = ticks_ms() 94 | if self._on_button_press: 95 | self._on_button_press() 96 | elif new_state[2] == 0 and previous_state[2] == 1 and self._on_button_release: 97 | self._on_button_release() 98 | 99 | return x_y_changed or button_state_changed 100 | 101 | @property 102 | def button_pressed(self): 103 | """ 104 | Returns True if the joystick button is pressed, False otherwise. 105 | """ 106 | return self._button_pressed 107 | 108 | @property 109 | def x(self) -> int: 110 | """ 111 | Returns the x-coordinate of the joystick position. 112 | """ 113 | return self._x 114 | 115 | @property 116 | def y(self) -> int: 117 | """ 118 | Returns the y-coordinate of the joystick position. 119 | """ 120 | return self._y 121 | 122 | @property 123 | def deadzone_threshold(self) -> int: 124 | """ 125 | Returns the deadzone threshold for joystick movement. 126 | """ 127 | return self._deadzone_threshold 128 | 129 | @deadzone_threshold.setter 130 | def deadzone_threshold(self, value: int): 131 | """ 132 | Sets the deadzone threshold for joystick movement. 133 | 134 | Parameters: 135 | value (int): The new deadzone threshold. 136 | """ 137 | if value < 0: 138 | raise ValueError("Deadzone threshold must be non-negative.") 139 | self._deadzone_threshold = value 140 | 141 | @property 142 | def on_button_press(self): 143 | """ 144 | Callback function to be called when the joystick button is pressed. 145 | """ 146 | return self._on_button_press 147 | 148 | @on_button_press.setter 149 | def on_button_press(self, callback): 150 | """ 151 | Sets the callback function to be called when the joystick button is pressed. 152 | 153 | Parameters: 154 | callback (callable): The function to call when the button is pressed. 155 | """ 156 | if not callable(callback): 157 | raise ValueError("Callback must be a callable function.") 158 | self._on_button_press = callback 159 | 160 | @property 161 | def on_button_release(self): 162 | """ 163 | Callback function to be called when the joystick button is released. 164 | """ 165 | return self._on_button_release 166 | 167 | @on_button_release.setter 168 | def on_button_release(self, callback): 169 | """ 170 | Sets the callback function to be called when the joystick button is released. 171 | 172 | Parameters: 173 | callback (callable): The function to call when the button is released. 174 | """ 175 | if not callable(callback): 176 | raise ValueError("Callback must be a callable function.") 177 | self._on_button_release = callback 178 | 179 | @property 180 | def on_button_long_press(self): 181 | """ 182 | Callback function to be called when the joystick button is long-pressed. 183 | """ 184 | return self._on_button_long_press 185 | 186 | @on_button_long_press.setter 187 | def on_button_long_press(self, callback): 188 | """ 189 | Sets the callback function to be called when the joystick button is long-pressed. 190 | 191 | Parameters: 192 | callback (callable): The function to call when the button is long-pressed. 193 | """ 194 | if not callable(callback): 195 | raise ValueError("Callback must be a callable function.") 196 | self._on_button_long_press = callback 197 | 198 | @property 199 | def long_press_duration(self) -> int: 200 | """ 201 | Returns the duration in milliseconds for a long press. 202 | """ 203 | return self._long_press_duration 204 | 205 | @long_press_duration.setter 206 | def long_press_duration(self, duration: int): 207 | """ 208 | Sets the duration in milliseconds for a long press. 209 | 210 | Parameters: 211 | duration (int): The new long press duration in milliseconds. 212 | """ 213 | if duration < 0: 214 | raise ValueError("Long press duration must be non-negative.") 215 | self._long_press_duration = duration -------------------------------------------------------------------------------- /src/modulino/knob.py: -------------------------------------------------------------------------------- 1 | from .modulino import Modulino 2 | 3 | class ModulinoKnob(Modulino): 4 | """ 5 | Class to interact with the rotary encoder of the Modulinio Knob. 6 | """ 7 | 8 | # This module can have one of two default addresses 9 | # This is for a use case where two encoders are bundled together in a package 10 | default_addresses = [0x74, 0x76] 11 | 12 | def __init__(self, i2c_bus = None, address = None): 13 | """ 14 | Initializes the Modulino Knob. 15 | 16 | Parameters: 17 | i2c_bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. 18 | address (int): The I2C address of the module. If not provided, the default address will be used. 19 | """ 20 | 21 | super().__init__(i2c_bus, address, "Knob") 22 | self._pressed: bool = None 23 | self._encoder_value: int = None 24 | self._value_range: tuple[int, int] = None 25 | 26 | # Encoder callbacks 27 | self._on_rotate_clockwise = None 28 | self._on_rotate_counter_clockwise = None 29 | self._on_press = None 30 | self._on_release = None 31 | 32 | # Detect bug in the set command that would make 33 | # the encoder value become negative after setting it to x with x != 0 34 | self._set_bug_detected: bool = False 35 | self._read_data() 36 | original_value: int = self._encoder_value 37 | self.value = 100 38 | self._read_data() 39 | 40 | # If the value is not 100, then the set command has a bug 41 | if (self._encoder_value != 100): 42 | self._set_bug_detected = True 43 | 44 | self.value = original_value 45 | 46 | # Reset state to make sure the first update doesn't trigger the callbacks 47 | self._encoder_value = None 48 | self._pressed_status: bool = None 49 | 50 | def _has_rotated_clockwise(self, previous_value: int, current_value: int) -> bool: 51 | """ 52 | Determines if the encoder has rotated clockwise. 53 | 54 | Parameters: 55 | previous_value (int): The previous value of the encoder. 56 | current_value (int): The current value of the encoder. 57 | 58 | Returns: 59 | bool: True if the encoder has rotated clockwise. 60 | """ 61 | # Calculate difference considering wraparound 62 | diff: int = (current_value - previous_value + 65536) % 65536 63 | # Clockwise rotation is indicated by a positive difference less than half the range 64 | return 0 < diff < 32768 65 | 66 | def _has_rotated_counter_clockwise(self, previous_value: int, current_value: int) -> bool: 67 | """ 68 | Determines if the encoder has rotated counter clockwise. 69 | 70 | Parameters: 71 | previous_value (int): The previous value of the encoder. 72 | current_value (int): The current value of the encoder. 73 | 74 | Returns: 75 | bool: True if the encoder has rotated counter clockwise. 76 | """ 77 | # Calculate difference considering wraparound 78 | diff: int = (previous_value - current_value + 65536) % 65536 79 | # Counter-clockwise rotation is indicated by a positive difference less than half the range 80 | return 0 < diff < 32768 81 | 82 | def _get_steps(self, previous_value: int, current_value: int) -> int: 83 | """ 84 | Calculates the number of steps the encoder has moved since the last update. 85 | """ 86 | # Calculate difference considering wraparound 87 | diff: int = (current_value - previous_value + 65536) % 65536 88 | # Clockwise rotation is indicated by a positive difference less than half the range 89 | if 0 < diff < 32768: 90 | return diff 91 | # Counter-clockwise rotation is indicated by a negative difference less than half the range 92 | elif 32768 < diff < 65536: 93 | return diff - 65536 94 | else: 95 | return 0 96 | 97 | def _read_data(self) -> None: 98 | """ 99 | Reads the encoder value and pressed status from the Modulino. 100 | Adjusts the value to the range if it is set. 101 | Converts the encoder value to a signed 16-bit integer. 102 | """ 103 | data: bytes = self.read(3) 104 | self._pressed = data[2] != 0 105 | self._encoder_value = int.from_bytes(data[0:2], 'little', True) 106 | 107 | # Convert to signed int (16 bits), range -32768 to 32767 108 | if self._encoder_value >= 32768: 109 | self._encoder_value = self._encoder_value - 65536 110 | 111 | if self._value_range is not None: 112 | # Constrain the value to the range self._value_range[0] to self._value_range[1] 113 | constrained_value: int = max(self._value_range[0], min(self._value_range[1], self._encoder_value)) 114 | 115 | if constrained_value != self._encoder_value: 116 | self.value = constrained_value 117 | 118 | def reset(self) -> None: 119 | """ 120 | Resets the encoder value to 0. 121 | """ 122 | self.value = 0 123 | 124 | def update(self) -> bool: 125 | """ 126 | Reads new data from the Modulino and calls the corresponding callbacks 127 | if the encoder value or pressed status has changed. 128 | 129 | Returns: 130 | bool: True if the encoder value or pressed status has changed. 131 | """ 132 | previous_value: int = self._encoder_value 133 | previous_pressed_status: bool = self._pressed 134 | 135 | self._read_data() 136 | 137 | # No need to execut the callbacks after the first update 138 | if previous_value is None or previous_pressed_status is None: 139 | return False 140 | 141 | has_rotated_clockwise: bool = self._has_rotated_clockwise(previous_value, self._encoder_value) 142 | has_rotated_counter_clockwise: bool = self._has_rotated_counter_clockwise(previous_value, self._encoder_value) 143 | 144 | # Figure out how many steps the encoder has moved since the last update 145 | steps: int = self._get_steps(previous_value, self._encoder_value) 146 | 147 | if self._on_rotate_clockwise and has_rotated_clockwise: 148 | self._on_rotate_clockwise(steps, self._encoder_value) 149 | 150 | if self._on_rotate_counter_clockwise and has_rotated_counter_clockwise: 151 | self._on_rotate_counter_clockwise(steps, self._encoder_value) 152 | 153 | if self._on_press and self._pressed and not previous_pressed_status: 154 | self._on_press() 155 | 156 | if self._on_release and not self._pressed and previous_pressed_status: 157 | self._on_release() 158 | 159 | return (self._encoder_value != previous_value) or (self._pressed != previous_pressed_status) 160 | 161 | @property 162 | def range(self) -> tuple[int, int]: 163 | """ 164 | Returns the range of the encoder value. 165 | """ 166 | return self._value_range 167 | 168 | @range.setter 169 | def range(self, value: tuple[int, int]) -> None: 170 | """ 171 | Sets the range of the encoder value. 172 | 173 | Parameters: 174 | value (tuple): A tuple with two integers representing the minimum and maximum values of the range. 175 | """ 176 | if value[0] < -32768 or value[1] > 32767: 177 | raise ValueError("Range must be between -32768 and 32767") 178 | 179 | self._value_range = value 180 | 181 | if self.value is None: 182 | return 183 | 184 | # Adjust existing value to the new range 185 | if self.value < self._value_range[0]: 186 | self.value = self._value_range[0] 187 | elif self.value > self._value_range[1]: 188 | self.value = self._value_range[1] 189 | 190 | @property 191 | def on_rotate_clockwise(self): 192 | """ 193 | Returns the callback for the rotate clockwise event. 194 | """ 195 | return self._on_rotate_clockwise 196 | 197 | @on_rotate_clockwise.setter 198 | def on_rotate_clockwise(self, value) -> None: 199 | """ 200 | Sets the callback for the rotate clockwise event. 201 | 202 | Parameters: 203 | value (function): The function to be called when the encoder is rotated clockwise. 204 | """ 205 | self._on_rotate_clockwise = value 206 | 207 | @property 208 | def on_rotate_counter_clockwise(self): 209 | """ 210 | Returns the callback for the rotate counter clockwise event. 211 | """ 212 | return self._on_rotate_counter_clockwise 213 | 214 | @on_rotate_counter_clockwise.setter 215 | def on_rotate_counter_clockwise(self, value) -> None: 216 | """ 217 | Sets the callback for the rotate counter clockwise event. 218 | 219 | Parameters: 220 | value (function): The function to be called when the encoder is rotated counter clockwise. 221 | """ 222 | self._on_rotate_counter_clockwise = value 223 | 224 | @property 225 | def on_press(self): 226 | """ 227 | Returns the callback for the press event. 228 | """ 229 | return self._on_press 230 | 231 | @on_press.setter 232 | def on_press(self, value) -> None: 233 | """ 234 | Sets the callback for the press event. 235 | 236 | Parameters: 237 | value (function): The function to be called when the encoder is pressed. 238 | """ 239 | self._on_press = value 240 | 241 | @property 242 | def on_release(self): 243 | """ 244 | Returns the callback for the release event. 245 | """ 246 | return self._on_release 247 | 248 | @on_release.setter 249 | def on_release(self, value) -> None: 250 | """ 251 | Sets the callback for the release event. 252 | 253 | Parameters: 254 | value (function): The function to be called when the encoder is released. 255 | """ 256 | self._on_release = value 257 | 258 | @property 259 | def value(self) -> int: 260 | """ 261 | Returns the current value of the encoder. 262 | """ 263 | return self._encoder_value 264 | 265 | @value.setter 266 | def value(self, new_value: int) -> None: 267 | """ 268 | Sets the value of the encoder. This overrides the previous value. 269 | 270 | Parameters: 271 | new_value (int): The new value of the encoder. 272 | """ 273 | if self._value_range is not None: 274 | if new_value < self._value_range[0] or new_value > self._value_range[1]: 275 | raise ValueError(f"Value {new_value} is out of range ({self._value_range[0]} to {self._value_range[1]})") 276 | 277 | if self._set_bug_detected: 278 | target_value: int = -new_value 279 | else: 280 | target_value: int = new_value 281 | 282 | buf: bytearray = bytearray(4) 283 | buf[0:2] = target_value.to_bytes(2, 'little') 284 | 285 | if self.write(buf): 286 | self._encoder_value = new_value 287 | 288 | @property 289 | def pressed(self) -> bool: 290 | """ 291 | Returns the pressed status of the encoder. 292 | """ 293 | return self._pressed -------------------------------------------------------------------------------- /src/modulino/pixels.py: -------------------------------------------------------------------------------- 1 | from .modulino import Modulino 2 | from .helpers import map_value_int 3 | 4 | from micropython import const 5 | 6 | class ModulinoColor: 7 | """ 8 | Class to represent an RGB color. 9 | It comes with predefined colors: 10 | - RED 11 | - GREEN 12 | - BLUE 13 | - YELLOW 14 | - CYAN 15 | - MAGENTA 16 | - WHITE 17 | 18 | They can be accessed e.g. as ModulinoColor.RED 19 | """ 20 | 21 | def __init__(self, r: int, g: int, b: int): 22 | """ 23 | Initializes the color with the given RGB values. 24 | 25 | Parameters: 26 | r (int): The red value of the color. 27 | g (int): The green value of the color. 28 | b (int): The blue value of the color. 29 | """ 30 | 31 | if r < 0 or r > 255: 32 | raise ValueError(f"Red value {r} should be between 0 and 255") 33 | if g < 0 or g > 255: 34 | raise ValueError(f"Green value {g} should be between 0 and 255") 35 | if b < 0 or b > 255: 36 | raise ValueError(f"Blue value {b} should be between 0 and 255") 37 | self.r = r 38 | self.g = g 39 | self.b = b 40 | 41 | def __int__(self) -> int: 42 | """ 43 | Return the 32-bit integer representation of the color. 44 | Used bits: 8 to 15 for blue, 16 to 23 for green, 24 to 31 for red. 45 | """ 46 | return (self.b << 8 | self.g << 16 | self.r << 24) 47 | 48 | ModulinoColor.RED = ModulinoColor(255, 0, 0) 49 | ModulinoColor.GREEN = ModulinoColor(0, 255, 0) 50 | ModulinoColor.BLUE = ModulinoColor(0, 0, 255) 51 | ModulinoColor.YELLOW = ModulinoColor(255, 255, 0) 52 | ModulinoColor.CYAN = ModulinoColor(0, 255, 255) 53 | ModulinoColor.MAGENTA = ModulinoColor(255, 0, 255) 54 | ModulinoColor.WHITE = ModulinoColor(255, 255, 255) 55 | 56 | NUM_LEDS = const(8) 57 | 58 | class ModulinoPixels(Modulino): 59 | """ 60 | Class to interact with the LEDs of the Modulino Pixels. 61 | """ 62 | 63 | default_addresses = [0x6C] 64 | 65 | def __init__(self, i2c_bus = None, address=None): 66 | """ 67 | Initializes the Modulino Pixels. 68 | 69 | Parameters: 70 | i2c_bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. 71 | address (int): The I2C address of the module. If not provided, the default address will be used. 72 | """ 73 | super().__init__(i2c_bus, address, "Pixels") 74 | self.clear_all() 75 | 76 | def set_range_rgb(self, index_from: int, index_to: int, r: int, g: int, b: int, brightness: int = 100) -> 'ModulinoPixels': 77 | """ 78 | Sets the color of the LEDs in the given range to the given RGB values. 79 | 80 | Parameters: 81 | index_from (int): The starting index of the range. 82 | index_to (int): The ending index (inclusive) of the range. 83 | r (int): The red value of the color. 84 | g (int): The green value of the color. 85 | b (int): The blue value of the color. 86 | brightness (int): The brightness of the LED. It should be a value between 0 and 100. 87 | 88 | Returns: 89 | ModulinoPixels: The object itself. Allows for daisy chaining of methods. 90 | """ 91 | self.set_range_color(index_from, index_to, ModulinoColor(r, g, b), brightness) 92 | return self 93 | 94 | def set_range_color(self, index_from: int, index_to: int, color: ModulinoColor, brightness: int = 100) -> 'ModulinoPixels': 95 | """ 96 | Sets the color of the LEDs in the given range to the given color. 97 | 98 | Parameters: 99 | index_from (int): The starting index of the range. 100 | index_to (int): The ending index (inclusive) of the range. 101 | color (ModulinoColor): The color of the LEDs. 102 | brightness (int): The brightness of the LED. It should be a value between 0 and 100. 103 | 104 | Returns: 105 | ModulinoPixels: The object itself. Allows for daisy chaining of methods. 106 | """ 107 | for i in range(index_from, index_to + 1): 108 | self.set_color(i, color, brightness) 109 | return self 110 | 111 | def set_all_rgb(self, r: int, g: int, b: int, brightness: int = 100) -> 'ModulinoPixels': 112 | """ 113 | Sets the color of all the LEDs to the given RGB values. 114 | 115 | Parameters: 116 | r (int): The red value of the color. 117 | g (int): The green value of the color. 118 | b (int): The blue value of the color. 119 | brightness (int): The brightness of the LED. It should be a value between 0 and 100. 120 | 121 | Returns: 122 | ModulinoPixels: The object itself. Allows for daisy chaining of methods. 123 | """ 124 | self.set_all_color(ModulinoColor(r, g, b), brightness) 125 | return self 126 | 127 | def set_all_color(self, color: ModulinoColor, brightness: int = 100) -> 'ModulinoPixels': 128 | """ 129 | Sets the color of all the LEDs to the given color. 130 | 131 | Parameters: 132 | color (ModulinoColor): The color of the LEDs. 133 | brightness (int): The brightness of the LED. It should be a value between 0 and 100. 134 | 135 | Returns: 136 | ModulinoPixels: The object itself. Allows for daisy chaining of methods. 137 | """ 138 | self.set_range_color(0, NUM_LEDS - 1, color, brightness) 139 | return self 140 | 141 | def set_color(self, idx: int, rgb: ModulinoColor, brightness: int = 100) -> 'ModulinoPixels': 142 | """ 143 | Sets the color of the given LED index to the given color. 144 | 145 | Parameters: 146 | idx (int): The index of the LED (0..7). 147 | rgb (ModulinoColor): The color of the LED. 148 | brightness (int): The brightness of the LED. It should be a value between 0 and 100. 149 | 150 | Returns: 151 | ModulinoPixels: The object itself. Allows for daisy chaining of methods. 152 | """ 153 | if idx < 0 or idx >= NUM_LEDS: 154 | raise ValueError(f"LED index out of range {idx} (Valid: 0..{NUM_LEDS - 1})") 155 | 156 | byte_index = idx * 4 157 | mapped_brightness = map_value_int(brightness, 0, 100, 0, 0x1f) 158 | color_data_bytes = int(rgb) | mapped_brightness | 0xE0 159 | self.data[byte_index: byte_index+4] = color_data_bytes.to_bytes(4, 'little') 160 | return self 161 | 162 | def set_rgb(self, idx: int, r: int, g: int, b: int, brightness: int = 100) -> 'ModulinoPixels': 163 | """ 164 | Set the color of the given LED index to the given RGB values. 165 | 166 | Parameters: 167 | idx (int): The index of the LED (0..7). 168 | r (int): The red value of the color. 169 | g (int): The green value of the color. 170 | b (int): The blue value of the color. 171 | brightness (int): The brightness of the LED. It should be a value between 0 and 100. 172 | 173 | Returns: 174 | ModulinoPixels: The object itself. Allows for daisy chaining of methods. 175 | """ 176 | self.set_color(idx, ModulinoColor(r, g, b), brightness) 177 | return self 178 | 179 | def set_brightness(self, idx: int, brightness: int) -> 'ModulinoPixels': 180 | """ 181 | Sets the brightness of the given LED index. 182 | 183 | Parameters: 184 | idx (int): The index of the LED (0..7). 185 | brightness (int): The brightness of the LED. It should be a value between 0 and 100. 186 | 187 | Returns: 188 | ModulinoPixels: The object itself. Allows for daisy chaining of methods. 189 | """ 190 | if idx < 0 or idx >= NUM_LEDS: 191 | raise ValueError(f"LED index out of range {idx} (Valid: 0..{NUM_LEDS - 1})") 192 | 193 | if brightness < 0 or brightness > 100: 194 | raise ValueError(f"Brightness value {brightness} should be between 0 and 100") 195 | 196 | byte_index = (idx * 4) # The brightness is stored in the first byte of the 4-byte data (little-endian) 197 | mapped_brightness = map_value_int(brightness, 0, 100, 0, 0x1f) # Map to 0..31 198 | self.data[byte_index] = mapped_brightness | 0xE0 # Fill bits 5..7 with 1 199 | return self 200 | 201 | def set_all_brightness(self, brightness: int) -> 'ModulinoPixels': 202 | """ 203 | Sets the brightness of all the LEDs. 204 | 205 | Parameters: 206 | brightness (int): The brightness of the LED. It should be a value between 0 and 100. 207 | 208 | Returns: 209 | ModulinoPixels: The object itself. Allows for daisy chaining of methods. 210 | """ 211 | for i in range(NUM_LEDS): 212 | self.set_brightness(i, brightness) 213 | return self 214 | 215 | def clear(self, idx: int) -> 'ModulinoPixels': 216 | """ 217 | Turns off the LED at the given index. 218 | 219 | Parameters: 220 | idx (int): The index of the LED (0..7). 221 | 222 | Returns: 223 | ModulinoPixels: The object itself. Allows for daisy chaining of methods. 224 | """ 225 | self.set_color(idx, ModulinoColor(0, 0, 0), 0) 226 | return self 227 | 228 | def clear_range(self, start: int, end: int) -> 'ModulinoPixels': 229 | """ 230 | Turns off the LEDs in the given range. 231 | 232 | Parameters: 233 | start (int): The starting index of the range. 234 | end (int): The ending index (inclusive) of the range. 235 | 236 | Returns: 237 | ModulinoPixels: The object itself. Allows for daisy chaining of methods. 238 | """ 239 | for i in range(start, end): 240 | self.clear(i) 241 | return self 242 | 243 | def clear_all(self) -> 'ModulinoPixels': 244 | """ 245 | Turns all the LEDs off. 246 | 247 | Returns: 248 | ModulinoPixels: The object itself. Allows for daisy chaining of methods. 249 | """ 250 | self.data = bytearray([0xE0] * NUM_LEDS * 4) 251 | return self 252 | 253 | def __setitem__(self, idx: int, color: tuple | ModulinoColor) -> None: 254 | """ 255 | Sets the color of the given LED index to the given color. 256 | This allows to use the object like an array, e.g. pixels[0] = (255, 0, 0, 50) 257 | 258 | Parameters: 259 | idx (int): The index of the LED (0..7). 260 | color (tuple | ModulinoColor): A tuple of three/four integers representing the RGB values (0-255) plus optional brightness (0-100). 261 | Alternatively, a ModulinoColor object can be provided. 262 | If None, the LED will be turned off. 263 | """ 264 | if color is None: 265 | self.clear(idx) 266 | return 267 | 268 | if isinstance(color, ModulinoColor): 269 | self.set_color(idx, color) 270 | return 271 | 272 | if not isinstance(color, tuple) or len(color) < 3: 273 | raise ValueError("Color must be a tuple of three or four integers representing the RGBA values.") 274 | brightness = 100 if len(color) == 3 else color[3] 275 | self.set_rgb(idx, color[0], color[1], color[2], brightness) 276 | 277 | def show(self) -> None: 278 | """ 279 | Applies the changes to the LEDs. This function needs to be called after any changes to the LEDs. 280 | Otherwise, the changes will not be visible. 281 | """ 282 | self.write(self.data) 283 | -------------------------------------------------------------------------------- /src/modulino/buttons.py: -------------------------------------------------------------------------------- 1 | from .modulino import Modulino 2 | from time import ticks_ms 3 | from micropython import const 4 | 5 | class ModulinoButtonsLED(): 6 | """ 7 | Class to interact with the LEDs of the Modulino Buttons. 8 | """ 9 | 10 | def __init__(self, buttons): 11 | self._value = 0 12 | self._buttons = buttons 13 | 14 | def on(self): 15 | """ Turns the LED on. """ 16 | self.value = 1 17 | 18 | def off(self): 19 | """ Turns the LED off. """ 20 | self.value = 0 21 | 22 | @property 23 | def value(self): 24 | """ Returns the value of the LED (1 for on, 0 for off). """ 25 | return self._value 26 | 27 | @value.setter 28 | def value(self, value): 29 | """ 30 | Sets the value of the LED (1 for on, 0 for off). 31 | Calling this method will update the physical status of the LED immediately. 32 | """ 33 | self._value = value 34 | self._buttons._update_leds() 35 | 36 | class ModulinoButtons(Modulino): 37 | """ 38 | Class to interact with the buttons of the Modulino Buttons. 39 | """ 40 | 41 | default_addresses = [0x7C] 42 | default_long_press_duration = const(1000) 43 | 44 | def __init__(self, i2c_bus = None, address = None): 45 | """ 46 | Initializes the Modulino Buttons. 47 | 48 | Parameters: 49 | i2c_bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. 50 | address (int): The I2C address of the module. If not provided, the default address will be used. 51 | """ 52 | 53 | super().__init__(i2c_bus, address, "Buttons") 54 | self.long_press_duration = self.default_long_press_duration 55 | 56 | self._current_buttons_status = [None, None, None] 57 | self._last_press_timestamps = [None, None, None] 58 | 59 | # Button callbacks 60 | self._on_button_a_press = None 61 | self._on_button_a_release = None 62 | self._on_button_b_press = None 63 | self._on_button_b_release = None 64 | self._on_button_c_press = None 65 | self._on_button_c_release = None 66 | self._on_button_a_long_press = None 67 | self._on_button_b_long_press = None 68 | self._on_button_c_long_press = None 69 | 70 | # LEDs 71 | self._led_a = ModulinoButtonsLED(self) 72 | self._led_b = ModulinoButtonsLED(self) 73 | self._led_c = ModulinoButtonsLED(self) 74 | 75 | @property 76 | def led_a(self) -> ModulinoButtonsLED: 77 | """ Returns the LED A object of the module. """ 78 | return self._led_a 79 | 80 | @property 81 | def led_b(self) -> ModulinoButtonsLED: 82 | """ Returns the LED B object of the module. """ 83 | return self._led_b 84 | 85 | @property 86 | def led_c(self) -> ModulinoButtonsLED: 87 | """ Returns the LED C object of the module. """ 88 | return self._led_c 89 | 90 | def _update_leds(self): 91 | """ 92 | Update the physical status of the button LEDs by writing the current values to the module. 93 | """ 94 | data = bytearray(3) 95 | data[0] = self._led_a.value 96 | data[1] = self._led_b.value 97 | data[2] = self._led_c.value 98 | self.write(data) 99 | 100 | def set_led_status(self, a: bool, b: bool, c: bool) -> None: 101 | """ 102 | Turn on or off the button LEDs according to the given status. 103 | 104 | Parameters: 105 | a (bool): The status of the LED A. 106 | b (bool): The status of the LED B. 107 | c (bool): The status of the LED C. 108 | """ 109 | self._led_a._value = 1 if a else 0 110 | self._led_b._value = 1 if b else 0 111 | self._led_c._value = 1 if c else 0 112 | self._update_leds() 113 | 114 | @property 115 | def long_press_duration(self) -> int: 116 | """ 117 | Returns the duration in milliseconds that the button must 118 | be pressed to trigger the long press event 119 | """ 120 | return self._long_press_duration 121 | 122 | @long_press_duration.setter 123 | def long_press_duration(self, value: int) -> None: 124 | """ 125 | Sets the duration in milliseconds that the button must 126 | be pressed to trigger the long press event 127 | """ 128 | self._long_press_duration = value 129 | 130 | @property 131 | def on_button_a_press(self): 132 | """ 133 | Returns the callback for the press event of button A. 134 | """ 135 | return self._on_button_a_press 136 | 137 | @on_button_a_press.setter 138 | def on_button_a_press(self, value) -> None: 139 | """ 140 | Sets the callback for the press event of button A. 141 | """ 142 | self._on_button_a_press = value 143 | 144 | @property 145 | def on_button_a_release(self): 146 | """ 147 | Returns the callback for the release event of button A. 148 | """ 149 | return self._on_button_a_release 150 | 151 | @on_button_a_release.setter 152 | def on_button_a_release(self, value) -> None: 153 | """ 154 | Sets the callback for the release event of button A. 155 | """ 156 | self._on_button_a_release = value 157 | 158 | @property 159 | def on_button_a_long_press(self): 160 | """ 161 | Returns the callback for the long press event of button A. 162 | """ 163 | return self._on_button_a_long_press 164 | 165 | @on_button_a_long_press.setter 166 | def on_button_a_long_press(self, value) -> None: 167 | """ 168 | Sets the callback for the long press event of button A. 169 | """ 170 | self._on_button_a_long_press = value 171 | 172 | @property 173 | def on_button_b_press(self): 174 | """ 175 | Returns the callback for the press event of button B. 176 | """ 177 | return self._on_button_b_press 178 | 179 | @on_button_b_press.setter 180 | def on_button_b_press(self, value) -> None: 181 | """ 182 | Sets the callback for the press event of button B. 183 | """ 184 | self._on_button_b_press = value 185 | 186 | @property 187 | def on_button_b_release(self): 188 | """ 189 | Returns the callback for the release event of button B. 190 | """ 191 | return self._on_button_b_release 192 | 193 | @on_button_b_release.setter 194 | def on_button_b_release(self, value) -> None: 195 | """ 196 | Sets the callback for the release event of button B. 197 | """ 198 | self._on_button_b_release = value 199 | 200 | @property 201 | def on_button_b_long_press(self): 202 | """ 203 | Returns the callback for the long press event of button B. 204 | """ 205 | return self._on_button_b_long_press 206 | 207 | @on_button_b_long_press.setter 208 | def on_button_b_long_press(self, value) -> None: 209 | """ 210 | Sets the callback for the long press event of button B. 211 | """ 212 | self._on_button_b_long_press = value 213 | 214 | @property 215 | def on_button_c_press(self): 216 | """ 217 | Returns the callback for the press event of button C. 218 | """ 219 | return self._on_button_c_press 220 | 221 | @on_button_c_press.setter 222 | def on_button_c_press(self, value) -> None: 223 | """ 224 | Sets the callback for the press event of button C. 225 | """ 226 | self._on_button_c_press = value 227 | 228 | @property 229 | def on_button_c_release(self): 230 | """ 231 | Returns the callback for the release event of button C. 232 | """ 233 | return self._on_button_c_release 234 | 235 | @on_button_c_release.setter 236 | def on_button_c_release(self, value) -> None: 237 | """ 238 | Sets the callback for the release event of button C. 239 | """ 240 | self._on_button_c_release = value 241 | 242 | @property 243 | def on_button_c_long_press(self): 244 | """ 245 | Returns the callback for the long press event of button C. 246 | """ 247 | return self._on_button_c_long_press 248 | 249 | @on_button_c_long_press.setter 250 | def on_button_c_long_press(self, value) -> None: 251 | """ 252 | Sets the callback for the long press event of button C. 253 | """ 254 | self._on_button_c_long_press = value 255 | 256 | def update(self) -> bool: 257 | """ 258 | Update the button status and call the corresponding callbacks. 259 | Returns True if any of the buttons has changed its state. 260 | 261 | Returns: 262 | bool: True if any of the buttons has changed its state. 263 | """ 264 | new_status = self.read(3) 265 | button_states_changed = new_status != self._current_buttons_status 266 | previous_status = self._current_buttons_status 267 | current_timestamp = ticks_ms() 268 | 269 | # Update status already in case it's accessed in one of the button callbacks 270 | self._current_buttons_status = new_status 271 | 272 | # Check for long press 273 | if(new_status[0] == 1 and previous_status[0] == 1 and self._last_press_timestamps[0] and current_timestamp - self._last_press_timestamps[0] > self.long_press_duration): 274 | self._last_press_timestamps[0] = None 275 | if self._on_button_a_long_press: 276 | self._on_button_a_long_press() 277 | 278 | if(new_status[1] == 1 and previous_status[1] == 1 and self._last_press_timestamps[1] and current_timestamp - self._last_press_timestamps[1] > self.long_press_duration): 279 | self._last_press_timestamps[1] = None 280 | if self._on_button_b_long_press: 281 | self._on_button_b_long_press() 282 | 283 | if(new_status[2] == 1 and previous_status[2] == 1 and self._last_press_timestamps[2] and current_timestamp - self._last_press_timestamps[2] > self.long_press_duration): 284 | self._last_press_timestamps[2] = None 285 | if self._on_button_c_long_press: 286 | self._on_button_c_long_press() 287 | 288 | # Check for press and release 289 | if(button_states_changed): 290 | 291 | if(new_status[0] == 1 and previous_status[0] == 0): 292 | self._last_press_timestamps[0] = ticks_ms() 293 | if(self._on_button_a_press): 294 | self._on_button_a_press() 295 | elif(new_status[0] == 0 and previous_status[0] == 1 and self._on_button_a_release): 296 | self._on_button_a_release() 297 | 298 | if(new_status[1] == 1 and previous_status[1] == 0): 299 | self._last_press_timestamps[1] = ticks_ms() 300 | if(self._on_button_b_press): 301 | self._on_button_b_press() 302 | elif(new_status[1] == 0 and previous_status[1] == 1 and self._on_button_b_release): 303 | self._on_button_b_release() 304 | 305 | if(new_status[2] == 1 and previous_status[2] == 0): 306 | self._last_press_timestamps[2] = ticks_ms() 307 | if(self._on_button_c_press): 308 | self._on_button_c_press() 309 | elif(new_status[2] == 0 and previous_status[2] == 1 and self._on_button_c_release): 310 | self._on_button_c_release() 311 | 312 | return button_states_changed 313 | 314 | def is_pressed(self, index: int) -> bool: 315 | """ 316 | Returns True if the button at the given index is currently pressed. 317 | 318 | Parameters: 319 | index (int): The index of the button. A = 0, B = 1, C = 2. 320 | """ 321 | return self._current_buttons_status[index] 322 | 323 | @property 324 | def button_a_pressed(self) -> bool: 325 | """ 326 | Returns True if button A is currently pressed. 327 | """ 328 | return self.is_pressed(0) 329 | 330 | @property 331 | def button_b_pressed(self) -> bool: 332 | """ 333 | Returns True if button B is currently pressed. 334 | """ 335 | return self.is_pressed(1) 336 | 337 | @property 338 | def button_c_pressed(self) -> bool: 339 | """ 340 | Returns True if button C is currently pressed. 341 | """ 342 | return self.is_pressed(2) -------------------------------------------------------------------------------- /examples/firmware_update.py: -------------------------------------------------------------------------------- 1 | """ 2 | This script is a firmware updater for the Modulino devices. 3 | 4 | It uses the I2C bootloader to flash the firmware to the device. 5 | The script finds all .bin files in the root directory and prompts the user to select a file to flash. 6 | It then scans the I2C bus for devices and prompts the user to select a device to flash. 7 | You must either know the I2C address of the device to be flashed or make sure that only one device is connected. 8 | The script sends a reset command to the device, erases the memory, and writes the firmware to the device in chunks. 9 | Finally, it starts the new firmware on the device. 10 | 11 | Initial author: Sebastian Romero (s.romero@arduino.cc) 12 | """ 13 | 14 | import os 15 | import sys 16 | import time 17 | from micropython import const 18 | from machine import I2C 19 | from modulino import Modulino 20 | 21 | BOOTLOADER_I2C_ADDRESS = const(0x64) 22 | ACK = const(0x79) 23 | BUSY = const(0x76) 24 | 25 | CMD_GET = const(0x00) # Gets the version and the allowed commands 26 | CMD_GET_LENGTH_V12 = const(20) # Length of the response data 27 | CMD_GET_VERSION = const(0x01) # Gets the protocol version 28 | CMD_GET_ID = const(0x02) # Get chip ID 29 | CMD_ERASE_NO_STRETCH = const(0x45) # Erase memory. Returns busy state while operation is ongoing 30 | CMD_GO = const(0x21) # Jumps to user application code located in the internal flash memory 31 | CMD_WRITE_NO_STRETCH = const(0x32) # Writes up to 256 bytes to the memory, starting from an address specified 32 | 33 | CHUNK_SIZE = const(128) # Size of the memory chunk to write 34 | 35 | bus = None # Change this to the I2C bus you are using on 3rd party host boards 36 | 37 | def wait_for_ack(bus): 38 | """ 39 | Wait for an acknowledgment from the I2C device. 40 | 41 | :return: True if an acknowledgment was received, otherwise False. 42 | """ 43 | res = bus.readfrom(BOOTLOADER_I2C_ADDRESS, 1)[0] 44 | if res != ACK: 45 | while res == BUSY: 46 | time.sleep(0.1) 47 | res = bus.readfrom(BOOTLOADER_I2C_ADDRESS, 1)[0] 48 | if res != ACK: 49 | print(f"❌ Error processing command. Result code: {hex(res)}") 50 | return False 51 | return True 52 | 53 | def execute_command(bus, opcode, command_params, response_length = 0, verbose=False): 54 | """ 55 | Execute an I2C command on the device. 56 | 57 | :param bus: The I2C bus to use. 58 | :param opcode: The command opcode. 59 | :param command_params: The buffer containing the command parameters. 60 | :param response_length: The expected length of the response data frame. 61 | :param verbose: Whether to print debug information. 62 | :return: The number of response bytes read, or None if an error occurred. 63 | """ 64 | if verbose: 65 | print(f"🕵️ Executing command {hex(opcode)}") 66 | 67 | cmd = bytes([opcode, 0xFF ^ opcode]) # Send command code and complement (XOR = 0x00) 68 | bus.writeto(BOOTLOADER_I2C_ADDRESS, cmd, True) 69 | if not wait_for_ack(bus): 70 | print(f"❌ Command not acknowledged: {hex(opcode)}") 71 | return None 72 | 73 | if command_params is not None: 74 | bus.writeto(BOOTLOADER_I2C_ADDRESS, command_params, True) 75 | if not wait_for_ack(bus): 76 | print("❌ Command failed") 77 | return None 78 | 79 | if response_length == 0: 80 | return None 81 | 82 | data = bus.readfrom(BOOTLOADER_I2C_ADDRESS, response_length) 83 | 84 | if not wait_for_ack(bus): 85 | print("❌ Failed completing command") 86 | return None 87 | 88 | return data 89 | 90 | def flash_firmware(device : Modulino, firmware_path, verbose=False): 91 | """ 92 | Flash the firmware to the I2C device. 93 | 94 | :param device: The Modulino device to flash. 95 | :param firmware_path: The binary firmware path. 96 | :param verbose: Whether to print debug information. 97 | :return: True if the flashing was successful, otherwise False. 98 | """ 99 | bus = device.i2c_bus 100 | print("🗑️ Erasing memory...") 101 | erase_params = bytearray([0xFF, 0xFF, 0x0]) # Mass erase flash 102 | execute_command(bus, CMD_ERASE_NO_STRETCH, erase_params, 0, verbose) 103 | 104 | with open(firmware_path, 'rb') as file: 105 | firmware_data = file.read() 106 | total_bytes = len(firmware_data) 107 | 108 | print(f"🔥 Writing {total_bytes} bytes") 109 | for i in range(0, total_bytes, CHUNK_SIZE): 110 | display_progress_bar(i, total_bytes) 111 | start_address = bytearray([8, 0, i // 256, i % 256]) # 4-byte address: byte 1 = MSB, byte 4 = LSB 112 | checksum = 0 113 | for b in start_address: 114 | checksum ^= b 115 | start_address.append(checksum) 116 | data_slice = firmware_data[i:i + CHUNK_SIZE] 117 | if not write_firmware_page(bus, start_address, data_slice): 118 | print(f"❌ Failed to write page {hex(i)}") 119 | return False 120 | time.sleep(0.01) # Give the device some time to process the data 121 | 122 | display_progress_bar(total_bytes, total_bytes) # Complete the progress bar 123 | 124 | print("🏃 Launching new firmware") 125 | go_params = bytearray([0x8, 0x00, 0x00, 0x00, 0x8]) 126 | execute_command(bus, CMD_GO, go_params, 0, verbose) # Jump to the application 127 | 128 | return True 129 | 130 | def write_firmware_page(bus, command_params, firmware_data): 131 | """ 132 | Write a page of the firmware to the I2C device. 133 | 134 | :param bus: The I2C bus to use. 135 | :param command_params: The buffer containing the command parameters. 136 | :param firmware_data: The buffer containing the firmware data. 137 | :return: True if the page was written successfully, otherwise False. 138 | """ 139 | cmd = bytes([CMD_WRITE_NO_STRETCH, 0xFF ^ CMD_WRITE_NO_STRETCH]) 140 | bus.writeto(BOOTLOADER_I2C_ADDRESS, cmd) 141 | if not wait_for_ack(bus): 142 | print("❌ Write command not acknowledged") 143 | return False 144 | 145 | bus.writeto(BOOTLOADER_I2C_ADDRESS, command_params) 146 | if not wait_for_ack(bus): 147 | print("❌ Failed to write command parameters") 148 | return False 149 | 150 | data_size = len(firmware_data) 151 | tmp_buffer = bytearray(data_size + 2) # Data plus size and checksum 152 | tmp_buffer[0] = data_size - 1 # Size of the data 153 | tmp_buffer[1:data_size + 1] = firmware_data 154 | tmp_buffer[-1] = 0 # Checksum placeholder 155 | for i in range(data_size + 1): # Calculate checksum over size byte + data bytes 156 | tmp_buffer[-1] ^= tmp_buffer[i] 157 | 158 | bus.writeto(BOOTLOADER_I2C_ADDRESS, tmp_buffer) 159 | if not wait_for_ack(bus): 160 | print("❌ Failed to write firmware") 161 | return False 162 | 163 | return True 164 | 165 | def display_progress_bar(current, total, bar_length=40): 166 | """ 167 | Print a progress bar to the terminal. 168 | 169 | :param current: The current progress value. 170 | :param total: The total progress value. 171 | :param bar_length: The length of the progress bar in characters. 172 | """ 173 | percent = float(current) / total 174 | arrow = '=' * int(round(percent * bar_length)) 175 | spaces = ' ' * (bar_length - len(arrow)) 176 | sys.stdout.write(f"\rProgress: [{arrow}{spaces}] {int(round(percent * 100))}%") 177 | if current == total: 178 | sys.stdout.write('\n') 179 | 180 | def find_bin_files(): 181 | """ 182 | Find all .bin files in the root directory. 183 | 184 | :return: A list of .bin file names. 185 | """ 186 | return [file for file in os.listdir('/') if file.endswith('.bin')] 187 | 188 | def select_file(bin_files): 189 | """ 190 | Prompt the user to select a .bin file to flash. 191 | 192 | :param bin_files: A list of .bin file names. 193 | :return: The selected .bin file name. 194 | """ 195 | if len(bin_files) == 0: 196 | print("❌ No .bin files found in the root directory.") 197 | return None 198 | 199 | if len(bin_files) == 1: 200 | confirm = input(f"📄 Found one binary file: {bin_files[0]}. Do you want to flash it? (yes/no) ") 201 | if confirm.lower() == 'yes': 202 | return bin_files[0] 203 | else: 204 | return None 205 | 206 | print("📄 Found binary files:") 207 | for index, file in enumerate(bin_files): 208 | print(f"{index + 1}. {file}") 209 | choice = int(input("Select the file to flash (number): ")) 210 | if choice < 1 or choice > len(bin_files): 211 | return None 212 | return bin_files[choice - 1] 213 | 214 | def select_device(bus : I2C) -> Modulino: 215 | """ 216 | Scan the I2C bus for devices and prompt the user to select one. 217 | 218 | :param bus: The I2C bus to scan. 219 | :return: The selected Modulino device. 220 | """ 221 | devices = Modulino.available_devices(bus) 222 | 223 | if len(devices) == 0: 224 | print("❌ No devices found") 225 | return None 226 | 227 | if len(devices) == 1: 228 | device = devices[0] 229 | confirm = input(f"🔌 Found {device.device_type} at address {hex(device.address)}. Do you want to update this device? (yes/no) ") 230 | if confirm.lower() == 'yes': 231 | return devices[0] 232 | else: 233 | return None 234 | 235 | print("🔌 Devices found:") 236 | for index, device in enumerate(devices): 237 | print(f"{index + 1}) {device.device_type} at {hex(device.address)}") 238 | choice = int(input("Select the device to flash (number): ")) 239 | if choice < 1 or choice > len(devices): 240 | return None 241 | return devices[choice - 1] 242 | 243 | def print_device_info(device : Modulino): 244 | """ 245 | Print information about the selected device. 246 | 247 | :param device: The Modulino device. 248 | """ 249 | 250 | bus = device.i2c_bus 251 | data = execute_command(bus, CMD_GET_VERSION, None, 1) 252 | if data is None: 253 | print("❌ Failed to get protocol version") 254 | return False 255 | print(f"ℹ️ Protocol version: {data[0] & 0xF}.{data[0] >> 4}") 256 | 257 | data = execute_command(bus, CMD_GET, None, CMD_GET_LENGTH_V12) 258 | if data is None: 259 | print("❌ Failed to get command list") 260 | return False 261 | 262 | print(f"ℹ️ Bootloader version: {(data[1] & 0xF)}.{data[1] >> 4}") 263 | print("👀 Supported commands:") 264 | print(", ".join([hex(byte) for byte in data[2:]])) 265 | 266 | data = execute_command(bus, CMD_GET_ID, None, 3) 267 | if data is None: 268 | print("❌ Failed to get device ID") 269 | return False 270 | 271 | chip_id = (data[0] << 8) | data[1] # Chip ID: Byte 1 = MSB, Byte 2 = LSB 272 | print(f"ℹ️ Chip ID: {chip_id}") 273 | 274 | def run(bus: I2C): 275 | """ 276 | Initialize the flashing process. 277 | Finds .bin files, scans for I2C devices, and flashes the selected firmware. 278 | 279 | :param bus: The I2C bus to use. If None, the default I2C bus will be used. 280 | """ 281 | 282 | bin_files = find_bin_files() 283 | if not bin_files: 284 | print("❌ No .bin files found in the root directory.") 285 | return 286 | 287 | bin_file = select_file(bin_files) 288 | if bin_file is None: 289 | print("❌ No file selected") 290 | return 291 | 292 | device = select_device(bus) 293 | if device is None: 294 | print("❌ No device selected") 295 | return 296 | 297 | print(f"🔄 Resetting device at address {hex(device.address)}") 298 | if device.enter_bootloader(): 299 | print("✅ Device reset successfully") 300 | else: 301 | print("❌ Failed to reset device") 302 | return 303 | 304 | print(f"🕵️ Flashing {bin_file} to device at address {hex(BOOTLOADER_I2C_ADDRESS)}") 305 | 306 | print_device_info(device) 307 | 308 | if flash_firmware(device, bin_file): 309 | print("✅ Firmware flashed successfully") 310 | else: 311 | print("❌ Failed to flash firmware") 312 | 313 | if __name__ == "__main__": 314 | print() 315 | run(bus) 316 | -------------------------------------------------------------------------------- /src/modulino/modulino.py: -------------------------------------------------------------------------------- 1 | from machine import Pin, I2C 2 | from time import sleep 3 | from micropython import const 4 | import re 5 | import os 6 | from collections import namedtuple 7 | 8 | I2CInterface = namedtuple('I2CInterface', ['type', 'bus_number', "scl", "sda"]) 9 | 10 | DEVICE_I2C_INTERFACES = { 11 | "Arduino Nano ESP32": I2CInterface("hw", 0, None, None), 12 | "Arduino Nano RP2040 Connect": I2CInterface("hw", 0, None, None), 13 | "Arduino Portenta H7": I2CInterface("hw", 3, None, None), 14 | "Arduino Portenta C33": I2CInterface("hw", 0, None, None), 15 | "Generic ESP32S3 module": I2CInterface("hw", 0, None, None), 16 | } 17 | 18 | PINSTRAP_ADDRESS_MAP = { 19 | 0x3C: "Buzzer", 20 | 0x7C: "Buttons", 21 | 0x76: "Knob", 22 | 0x74: "Knob", 23 | 0x6C: "Pixels", 24 | 0x58: "Joystick", 25 | 0x4: "Latch Relay", 26 | 0x70: "Vibro" 27 | } 28 | 29 | _BOOTLOADER_ADDRESS = const(0x64) 30 | 31 | class _I2CHelper: 32 | """ 33 | A helper class for interacting with I2C devices on supported boards. 34 | """ 35 | i2c_bus: I2C = None 36 | frequency: int = const(100000) # Modulinos operate at 100kHz 37 | 38 | @staticmethod 39 | def extract_i2c_info(i2c_bus: I2C) -> tuple[int, int, int]: 40 | bus_info = str(i2c_bus) 41 | # Use regex to find the values of the interface, scl, and sda 42 | interface_match = re.search(r'I2C\((\d+)', bus_info) 43 | scl_match = re.search(r'scl=(\d+)', bus_info) 44 | sda_match = re.search(r'sda=(\d+)', bus_info) 45 | 46 | # Extract the values if the matches are found 47 | interface = int(interface_match.group(1)) if interface_match else None 48 | scl = int(scl_match.group(1)) if scl_match else None 49 | sda = int(sda_match.group(1)) if sda_match else None 50 | 51 | return interface, scl, sda 52 | 53 | @staticmethod 54 | def reset_bus(i2c_bus: I2C) -> I2C: 55 | """ 56 | Resets the I2C bus in case it got stuck. To unblock the bus the SDA line is kept high for 20 clock cycles 57 | Which causes the triggering of a NAK message. 58 | """ 59 | 60 | # This is a workaround to get the SCL and SDA pins from a given bus object. 61 | # Unfortunately the I2C class does not expose those attributes directly. 62 | interface, scl_pin_number, sda_pin_number = _I2CHelper.extract_i2c_info(i2c_bus) 63 | 64 | # Detach pins from I2C and configure them as GPIO outputs in open-drain mode 65 | scl_pin = Pin(scl_pin_number, Pin.OUT, Pin.OPEN_DRAIN) 66 | sda_pin = Pin(sda_pin_number, Pin.OUT, Pin.OPEN_DRAIN) 67 | 68 | # Set both lines high initially 69 | scl_pin.value(1) 70 | sda_pin.value(1) 71 | sleep(0.001) # 1 millisecond delay to stabilize bus 72 | 73 | # Pulse the SCL line 9 times to release any stuck device 74 | for _ in range(9): 75 | scl_pin.value(0) 76 | sleep(0.001) # 1 millisecond delay for each pulse 77 | scl_pin.value(1) 78 | sleep(0.001) 79 | 80 | # Ensure SDA is high before re-initializing 81 | sda_pin.value(1) 82 | scl_pin.value(1) 83 | sleep(0.001) # 1 millisecond delay to stabilize bus 84 | 85 | # Need to re-initialize the bus after resetting it 86 | return I2C(interface, freq=_I2CHelper.frequency) 87 | 88 | @staticmethod 89 | def get_interface() -> I2C: 90 | if _I2CHelper.i2c_bus is None: 91 | _I2CHelper.i2c_bus = _I2CHelper._find_interface() 92 | _I2CHelper.i2c_bus = _I2CHelper.reset_bus(_I2CHelper.i2c_bus) 93 | return _I2CHelper.i2c_bus 94 | 95 | @staticmethod 96 | def _find_interface() -> I2C: 97 | """ 98 | Returns an instance of the I2C interface for the current board. 99 | 100 | Raises: 101 | RuntimeError: If the current board is not supported. 102 | 103 | Returns: 104 | I2C: An instance of the I2C interface. 105 | """ 106 | board_name = os.uname().machine.split(' with ')[0] 107 | interface_info = DEVICE_I2C_INTERFACES.get(board_name, None) 108 | 109 | if interface_info is None: 110 | raise RuntimeError(f"I2C interface couldn't be determined automatically for '{board_name}'.") 111 | 112 | if interface_info.type == "hw": 113 | return I2C(interface_info.bus_number, freq=_I2CHelper.frequency) 114 | 115 | if interface_info.type == "sw": 116 | from machine import SoftI2C, Pin 117 | return SoftI2C(scl=Pin(interface_info.scl), sda=Pin(interface_info.sda), freq=_I2CHelper.frequency) 118 | 119 | class Modulino: 120 | """ 121 | Base class for all Modulino devices. 122 | """ 123 | 124 | default_addresses: list[int] = [] 125 | """ 126 | A list of default addresses that the modulino can have. 127 | This list needs to be overridden derived classes. 128 | """ 129 | 130 | convert_default_addresses: bool = True 131 | """ 132 | Determines if the default addresses need to be converted from 8-bit to 7-bit. 133 | Addresses of modulinos without native I2C modules need to be converted. 134 | This class variable needs to be overridden in derived classes. 135 | """ 136 | 137 | def __init__(self, i2c_bus: I2C = None, address: int = None, name: str = None, check_connection: bool = True) -> None: 138 | """ 139 | Initializes the Modulino object with the given i2c bus and address. 140 | If the address is not provided, the device will try to auto discover it. 141 | If the address is provided, the device will check if it is connected to the bus. 142 | If the address is 8-bit, it will be converted to 7-bit. 143 | If no bus is provided, the default bus will be used if available. 144 | 145 | Parameters: 146 | i2c_bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. 147 | address (int): The address of the device. If not provided, the device will try to auto discover it. 148 | name (str): The name of the device. 149 | check_connection (bool): Whether to check if the device is connected to the bus. 150 | """ 151 | 152 | if i2c_bus is None: 153 | self.i2c_bus = _I2CHelper.get_interface() 154 | else: 155 | self.i2c_bus = i2c_bus 156 | 157 | self.name = name 158 | self.address = address 159 | 160 | if self.address is None: 161 | if len(self.default_addresses) == 0: 162 | raise RuntimeError(f"No default addresses defined for the {self.name} device.") 163 | 164 | if self.convert_default_addresses: 165 | # Need to convert the 8-bit address to 7-bit 166 | actual_addresses = list(map(lambda addr: addr >> 1, self.default_addresses)) 167 | self.address = self.discover(actual_addresses) 168 | else: 169 | self.address = self.discover(self.default_addresses) 170 | 171 | if self.address is None: 172 | raise RuntimeError(f"Couldn't find the {self.name} device on the bus. Try resetting the board.") 173 | elif check_connection and not self.connected: 174 | raise RuntimeError(f"Couldn't find a {self.name} device with address {hex(self.address)} on the bus. Try resetting the board.") 175 | 176 | def discover(self, default_addresses: list[int]) -> int | None: 177 | """ 178 | Tries to find the given modulino device in the device chain 179 | based on the pre-defined default addresses. The first address found will be returned. 180 | If the address has been changed to a custom one it won't be found with this function. 181 | 182 | Returns: 183 | int | None: The address of the device if found, None otherwise. 184 | """ 185 | if len(default_addresses) == 0: 186 | return None 187 | 188 | devices_on_bus = Modulino.scan(self.i2c_bus, default_addresses) 189 | if len(devices_on_bus) > 0: 190 | return devices_on_bus[0] 191 | return None 192 | 193 | def __bool__(self) -> bool: 194 | """ 195 | Boolean cast operator to determine if the given i2c device has a correct address 196 | and if the bus is defined. 197 | In case of auto discovery this also means that the device was found on the bus 198 | because otherwise the address would be None. 199 | """ 200 | # Check if a valid i2c address is set and bus is defined 201 | return self.i2c_bus is not None and self.address is not None and self.address <= 127 and self.address >= 0 202 | 203 | @property 204 | def connected(self) -> bool: 205 | """ 206 | Determines if the given modulino is connected to the i2c bus. 207 | """ 208 | if not bool(self): 209 | return False 210 | 211 | try: 212 | self.i2c_bus.writeto(self.address, b'') 213 | return True 214 | except OSError: 215 | return False 216 | 217 | @property 218 | def pin_strap_address(self) -> int | None: 219 | """ 220 | Returns the pin strap i2c address of the modulino. 221 | This address is set via resistors on the modulino board. 222 | Since all modulinos generally use the same firmware, the pinstrap address 223 | is needed to determine the type of the modulino at boot time, so it know what to do. 224 | At boot it checks the internal flash in case its address has been overridden by the user 225 | which would take precedence. 226 | 227 | Returns: 228 | int | None: The pin strap address of the modulino. 229 | """ 230 | if self.address is None: 231 | return None 232 | data = self.i2c_bus.readfrom(self.address, 1, True) 233 | # The first byte is always the pinstrap address 234 | return data[0] 235 | 236 | @property 237 | def device_type(self) -> str | None: 238 | """ 239 | Returns the type of the modulino based on the pinstrap address as a string. 240 | """ 241 | return PINSTRAP_ADDRESS_MAP.get(self.pin_strap_address, None) 242 | 243 | def change_address(self, new_address: int): 244 | """ 245 | Sets the address of the i2c device to the given value. 246 | This is only supported on Modulinos that have a microcontroller. 247 | """ 248 | # TODO: Check if device supports this feature by looking at the type 249 | 250 | data = bytearray(40) 251 | # Set the first two bytes to 'C' and 'F' followed by the new address 252 | data[0:2] = b'CF' 253 | data[2] = new_address * 2 254 | 255 | try: 256 | self.write(data) 257 | except OSError: 258 | pass # Device resets immediately and causes ENODEV to be thrown which is expected 259 | 260 | self.address = new_address 261 | 262 | def enter_bootloader(self): 263 | """ 264 | Enters the I2C bootloader of the device. 265 | This is only supported on Modulinos that have a microcontroller. 266 | 267 | Returns: 268 | bool: True if the device entered bootloader mode, False otherwise. 269 | """ 270 | buffer = b'DIE' 271 | buffer += b'\x00' * (8 - len(buffer)) # Pad buffer to 8 bytes 272 | try: 273 | self.i2c_bus.writeto(self.address, buffer, True) 274 | sleep(0.25) # Wait for the device to reset 275 | return True 276 | except OSError as e: 277 | # ENODEV (e.errno == 19) can be thrown if the device resets while writing out the buffer 278 | return False 279 | 280 | def read(self, amount_of_bytes: int) -> bytes | None: 281 | """ 282 | Reads the given amount of bytes from the i2c device and returns the data. 283 | It skips the first byte which is the pinstrap address. 284 | 285 | Returns: 286 | bytes | None: The data read from the device. 287 | """ 288 | 289 | if self.address is None: 290 | return None 291 | 292 | data = self.i2c_bus.readfrom(self.address, amount_of_bytes + 1, True) 293 | if len(data) < amount_of_bytes + 1: 294 | return None # Something went wrong in the data transmission 295 | 296 | # data[0] is always the pinstrap address 297 | return data[1:] 298 | 299 | def write(self, data_buffer: bytearray) -> bool: 300 | """ 301 | Writes the given buffer to the i2c device. 302 | 303 | Parameters: 304 | data_buffer (bytearray): The data to be written to the device. 305 | 306 | Returns: 307 | bool: True if the data was written successfully, False otherwise. 308 | """ 309 | if self.address is None: 310 | return False 311 | self.i2c_bus.writeto(self.address, data_buffer) 312 | return True 313 | 314 | @property 315 | def has_default_address(self) -> bool: 316 | """ 317 | Determines if the given modulino has a default address 318 | or if a custom one was set. 319 | """ 320 | return self.address in self.default_addresses 321 | 322 | @staticmethod 323 | def scan(bus: I2C, target_addresses = None) -> list[int]: 324 | addresses = bytearray() # Use 8bit data type 325 | # General call address (0x00) is skipped in default range 326 | candidates = target_addresses if target_addresses is not None else range(1,128) 327 | 328 | for address in candidates: 329 | try: 330 | bus.writeto(address, b'') 331 | addresses.append(address) 332 | except OSError: 333 | pass 334 | return list(addresses) 335 | 336 | @staticmethod 337 | def available_devices(bus: I2C = None) -> list[Modulino]: 338 | """ 339 | Finds all devices on the i2c bus and returns them as a list of Modulino objects. 340 | 341 | Parameters: 342 | bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used. 343 | 344 | Returns: 345 | list: A list of Modulino objects. 346 | """ 347 | if bus is None: 348 | bus = _I2CHelper.get_interface() 349 | device_addresses = Modulino.scan(bus) 350 | devices = [] 351 | for address in device_addresses: 352 | if address == _BOOTLOADER_ADDRESS: 353 | # Skip bootloader address 354 | continue 355 | device = Modulino(i2c_bus=bus, address=address, check_connection=False) 356 | devices.append(device) 357 | return devices 358 | 359 | @staticmethod 360 | def reset_bus(i2c_bus: I2C) -> I2C: 361 | """ 362 | Resets the i2c bus. This is useful when the bus is in an unknown state. 363 | The modulinos that are equipped with a micro controller use DMA operations. 364 | If the host board does a reset during such operation it can make the bus get stuck. 365 | 366 | Returns: 367 | I2C: A new i2c bus object after resetting the bus. 368 | """ 369 | return _I2CHelper.reset_bus(i2c_bus) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /src/modulino/lib/vl53l4cd.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries 2 | # SPDX-FileCopyrightText: Copyright (c) 2022 Carter Nelson for Adafruit Industries 3 | # 4 | # SPDX-License-Identifier: MIT 5 | """ 6 | `adafruit_vl53l4cd` 7 | ================================================================================ 8 | 9 | CircuitPython helper library for the VL53L4CD time of flight distance sensor. 10 | 11 | 12 | * Author(s): Carter Nelson 13 | 14 | Implementation Notes 15 | -------------------- 16 | 17 | **Hardware:** 18 | 19 | * `Adafruit VL53L4CD Time of Flight Distance Sensor `_ 20 | 21 | **Software and Dependencies:** 22 | 23 | * Adafruit CircuitPython firmware for the supported boards: 24 | https://circuitpython.org/downloads 25 | * Adafruit's Bus Device library: https://github.com/adafruit/Adafruit_CircuitPython_BusDevice 26 | """ 27 | 28 | import time 29 | import struct 30 | from micropython import const 31 | 32 | __version__ = "0.0.0+auto.0" 33 | __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_VL53L4CD.git" 34 | 35 | _VL53L4CD_SOFT_RESET = const(0x0000) 36 | _VL53L4CD_I2C_SLAVE_DEVICE_ADDRESS = const(0x0001) 37 | _VL53L4CD_VHV_CONFIG_TIMEOUT_MACROP_LOOP_BOUND = const(0x0008) 38 | _VL53L4CD_XTALK_PLANE_OFFSET_KCPS = const(0x0016) 39 | _VL53L4CD_XTALK_X_PLANE_GRADIENT_KCPS = const(0x0018) 40 | _VL53L4CD_XTALK_Y_PLANE_GRADIENT_KCPS = const(0x001A) 41 | _VL53L4CD_RANGE_OFFSET_MM = const(0x001E) 42 | _VL53L4CD_INNER_OFFSET_MM = const(0x0020) 43 | _VL53L4CD_OUTER_OFFSET_MM = const(0x0022) 44 | _VL53L4CD_I2C_FAST_MODE_PLUS = const(0x002D) 45 | _VL53L4CD_GPIO_HV_MUX_CTRL = const(0x0030) 46 | _VL53L4CD_GPIO_TIO_HV_STATUS = const(0x0031) 47 | _VL53L4CD_SYSTEM_INTERRUPT = const(0x0046) 48 | _VL53L4CD_RANGE_CONFIG_A = const(0x005E) 49 | _VL53L4CD_RANGE_CONFIG_B = const(0x0061) 50 | _VL53L4CD_RANGE_CONFIG_SIGMA_THRESH = const(0x0064) 51 | _VL53L4CD_MIN_COUNT_RATE_RTN_LIMIT_MCPS = const(0x0066) 52 | _VL53L4CD_INTERMEASUREMENT_MS = const(0x006C) 53 | _VL53L4CD_THRESH_HIGH = const(0x0072) 54 | _VL53L4CD_THRESH_LOW = const(0x0074) 55 | _VL53L4CD_SYSTEM_INTERRUPT_CLEAR = const(0x0086) 56 | _VL53L4CD_SYSTEM_START = const(0x0087) 57 | _VL53L4CD_RESULT_RANGE_STATUS = const(0x0089) 58 | _VL53L4CD_RESULT_SPAD_NB = const(0x008C) 59 | _VL53L4CD_RESULT_SIGNAL_RATE = const(0x008E) 60 | _VL53L4CD_RESULT_AMBIENT_RATE = const(0x0090) 61 | _VL53L4CD_RESULT_SIGMA = const(0x0092) 62 | _VL53L4CD_RESULT_DISTANCE = const(0x0096) 63 | 64 | _VL53L4CD_RESULT_OSC_CALIBRATE_VAL = const(0x00DE) 65 | _VL53L4CD_FIRMWARE_SYSTEM_STATUS = const(0x00E5) 66 | _VL53L4CD_IDENTIFICATION_MODEL_ID = const(0x010F) 67 | 68 | RANGE_VALID = const(0x00) 69 | RANGE_WARN_SIGMA_ABOVE = const(0x01) 70 | RANGE_WARN_SIGMA_BELOW = const(0x02) 71 | RANGE_ERROR_DISTANCE_BELOW_DETECTION_THRESHOLD = const(0x03) 72 | RANGE_ERROR_INVALID_PHASE = const(0x04) 73 | RANGE_ERROR_HW_FAIL = const(0x05) 74 | RANGE_WARN_NO_WRAP_AROUND_CHECK = const(0x06) 75 | RANGE_ERROR_WRAPPED_TARGET_PHASE_MISMATCH = const(0x07) 76 | RANGE_ERROR_PROCESSING_FAIL = const(0x08) 77 | RANGE_ERROR_CROSSTALK_FAIL = const(0x09) 78 | RANGE_ERROR_INTERRUPT = const(0x0A) 79 | RANGE_ERROR_MERGED_TARGET = const(0x0B) 80 | RANGE_ERROR_SIGNAL_TOO_WEAK = const(0x0C) 81 | RANGE_ERROR_OTHER = const(0xFF) 82 | 83 | 84 | class VL53L4CD: 85 | """Driver for the VL53L4CD distance sensor.""" 86 | 87 | def __init__(self, i2c, address=41): 88 | self._i2c = i2c 89 | self._device_address = address 90 | model_id, module_type = self.model_info 91 | # if model_id != 0xEB or module_type != 0xAA: 92 | # raise RuntimeError(f"Wrong sensor ID ({model_id}) or type!") 93 | self._ranging = False 94 | self._sensor_init() 95 | 96 | def _sensor_init(self): 97 | # pylint: disable=line-too-long 98 | init_seq = ( 99 | # value addr : description 100 | b"\x12" # 0x2d : set bit 2 and 5 to 1 for fast plus mode (1MHz I2C), else don't touch 101 | b"\x00" # 0x2e : bit 0 if I2C pulled up at 1.8V, else set bit 0 to 1 (pull up at AVDD) 102 | b"\x00" # 0x2f : bit 0 if GPIO pulled up at 1.8V, else set bit 0 to 1 (pull up at AVDD) 103 | b"\x11" # 0x30 : set bit 4 to 0 for active high interrupt and 1 for active low (bits 3:0 must be 0x1) 104 | b"\x02" # 0x31 : bit 1 = interrupt depending on the polarity 105 | b"\x00" # 0x32 : not user-modifiable 106 | b"\x02" # 0x33 : not user-modifiable 107 | b"\x08" # 0x34 : not user-modifiable 108 | b"\x00" # 0x35 : not user-modifiable 109 | b"\x08" # 0x36 : not user-modifiable 110 | b"\x10" # 0x37 : not user-modifiable 111 | b"\x01" # 0x38 : not user-modifiable 112 | b"\x01" # 0x39 : not user-modifiable 113 | b"\x00" # 0x3a : not user-modifiable 114 | b"\x00" # 0x3b : not user-modifiable 115 | b"\x00" # 0x3c : not user-modifiable 116 | b"\x00" # 0x3d : not user-modifiable 117 | b"\xFF" # 0x3e : not user-modifiable 118 | b"\x00" # 0x3f : not user-modifiable 119 | b"\x0F" # 0x40 : not user-modifiable 120 | b"\x00" # 0x41 : not user-modifiable 121 | b"\x00" # 0x42 : not user-modifiable 122 | b"\x00" # 0x43 : not user-modifiable 123 | b"\x00" # 0x44 : not user-modifiable 124 | b"\x00" # 0x45 : not user-modifiable 125 | b"\x20" # 0x46 : interrupt configuration 0->level low detection, 1-> level high, 2-> Out of window, 3->In window, 0x20-> New sample ready , TBC 126 | b"\x0B" # 0x47 : not user-modifiable 127 | b"\x00" # 0x48 : not user-modifiable 128 | b"\x00" # 0x49 : not user-modifiable 129 | b"\x02" # 0x4a : not user-modifiable 130 | b"\x14" # 0x4b : not user-modifiable 131 | b"\x21" # 0x4c : not user-modifiable 132 | b"\x00" # 0x4d : not user-modifiable 133 | b"\x00" # 0x4e : not user-modifiable 134 | b"\x05" # 0x4f : not user-modifiable 135 | b"\x00" # 0x50 : not user-modifiable 136 | b"\x00" # 0x51 : not user-modifiable 137 | b"\x00" # 0x52 : not user-modifiable 138 | b"\x00" # 0x53 : not user-modifiable 139 | b"\xC8" # 0x54 : not user-modifiable 140 | b"\x00" # 0x55 : not user-modifiable 141 | b"\x00" # 0x56 : not user-modifiable 142 | b"\x38" # 0x57 : not user-modifiable 143 | b"\xFF" # 0x58 : not user-modifiable 144 | b"\x01" # 0x59 : not user-modifiable 145 | b"\x00" # 0x5a : not user-modifiable 146 | b"\x08" # 0x5b : not user-modifiable 147 | b"\x00" # 0x5c : not user-modifiable 148 | b"\x00" # 0x5d : not user-modifiable 149 | b"\x01" # 0x5e : not user-modifiable 150 | b"\xCC" # 0x5f : not user-modifiable 151 | b"\x07" # 0x60 : not user-modifiable 152 | b"\x01" # 0x61 : not user-modifiable 153 | b"\xF1" # 0x62 : not user-modifiable 154 | b"\x05" # 0x63 : not user-modifiable 155 | b"\x00" # 0x64 : Sigma threshold MSB (mm in 14.2 format for MSB+LSB), default value 90 mm 156 | b"\xA0" # 0x65 : Sigma threshold LSB 157 | b"\x00" # 0x66 : Min count Rate MSB (MCPS in 9.7 format for MSB+LSB) 158 | b"\x80" # 0x67 : Min count Rate LSB 159 | b"\x08" # 0x68 : not user-modifiable 160 | b"\x38" # 0x69 : not user-modifiable 161 | b"\x00" # 0x6a : not user-modifiable 162 | b"\x00" # 0x6b : not user-modifiable 163 | b"\x00" # 0x6c : Intermeasurement period MSB, 32 bits register 164 | b"\x00" # 0x6d : Intermeasurement period 165 | b"\x0F" # 0x6e : Intermeasurement period 166 | b"\x89" # 0x6f : Intermeasurement period LSB 167 | b"\x00" # 0x70 : not user-modifiable 168 | b"\x00" # 0x71 : not user-modifiable 169 | b"\x00" # 0x72 : distance threshold high MSB (in mm, MSB+LSB) 170 | b"\x00" # 0x73 : distance threshold high LSB 171 | b"\x00" # 0x74 : distance threshold low MSB ( in mm, MSB+LSB) 172 | b"\x00" # 0x75 : distance threshold low LSB 173 | b"\x00" # 0x76 : not user-modifiable 174 | b"\x01" # 0x77 : not user-modifiable 175 | b"\x07" # 0x78 : not user-modifiable 176 | b"\x05" # 0x79 : not user-modifiable 177 | b"\x06" # 0x7a : not user-modifiable 178 | b"\x06" # 0x7b : not user-modifiable 179 | b"\x00" # 0x7c : not user-modifiable 180 | b"\x00" # 0x7d : not user-modifiable 181 | b"\x02" # 0x7e : not user-modifiable 182 | b"\xC7" # 0x7f : not user-modifiable 183 | b"\xFF" # 0x80 : not user-modifiable 184 | b"\x9B" # 0x81 : not user-modifiable 185 | b"\x00" # 0x82 : not user-modifiable 186 | b"\x00" # 0x83 : not user-modifiable 187 | b"\x00" # 0x84 : not user-modifiable 188 | b"\x01" # 0x85 : not user-modifiable 189 | b"\x00" # 0x86 : clear interrupt, 0x01=clear 190 | b"\x00" # 0x87 : ranging, 0x00=stop, 0x40=start 191 | ) 192 | self._wait_for_boot() 193 | self._write_register(0x002D, init_seq) 194 | self._start_vhv() 195 | self.clear_interrupt() 196 | self.stop_ranging() 197 | self._write_register(_VL53L4CD_VHV_CONFIG_TIMEOUT_MACROP_LOOP_BOUND, b"\x09") 198 | self._write_register(0x0B, b"\x00") 199 | self._write_register(0x0024, b"\x05\x00") 200 | self.inter_measurement = 0 201 | self.timing_budget = 50 202 | 203 | @property 204 | def model_info(self): 205 | """A 2 tuple of Model ID and Module Type.""" 206 | info = self._read_register(_VL53L4CD_IDENTIFICATION_MODEL_ID, 2) 207 | return info[0], info[1] # Model ID, Module Type 208 | 209 | @property 210 | def distance(self): 211 | """The distance in units of centimeters.""" 212 | dist = self._read_register(_VL53L4CD_RESULT_DISTANCE, 2) 213 | dist = struct.unpack(">H", dist)[0] 214 | return dist / 10 215 | 216 | @property 217 | def range_status(self): 218 | """Measurement validity. If the range status is equal to 0, the distance is valid.""" 219 | status_rtn = [ 220 | RANGE_ERROR_OTHER, 221 | RANGE_ERROR_OTHER, 222 | RANGE_ERROR_OTHER, 223 | RANGE_ERROR_HW_FAIL, 224 | RANGE_WARN_SIGMA_BELOW, 225 | RANGE_ERROR_INVALID_PHASE, 226 | RANGE_WARN_SIGMA_ABOVE, 227 | RANGE_ERROR_WRAPPED_TARGET_PHASE_MISMATCH, 228 | RANGE_ERROR_DISTANCE_BELOW_DETECTION_THRESHOLD, 229 | RANGE_VALID, 230 | RANGE_ERROR_OTHER, 231 | RANGE_ERROR_OTHER, 232 | RANGE_ERROR_CROSSTALK_FAIL, 233 | RANGE_ERROR_OTHER, 234 | RANGE_ERROR_OTHER, 235 | RANGE_ERROR_OTHER, 236 | RANGE_ERROR_OTHER, 237 | RANGE_ERROR_OTHER, 238 | RANGE_ERROR_INTERRUPT, 239 | RANGE_WARN_NO_WRAP_AROUND_CHECK, 240 | RANGE_ERROR_OTHER, 241 | RANGE_ERROR_OTHER, 242 | RANGE_ERROR_MERGED_TARGET, 243 | RANGE_ERROR_SIGNAL_TOO_WEAK, 244 | ] 245 | status = self._read_register(_VL53L4CD_RESULT_RANGE_STATUS, 1) 246 | status = struct.unpack(">B", status)[0] 247 | status = status & 0x1F 248 | if status < 24: 249 | status = status_rtn[status] 250 | else: 251 | status = RANGE_ERROR_OTHER 252 | return status 253 | 254 | @property 255 | def sigma(self): 256 | """Sigma estimator for the noise in the reported target distance in units of centimeters.""" 257 | sigma = self._read_register(_VL53L4CD_RESULT_SIGMA, 2) 258 | sigma = struct.unpack(">H", sigma)[0] 259 | return sigma / 40 260 | 261 | @property 262 | def timing_budget(self): 263 | """Ranging duration in milliseconds. Valid range is 10ms to 200ms.""" 264 | osc_freq = struct.unpack(">H", self._read_register(0x0006, 2))[0] 265 | 266 | macro_period_us = 16 * (int(2304 * (0x40000000 / osc_freq)) >> 6) 267 | 268 | macrop_high = struct.unpack( 269 | ">H", self._read_register(_VL53L4CD_RANGE_CONFIG_A, 2) 270 | )[0] 271 | 272 | ls_byte = (macrop_high & 0x00FF) << 4 273 | ms_byte = (macrop_high & 0xFF00) >> 8 274 | ms_byte = 0x04 - (ms_byte - 1) - 1 275 | 276 | timing_budget_ms = ( 277 | ((ls_byte + 1) * (macro_period_us >> 6)) - ((macro_period_us >> 6) >> 1) 278 | ) >> 12 279 | if ms_byte < 12: 280 | timing_budget_ms >>= ms_byte 281 | if self.inter_measurement == 0: 282 | # mode continuous 283 | timing_budget_ms += 2500 284 | else: 285 | # mode autonomous 286 | timing_budget_ms *= 2 287 | timing_budget_ms += 4300 288 | 289 | return int(timing_budget_ms / 1000) 290 | 291 | @timing_budget.setter 292 | def timing_budget(self, val): 293 | if self._ranging: 294 | raise RuntimeError("Must stop ranging first.") 295 | 296 | if not 10 <= val <= 200: 297 | raise ValueError("Timing budget range duration must be 10ms to 200ms.") 298 | 299 | inter_meas = self.inter_measurement 300 | if inter_meas != 0 and val > inter_meas: 301 | raise ValueError( 302 | "Timing budget can not be greater than inter-measurement period ({})".format( 303 | inter_meas 304 | ) 305 | ) 306 | 307 | osc_freq = struct.unpack(">H", self._read_register(0x0006, 2))[0] 308 | if osc_freq == 0: 309 | raise RuntimeError("Osc frequency is 0.") 310 | 311 | timing_budget_us = val * 1000 312 | macro_period_us = int(2304 * (0x40000000 / osc_freq)) >> 6 313 | 314 | if inter_meas == 0: 315 | # continuous mode 316 | timing_budget_us -= 2500 317 | else: 318 | # autonomous mode 319 | timing_budget_us -= 4300 320 | timing_budget_us //= 2 321 | 322 | # VL53L4CD_RANGE_CONFIG_A register 323 | ms_byte = 0 324 | timing_budget_us <<= 12 325 | tmp = macro_period_us * 16 326 | ls_byte = int(((timing_budget_us + ((tmp >> 6) >> 1)) / (tmp >> 6)) - 1) 327 | while ls_byte & 0xFFFFFF00 > 0: 328 | ls_byte >>= 1 329 | ms_byte += 1 330 | ms_byte = (ms_byte << 8) + (ls_byte & 0xFF) 331 | self._write_register(_VL53L4CD_RANGE_CONFIG_A, struct.pack(">H", ms_byte)) 332 | 333 | # VL53L4CD_RANGE_CONFIG_B register 334 | ms_byte = 0 335 | tmp = macro_period_us * 12 336 | ls_byte = int(((timing_budget_us + ((tmp >> 6) >> 1)) / (tmp >> 6)) - 1) 337 | while ls_byte & 0xFFFFFF00 > 0: 338 | ls_byte >>= 1 339 | ms_byte += 1 340 | ms_byte = (ms_byte << 8) + (ls_byte & 0xFF) 341 | self._write_register(_VL53L4CD_RANGE_CONFIG_B, struct.pack(">H", ms_byte)) 342 | 343 | @property 344 | def inter_measurement(self): 345 | """ 346 | Inter-measurement period in milliseconds. Valid range is timing_budget to 347 | 5000ms, or 0 to disable. 348 | """ 349 | reg_val = struct.unpack( 350 | ">I", self._read_register(_VL53L4CD_INTERMEASUREMENT_MS, 4) 351 | )[0] 352 | clock_pll = struct.unpack( 353 | ">H", self._read_register(_VL53L4CD_RESULT_OSC_CALIBRATE_VAL, 2) 354 | )[0] 355 | clock_pll &= 0x3FF 356 | clock_pll = int(1.065 * clock_pll) 357 | return int(reg_val / clock_pll) 358 | 359 | @inter_measurement.setter 360 | def inter_measurement(self, val): 361 | if self._ranging: 362 | raise RuntimeError("Must stop ranging first.") 363 | 364 | timing_bud = self.timing_budget 365 | if val != 0 and val < timing_bud: 366 | raise ValueError( 367 | "Inter-measurement period can not be less than timing budget ({})".format( 368 | timing_bud 369 | ) 370 | ) 371 | 372 | clock_pll = struct.unpack( 373 | ">H", self._read_register(_VL53L4CD_RESULT_OSC_CALIBRATE_VAL, 2) 374 | )[0] 375 | clock_pll &= 0x3FF 376 | int_meas = int(1.055 * val * clock_pll) 377 | self._write_register(_VL53L4CD_INTERMEASUREMENT_MS, struct.pack(">I", int_meas)) 378 | 379 | # need to reset timing budget so that it will be based on new inter-measurement period 380 | self.timing_budget = timing_bud 381 | 382 | def start_ranging(self): 383 | """Starts ranging operation.""" 384 | # start ranging depending inter-measurement setting 385 | if self.inter_measurement == 0: 386 | # continuous mode 387 | self._write_register(_VL53L4CD_SYSTEM_START, b"\x21") 388 | else: 389 | # autonomous mode 390 | self._write_register(_VL53L4CD_SYSTEM_START, b"\x40") 391 | 392 | # wait for data ready 393 | timed_out = True 394 | for _ in range(1000): 395 | if self.data_ready: 396 | timed_out = False 397 | break 398 | time.sleep(0.001) 399 | if timed_out: 400 | raise TimeoutError("Time out waiting for data ready.") 401 | 402 | self.clear_interrupt() 403 | self._ranging = True 404 | 405 | def stop_ranging(self): 406 | """Stops ranging operation.""" 407 | self._write_register(_VL53L4CD_SYSTEM_START, b"\x00") 408 | self._ranging = False 409 | 410 | def clear_interrupt(self): 411 | """Clears new data interrupt.""" 412 | self._write_register(_VL53L4CD_SYSTEM_INTERRUPT_CLEAR, b"\x01") 413 | 414 | @property 415 | def data_ready(self): 416 | """Returns true if new data is ready, otherwise false.""" 417 | if ( 418 | self._read_register(_VL53L4CD_GPIO_TIO_HV_STATUS)[0] & 0x01 419 | == self._interrupt_polarity 420 | ): 421 | return True 422 | return False 423 | 424 | @property 425 | def _interrupt_polarity(self): 426 | int_pol = self._read_register(_VL53L4CD_GPIO_HV_MUX_CTRL)[0] & 0x10 427 | int_pol = (int_pol >> 4) & 0x01 428 | return 0 if int_pol else 1 429 | 430 | def _wait_for_boot(self): 431 | for _ in range(1000): 432 | if self._read_register(_VL53L4CD_FIRMWARE_SYSTEM_STATUS)[0] == 0x03: 433 | return 434 | time.sleep(0.001) 435 | raise TimeoutError("Time out waiting for system boot.") 436 | 437 | def _start_vhv(self): 438 | self.start_ranging() 439 | for _ in range(1000): 440 | if self.data_ready: 441 | return 442 | time.sleep(0.001) 443 | raise TimeoutError("Time out starting VHV.") 444 | 445 | def _write_register(self, address, data, length=None): 446 | if length is None: 447 | length = len(data) 448 | self._i2c.writeto(self._device_address, struct.pack(">H", address) + data[:length]) 449 | 450 | def _read_register(self, address, length=1): 451 | data = bytearray(length) 452 | self._i2c.writeto(self._device_address, struct.pack(">H", address), False) 453 | self._i2c.readfrom_into(self._device_address, data) 454 | return data 455 | 456 | def set_address(self, new_address): 457 | """ 458 | Set a new I2C address to the instantaited object. This is only called when using 459 | multiple VL53L4CD sensors on the same I2C bus (SDA & SCL pins). See also the 460 | `example `_ for proper usage. 461 | """ 462 | self._write_register( 463 | _VL53L4CD_I2C_SLAVE_DEVICE_ADDRESS, struct.pack(">B", new_address) 464 | ) 465 | self._device_address = new_address -------------------------------------------------------------------------------- /docs/assets/library-banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | --------------------------------------------------------------------------------