Skip to content

Commit ce8b36e

Browse files
OSUpdate: pause when wifi goes away, then redownload
1 parent 28147fb commit ce8b36e

File tree

4 files changed

+168
-19
lines changed

4 files changed

+168
-19
lines changed

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

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,9 @@ def update_with_lvgl(self, url):
308308

309309
while elapsed < max_wait and self.has_foreground():
310310
if self.connectivity_manager.is_online():
311-
print("OSUpdate: Network reconnected, resuming download")
311+
print("OSUpdate: Network reconnected, waiting for stabilization...")
312+
time.sleep(2) # Let routing table and DNS fully stabilize
313+
print("OSUpdate: Resuming download")
312314
self.set_state(UpdateState.DOWNLOADING)
313315
break # Exit wait loop and retry download
314316

@@ -398,6 +400,33 @@ def __init__(self, requests_module=None, partition_module=None, connectivity_man
398400
print("UpdateDownloader: Partition module not available, will simulate")
399401
self.simulate = True
400402

403+
def _is_network_error(self, exception):
404+
"""Check if exception is a network connectivity error that should trigger pause.
405+
406+
Args:
407+
exception: Exception to check
408+
409+
Returns:
410+
bool: True if this is a recoverable network error
411+
"""
412+
error_str = str(exception).lower()
413+
error_repr = repr(exception).lower()
414+
415+
# Check for common network error codes and messages
416+
# -113 = ECONNABORTED (connection aborted)
417+
# -104 = ECONNRESET (connection reset by peer)
418+
# -110 = ETIMEDOUT (connection timed out)
419+
# -118 = EHOSTUNREACH (no route to host)
420+
network_indicators = [
421+
'-113', '-104', '-110', '-118', # Error codes
422+
'econnaborted', 'econnreset', 'etimedout', 'ehostunreach', # Error names
423+
'connection reset', 'connection aborted', # Error messages
424+
'broken pipe', 'network unreachable', 'host unreachable'
425+
]
426+
427+
return any(indicator in error_str or indicator in error_repr
428+
for indicator in network_indicators)
429+
401430
def download_and_install(self, url, progress_callback=None, should_continue_callback=None):
402431
"""Download firmware and install to OTA partition.
403432
@@ -467,27 +496,43 @@ def download_and_install(self, url, progress_callback=None, should_continue_call
467496
response.close()
468497
return result
469498

470-
# Check network connection (if monitoring enabled)
499+
# Check network connection before reading
471500
if self.connectivity_manager:
472501
is_online = self.connectivity_manager.is_online()
473502
elif ConnectivityManager._instance:
474-
# Use global instance if available
475503
is_online = ConnectivityManager._instance.is_online()
476504
else:
477-
# No connectivity checking available
478505
is_online = True
479506

480507
if not is_online:
481-
print("UpdateDownloader: Network lost, pausing download")
508+
print("UpdateDownloader: Network lost (pre-check), pausing download")
482509
self.is_paused = True
483510
self.bytes_written_so_far = bytes_written
484511
result['paused'] = True
485512
result['bytes_written'] = bytes_written
486513
response.close()
487514
return result
488515

489-
# Read next chunk
490-
chunk = response.raw.read(chunk_size)
516+
# Read next chunk (may raise exception if network drops)
517+
try:
518+
chunk = response.raw.read(chunk_size)
519+
except Exception as read_error:
520+
# Check if this is a network error that should trigger pause
521+
if self._is_network_error(read_error):
522+
print(f"UpdateDownloader: Network error during read ({read_error}), pausing")
523+
self.is_paused = True
524+
self.bytes_written_so_far = bytes_written
525+
result['paused'] = True
526+
result['bytes_written'] = bytes_written
527+
try:
528+
response.close()
529+
except:
530+
pass
531+
return result
532+
else:
533+
# Non-network error, re-raise
534+
raise
535+
491536
if not chunk:
492537
break
493538

@@ -527,8 +572,17 @@ def download_and_install(self, url, progress_callback=None, should_continue_call
527572
print(f"UpdateDownloader: {result['error']}")
528573

529574
except Exception as e:
530-
result['error'] = str(e)
531-
print(f"UpdateDownloader: Error during download: {e}") # -113 when wifi disconnected
575+
# Check if this is a network error that should trigger pause
576+
if self._is_network_error(e):
577+
print(f"UpdateDownloader: Network error ({e}), pausing download")
578+
self.is_paused = True
579+
self.bytes_written_so_far = result.get('bytes_written', self.bytes_written_so_far)
580+
result['paused'] = True
581+
result['bytes_written'] = self.bytes_written_so_far
582+
else:
583+
# Non-network error
584+
result['error'] = str(e)
585+
print(f"UpdateDownloader: Error during download: {e}")
532586

533587
return result
534588

internal_filesystem/lib/mpos/net/wifi_service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,8 @@ def disconnect(network_module=None):
247247
wlan.active(False)
248248
print("WifiService: Disconnected and WiFi disabled")
249249
except Exception as e:
250-
print(f"WifiService: Error disconnecting: {e}")
250+
#print(f"WifiService: Error disconnecting: {e}") # probably "Wifi Not Started" so harmless
251+
pass
251252

252253
@staticmethod
253254
def get_saved_networks():

tests/network_test_helper.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,15 +122,17 @@ class MockRaw:
122122
Simulates the 'raw' attribute of requests.Response for chunked reading.
123123
"""
124124

125-
def __init__(self, content):
125+
def __init__(self, content, fail_after_bytes=None):
126126
"""
127127
Initialize mock raw response.
128128
129129
Args:
130130
content: Binary content to stream
131+
fail_after_bytes: If set, raise OSError(-113) after reading this many bytes
131132
"""
132133
self.content = content
133134
self.position = 0
135+
self.fail_after_bytes = fail_after_bytes
134136

135137
def read(self, size):
136138
"""
@@ -141,7 +143,14 @@ def read(self, size):
141143
142144
Returns:
143145
bytes: Chunk of data (may be smaller than size at end of stream)
146+
147+
Raises:
148+
OSError: If fail_after_bytes is set and reached
144149
"""
150+
# Check if we should simulate network failure
151+
if self.fail_after_bytes is not None and self.position >= self.fail_after_bytes:
152+
raise OSError(-113, "ECONNABORTED")
153+
145154
chunk = self.content[self.position:self.position + size]
146155
self.position += len(chunk)
147156
return chunk
@@ -154,7 +163,7 @@ class MockResponse:
154163
Simulates requests.Response object with status code, text, headers, etc.
155164
"""
156165

157-
def __init__(self, status_code=200, text='', headers=None, content=b''):
166+
def __init__(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None):
158167
"""
159168
Initialize mock response.
160169
@@ -163,6 +172,7 @@ def __init__(self, status_code=200, text='', headers=None, content=b''):
163172
text: Response text content (default: '')
164173
headers: Response headers dict (default: {})
165174
content: Binary response content (default: b'')
175+
fail_after_bytes: If set, raise OSError after reading this many bytes
166176
"""
167177
self.status_code = status_code
168178
self.text = text
@@ -171,7 +181,7 @@ def __init__(self, status_code=200, text='', headers=None, content=b''):
171181
self._closed = False
172182

173183
# Mock raw attribute for streaming
174-
self.raw = MockRaw(content)
184+
self.raw = MockRaw(content, fail_after_bytes=fail_after_bytes)
175185

176186
def close(self):
177187
"""Close the response."""
@@ -197,6 +207,7 @@ def __init__(self):
197207
self.last_headers = None
198208
self.last_timeout = None
199209
self.last_stream = None
210+
self.last_request = None # Full request info dict
200211
self.next_response = None
201212
self.raise_exception = None
202213
self.call_history = []
@@ -222,14 +233,17 @@ def get(self, url, stream=False, timeout=None, headers=None):
222233
self.last_timeout = timeout
223234
self.last_stream = stream
224235

225-
# Record call in history
226-
self.call_history.append({
236+
# Store full request info
237+
self.last_request = {
227238
'method': 'GET',
228239
'url': url,
229240
'stream': stream,
230241
'timeout': timeout,
231-
'headers': headers
232-
})
242+
'headers': headers or {}
243+
}
244+
245+
# Record call in history
246+
self.call_history.append(self.last_request.copy())
233247

234248
if self.raise_exception:
235249
exc = self.raise_exception
@@ -287,7 +301,7 @@ def post(self, url, data=None, json=None, timeout=None, headers=None):
287301

288302
return MockResponse()
289303

290-
def set_next_response(self, status_code=200, text='', headers=None, content=b''):
304+
def set_next_response(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None):
291305
"""
292306
Configure the next response to return.
293307
@@ -296,11 +310,12 @@ def set_next_response(self, status_code=200, text='', headers=None, content=b'')
296310
text: Response text (default: '')
297311
headers: Response headers dict (default: {})
298312
content: Binary response content (default: b'')
313+
fail_after_bytes: If set, raise OSError after reading this many bytes
299314
300315
Returns:
301316
MockResponse: The configured response object
302317
"""
303-
self.next_response = MockResponse(status_code, text, headers, content)
318+
self.next_response = MockResponse(status_code, text, headers, content, fail_after_bytes=fail_after_bytes)
304319
return self.next_response
305320

306321
def set_exception(self, exception):

tests/test_osupdate.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,4 +381,83 @@ def test_download_exact_chunk_multiple(self):
381381
self.assertEqual(result['total_size'], 8192)
382382
self.assertEqual(result['bytes_written'], 8192)
383383

384+
def test_network_error_detection_econnaborted(self):
385+
"""Test that ECONNABORTED error is detected as network error."""
386+
error = OSError(-113, "ECONNABORTED")
387+
self.assertTrue(self.downloader._is_network_error(error))
388+
389+
def test_network_error_detection_econnreset(self):
390+
"""Test that ECONNRESET error is detected as network error."""
391+
error = OSError(-104, "ECONNRESET")
392+
self.assertTrue(self.downloader._is_network_error(error))
393+
394+
def test_network_error_detection_etimedout(self):
395+
"""Test that ETIMEDOUT error is detected as network error."""
396+
error = OSError(-110, "ETIMEDOUT")
397+
self.assertTrue(self.downloader._is_network_error(error))
398+
399+
def test_network_error_detection_ehostunreach(self):
400+
"""Test that EHOSTUNREACH error is detected as network error."""
401+
error = OSError(-118, "EHOSTUNREACH")
402+
self.assertTrue(self.downloader._is_network_error(error))
403+
404+
def test_network_error_detection_by_message(self):
405+
"""Test that network errors are detected by message."""
406+
self.assertTrue(self.downloader._is_network_error(Exception("Connection reset by peer")))
407+
self.assertTrue(self.downloader._is_network_error(Exception("Connection aborted")))
408+
self.assertTrue(self.downloader._is_network_error(Exception("Broken pipe")))
409+
410+
def test_non_network_error_not_detected(self):
411+
"""Test that non-network errors are not detected as network errors."""
412+
self.assertFalse(self.downloader._is_network_error(ValueError("Invalid data")))
413+
self.assertFalse(self.downloader._is_network_error(Exception("File not found")))
414+
self.assertFalse(self.downloader._is_network_error(KeyError("missing")))
415+
416+
def test_download_pauses_on_network_error_during_read(self):
417+
"""Test that download pauses when network error occurs during read."""
418+
# Set up mock to raise network error after first chunk
419+
test_data = b'G' * 16384 # 4 chunks
420+
self.mock_requests.set_next_response(
421+
status_code=200,
422+
headers={'Content-Length': '16384'},
423+
content=test_data,
424+
fail_after_bytes=4096 # Fail after first chunk
425+
)
426+
427+
result = self.downloader.download_and_install(
428+
"http://example.com/update.bin"
429+
)
430+
431+
self.assertFalse(result['success'])
432+
self.assertTrue(result['paused'])
433+
self.assertEqual(result['bytes_written'], 4096) # Should have written first chunk
434+
self.assertIsNone(result['error']) # Pause, not error
435+
436+
def test_download_resumes_from_saved_position(self):
437+
"""Test that download resumes from the last written position."""
438+
# Simulate partial download
439+
test_data = b'H' * 12288 # 3 chunks
440+
self.downloader.bytes_written_so_far = 8192 # Already downloaded 2 chunks
441+
self.downloader.total_size_expected = 12288
442+
443+
# Server should receive Range header
444+
remaining_data = b'H' * 4096 # Last chunk
445+
self.mock_requests.set_next_response(
446+
status_code=206, # Partial content
447+
headers={'Content-Length': '4096'}, # Remaining bytes
448+
content=remaining_data
449+
)
450+
451+
result = self.downloader.download_and_install(
452+
"http://example.com/update.bin"
453+
)
454+
455+
self.assertTrue(result['success'])
456+
self.assertEqual(result['bytes_written'], 12288)
457+
# Check that Range header was set
458+
last_request = self.mock_requests.last_request
459+
self.assertIsNotNone(last_request)
460+
self.assertIn('Range', last_request['headers'])
461+
self.assertEqual(last_request['headers']['Range'], 'bytes=8192-')
462+
384463

0 commit comments

Comments
 (0)