OSUpdate app: show download speed

DownloadManager: add support for download speed
This commit is contained in:
Thomas Farstrike
2025-12-17 15:02:31 +01:00
parent c944e6924e
commit 23a8f92ea9
3 changed files with 128 additions and 28 deletions
@@ -20,6 +20,7 @@ class OSUpdate(Activity):
main_screen = None
progress_label = None
progress_bar = None
speed_label = None
# State management
current_state = None
@@ -249,7 +250,12 @@ class OSUpdate(Activity):
self.progress_label = lv.label(self.main_screen)
self.progress_label.set_text("OS Update: 0.00%")
self.progress_label.align(lv.ALIGN.CENTER, 0, 0)
self.progress_label.align(lv.ALIGN.CENTER, 0, -15)
self.speed_label = lv.label(self.main_screen)
self.speed_label.set_text("Speed: -- KB/s")
self.speed_label.align(lv.ALIGN.CENTER, 0, 10)
self.progress_bar = lv.bar(self.main_screen)
self.progress_bar.set_size(200, 20)
self.progress_bar.align(lv.ALIGN.BOTTOM_MID, 0, -50)
@@ -273,14 +279,36 @@ class OSUpdate(Activity):
self.schedule_show_update_info()
async def async_progress_callback(self, percent):
"""Async progress callback for DownloadManager."""
print(f"OTA Update: {percent:.1f}%")
"""Async progress callback for DownloadManager.
Args:
percent: Progress percentage with 2 decimal places (0.00 - 100.00)
"""
print(f"OTA Update: {percent:.2f}%")
# UI updates are safe from async context in MicroPythonOS (runs on main thread)
if self.has_foreground():
self.progress_bar.set_value(int(percent), True)
self.progress_label.set_text(f"OTA Update: {percent:.2f}%")
await TaskManager.sleep_ms(50)
async def async_speed_callback(self, bytes_per_second):
"""Async speed callback for DownloadManager.
Args:
bytes_per_second: Download speed in bytes per second
"""
# Convert to human-readable format
if bytes_per_second >= 1024 * 1024:
speed_str = f"{bytes_per_second / (1024 * 1024):.1f} MB/s"
elif bytes_per_second >= 1024:
speed_str = f"{bytes_per_second / 1024:.1f} KB/s"
else:
speed_str = f"{bytes_per_second:.0f} B/s"
print(f"Download speed: {speed_str}")
if self.has_foreground() and self.speed_label:
self.speed_label.set_text(f"Speed: {speed_str}")
async def perform_update(self):
"""Download and install update using async patterns.
@@ -295,6 +323,7 @@ class OSUpdate(Activity):
result = await self.update_downloader.download_and_install(
url,
progress_callback=self.async_progress_callback,
speed_callback=self.async_speed_callback,
should_continue_callback=self.has_foreground
)
@@ -531,7 +560,7 @@ class UpdateDownloader:
percent = (self.bytes_written_so_far / self.total_size_expected) * 100
await self._progress_callback(min(percent, 100.0))
async def download_and_install(self, url, progress_callback=None, should_continue_callback=None):
async def download_and_install(self, url, progress_callback=None, speed_callback=None, should_continue_callback=None):
"""Download firmware and install to OTA partition using async DownloadManager.
Supports pause/resume on wifi loss using HTTP Range headers.
@@ -539,7 +568,9 @@ class UpdateDownloader:
Args:
url: URL to download firmware from
progress_callback: Optional async callback function(percent: float)
Called by DownloadManager with progress 0-100
Called by DownloadManager with progress 0.00-100.00 (2 decimal places)
speed_callback: Optional async callback function(bytes_per_second: float)
Called periodically with download speed
should_continue_callback: Optional callback function() -> bool
Returns False to cancel download
@@ -595,12 +626,13 @@ class UpdateDownloader:
self.total_size_expected = 0
# Download with streaming chunk callback
# Progress is reported by DownloadManager via progress_callback
# Progress and speed are reported by DownloadManager via callbacks
print(f"UpdateDownloader: Starting async download from {url}")
success = await dm.download_url(
url,
chunk_callback=chunk_handler,
progress_callback=progress_callback, # Let DownloadManager handle progress
speed_callback=speed_callback, # Let DownloadManager handle speed
headers=headers
)
@@ -11,7 +11,8 @@ Features:
- Automatic session lifecycle management
- Thread-safe session access
- Retry logic (3 attempts per chunk, 10s timeout)
- Progress tracking
- Progress tracking with 2-decimal precision
- Download speed reporting
- Resume support via Range headers
Example:
@@ -20,14 +21,18 @@ Example:
# Download to memory
data = await DownloadManager.download_url("https://api.example.com/data.json")
# Download to file with progress
async def progress(pct):
print(f"{pct}%")
# Download to file with progress and speed
async def on_progress(pct):
print(f"{pct:.2f}%") # e.g., "45.67%"
async def on_speed(speed_bps):
print(f"{speed_bps / 1024:.1f} KB/s")
success = await DownloadManager.download_url(
"https://example.com/file.bin",
outfile="/sdcard/file.bin",
progress_callback=progress
progress_callback=on_progress,
speed_callback=on_speed
)
# Stream processing
@@ -46,6 +51,7 @@ _DEFAULT_CHUNK_SIZE = 1024 # 1KB chunks
_DEFAULT_TOTAL_SIZE = 100 * 1024 # 100KB default if Content-Length missing
_MAX_RETRIES = 3 # Retry attempts per chunk
_CHUNK_TIMEOUT_SECONDS = 10 # Timeout per chunk read
_SPEED_UPDATE_INTERVAL_MS = 1000 # Update speed every 1 second
# Module-level state (singleton pattern)
_session = None
@@ -169,7 +175,8 @@ async def close_session():
async def download_url(url, outfile=None, total_size=None,
progress_callback=None, chunk_callback=None, headers=None):
progress_callback=None, chunk_callback=None, headers=None,
speed_callback=None):
"""Download a URL with flexible output modes.
This async download function can be used in 3 ways:
@@ -182,11 +189,14 @@ async def download_url(url, outfile=None, total_size=None,
outfile (str, optional): Path to write file. If None, returns bytes.
total_size (int, optional): Expected size in bytes for progress tracking.
If None, uses Content-Length header or defaults to 100KB.
progress_callback (coroutine, optional): async def callback(percent: int)
Called with progress 0-100.
progress_callback (coroutine, optional): async def callback(percent: float)
Called with progress 0.00-100.00 (2 decimal places).
Only called when progress changes by at least 0.01%.
chunk_callback (coroutine, optional): async def callback(chunk: bytes)
Called for each chunk. Cannot use with outfile.
headers (dict, optional): HTTP headers (e.g., {'Range': 'bytes=1000-'})
speed_callback (coroutine, optional): async def callback(bytes_per_second: float)
Called periodically (every ~1 second) with download speed.
Returns:
bytes: Downloaded content (if outfile and chunk_callback are None)
@@ -199,14 +209,18 @@ async def download_url(url, outfile=None, total_size=None,
# Download to memory
data = await DownloadManager.download_url("https://example.com/file.json")
# Download to file with progress
# Download to file with progress and speed
async def on_progress(percent):
print(f"Progress: {percent}%")
print(f"Progress: {percent:.2f}%")
async def on_speed(bps):
print(f"Speed: {bps / 1024:.1f} KB/s")
success = await DownloadManager.download_url(
"https://example.com/large.bin",
outfile="/sdcard/large.bin",
progress_callback=on_progress
progress_callback=on_progress,
speed_callback=on_speed
)
# Stream processing
@@ -282,6 +296,18 @@ async def download_url(url, outfile=None, total_size=None,
chunks = []
partial_size = 0
chunk_size = _DEFAULT_CHUNK_SIZE
# Progress tracking with 2-decimal precision
last_progress_pct = -1.0 # Track last reported progress to avoid duplicates
# Speed tracking
speed_bytes_since_last_update = 0
speed_last_update_time = None
try:
import time
speed_last_update_time = time.ticks_ms()
except ImportError:
pass # time module not available
print(f"DownloadManager: {'Writing to ' + outfile if outfile else 'Downloading'} {total_size} bytes in chunks of size {chunk_size}")
@@ -317,12 +343,31 @@ async def download_url(url, outfile=None, total_size=None,
else:
chunks.append(chunk_data)
# Report progress
partial_size += len(chunk_data)
progress_pct = round((partial_size * 100) / int(total_size))
print(f"DownloadManager: Progress: {partial_size} / {total_size} bytes = {progress_pct}%")
if progress_callback:
# Track bytes for speed calculation
chunk_len = len(chunk_data)
partial_size += chunk_len
speed_bytes_since_last_update += chunk_len
# Report progress with 2-decimal precision
# Only call callback if progress changed by at least 0.01%
progress_pct = round((partial_size * 100) / int(total_size), 2)
if progress_callback and progress_pct != last_progress_pct:
print(f"DownloadManager: Progress: {partial_size} / {total_size} bytes = {progress_pct:.2f}%")
await progress_callback(progress_pct)
last_progress_pct = progress_pct
# Report speed periodically
if speed_callback and speed_last_update_time is not None:
import time
current_time = time.ticks_ms()
elapsed_ms = time.ticks_diff(current_time, speed_last_update_time)
if elapsed_ms >= _SPEED_UPDATE_INTERVAL_MS:
# Calculate bytes per second
bytes_per_second = (speed_bytes_since_last_update * 1000) / elapsed_ms
await speed_callback(bytes_per_second)
# Reset for next interval
speed_bytes_since_last_update = 0
speed_last_update_time = current_time
else:
# Chunk is None, download complete
print(f"DownloadManager: Finished downloading {url}")
+29 -6
View File
@@ -699,15 +699,18 @@ class MockDownloadManager:
self.url_received = None
self.call_history = []
self.chunk_size = 1024 # Default chunk size for streaming
self.simulated_speed_bps = 100 * 1024 # 100 KB/s default simulated speed
async def download_url(self, url, outfile=None, total_size=None,
progress_callback=None, chunk_callback=None, headers=None):
progress_callback=None, chunk_callback=None, headers=None,
speed_callback=None):
"""
Mock async download with flexible output modes.
Simulates the real DownloadManager behavior including:
- Streaming chunks via chunk_callback
- Progress reporting via progress_callback (based on total size)
- Progress reporting via progress_callback with 2-decimal precision
- Speed reporting via speed_callback
- Network failure simulation
Args:
@@ -715,8 +718,11 @@ class MockDownloadManager:
outfile: Path to write file (optional)
total_size: Expected size for progress tracking (optional)
progress_callback: Async callback for progress updates (optional)
Called with percent as float with 2 decimal places (0.00-100.00)
chunk_callback: Async callback for streaming chunks (optional)
headers: HTTP headers dict (optional)
speed_callback: Async callback for speed updates (optional)
Called with bytes_per_second as float
Returns:
bytes: Downloaded content (if outfile and chunk_callback are None)
@@ -732,7 +738,8 @@ class MockDownloadManager:
'total_size': total_size,
'headers': headers,
'has_progress_callback': progress_callback is not None,
'has_chunk_callback': chunk_callback is not None
'has_chunk_callback': chunk_callback is not None,
'has_speed_callback': speed_callback is not None
})
if self.should_fail:
@@ -751,6 +758,13 @@ class MockDownloadManager:
# Use provided total_size or actual data size for progress calculation
effective_total_size = total_size if total_size else total_data_size
# Track progress to avoid duplicate callbacks
last_progress_pct = -1.0
# Track speed reporting (simulate every ~1000 bytes for testing)
bytes_since_speed_update = 0
speed_update_threshold = 1000
while bytes_sent < total_data_size:
# Check if we should simulate network failure
@@ -768,11 +782,20 @@ class MockDownloadManager:
chunks.append(chunk)
bytes_sent += len(chunk)
bytes_since_speed_update += len(chunk)
# Report progress (like real DownloadManager does)
# Report progress with 2-decimal precision (like real DownloadManager)
# Only call callback if progress changed by at least 0.01%
if progress_callback and effective_total_size > 0:
percent = round((bytes_sent * 100) / effective_total_size)
await progress_callback(percent)
percent = round((bytes_sent * 100) / effective_total_size, 2)
if percent != last_progress_pct:
await progress_callback(percent)
last_progress_pct = percent
# Report speed periodically
if speed_callback and bytes_since_speed_update >= speed_update_threshold:
await speed_callback(self.simulated_speed_bps)
bytes_since_speed_update = 0
# Return based on mode
if outfile or chunk_callback: