Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit ef0301c

Browse files
authoredFeb 20, 2025··
Merge pull request #10 from arduino/fw-flasher
Add example to update firmware on modulinos with MCU
2 parents f1ed6bb + 20e361b commit ef0301c

File tree

3 files changed

+331
-3
lines changed

3 files changed

+331
-3
lines changed
 

‎examples/change_address.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
from modulino import Modulino
1313

1414
print()
15-
devices = Modulino.available_devices()
15+
bus = None # Change this to the I2C bus you are using on 3rd party host boards
16+
devices = Modulino.available_devices(bus)
1617

1718
if len(devices) == 0:
1819
print("No devices found on the bus. Try resetting the board.")

‎examples/firmware_update.py

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
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+
data = execute_command(bus, CMD_GET_VERSION, None, 1, verbose)
101+
if data is None:
102+
print("❌ Failed to get protocol version")
103+
return False
104+
print(f"ℹ️ Protocol version: {data[0] & 0xF}.{data[0] >> 4}")
105+
106+
data = execute_command(bus, CMD_GET, None, CMD_GET_LENGTH_V12, verbose)
107+
if data is None:
108+
print("❌ Failed to get command list")
109+
return False
110+
111+
print(f"ℹ️ Bootloader version: {(data[1] & 0xF)}.{data[1] >> 4}")
112+
print("👀 Supported commands:")
113+
print(", ".join([hex(byte) for byte in data[2:]]))
114+
115+
data = execute_command(bus, CMD_GET_ID, None, 3, verbose)
116+
if data is None:
117+
print("❌ Failed to get device ID")
118+
return False
119+
120+
chip_id = (data[0] << 8) | data[1] # Chip ID: Byte 1 = MSB, Byte 2 = LSB
121+
print(f"ℹ️ Chip ID: {chip_id}")
122+
123+
print("🗑️ Erasing memory...")
124+
erase_params = bytearray([0xFF, 0xFF, 0x0]) # Mass erase flash
125+
execute_command(bus, CMD_ERASE_NO_STRETCH, erase_params, 0, verbose)
126+
127+
with open(firmware_path, 'rb') as file:
128+
firmware_data = file.read()
129+
total_bytes = len(firmware_data)
130+
131+
print(f"🔥 Writing {total_bytes} bytes")
132+
for i in range(0, total_bytes, CHUNK_SIZE):
133+
progress_bar(i, total_bytes)
134+
start_address = bytearray([8, 0, i // 256, i % 256]) # 4-byte address: byte 1 = MSB, byte 4 = LSB
135+
checksum = 0
136+
for b in start_address:
137+
checksum ^= b
138+
start_address.append(checksum)
139+
data_slice = firmware_data[i:i + CHUNK_SIZE]
140+
if not write_firmware_page(bus, start_address, data_slice):
141+
print(f"❌ Failed to write page {hex(i)}")
142+
return False
143+
time.sleep(0.01) # Give the device some time to process the data
144+
145+
progress_bar(total_bytes, total_bytes) # Complete the progress bar
146+
147+
print("🏃 Launching new firmware")
148+
go_params = bytearray([0x8, 0x00, 0x00, 0x00, 0x8])
149+
execute_command(bus, CMD_GO, go_params, 0, verbose) # Jump to the application
150+
151+
return True
152+
153+
def write_firmware_page(bus, command_params, firmware_data):
154+
"""
155+
Write a page of the firmware to the I2C device.
156+
157+
:param bus: The I2C bus to use.
158+
:param command_params: The buffer containing the command parameters.
159+
:param firmware_data: The buffer containing the firmware data.
160+
:return: True if the page was written successfully, otherwise False.
161+
"""
162+
cmd = bytes([CMD_WRITE_NO_STRETCH, 0xFF ^ CMD_WRITE_NO_STRETCH])
163+
bus.writeto(BOOTLOADER_I2C_ADDRESS, cmd)
164+
if not wait_for_ack(bus):
165+
print("❌ Write command not acknowledged")
166+
return False
167+
168+
bus.writeto(BOOTLOADER_I2C_ADDRESS, command_params)
169+
if not wait_for_ack(bus):
170+
print("❌ Failed to write command parameters")
171+
return False
172+
173+
data_size = len(firmware_data)
174+
tmp_buffer = bytearray(data_size + 2) # Data plus size and checksum
175+
tmp_buffer[0] = data_size - 1 # Size of the data
176+
tmp_buffer[1:data_size + 1] = firmware_data
177+
tmp_buffer[-1] = 0 # Checksum placeholder
178+
for i in range(data_size + 1): # Calculate checksum over size byte + data bytes
179+
tmp_buffer[-1] ^= tmp_buffer[i]
180+
181+
bus.writeto(BOOTLOADER_I2C_ADDRESS, tmp_buffer)
182+
if not wait_for_ack(bus):
183+
print("❌ Failed to write firmware")
184+
return False
185+
186+
return True
187+
188+
def progress_bar(current, total, bar_length=40):
189+
"""
190+
Print a progress bar to the terminal.
191+
192+
:param current: The current progress value.
193+
:param total: The total progress value.
194+
:param bar_length: The length of the progress bar in characters.
195+
"""
196+
percent = float(current) / total
197+
arrow = '=' * int(round(percent * bar_length))
198+
spaces = ' ' * (bar_length - len(arrow))
199+
sys.stdout.write(f"\rProgress: [{arrow}{spaces}] {int(round(percent * 100))}%")
200+
if current == total:
201+
sys.stdout.write('\n')
202+
203+
def find_bin_files():
204+
"""
205+
Find all .bin files in the root directory.
206+
207+
:return: A list of .bin file names.
208+
"""
209+
return [file for file in os.listdir('/') if file.endswith('.bin')]
210+
211+
def select_file(bin_files):
212+
"""
213+
Prompt the user to select a .bin file to flash.
214+
215+
:param bin_files: A list of .bin file names.
216+
:return: The selected .bin file name.
217+
"""
218+
if len(bin_files) == 0:
219+
print("❌ No .bin files found in the root directory.")
220+
return None
221+
222+
if len(bin_files) == 1:
223+
confirm = input(f"📄 Found one binary file: {bin_files[0]}. Do you want to flash it? (yes/no) ")
224+
if confirm.lower() == 'yes':
225+
return bin_files[0]
226+
else:
227+
return None
228+
229+
print("📄 Found binary files:")
230+
for index, file in enumerate(bin_files):
231+
print(f"{index + 1}. {file}")
232+
choice = int(input("Select the file to flash (number): "))
233+
if choice < 1 or choice > len(bin_files):
234+
return None
235+
return bin_files[choice - 1]
236+
237+
def select_device(bus : I2C) -> Modulino:
238+
"""
239+
Scan the I2C bus for devices and prompt the user to select one.
240+
241+
:param bus: The I2C bus to scan.
242+
:return: The selected Modulino device.
243+
"""
244+
devices = Modulino.available_devices(bus)
245+
246+
if len(devices) == 0:
247+
print("❌ No devices found")
248+
return None
249+
250+
if len(devices) == 1:
251+
device = devices[0]
252+
confirm = input(f"🔌 Found {device.device_type} at address {hex(device.address)}. Do you want to update this device? (yes/no) ")
253+
if confirm.lower() == 'yes':
254+
return devices[0]
255+
else:
256+
return None
257+
258+
print("🔌 Devices found:")
259+
for index, device in enumerate(devices):
260+
print(f"{index + 1}) {device.device_type} at {hex(device.address)}")
261+
choice = int(input("Select the device to flash (number): "))
262+
if choice < 1 or choice > len(devices):
263+
return None
264+
return devices[choice - 1]
265+
266+
def run(bus: I2C):
267+
"""
268+
Initialize the flashing process.
269+
Finds .bin files, scans for I2C devices, and flashes the selected firmware.
270+
271+
:param bus: The I2C bus to use. If None, the default I2C bus will be used.
272+
"""
273+
274+
bin_files = find_bin_files()
275+
if not bin_files:
276+
print("❌ No .bin files found in the root directory.")
277+
return
278+
279+
bin_file = select_file(bin_files)
280+
if bin_file is None:
281+
print("❌ No file selected")
282+
return
283+
284+
device = select_device(bus)
285+
if device is None:
286+
print("❌ No device selected")
287+
return
288+
289+
print(f"🔄 Resetting device at address {hex(device.address)}")
290+
if device.enter_bootloader():
291+
print("✅ Device reset successfully")
292+
else:
293+
print("❌ Failed to reset device")
294+
return
295+
296+
print(f"🕵️ Flashing {bin_file} to device at address {hex(BOOTLOADER_I2C_ADDRESS)}")
297+
298+
if flash_firmware(device, bin_file):
299+
print("✅ Firmware flashed successfully")
300+
else:
301+
print("❌ Failed to flash firmware")
302+
303+
if __name__ == "__main__":
304+
print()
305+
run(bus)

‎src/modulino/modulino.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,24 @@ def change_address(self, new_address: int):
250250

251251
self.address = new_address
252252

253+
def enter_bootloader(self):
254+
"""
255+
Enters the I2C bootloader of the device.
256+
This is only supported on Modulinos that have a microcontroller.
257+
258+
Returns:
259+
bool: True if the device entered bootloader mode, False otherwise.
260+
"""
261+
buffer = b'DIE'
262+
buffer += b'\x00' * (8 - len(buffer)) # Pad buffer to 8 bytes
263+
try:
264+
self.i2c_bus.writeto(self.address, buffer, True)
265+
sleep(0.25) # Wait for the device to reset
266+
return True
267+
except OSError as e:
268+
# ENODEV (e.errno == 19) can be thrown if either the device reset while writing out the buffer
269+
return False
270+
253271
def read(self, amount_of_bytes: int) -> bytes | None:
254272
"""
255273
Reads the given amount of bytes from the i2c device and returns the data.
@@ -293,14 +311,18 @@ def has_default_address(self) -> bool:
293311
return self.address in self.default_addresses
294312

295313
@staticmethod
296-
def available_devices() -> list[Modulino]:
314+
def available_devices(bus: I2C = None) -> list[Modulino]:
297315
"""
298316
Finds all devices on the i2c bus and returns them as a list of Modulino objects.
299317
318+
Parameters:
319+
bus (I2C): The I2C bus to use. If not provided, the default I2C bus will be used.
320+
300321
Returns:
301322
list: A list of Modulino objects.
302323
"""
303-
bus = _I2CHelper.get_interface()
324+
if bus is None:
325+
bus = _I2CHelper.get_interface()
304326
device_addresses = bus.scan()
305327
devices = []
306328
for address in device_addresses:

0 commit comments

Comments
 (0)
Please sign in to comment.