Skip to content

Commit 23a8f92

Browse files
OSUpdate app: show download speed
DownloadManager: add support for download speed
1 parent c944e69 commit 23a8f92

File tree

3 files changed

+128
-28
lines changed

3 files changed

+128
-28
lines changed

internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class OSUpdate(Activity):
2020
main_screen = None
2121
progress_label = None
2222
progress_bar = None
23+
speed_label = None
2324

2425
# State management
2526
current_state = None
@@ -249,7 +250,12 @@ def install_button_click(self):
249250

250251
self.progress_label = lv.label(self.main_screen)
251252
self.progress_label.set_text("OS Update: 0.00%")
252-
self.progress_label.align(lv.ALIGN.CENTER, 0, 0)
253+
self.progress_label.align(lv.ALIGN.CENTER, 0, -15)
254+
255+
self.speed_label = lv.label(self.main_screen)
256+
self.speed_label.set_text("Speed: -- KB/s")
257+
self.speed_label.align(lv.ALIGN.CENTER, 0, 10)
258+
253259
self.progress_bar = lv.bar(self.main_screen)
254260
self.progress_bar.set_size(200, 20)
255261
self.progress_bar.align(lv.ALIGN.BOTTOM_MID, 0, -50)
@@ -273,14 +279,36 @@ def check_again_click(self):
273279
self.schedule_show_update_info()
274280

275281
async def async_progress_callback(self, percent):
276-
"""Async progress callback for DownloadManager."""
277-
print(f"OTA Update: {percent:.1f}%")
282+
"""Async progress callback for DownloadManager.
283+
284+
Args:
285+
percent: Progress percentage with 2 decimal places (0.00 - 100.00)
286+
"""
287+
print(f"OTA Update: {percent:.2f}%")
278288
# UI updates are safe from async context in MicroPythonOS (runs on main thread)
279289
if self.has_foreground():
280290
self.progress_bar.set_value(int(percent), True)
281291
self.progress_label.set_text(f"OTA Update: {percent:.2f}%")
282292
await TaskManager.sleep_ms(50)
283293

294+
async def async_speed_callback(self, bytes_per_second):
295+
"""Async speed callback for DownloadManager.
296+
297+
Args:
298+
bytes_per_second: Download speed in bytes per second
299+
"""
300+
# Convert to human-readable format
301+
if bytes_per_second >= 1024 * 1024:
302+
speed_str = f"{bytes_per_second / (1024 * 1024):.1f} MB/s"
303+
elif bytes_per_second >= 1024:
304+
speed_str = f"{bytes_per_second / 1024:.1f} KB/s"
305+
else:
306+
speed_str = f"{bytes_per_second:.0f} B/s"
307+
308+
print(f"Download speed: {speed_str}")
309+
if self.has_foreground() and self.speed_label:
310+
self.speed_label.set_text(f"Speed: {speed_str}")
311+
284312
async def perform_update(self):
285313
"""Download and install update using async patterns.
286314
@@ -295,6 +323,7 @@ async def perform_update(self):
295323
result = await self.update_downloader.download_and_install(
296324
url,
297325
progress_callback=self.async_progress_callback,
326+
speed_callback=self.async_speed_callback,
298327
should_continue_callback=self.has_foreground
299328
)
300329

@@ -531,15 +560,17 @@ async def _flush_buffer(self):
531560
percent = (self.bytes_written_so_far / self.total_size_expected) * 100
532561
await self._progress_callback(min(percent, 100.0))
533562

534-
async def download_and_install(self, url, progress_callback=None, should_continue_callback=None):
563+
async def download_and_install(self, url, progress_callback=None, speed_callback=None, should_continue_callback=None):
535564
"""Download firmware and install to OTA partition using async DownloadManager.
536565
537566
Supports pause/resume on wifi loss using HTTP Range headers.
538567
539568
Args:
540569
url: URL to download firmware from
541570
progress_callback: Optional async callback function(percent: float)
542-
Called by DownloadManager with progress 0-100
571+
Called by DownloadManager with progress 0.00-100.00 (2 decimal places)
572+
speed_callback: Optional async callback function(bytes_per_second: float)
573+
Called periodically with download speed
543574
should_continue_callback: Optional callback function() -> bool
544575
Returns False to cancel download
545576
@@ -595,12 +626,13 @@ async def chunk_handler(chunk):
595626
self.total_size_expected = 0
596627

597628
# Download with streaming chunk callback
598-
# Progress is reported by DownloadManager via progress_callback
629+
# Progress and speed are reported by DownloadManager via callbacks
599630
print(f"UpdateDownloader: Starting async download from {url}")
600631
success = await dm.download_url(
601632
url,
602633
chunk_callback=chunk_handler,
603634
progress_callback=progress_callback, # Let DownloadManager handle progress
635+
speed_callback=speed_callback, # Let DownloadManager handle speed
604636
headers=headers
605637
)
606638

internal_filesystem/lib/mpos/net/download_manager.py

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
- Automatic session lifecycle management
1212
- Thread-safe session access
1313
- Retry logic (3 attempts per chunk, 10s timeout)
14-
- Progress tracking
14+
- Progress tracking with 2-decimal precision
15+
- Download speed reporting
1516
- Resume support via Range headers
1617
1718
Example:
@@ -20,14 +21,18 @@
2021
# Download to memory
2122
data = await DownloadManager.download_url("https://api.example.com/data.json")
2223
23-
# Download to file with progress
24-
async def progress(pct):
25-
print(f"{pct}%")
24+
# Download to file with progress and speed
25+
async def on_progress(pct):
26+
print(f"{pct:.2f}%") # e.g., "45.67%"
27+
28+
async def on_speed(speed_bps):
29+
print(f"{speed_bps / 1024:.1f} KB/s")
2630
2731
success = await DownloadManager.download_url(
2832
"https://example.com/file.bin",
2933
outfile="/sdcard/file.bin",
30-
progress_callback=progress
34+
progress_callback=on_progress,
35+
speed_callback=on_speed
3136
)
3237
3338
# Stream processing
@@ -46,6 +51,7 @@ async def process_chunk(chunk):
4651
_DEFAULT_TOTAL_SIZE = 100 * 1024 # 100KB default if Content-Length missing
4752
_MAX_RETRIES = 3 # Retry attempts per chunk
4853
_CHUNK_TIMEOUT_SECONDS = 10 # Timeout per chunk read
54+
_SPEED_UPDATE_INTERVAL_MS = 1000 # Update speed every 1 second
4955

5056
# Module-level state (singleton pattern)
5157
_session = None
@@ -169,7 +175,8 @@ async def close_session():
169175

170176

171177
async def download_url(url, outfile=None, total_size=None,
172-
progress_callback=None, chunk_callback=None, headers=None):
178+
progress_callback=None, chunk_callback=None, headers=None,
179+
speed_callback=None):
173180
"""Download a URL with flexible output modes.
174181
175182
This async download function can be used in 3 ways:
@@ -182,11 +189,14 @@ async def download_url(url, outfile=None, total_size=None,
182189
outfile (str, optional): Path to write file. If None, returns bytes.
183190
total_size (int, optional): Expected size in bytes for progress tracking.
184191
If None, uses Content-Length header or defaults to 100KB.
185-
progress_callback (coroutine, optional): async def callback(percent: int)
186-
Called with progress 0-100.
192+
progress_callback (coroutine, optional): async def callback(percent: float)
193+
Called with progress 0.00-100.00 (2 decimal places).
194+
Only called when progress changes by at least 0.01%.
187195
chunk_callback (coroutine, optional): async def callback(chunk: bytes)
188196
Called for each chunk. Cannot use with outfile.
189197
headers (dict, optional): HTTP headers (e.g., {'Range': 'bytes=1000-'})
198+
speed_callback (coroutine, optional): async def callback(bytes_per_second: float)
199+
Called periodically (every ~1 second) with download speed.
190200
191201
Returns:
192202
bytes: Downloaded content (if outfile and chunk_callback are None)
@@ -199,14 +209,18 @@ async def download_url(url, outfile=None, total_size=None,
199209
# Download to memory
200210
data = await DownloadManager.download_url("https://example.com/file.json")
201211
202-
# Download to file with progress
212+
# Download to file with progress and speed
203213
async def on_progress(percent):
204-
print(f"Progress: {percent}%")
214+
print(f"Progress: {percent:.2f}%")
215+
216+
async def on_speed(bps):
217+
print(f"Speed: {bps / 1024:.1f} KB/s")
205218
206219
success = await DownloadManager.download_url(
207220
"https://example.com/large.bin",
208221
outfile="/sdcard/large.bin",
209-
progress_callback=on_progress
222+
progress_callback=on_progress,
223+
speed_callback=on_speed
210224
)
211225
212226
# Stream processing
@@ -282,6 +296,18 @@ async def on_chunk(chunk):
282296
chunks = []
283297
partial_size = 0
284298
chunk_size = _DEFAULT_CHUNK_SIZE
299+
300+
# Progress tracking with 2-decimal precision
301+
last_progress_pct = -1.0 # Track last reported progress to avoid duplicates
302+
303+
# Speed tracking
304+
speed_bytes_since_last_update = 0
305+
speed_last_update_time = None
306+
try:
307+
import time
308+
speed_last_update_time = time.ticks_ms()
309+
except ImportError:
310+
pass # time module not available
285311

286312
print(f"DownloadManager: {'Writing to ' + outfile if outfile else 'Downloading'} {total_size} bytes in chunks of size {chunk_size}")
287313

@@ -317,12 +343,31 @@ async def on_chunk(chunk):
317343
else:
318344
chunks.append(chunk_data)
319345

320-
# Report progress
321-
partial_size += len(chunk_data)
322-
progress_pct = round((partial_size * 100) / int(total_size))
323-
print(f"DownloadManager: Progress: {partial_size} / {total_size} bytes = {progress_pct}%")
324-
if progress_callback:
346+
# Track bytes for speed calculation
347+
chunk_len = len(chunk_data)
348+
partial_size += chunk_len
349+
speed_bytes_since_last_update += chunk_len
350+
351+
# Report progress with 2-decimal precision
352+
# Only call callback if progress changed by at least 0.01%
353+
progress_pct = round((partial_size * 100) / int(total_size), 2)
354+
if progress_callback and progress_pct != last_progress_pct:
355+
print(f"DownloadManager: Progress: {partial_size} / {total_size} bytes = {progress_pct:.2f}%")
325356
await progress_callback(progress_pct)
357+
last_progress_pct = progress_pct
358+
359+
# Report speed periodically
360+
if speed_callback and speed_last_update_time is not None:
361+
import time
362+
current_time = time.ticks_ms()
363+
elapsed_ms = time.ticks_diff(current_time, speed_last_update_time)
364+
if elapsed_ms >= _SPEED_UPDATE_INTERVAL_MS:
365+
# Calculate bytes per second
366+
bytes_per_second = (speed_bytes_since_last_update * 1000) / elapsed_ms
367+
await speed_callback(bytes_per_second)
368+
# Reset for next interval
369+
speed_bytes_since_last_update = 0
370+
speed_last_update_time = current_time
326371
else:
327372
# Chunk is None, download complete
328373
print(f"DownloadManager: Finished downloading {url}")

tests/network_test_helper.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -699,24 +699,30 @@ def __init__(self):
699699
self.url_received = None
700700
self.call_history = []
701701
self.chunk_size = 1024 # Default chunk size for streaming
702+
self.simulated_speed_bps = 100 * 1024 # 100 KB/s default simulated speed
702703

703704
async def download_url(self, url, outfile=None, total_size=None,
704-
progress_callback=None, chunk_callback=None, headers=None):
705+
progress_callback=None, chunk_callback=None, headers=None,
706+
speed_callback=None):
705707
"""
706708
Mock async download with flexible output modes.
707709
708710
Simulates the real DownloadManager behavior including:
709711
- Streaming chunks via chunk_callback
710-
- Progress reporting via progress_callback (based on total size)
712+
- Progress reporting via progress_callback with 2-decimal precision
713+
- Speed reporting via speed_callback
711714
- Network failure simulation
712715
713716
Args:
714717
url: URL to download
715718
outfile: Path to write file (optional)
716719
total_size: Expected size for progress tracking (optional)
717720
progress_callback: Async callback for progress updates (optional)
721+
Called with percent as float with 2 decimal places (0.00-100.00)
718722
chunk_callback: Async callback for streaming chunks (optional)
719723
headers: HTTP headers dict (optional)
724+
speed_callback: Async callback for speed updates (optional)
725+
Called with bytes_per_second as float
720726
721727
Returns:
722728
bytes: Downloaded content (if outfile and chunk_callback are None)
@@ -732,7 +738,8 @@ async def download_url(self, url, outfile=None, total_size=None,
732738
'total_size': total_size,
733739
'headers': headers,
734740
'has_progress_callback': progress_callback is not None,
735-
'has_chunk_callback': chunk_callback is not None
741+
'has_chunk_callback': chunk_callback is not None,
742+
'has_speed_callback': speed_callback is not None
736743
})
737744

738745
if self.should_fail:
@@ -751,6 +758,13 @@ async def download_url(self, url, outfile=None, total_size=None,
751758

752759
# Use provided total_size or actual data size for progress calculation
753760
effective_total_size = total_size if total_size else total_data_size
761+
762+
# Track progress to avoid duplicate callbacks
763+
last_progress_pct = -1.0
764+
765+
# Track speed reporting (simulate every ~1000 bytes for testing)
766+
bytes_since_speed_update = 0
767+
speed_update_threshold = 1000
754768

755769
while bytes_sent < total_data_size:
756770
# Check if we should simulate network failure
@@ -768,11 +782,20 @@ async def download_url(self, url, outfile=None, total_size=None,
768782
chunks.append(chunk)
769783

770784
bytes_sent += len(chunk)
785+
bytes_since_speed_update += len(chunk)
771786

772-
# Report progress (like real DownloadManager does)
787+
# Report progress with 2-decimal precision (like real DownloadManager)
788+
# Only call callback if progress changed by at least 0.01%
773789
if progress_callback and effective_total_size > 0:
774-
percent = round((bytes_sent * 100) / effective_total_size)
775-
await progress_callback(percent)
790+
percent = round((bytes_sent * 100) / effective_total_size, 2)
791+
if percent != last_progress_pct:
792+
await progress_callback(percent)
793+
last_progress_pct = percent
794+
795+
# Report speed periodically
796+
if speed_callback and bytes_since_speed_update >= speed_update_threshold:
797+
await speed_callback(self.simulated_speed_bps)
798+
bytes_since_speed_update = 0
776799

777800
# Return based on mode
778801
if outfile or chunk_callback:

0 commit comments

Comments
 (0)