scrollkit 0.8.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. scrollkit/__init__.py +16 -0
  2. scrollkit/app/__init__.py +6 -0
  3. scrollkit/app/base.py +918 -0
  4. scrollkit/app/memory.py +70 -0
  5. scrollkit/config/__init__.py +1 -0
  6. scrollkit/config/settings_manager.py +215 -0
  7. scrollkit/config/transition_names.py +31 -0
  8. scrollkit/dev/__init__.py +41 -0
  9. scrollkit/dev/capabilities.py +374 -0
  10. scrollkit/dev/harness.py +383 -0
  11. scrollkit/dev/metrics.py +91 -0
  12. scrollkit/dev/performance.py +174 -0
  13. scrollkit/dev/validation.py +245 -0
  14. scrollkit/display/__init__.py +14 -0
  15. scrollkit/display/_graphics.py +299 -0
  16. scrollkit/display/_recording.py +165 -0
  17. scrollkit/display/_sim_backend.py +99 -0
  18. scrollkit/display/bitmap_text.py +408 -0
  19. scrollkit/display/boards.py +186 -0
  20. scrollkit/display/colors.py +176 -0
  21. scrollkit/display/content.py +604 -0
  22. scrollkit/display/gradient_text.py +167 -0
  23. scrollkit/display/interface.py +126 -0
  24. scrollkit/display/simulator.py +80 -0
  25. scrollkit/display/text_fill.py +59 -0
  26. scrollkit/display/text_pixels.py +250 -0
  27. scrollkit/display/unified.py +595 -0
  28. scrollkit/effects/__init__.py +28 -0
  29. scrollkit/effects/drip_splash.py +253 -0
  30. scrollkit/effects/easing.py +131 -0
  31. scrollkit/effects/overlay.py +92 -0
  32. scrollkit/effects/particles.py +355 -0
  33. scrollkit/effects/reveal_splash.py +132 -0
  34. scrollkit/effects/scrolling.py +363 -0
  35. scrollkit/effects/swarm_reveal.py +512 -0
  36. scrollkit/effects/text_render.py +25 -0
  37. scrollkit/effects/transitions.py +873 -0
  38. scrollkit/exceptions.py +55 -0
  39. scrollkit/network/__init__.py +1 -0
  40. scrollkit/network/http_client.py +505 -0
  41. scrollkit/network/mdns.py +44 -0
  42. scrollkit/network/wifi_manager.py +382 -0
  43. scrollkit/ota/__init__.py +13 -0
  44. scrollkit/ota/client.py +528 -0
  45. scrollkit/ota/display_progress.py +125 -0
  46. scrollkit/ota/manifest.py +206 -0
  47. scrollkit/ota/publish.py +379 -0
  48. scrollkit/simulator/ATTRIBUTION.md +20 -0
  49. scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
  50. scrollkit/simulator/LICENSE +176 -0
  51. scrollkit/simulator/README.md +87 -0
  52. scrollkit/simulator/__init__.py +11 -0
  53. scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
  54. scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
  55. scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
  56. scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
  57. scrollkit/simulator/adafruit_display_text/label.py +336 -0
  58. scrollkit/simulator/bitmaptools.py +50 -0
  59. scrollkit/simulator/core/__init__.py +8 -0
  60. scrollkit/simulator/core/color_utils.py +62 -0
  61. scrollkit/simulator/core/device_benchmarks.json +254 -0
  62. scrollkit/simulator/core/feasibility.py +160 -0
  63. scrollkit/simulator/core/hardware_profile.py +191 -0
  64. scrollkit/simulator/core/led_matrix.py +307 -0
  65. scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
  66. scrollkit/simulator/core/performance_manager.py +253 -0
  67. scrollkit/simulator/core/pixel_buffer.py +188 -0
  68. scrollkit/simulator/devices/__init__.py +7 -0
  69. scrollkit/simulator/devices/base_device.py +79 -0
  70. scrollkit/simulator/devices/matrixportal_s3.py +87 -0
  71. scrollkit/simulator/displayio/__init__.py +12 -0
  72. scrollkit/simulator/displayio/bitmap.py +158 -0
  73. scrollkit/simulator/displayio/display.py +195 -0
  74. scrollkit/simulator/displayio/fourwire.py +54 -0
  75. scrollkit/simulator/displayio/group.py +125 -0
  76. scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
  77. scrollkit/simulator/displayio/palette.py +115 -0
  78. scrollkit/simulator/displayio/tilegrid.py +155 -0
  79. scrollkit/simulator/fonts/3x5.bdf +2474 -0
  80. scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
  81. scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
  82. scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
  83. scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
  84. scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
  85. scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
  86. scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
  87. scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
  88. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
  89. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
  90. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
  91. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
  92. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
  93. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
  94. scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
  95. scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
  96. scrollkit/simulator/fonts/viii.bdf +2659 -0
  97. scrollkit/simulator/terminalio/__init__.py +20 -0
  98. scrollkit/utils/__init__.py +1 -0
  99. scrollkit/utils/color_utils.py +54 -0
  100. scrollkit/utils/diagnostics.py +227 -0
  101. scrollkit/utils/error_handler.py +347 -0
  102. scrollkit/utils/system_utils.py +245 -0
  103. scrollkit/utils/url_utils.py +46 -0
  104. scrollkit/web/__init__.py +6 -0
  105. scrollkit/web/settings_server.py +328 -0
  106. scrollkit/web/wifi_setup.py +331 -0
  107. scrollkit-0.8.3.dist-info/METADATA +248 -0
  108. scrollkit-0.8.3.dist-info/RECORD +111 -0
  109. scrollkit-0.8.3.dist-info/WHEEL +5 -0
  110. scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
  111. scrollkit-0.8.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,528 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """OTA update client for CircuitPython devices.
3
+
4
+ Handles downloading and applying updates safely.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import gc
10
+ import json
11
+ import hashlib
12
+ try:
13
+ from typing import Any, Callable, Dict, Optional, Tuple, Union
14
+ except ImportError: # CircuitPython has no 'typing' module
15
+ pass
16
+
17
+ from ..exceptions import NetworkError, OTAError
18
+ from .manifest import UpdateManifest
19
+
20
+ import sys
21
+
22
+ # Decide the platform by the interpreter, not by which HTTP module happens to be
23
+ # importable: ``adafruit_requests`` is pip-installable on desktop but exposes no
24
+ # module-level ``get`` (it is Session-based), so keying off its mere presence
25
+ # mis-detected desktop as CircuitPython and crashed the update check.
26
+ if getattr(sys.implementation, "name", "") == "circuitpython":
27
+ import adafruit_requests as requests
28
+ import storage
29
+ import microcontroller
30
+ import supervisor
31
+ PLATFORM = 'circuitpython'
32
+ else:
33
+ try:
34
+ import requests
35
+ PLATFORM = 'desktop'
36
+ except ImportError:
37
+ requests = None
38
+ PLATFORM = 'unknown'
39
+
40
+
41
+ __all__ = ['OTAClient']
42
+
43
+ class OTAClient:
44
+ """OTA update client for CircuitPython devices.
45
+
46
+ Handles the client side of OTA updates:
47
+ - Check for updates
48
+ - Download update packages
49
+ - Verify integrity
50
+ - Apply updates safely
51
+ """
52
+
53
+ server_url: str
54
+ current_version: str
55
+ update_dir: str
56
+ backup_dir: str
57
+ download_timeout: int
58
+ chunk_size: int
59
+ session: Any
60
+ last_check: Any
61
+ available_update: Optional[UpdateManifest]
62
+ update_in_progress: bool
63
+ on_update_available: Optional[Callable[[UpdateManifest], None]]
64
+ on_update_progress: Optional[Callable[[str, float], None]]
65
+ on_update_complete: Optional[Callable[[str], None]]
66
+ on_update_error: Optional[Callable[[str], None]]
67
+
68
+ @classmethod
69
+ def for_github(cls, owner, repo, branch="releases", current_version="0.0.0",
70
+ update_dir="/updates", backup_dir="/backup", session=None):
71
+ """Build an OTAClient that fetches updates from GitHub raw content.
72
+
73
+ Constructs the base URL
74
+ ``https://raw.githubusercontent.com/{owner}/{repo}/{branch}`` from which
75
+ ``manifest.json`` and the files it lists are downloaded. Publish an update
76
+ by committing a ``manifest.json`` (and the new ``/src`` files) to that
77
+ branch — e.g. a dedicated ``releases`` branch or a tag.
78
+
79
+ Recovery does not depend on this: the frozen ``boot.py`` + update system
80
+ stay intact regardless of any ``/src`` payload, so a bad update can always
81
+ be re-fetched on the next boot.
82
+
83
+ ``session`` is an optional Session-style HTTP client (anything exposing
84
+ ``.get(url, timeout=...)``) — on CircuitPython the app injects its
85
+ ``adafruit_requests.Session`` here, since modern ``adafruit_requests`` has
86
+ no module-level ``get``.
87
+ """
88
+ url = "https://raw.githubusercontent.com/{}/{}/{}".format(owner, repo, branch)
89
+ return cls(url, current_version=current_version,
90
+ update_dir=update_dir, backup_dir=backup_dir, session=session)
91
+
92
+ def __init__(
93
+ self,
94
+ update_server_url: str,
95
+ current_version: str = "0.5.0",
96
+ update_dir: str = "/updates",
97
+ backup_dir: str = "/backup",
98
+ session: Any = None,
99
+ ) -> None:
100
+ """Initialize OTA client.
101
+
102
+ Args:
103
+ update_server_url: URL of update server
104
+ current_version: Current application version
105
+ update_dir: Directory for downloaded updates
106
+ backup_dir: Directory for backups
107
+ session: Optional Session-style HTTP client (exposing
108
+ ``.get(url, timeout=...)``). On CircuitPython modern
109
+ ``adafruit_requests`` is Session-based and has no module-level
110
+ ``get``, so the app injects its existing
111
+ ``adafruit_requests.Session`` here. Read live at each request
112
+ (see ``_http_get``), so it may be assigned/replaced after
113
+ construction. When ``None`` the module-level ``requests.get`` is
114
+ used (desktop, where the PyPI ``requests`` module has ``.get``).
115
+ """
116
+ self.server_url = update_server_url.rstrip('/')
117
+ self.current_version = current_version
118
+ self.update_dir = update_dir
119
+ self.backup_dir = backup_dir
120
+ self.download_timeout = 30
121
+ self.chunk_size = 1024
122
+ self.session = session
123
+
124
+ self.last_check = None
125
+ self.available_update = None
126
+ self.update_in_progress = False
127
+
128
+ self.on_update_available = None
129
+ self.on_update_progress = None
130
+ self.on_update_complete = None
131
+ self.on_update_error = None
132
+
133
+ def set_callbacks(
134
+ self,
135
+ on_available: Optional[Callable[[UpdateManifest], None]] = None,
136
+ on_progress: Optional[Callable[[str, float], None]] = None,
137
+ on_complete: Optional[Callable[[str], None]] = None,
138
+ on_error: Optional[Callable[[str], None]] = None,
139
+ ) -> None:
140
+ """Set update callbacks.
141
+
142
+ Args:
143
+ on_available: Called when update is available
144
+ on_progress: Called during download/install
145
+ on_complete: Called when update completes
146
+ on_error: Called on error
147
+ """
148
+ if on_available:
149
+ self.on_update_available = on_available
150
+ if on_progress:
151
+ self.on_update_progress = on_progress
152
+ if on_complete:
153
+ self.on_update_complete = on_complete
154
+ if on_error:
155
+ self.on_update_error = on_error
156
+
157
+ def _http_get(self, url: str) -> Any:
158
+ """Perform an HTTP GET, preferring an injected Session.
159
+
160
+ ``self.session`` is read live (never cached) so the app can create or
161
+ rebuild the session during WiFi connect and assign ``client.session``
162
+ right before use. When a session is present, its ``.get`` is used (modern
163
+ ``adafruit_requests`` is Session-based and exposes no module-level
164
+ ``get``); otherwise the module-level ``requests.get`` is used (desktop).
165
+ """
166
+ try:
167
+ if self.session is not None:
168
+ return self.session.get(url, timeout=self.download_timeout)
169
+ return requests.get(url, timeout=self.download_timeout)
170
+ except Exception as e:
171
+ # Typed boundary error (no `from e` chaining: heap fragmentation on
172
+ # CircuitPython). The public check/download methods catch it and
173
+ # return their (ok, reason) tuple.
174
+ raise NetworkError("OTA GET %s failed: %s: %s" % (url, type(e).__name__, e))
175
+
176
+ def check_for_updates(self) -> Tuple[bool, Union[str, UpdateManifest]]:
177
+ """Check if updates are available.
178
+
179
+ Returns:
180
+ tuple: (has_update, manifest_or_error)
181
+ """
182
+ if self.session is None and not requests:
183
+ return False, "Requests library not available"
184
+
185
+ try:
186
+ url = f"{self.server_url}/manifest.json"
187
+ response = self._http_get(url)
188
+
189
+ if response.status_code != 200:
190
+ return False, f"Server error: {response.status_code}"
191
+
192
+ try:
193
+ manifest_data = response.json()
194
+ manifest = UpdateManifest.from_dict(manifest_data)
195
+ except ValueError as e: # CircuitPython: json.loads raises ValueError
196
+ return False, f"Invalid manifest: {e}"
197
+
198
+ is_valid, error = manifest.validate()
199
+ if not is_valid:
200
+ return False, f"Invalid manifest: {error}"
201
+
202
+ if manifest.compare_version(self.current_version) > 0:
203
+ self.available_update = manifest
204
+ if self.on_update_available:
205
+ self.on_update_available(manifest)
206
+ return True, manifest
207
+
208
+ return False, "No updates available"
209
+
210
+ except Exception as e:
211
+ # A NetworkError from _http_get on an unreachable server lands here and
212
+ # becomes the (False, reason) tuple the public contract promises.
213
+ error_msg = f"Update check failed: {e}"
214
+ if self.on_update_error:
215
+ self.on_update_error(error_msg)
216
+ return False, error_msg
217
+ finally:
218
+ if 'response' in locals():
219
+ try:
220
+ response.close()
221
+ except Exception:
222
+ pass
223
+ gc.collect()
224
+
225
+ def download_update(self, manifest: Optional[UpdateManifest] = None) -> Tuple[bool, str]:
226
+ """Download update package.
227
+
228
+ Args:
229
+ manifest: Update manifest (uses available_update if None)
230
+
231
+ Returns:
232
+ tuple: (success, error_message)
233
+ """
234
+ if manifest is None:
235
+ manifest = self.available_update
236
+
237
+ if not manifest:
238
+ return False, "No update manifest available"
239
+
240
+ if self.session is None and not requests:
241
+ return False, "Requests library not available"
242
+
243
+ try:
244
+ self.update_in_progress = True
245
+
246
+ self._ensure_directory(self.update_dir)
247
+
248
+ if PLATFORM == 'circuitpython':
249
+ try:
250
+ import os
251
+ stat = os.statvfs('/')
252
+ free_space = stat[1] * stat[3]
253
+
254
+ required_space = manifest.calculate_total_size() * 2
255
+ if free_space < required_space:
256
+ return False, f"Insufficient storage: {free_space} < {required_space}"
257
+ except Exception:
258
+ pass
259
+
260
+ total_files = len(manifest.files)
261
+ completed_files = 0
262
+
263
+ for file_path, file_info in manifest.files.items():
264
+ if self.on_update_progress:
265
+ progress = (completed_files / total_files) * 0.8
266
+ self.on_update_progress(f"Downloading {file_path}", progress)
267
+
268
+ try:
269
+ self._download_file(file_path, file_info)
270
+ except (NetworkError, OTAError) as e:
271
+ return False, f"Failed to download {file_path}: {e}"
272
+
273
+ completed_files += 1
274
+ gc.collect()
275
+
276
+ manifest_path = f"{self.update_dir}/manifest.json"
277
+ try:
278
+ with open(manifest_path, 'w') as f:
279
+ f.write(manifest.to_json())
280
+ except (OSError, IOError) as e:
281
+ return False, f"Failed to save manifest: {e}"
282
+
283
+ if self.on_update_progress:
284
+ self.on_update_progress("Download complete", 0.8)
285
+
286
+ return True, ""
287
+
288
+ except Exception as e:
289
+ error_msg = f"Download failed: {e}"
290
+ if self.on_update_error:
291
+ self.on_update_error(error_msg)
292
+ return False, error_msg
293
+ finally:
294
+ gc.collect()
295
+
296
+ def _download_file(self, file_path: str, file_info: Dict[str, Any]) -> None:
297
+ """Download and verify a single file.
298
+
299
+ Returns None on success. Raises ``OTAError`` on a server error, size
300
+ mismatch, or checksum mismatch, and propagates ``NetworkError`` from the
301
+ HTTP GET. ``download_update`` catches both and turns them into its
302
+ ``(False, reason)`` tuple. The native response is always closed
303
+ (``finally``) so its socket is released.
304
+
305
+ Args:
306
+ file_path: Target file path
307
+ file_info: File metadata dict
308
+ """
309
+ response = None
310
+ try:
311
+ url = f"{self.server_url}/files/{file_path.lstrip('/')}"
312
+ response = self._http_get(url)
313
+
314
+ if response.status_code != 200:
315
+ raise OTAError("Server error %d for %s"
316
+ % (response.status_code, file_path))
317
+
318
+ content = response.content
319
+
320
+ if len(content) != file_info['size']:
321
+ raise OTAError("Size mismatch for %s: %d != %d"
322
+ % (file_path, len(content), file_info['size']))
323
+
324
+ actual_checksum = hashlib.sha256(content).hexdigest()
325
+ if actual_checksum != file_info['checksum']:
326
+ raise OTAError("Checksum mismatch for %s" % file_path)
327
+
328
+ local_path = f"{self.update_dir}/{file_path.lstrip('/')}"
329
+ self._ensure_directory_for_file(local_path)
330
+
331
+ with open(local_path, 'wb') as f:
332
+ f.write(content)
333
+ finally:
334
+ if response is not None:
335
+ try:
336
+ response.close()
337
+ except Exception:
338
+ pass
339
+
340
+ def apply_update(self, manifest: Optional[UpdateManifest] = None) -> Tuple[bool, str]:
341
+ """Apply downloaded update.
342
+
343
+ Args:
344
+ manifest: Update manifest (loads from file if None)
345
+
346
+ Returns:
347
+ tuple: (success, error_message)
348
+ """
349
+ if not manifest:
350
+ manifest_path = f"{self.update_dir}/manifest.json"
351
+ try:
352
+ with open(manifest_path, 'r') as f:
353
+ manifest_data = json.loads(f.read())
354
+ manifest = UpdateManifest.from_dict(manifest_data)
355
+ except Exception as e:
356
+ return False, f"Cannot load manifest: {e}"
357
+
358
+ try:
359
+ if self.on_update_progress:
360
+ self.on_update_progress("Preparing update", 0.8)
361
+
362
+ backup_success, backup_error = self._create_backup(manifest)
363
+ if not backup_success:
364
+ return False, f"Backup failed: {backup_error}"
365
+
366
+ if self.on_update_progress:
367
+ self.on_update_progress("Installing files", 0.85)
368
+
369
+ install_success, install_error = self._install_files(manifest)
370
+ if not install_success:
371
+ self._restore_backup(manifest)
372
+ return False, f"Install failed: {install_error}"
373
+
374
+ if self.on_update_progress:
375
+ self.on_update_progress("Finalizing update", 0.95)
376
+
377
+ self.current_version = manifest.version
378
+ self._cleanup_update_files()
379
+
380
+ if self.on_update_progress:
381
+ self.on_update_progress("Update complete", 1.0)
382
+
383
+ if self.on_update_complete:
384
+ self.on_update_complete(manifest.version)
385
+
386
+ return True, ""
387
+
388
+ except Exception as e:
389
+ error_msg = f"Update failed: {e}"
390
+ if self.on_update_error:
391
+ self.on_update_error(error_msg)
392
+ return False, error_msg
393
+ finally:
394
+ self.update_in_progress = False
395
+ gc.collect()
396
+
397
+ def _create_backup(self, manifest: UpdateManifest) -> Tuple[bool, str]:
398
+ """Create backup of current files.
399
+
400
+ Args:
401
+ manifest: Update manifest
402
+
403
+ Returns:
404
+ tuple: (success, error_message)
405
+ """
406
+ try:
407
+ self._ensure_directory(self.backup_dir)
408
+
409
+ for file_path in manifest.files.keys():
410
+ if self._file_exists(file_path):
411
+ backup_path = f"{self.backup_dir}/{file_path.lstrip('/')}"
412
+ self._ensure_directory_for_file(backup_path)
413
+
414
+ with open(file_path, 'rb') as src:
415
+ with open(backup_path, 'wb') as dst:
416
+ while True:
417
+ chunk = src.read(self.chunk_size)
418
+ if not chunk:
419
+ break
420
+ dst.write(chunk)
421
+
422
+ return True, ""
423
+
424
+ except Exception as e:
425
+ return False, str(e)
426
+
427
+ def _install_files(self, manifest: UpdateManifest) -> Tuple[bool, str]:
428
+ """Install files from update directory.
429
+
430
+ Args:
431
+ manifest: Update manifest
432
+
433
+ Returns:
434
+ tuple: (success, error_message)
435
+ """
436
+ try:
437
+ for file_path in manifest.files.keys():
438
+ source_path = f"{self.update_dir}/{file_path.lstrip('/')}"
439
+
440
+ self._ensure_directory_for_file(file_path)
441
+
442
+ with open(source_path, 'rb') as src:
443
+ with open(file_path, 'wb') as dst:
444
+ while True:
445
+ chunk = src.read(self.chunk_size)
446
+ if not chunk:
447
+ break
448
+ dst.write(chunk)
449
+
450
+ return True, ""
451
+
452
+ except Exception as e:
453
+ return False, str(e)
454
+
455
+ def _restore_backup(self, manifest: UpdateManifest) -> None:
456
+ """Restore files from backup.
457
+
458
+ Args:
459
+ manifest: Update manifest
460
+ """
461
+ try:
462
+ for file_path in manifest.files.keys():
463
+ backup_path = f"{self.backup_dir}/{file_path.lstrip('/')}"
464
+
465
+ if self._file_exists(backup_path):
466
+ with open(backup_path, 'rb') as src:
467
+ with open(file_path, 'wb') as dst:
468
+ while True:
469
+ chunk = src.read(self.chunk_size)
470
+ if not chunk:
471
+ break
472
+ dst.write(chunk)
473
+ except Exception as e:
474
+ print(f"Backup restore failed: {e}")
475
+
476
+ def _cleanup_update_files(self) -> None:
477
+ """Clean up downloaded update files."""
478
+ try:
479
+ import os
480
+
481
+ for root, dirs, files in os.walk(self.update_dir):
482
+ for file in files:
483
+ try:
484
+ os.remove(os.path.join(root, file))
485
+ except Exception:
486
+ pass
487
+
488
+ except Exception:
489
+ pass
490
+
491
+ def _ensure_directory(self, path: str) -> None:
492
+ """Ensure directory exists."""
493
+ try:
494
+ import os
495
+ os.makedirs(path, exist_ok=True)
496
+ except Exception:
497
+ pass
498
+
499
+ def _ensure_directory_for_file(self, file_path: str) -> None:
500
+ """Ensure directory exists for file path."""
501
+ try:
502
+ import os
503
+ directory = os.path.dirname(file_path)
504
+ if directory:
505
+ os.makedirs(directory, exist_ok=True)
506
+ except Exception:
507
+ pass
508
+
509
+ def _file_exists(self, path: str) -> bool:
510
+ """Check if file exists."""
511
+ try:
512
+ with open(path, 'r'):
513
+ return True
514
+ except Exception:
515
+ return False
516
+
517
+ def reboot_device(self) -> None:
518
+ """Reboot the device to complete update."""
519
+ if PLATFORM == 'circuitpython':
520
+ try:
521
+ microcontroller.reset()
522
+ except Exception:
523
+ try:
524
+ supervisor.reload()
525
+ except Exception:
526
+ print("Cannot reboot - please manually restart")
527
+ else:
528
+ print("Reboot not supported on this platform")
@@ -0,0 +1,125 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Display-progress + staged-install flow around an ``OTAClient``.
3
+
4
+ ``OTAClient`` is headless: it downloads/applies updates and reports progress
5
+ through callbacks, but knows nothing about a display. This adapter wires those
6
+ callbacks to an on-panel status frame and owns the staged-install UX (show
7
+ "Installing… DO NOT UNPLUG!", apply, reboot), so an app gets a complete on-device
8
+ update experience without coupling the client to the display.
9
+
10
+ It takes an ALREADY-CONFIGURED client (e.g. ``OTAClient.for_github(...)``), so the
11
+ update source/channel stays the app's concern — this layer is source-agnostic::
12
+
13
+ from scrollkit.ota.client import OTAClient
14
+ from scrollkit.ota.display_progress import OTAProgressDisplay
15
+
16
+ client = OTAClient.for_github("owner", "repo", branch="live",
17
+ current_version="1.0.0")
18
+ ota = OTAProgressDisplay(client, display=app.display)
19
+ # on boot, before the display loop starts:
20
+ await ota.install_pending()
21
+ # from a web "update" route (synchronous, safe off the display loop):
22
+ ota.schedule_update() # then reboot; install_pending() applies it next boot
23
+
24
+ Every method is defensive — a display or client error is swallowed rather than
25
+ propagated into the boot/OTA flow.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+
31
+
32
+ __all__ = ['OTAProgressDisplay']
33
+
34
+ class OTAProgressDisplay:
35
+ """Display-progress adapter + staged-install flow over an ``OTAClient``."""
36
+
37
+ def __init__(self, client, display=None):
38
+ self.client = client
39
+ self.display = display
40
+ self._last_msg = None
41
+ self.client.set_callbacks(on_progress=self._on_progress,
42
+ on_error=self._on_error)
43
+
44
+ def attach_display(self, display):
45
+ """Attach (or replace) the display used for progress frames."""
46
+ self.display = display
47
+
48
+ # ---- callbacks (sync; the OTAClient calls these) ----
49
+ def _on_progress(self, message, progress):
50
+ self._last_msg = message
51
+ print("OTA: %s (%.0f%%)" % (message, (progress or 0) * 100))
52
+
53
+ def _on_error(self, message):
54
+ print("OTA error:", message)
55
+
56
+ # ---- staged install flow ----
57
+ def has_pending(self):
58
+ """True if an update has been downloaded to the staging dir.
59
+
60
+ ``os.stat`` (not ``os.path.exists``) — CircuitPython has no ``os.path``.
61
+ """
62
+ try:
63
+ os.stat("%s/manifest.json" % self.client.update_dir)
64
+ return True
65
+ except OSError:
66
+ return False
67
+
68
+ def schedule_update(self):
69
+ """Check for + download a newer release. Returns True if one is staged.
70
+
71
+ Synchronous (callable from the web request thread). The caller reboots so
72
+ ``install_pending()`` applies it on the next boot.
73
+ """
74
+ try:
75
+ has_update, info = self.client.check_for_updates()
76
+ if not has_update:
77
+ return False
78
+ ok, _err = self.client.download_update(info)
79
+ return bool(ok)
80
+ except Exception as e: # never crash the request/app
81
+ print("OTA schedule failed:", e)
82
+ return False
83
+
84
+ async def install_pending(self):
85
+ """If an update is staged, show progress, apply it, and reboot. Returns bool."""
86
+ if not self.has_pending():
87
+ return False
88
+ await self._show(["Installing", "DO NOT", "UNPLUG!"])
89
+ try:
90
+ ok, err = self.client.apply_update()
91
+ except Exception as e:
92
+ print("OTA apply failed:", e)
93
+ return False
94
+ if ok:
95
+ await self._show(["Updated!", "Reboot..."])
96
+ self.client.reboot_device()
97
+ return True
98
+ print("OTA apply error:", err)
99
+ return False
100
+
101
+ async def _show(self, lines, color=0xFFAA00):
102
+ """Paint a short multi-line status frame, vertically centered.
103
+
104
+ Keep each line <= ~10 chars: a 64px panel fits ~10 glyphs at the default
105
+ font, so a single long "Installing update... Do not unplug!" line runs off
106
+ the edge and clips — exactly the message the user must be able to read. The
107
+ install is one blocking call (no display loop), so a horizontal scroll
108
+ couldn't animate; stacked short lines keep it legible. ``lines`` may be a
109
+ single string. Defensive — never raises into the OTA flow.
110
+ """
111
+ if not self.display:
112
+ return
113
+ if isinstance(lines, str):
114
+ lines = [lines]
115
+ try:
116
+ await self.display.clear()
117
+ line_h = 9 # ~8px glyphs + 1px gap
118
+ height = getattr(self.display, "height", 32)
119
+ top = max(0, (height - len(lines) * line_h) // 2)
120
+ for i, line in enumerate(lines):
121
+ # draw_text y is the BASELINE; sit it near the bottom of each band.
122
+ await self.display.draw_text(line, 1, top + i * line_h + 7, color)
123
+ await self.display.show()
124
+ except Exception:
125
+ pass