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.
- scrollkit/__init__.py +16 -0
- scrollkit/app/__init__.py +6 -0
- scrollkit/app/base.py +918 -0
- scrollkit/app/memory.py +70 -0
- scrollkit/config/__init__.py +1 -0
- scrollkit/config/settings_manager.py +215 -0
- scrollkit/config/transition_names.py +31 -0
- scrollkit/dev/__init__.py +41 -0
- scrollkit/dev/capabilities.py +374 -0
- scrollkit/dev/harness.py +383 -0
- scrollkit/dev/metrics.py +91 -0
- scrollkit/dev/performance.py +174 -0
- scrollkit/dev/validation.py +245 -0
- scrollkit/display/__init__.py +14 -0
- scrollkit/display/_graphics.py +299 -0
- scrollkit/display/_recording.py +165 -0
- scrollkit/display/_sim_backend.py +99 -0
- scrollkit/display/bitmap_text.py +408 -0
- scrollkit/display/boards.py +186 -0
- scrollkit/display/colors.py +176 -0
- scrollkit/display/content.py +604 -0
- scrollkit/display/gradient_text.py +167 -0
- scrollkit/display/interface.py +126 -0
- scrollkit/display/simulator.py +80 -0
- scrollkit/display/text_fill.py +59 -0
- scrollkit/display/text_pixels.py +250 -0
- scrollkit/display/unified.py +595 -0
- scrollkit/effects/__init__.py +28 -0
- scrollkit/effects/drip_splash.py +253 -0
- scrollkit/effects/easing.py +131 -0
- scrollkit/effects/overlay.py +92 -0
- scrollkit/effects/particles.py +355 -0
- scrollkit/effects/reveal_splash.py +132 -0
- scrollkit/effects/scrolling.py +363 -0
- scrollkit/effects/swarm_reveal.py +512 -0
- scrollkit/effects/text_render.py +25 -0
- scrollkit/effects/transitions.py +873 -0
- scrollkit/exceptions.py +55 -0
- scrollkit/network/__init__.py +1 -0
- scrollkit/network/http_client.py +505 -0
- scrollkit/network/mdns.py +44 -0
- scrollkit/network/wifi_manager.py +382 -0
- scrollkit/ota/__init__.py +13 -0
- scrollkit/ota/client.py +528 -0
- scrollkit/ota/display_progress.py +125 -0
- scrollkit/ota/manifest.py +206 -0
- scrollkit/ota/publish.py +379 -0
- scrollkit/simulator/ATTRIBUTION.md +20 -0
- scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
- scrollkit/simulator/LICENSE +176 -0
- scrollkit/simulator/README.md +87 -0
- scrollkit/simulator/__init__.py +11 -0
- scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
- scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
- scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
- scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
- scrollkit/simulator/adafruit_display_text/label.py +336 -0
- scrollkit/simulator/bitmaptools.py +50 -0
- scrollkit/simulator/core/__init__.py +8 -0
- scrollkit/simulator/core/color_utils.py +62 -0
- scrollkit/simulator/core/device_benchmarks.json +254 -0
- scrollkit/simulator/core/feasibility.py +160 -0
- scrollkit/simulator/core/hardware_profile.py +191 -0
- scrollkit/simulator/core/led_matrix.py +307 -0
- scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
- scrollkit/simulator/core/performance_manager.py +253 -0
- scrollkit/simulator/core/pixel_buffer.py +188 -0
- scrollkit/simulator/devices/__init__.py +7 -0
- scrollkit/simulator/devices/base_device.py +79 -0
- scrollkit/simulator/devices/matrixportal_s3.py +87 -0
- scrollkit/simulator/displayio/__init__.py +12 -0
- scrollkit/simulator/displayio/bitmap.py +158 -0
- scrollkit/simulator/displayio/display.py +195 -0
- scrollkit/simulator/displayio/fourwire.py +54 -0
- scrollkit/simulator/displayio/group.py +125 -0
- scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
- scrollkit/simulator/displayio/palette.py +115 -0
- scrollkit/simulator/displayio/tilegrid.py +155 -0
- scrollkit/simulator/fonts/3x5.bdf +2474 -0
- scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
- scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
- scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
- scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
- scrollkit/simulator/fonts/viii.bdf +2659 -0
- scrollkit/simulator/terminalio/__init__.py +20 -0
- scrollkit/utils/__init__.py +1 -0
- scrollkit/utils/color_utils.py +54 -0
- scrollkit/utils/diagnostics.py +227 -0
- scrollkit/utils/error_handler.py +347 -0
- scrollkit/utils/system_utils.py +245 -0
- scrollkit/utils/url_utils.py +46 -0
- scrollkit/web/__init__.py +6 -0
- scrollkit/web/settings_server.py +328 -0
- scrollkit/web/wifi_setup.py +331 -0
- scrollkit-0.8.3.dist-info/METADATA +248 -0
- scrollkit-0.8.3.dist-info/RECORD +111 -0
- scrollkit-0.8.3.dist-info/WHEEL +5 -0
- scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
- scrollkit-0.8.3.dist-info/top_level.txt +1 -0
scrollkit/ota/client.py
ADDED
|
@@ -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
|