Skip to content

Commit dec5ebb

Browse files
Rework of teensy progress, adding descriptions, handling for various illegal user flows
1 parent df6675e commit dec5ebb

File tree

8 files changed

+304
-102
lines changed

8 files changed

+304
-102
lines changed

MicroPython_Firmware_Uploader/MicroPython_Firmware_Uploader.py

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
AUxEsptoolEraseFlash
1515
from .mpremote_utils import MPRemoteSession
1616
from .au_act_rp2 import AUxRp2UploadRp2
17-
from .au_act_teensy import AUxTeensyUploadTeensy, TeensyProgress
17+
from .au_act_teensy import AUxTeensyUploadTeensy
1818
from .pyqt_utils import PopupListButton
1919
from .firmware_utils import FirmwareFile, resource_path, GithubFirmware
2020

@@ -97,8 +97,6 @@ def __init__(self, parent: QWidget = None) -> None:
9797
# ---------------------------- Variables/Objects ------------------
9898
self.flashSize = 0
9999

100-
self.teensyProgress = TeensyProgress() # Progress tracking for Teensy uploads. Its done in a different way for ESP32 and RP2...
101-
102100
self.githubFirmware = GithubFirmware(_RELEASE_REPO, _BOARD_MANIFEST_FILE, _RESOURCE_DIRECTORY)
103101

104102
if self.githubFirmware.offline:
@@ -144,13 +142,16 @@ def __init__(self, parent: QWidget = None) -> None:
144142

145143
# Populate the device button with the list of devices from the latest release
146144
# Add an automatic device detection option to the list of devices
147-
self.device_button.addIconItem(_AUTO_DETECT_DECORATOR, "Automatically detect the device", resource_path("auto_detect.png", _RESOURCE_DIRECTORY))
145+
self.device_button.addIconItem(_AUTO_DETECT_DECORATOR, "If your board is already running MicroPython, automatically selects board.", resource_path("auto_detect.png", _RESOURCE_DIRECTORY))
148146

149-
for device, imagePath in self.githubFirmware.get_all_board_image_paths():
150-
self.device_button.addIconItem(device, "", imagePath)
147+
for device, description, imagePath in self.githubFirmware.get_all_board_icon_info():
148+
self.device_button.addIconItem(device, description, imagePath)
151149

152150
# Add the local firmware option to the list of firmware files
153-
self.firmware_button.addIconItem(_LOCAL_DECORATOR, "Local firmware file", resource_path("fw_local.png", _RESOURCE_DIRECTORY))
151+
self.firmware_button.addIconItem(_LOCAL_DECORATOR,
152+
"Manually select a firmware file from your local machine.",
153+
resource_path("fw_local.png",_RESOURCE_DIRECTORY)
154+
)
154155

155156
# Attach the browse callback to the firmware button's textChanged signal
156157
self.firmware_button.textChanged.connect(self.on_fw_button_pressed)
@@ -296,13 +297,15 @@ def update_firmware_list(self) -> None:
296297

297298
self.firmware_button.items.clear()
298299
# Add the local firmware option to the list of firmware files
299-
self.firmware_button.addIconItem(_LOCAL_DECORATOR, "Local firmware file", resource_path("fw_local.png", _RESOURCE_DIRECTORY))
300+
self.firmware_button.addIconItem(_LOCAL_DECORATOR,
301+
"Manually select a firmware file from your local machine.",
302+
resource_path("fw_local.png",_RESOURCE_DIRECTORY)
303+
)
300304

301305
# Check the currently selected device item and update the firmware list accordingly
302306
self.firmware_button.setText(_FIRMWARE_CHOICE_DECORATOR)
303307
currentDevice = self.device_button.text()
304308
for fw in self.githubFirmware.deviceDict[currentDevice]:
305-
# print("Adding firmware: ", fw.displayName, " for device: ", currentDevice)
306309
self.firmware_button.addIconItem(fw.displayName, fw.description(), fw.fw_image_path())
307310

308311
def show_user_message(self, message: str, windowTitle: str, warning = False) -> None:
@@ -369,16 +372,8 @@ def parse_progress(self, msg: str) -> None:
369372
"""Parse the progress message."""
370373
if self.is_esp32_upload():
371374
self.parse_esp32_progress(msg)
372-
elif self.is_teensy_upload():
373-
progress = self.teensyProgress.parse_message(msg)
374-
# update the progress bar with the extracted percentage
375-
if progress is not None:
376-
if progress > 0 and progress < 100:
377-
# emit the progress signal to update the progress bar in the GUI
378-
self.sig_progress.emit(progress)
379375

380-
# rp2 progress is handled down a few levels while the firmware is being uploaded.
381-
# this is because it is not a parsing of the output message but rather a manual tracking of the copy progress.
376+
# rp2 progress and teensy progress are handled down a few levels while the firmware is being uploaded.
382377

383378
@pyqtSlot(str)
384379
def appendMessage(self, msg: str) -> None:
@@ -432,6 +427,19 @@ def progress_bar_start(self, label) -> None:
432427

433428
@pyqtSlot(int)
434429
def on_progress(self, progress: int) -> None:
430+
if progress < 0:
431+
# This indicates an error from the worker. We can handle differently for different platforms.
432+
# For now, this will only happen when teensy times out waiting for the bootloader
433+
if self.is_teensy_upload():
434+
# Open a popup warning the user that the upload failed.
435+
# Also end the upload and allow them to try again by re-enabling the back button (interface)
436+
error_message = "Teensy upload timed out waiting for the bootloader to be ready. Please try again."
437+
# If on MacOS, also inform user they need to check their "Input Monitoring" security permissions
438+
if platform.system() == "Darwin":
439+
error_message += "\n\nIf you are on MacOS, please also check your \"Input Monitoring\" security permissions for this application."
440+
self.show_user_message(error_message, "Teensy Upload Error", warning=True)
441+
self.end_upload_with_message(error_message)
442+
435443
self.progress_bar.setValue(progress)
436444

437445
#--------------------------------------------------------------
@@ -465,15 +473,15 @@ def on_finished(self, status, action_type, job_id) -> None:
465473
self.writeMessage("Reset complete...")
466474
# Emit a signal to update the progress bar to 100%
467475
self.sig_progress.emit(100)
468-
self.writeMessage("DONE: Firmware file copied to ESP32 device.\n")
476+
self.writeMessage("DONE: ESP32 Upload Job Complete.\n")
469477
self.disable_interface(False)
470478

471479
if action_type == AUxRp2UploadRp2.ACTION_ID:
472-
self.end_upload_with_message("DONE: Firmware file copied to RP2 device.\n")
480+
self.end_upload_with_message("DONE: RP2 Upload Job Complete.\n")
473481

474482
if action_type == AUxTeensyUploadTeensy.ACTION_ID:
475483
self.sig_progress.emit(100)
476-
self.end_upload_with_message("DONE: Firmware file copied to Teensy device.\n")
484+
self.end_upload_with_message("DONE: Teensy Upload Job Complete\n")
477485

478486
# --------------------------------------------------------------
479487
# on_port_combobox()
@@ -782,17 +790,10 @@ def end_upload_with_message(self, msg: str) -> None:
782790
"""End the upload with a message."""
783791
self.writeMessage(msg)
784792
self.cleanup_temp()
785-
# self.switch_to_page_one()
786793
self.disable_interface(False)
787-
# self.progress_bar.hide()
788-
# self.progress_label.hide()
789794

790795
def get_mpremote_session(self) -> MPRemoteSession:
791796
"""Get an mpremote session for the current port."""
792-
# portName = self.port_combobox.currentText() # This will be something like "USB Serial Device (COM3)"
793-
# portName = portName.split("(")[1].split(")")[0].strip() # This will be something like "COM3" and is what mpremote wants
794-
# print("Port: ", self.port_button.text())
795-
796797
# check if we are on mac or linux and if so, we need to add the /dev/ prefix to the port name
797798
if platform.system() == "Darwin" or platform.system().startswith("Linux"):
798799
portName = "/dev/" + self.port_button.text()
@@ -820,10 +821,17 @@ def do_upload_rp2(self, fwFile = None) -> None:
820821

821822
# Start an mpremote session for selected port
822823
mpr = self.get_mpremote_session()
824+
825+
mpr_base_platform = None
826+
827+
# This means we have a valid mpremote session
828+
if mpr is not None:
829+
mpr_base_platform = mpr.get_base_platform()
830+
self.writeMessage("MPRemote session validated. Platform: " + mpr_base_platform + "\n")
823831

824832
# If we are able to use mpremote to enter bootloader mode, do so
825833
# Otherwise, prompt the user to press the correct button sequence to enter bootloader mode
826-
if mpr is not None:
834+
if mpr_base_platform == "rp2":
827835
self.writeMessage("Able to automatically enter bootloader mode...\n")
828836
mpr.enter_bootloader()
829837
self.writeMessage("Entering bootloader mode...\n")
@@ -905,12 +913,12 @@ def do_upload_teensy(self, fwFile = None, boardName = None) -> None:
905913
theJob = AxJob(AUxTeensyUploadTeensy.ACTION_ID, {"loader": resource_path("teensy_loader_cli.exe", _RESOURCE_DIRECTORY),
906914
"mcu":"imxrt1062",
907915
"board": boardName,
908-
"file": fwFile
916+
"file": fwFile,
917+
"size": self.get_current_firmware_file_size(),
909918
})
910919

911920

912-
# Show to progress bar and set it to 0. Reset our teensy progress bar and save the file size
913-
self.teensyProgress.reset(self.get_current_firmware_file_size())
921+
# Show progress bar and set it to 0.
914922
self.progress_bar_start("Teensy Upload Progress:")
915923

916924
self._worker.add_job(theJob)
Lines changed: 108 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,29 @@
11
from .au_action import AxAction, AxJob
22
import shutil
3-
from os import system
3+
from os import system, set_blocking
44
import subprocess
55
import sys
6-
7-
class AUxTeensyUploadTeensy(AxAction):
8-
ACTION_ID = "upload-teensy"
9-
NAME = "Teensy Upload"
10-
11-
def __init__(self) -> None:
12-
super().__init__(self.ACTION_ID, self.NAME)
13-
14-
def run_job(self, job:AxJob, **kwargs):
15-
# Ensure stdout of our subprocess gets routed through our stdout to the wedge thingy and then the UI.
16-
# Basic os.system() and subprocess.run() don't do this.
17-
18-
# Some platforms (Windows) have CREATE_NO_WINDOW, but others (linux) don't.
19-
# So we need to check if the flag exists and only use it if it does.
20-
if hasattr(subprocess, 'CREATE_NO_WINDOW'):
21-
teensy_proc = subprocess.Popen([job.loader, f"--mcu={job.mcu}", "-v", "-w", job.board, job.file],
22-
creationflags=subprocess.CREATE_NO_WINDOW,
23-
stdout=subprocess.PIPE,
24-
stderr=subprocess.PIPE)
25-
else:
26-
teensy_proc = subprocess.Popen([job.loader, f"--mcu={job.mcu}", "-v", "-w", job.board, job.file],
27-
stdout=subprocess.PIPE,
28-
stderr=subprocess.PIPE)
29-
30-
while True:
31-
output = teensy_proc.stdout.read(1) # Read a single character
32-
if teensy_proc.poll() is not None and not output:
33-
break
34-
if output:
35-
sys.stdout.write(output.decode()) # Write the character to stdout
36-
sys.stdout.flush() # Ensure it appears immediately
37-
38-
teensy_proc.wait()
39-
print("\nTeensy Upload Done.\n")
6+
from time import perf_counter
7+
import select
408

419
class TeensyProgress(object):
4210
"""Class to keep track of the Teensy progress."""
4311
# See https://github.com/PaulStoffregen/teensy_loader_cli/blob/master/teensy_loader_cli.c
4412
# a "." character is printed for every block of data written to the teensy. For TEENSY4.0 andd TEENSY4.1 a block is 1024
4513
kTeensyBlockSize = 1024
14+
# This is the maximum time we will wait to receive the "Programming" message from the teensy loader
15+
# before setting the timeout flag
16+
# TODO: What is the absolute minimum times we can have here? This was arbitrary...
17+
kMaximumWaitForBootloader = 5 # seconds
4618

4719
def __init__(self) -> None:
4820
self.progSeen = False
4921
self.dotsWritten = 0
5022
self.currentMessage = ""
5123
self.percent = 0
5224
self.size = 0
25+
self.startTime = None
26+
self.timeout = False
5327

5428
def reset(self, size = 0) -> None:
5529
"""Reset the Teensy progress."""
@@ -58,6 +32,28 @@ def reset(self, size = 0) -> None:
5832
self.currentMessage = ""
5933
self.percent = 0
6034
self.size = size
35+
self.timeout = False
36+
# Create a new start time
37+
self.startTime = perf_counter()
38+
39+
def elapsed_time(self) -> float:
40+
"""Get the elapsed time since the start time."""
41+
if self.startTime is None:
42+
return 0
43+
44+
return perf_counter() - self.startTime
45+
46+
def check_timeout(self) -> bool:
47+
"""Check if the Teensy progress has timed out."""
48+
if self.startTime is None:
49+
return False
50+
51+
elapsed_time = self.elapsed_time()
52+
if (elapsed_time > self.kMaximumWaitForBootloader) and not self.progSeen:
53+
self.timeout = True
54+
return True
55+
56+
return False
6157

6258
def dots_to_percent(self, numDots: int) -> int:
6359
"""Convert the number of dots to a percentage."""
@@ -91,5 +87,82 @@ def parse_message(self, msg: str) -> int:
9187
self.dotsWritten += self.currentMessage.count(".")
9288
self.currentMessage = ""
9389
self.percent = self.dots_to_percent(self.dotsWritten)
90+
91+
else:
92+
# If we haven't seen the "Programming" message yet, we need to check against our start time to see if we should time out
93+
if self.check_timeout():
94+
self.percent = 0
9495

9596
return self.percent
97+
98+
class AUxTeensyUploadTeensy(AxAction):
99+
ACTION_ID = "upload-teensy"
100+
NAME = "Teensy Upload"
101+
102+
def __init__(self) -> None:
103+
super().__init__(self.ACTION_ID, self.NAME)
104+
self.report_progress = None
105+
self.__teensy_prog = TeensyProgress()
106+
107+
def run_job(self, job:AxJob, **kwargs):
108+
self.report_progress = kwargs["worker_cb"]
109+
110+
self.__teensy_prog.reset(job.size)
111+
# Ensure stdout of our subprocess gets routed through our stdout to the wedge thingy and then the UI.
112+
# Basic os.system() and subprocess.run() don't do this.
113+
114+
# Some platforms (Windows) have CREATE_NO_WINDOW, but others (linux) don't.
115+
# So we need to check if the flag exists and only use it if it does.
116+
if hasattr(subprocess, 'CREATE_NO_WINDOW'):
117+
teensy_proc = subprocess.Popen([job.loader, f"--mcu={job.mcu}", "-v", "-w", job.board, job.file],
118+
creationflags=subprocess.CREATE_NO_WINDOW,
119+
stdout=subprocess.PIPE,
120+
stderr=subprocess.PIPE)
121+
else:
122+
teensy_proc = subprocess.Popen([job.loader, f"--mcu={job.mcu}", "-v", "-w", job.board, job.file],
123+
stdout=subprocess.PIPE,
124+
stderr=subprocess.PIPE)
125+
126+
while not self.__teensy_prog.timeout:
127+
# Until we are programming, we can use communicate to get the output with a timeout
128+
if not self.__teensy_prog.progSeen:
129+
# NOTE: THIS REQUIRES PYTHON 3.12+ ON WINDOWS OR 3.5+ ON UNIX-LIKE
130+
set_blocking(teensy_proc.stdout.fileno(), False) # Set the stdout to non-blocking
131+
132+
# Read a chunk of output (polling, since select.select does not work with pipes on Windows)
133+
output = teensy_proc.stdout.read(1024)
134+
135+
# Feed output to the progress class and to the console
136+
if output:
137+
sys.stdout.write(output.decode())
138+
sys.stdout.flush()
139+
# Also feed the output to the progress class
140+
self.__teensy_prog.parse_message(output.decode())
141+
142+
# If we have timed out waiting for the bootloader, we need to break out of the loop
143+
if self.__teensy_prog.check_timeout():
144+
# Kill the process
145+
teensy_proc.kill()
146+
self.report_progress(-1) # Report -1 to indicate a timeout
147+
sys.stdout.write("\nTeensy Upload Error: Timed Out...\n")
148+
sys.stdout.flush()
149+
break
150+
151+
if self.__teensy_prog.progSeen:
152+
# Now, make read blocking again (Likely not necessary, but it's how I thought about it originally)
153+
set_blocking(teensy_proc.stdout.fileno(), True)
154+
# After we see the "Programming" message, we need to read the output one character at a time
155+
# to get the progress percentage and display interactive content to the user with blocking reads and writes
156+
output = teensy_proc.stdout.read(1) # Read a single character
157+
if teensy_proc.poll() is not None and not output:
158+
break
159+
if output:
160+
sys.stdout.write(output.decode()) # Write the character to stdout
161+
sys.stdout.flush() # Ensure it appears immediately
162+
# Also feed the output to the progress class
163+
percent = self.__teensy_prog.parse_message(output.decode())
164+
if percent > 0 and percent < 100:
165+
self.report_progress(percent)
166+
167+
teensy_proc.wait()
168+
print("\nTeensy Upload Done.\n")

0 commit comments

Comments
 (0)