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,55 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """ScrollKit exception hierarchy.
3
+
4
+ Intentionally minimal: only exceptions the library ACTUALLY raises live here.
5
+ An earlier hierarchy shipped 13 classes of which the library raised exactly one
6
+ (FeasibilityError) — every ``except NetworkError`` etc. could only ever fire if
7
+ a *caller* raised it, giving downstream users a false contract. This release
8
+ keeps just the four that are raised at a real boundary.
9
+
10
+ All are plain ``Exception`` subclasses for CircuitPython compatibility.
11
+ """
12
+
13
+
14
+ class ScrollKitError(Exception):
15
+ """Base exception for all ScrollKit errors."""
16
+
17
+
18
+ # 0.8.x compatibility alias: the base was named SLDKError before the rename.
19
+ # Kept until a future 0.9.0.
20
+ SLDKError = ScrollKitError
21
+
22
+
23
+ class NetworkError(ScrollKitError):
24
+ """A network request failed at the HttpClient boundary.
25
+
26
+ Raised by ``HttpClient.get`` / ``get_sync`` / ``post`` after retries are
27
+ exhausted (or when no HTTP client is available). ``HttpClient.last_error``
28
+ retains the raw underlying exception for diagnostics.
29
+ """
30
+
31
+
32
+ class OTAError(ScrollKitError):
33
+ """An OTA update step failed (server error, size or checksum mismatch).
34
+
35
+ Raised internally by ``OTAClient`` at its download/verify boundary; the
36
+ public ``OTAClient`` methods catch it and return their ``(ok, reason)``
37
+ tuple, so it does not escape the public API.
38
+ """
39
+
40
+
41
+ class FeasibilityError(ScrollKitError):
42
+ """A modeled frame busts the device time or RAM budget under strict mode.
43
+
44
+ Raised only by the desktop simulator's ``PerformanceManager`` when strict
45
+ hardware simulation is enabled and a frame's modeled cost exceeds the
46
+ per-frame budget (steady-state median or the single-frame transient ceiling)
47
+ or modeled peak RAM exceeds the device's usable RAM. Never raised on
48
+ CircuitPython, where the timing model is a no-op. Defined here (not in the
49
+ simulator) so the harness, tests, and callers can import it without pulling
50
+ in the simulator.
51
+ """
52
+
53
+
54
+ __all__ = ["ScrollKitError", "SLDKError", "NetworkError", "OTAError",
55
+ "FeasibilityError"]
@@ -0,0 +1 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
@@ -0,0 +1,505 @@
1
+ """
2
+ HTTP client for making API requests.
3
+ Supports both adafruit_requests (CircuitPython) and urllib (standard Python).
4
+ Copyright (c) 2024-2026 Michael Winslow Czeiszperger
5
+ """
6
+ import json
7
+ import gc
8
+
9
+ from scrollkit.exceptions import NetworkError
10
+
11
+
12
+ __all__ = ['HttpClient', 'BaseResponse', 'UrllibResponse', 'MockResponse']
13
+
14
+ def _logger():
15
+ # Lazy: constructing ErrorHandler does a filesystem write-test, so it must
16
+ # not run merely from importing this module. Its own __new__ singleton
17
+ # guard makes repeat calls cheap.
18
+ from scrollkit.utils.error_handler import ErrorHandler
19
+ return ErrorHandler("error_log")
20
+
21
+ class BaseResponse:
22
+ """Base class for all response types with common functionality"""
23
+
24
+ def __init__(self, status_code=200, text="", content=None, headers=None):
25
+ self.status_code = status_code
26
+ self.text = text
27
+ self.content = content if content is not None else text.encode('utf-8')
28
+ # Response headers (e.g. for reading the 'Date' header as a time source).
29
+ # The native adafruit_requests response already exposes .headers; these
30
+ # wrappers carry it through so the same code works on desktop and device.
31
+ self.headers = headers if headers is not None else {}
32
+ self._json_cache = None
33
+ self._read_position = 0
34
+
35
+ def json(self):
36
+ """Parse the response as JSON"""
37
+ if self._json_cache is None:
38
+ # An HTTP error body isn't JSON we failed to parse \u2014 surface the
39
+ # status straight, rather than mislabeling it a JSON syntax error.
40
+ if getattr(self, 'status_code', 200) >= 400:
41
+ raise ValueError(f"HTTP error {self.status_code}: {self.text}")
42
+ try:
43
+ text_to_parse = self.text.strip()
44
+ if text_to_parse.startswith('\ufeff'):
45
+ text_to_parse = text_to_parse[1:]
46
+ if not text_to_parse:
47
+ self._json_cache = {}
48
+ return self._json_cache
49
+ self._json_cache = json.loads(text_to_parse)
50
+ except (ValueError, AttributeError) as e:
51
+ _logger().error(e, f"JSON parse error: {str(e)}")
52
+ raise ValueError(f"syntax error in JSON: {str(e)}")
53
+ return self._json_cache
54
+
55
+ def close(self):
56
+ pass
57
+
58
+ def read(self, size=-1):
59
+ if size == -1:
60
+ result = self.content[self._read_position:]
61
+ self._read_position = len(self.content)
62
+ else:
63
+ result = self.content[self._read_position:self._read_position + size]
64
+ self._read_position += len(result)
65
+ return result
66
+
67
+
68
+ class UrllibResponse(BaseResponse):
69
+ """Wrapper for urllib responses to match adafruit_requests interface"""
70
+
71
+ def __init__(self, urllib_response):
72
+ content = urllib_response.read()
73
+ try:
74
+ text = content.decode('utf-8')
75
+ except UnicodeDecodeError:
76
+ text = ""
77
+ try:
78
+ hdrs = {k: v for k, v in urllib_response.getheaders()}
79
+ except Exception:
80
+ hdrs = {}
81
+ super().__init__(urllib_response.status, text, content, headers=hdrs)
82
+
83
+
84
+ class MockResponse(BaseResponse):
85
+ """Mock response for development mode testing"""
86
+
87
+
88
+ class HttpClient:
89
+ """
90
+ HTTP client supporting both CircuitPython (adafruit_requests) and
91
+ standard Python (urllib). Supports a pluggable mock data provider
92
+ for development without network access.
93
+ """
94
+
95
+ def __init__(self, session=None, mock_provider=None, timeout=6,
96
+ session_rebuild_threshold=2):
97
+ """
98
+ Initialize the HTTP client.
99
+
100
+ Args:
101
+ session: The underlying session (adafruit_requests.Session or None)
102
+ mock_provider: Optional callable(url) -> MockResponse or None.
103
+ Called when no session is available and use_live_data is False.
104
+ timeout: Per-request timeout in seconds. ``adafruit_requests`` is
105
+ synchronous, so without a timeout a hung socket blocks the
106
+ whole asyncio event loop forever (the display freezes). This
107
+ bounds connect/read so a flaky network raises instead of
108
+ wedging.
109
+
110
+ INVARIANT: HTTP timeout < watchdog timeout. A fetch that runs
111
+ the full timeout blocks the loop (and thus watchdog feeding)
112
+ for that long, so it must finish inside the watchdog window or
113
+ it triggers a false reset. Default 6s sits below the ESP32-S3
114
+ ~8s watchdog (see ScrollKitApp.watchdog_timeout).
115
+ session_rebuild_threshold: After this many CONSECUTIVE failed requests
116
+ the session is torn down and recreated (fresh
117
+ ``SocketPool(wifi.radio)`` + ssl context). The dominant field
118
+ wedge is a session whose sockets/TLS state get stuck — a
119
+ read/connect timeout, mbedTLS/SSL error, ConnectionError or
120
+ OSError — after which EVERY fetch through that session fails
121
+ identically until the radio is re-initialised. None of those
122
+ are ``OutOfRetries``, so the old "rebuild only on OutOfRetries"
123
+ never fired and the box served stale data for days. Rebuilding
124
+ on any repeated failure clears it without a reboot. Default 2
125
+ (recover fast) but >1 so a single blip doesn't thrash the
126
+ pool. Device-only; a no-op on the desktop urllib path.
127
+ """
128
+ self.session = session
129
+ self.use_live_data = True
130
+ self.mock_provider = mock_provider
131
+ self.timeout = timeout
132
+
133
+ # --- resilience / diagnostics state ---------------------------------
134
+ # Rebuild the wedged session after this many consecutive failures.
135
+ self.session_rebuild_threshold = session_rebuild_threshold
136
+ # Consecutive request failures since the last success OR rebuild (gates
137
+ # the rebuild; reset to 0 on either).
138
+ self._failures_since_rebuild = 0
139
+ # The most recent request exception, surfaced so callers/diagnostics can
140
+ # record WHY fetching failed. Cleared on the next success.
141
+ self.last_error = None
142
+ # time.monotonic() of the last successful request (None until one
143
+ # succeeds) — drives a "seconds since last success" staleness signal.
144
+ self._last_success_time = None
145
+
146
+ # Platform detection
147
+ try:
148
+ from scrollkit.network.wifi_manager import is_dev_mode
149
+ dev_mode = is_dev_mode()
150
+ except ImportError:
151
+ dev_mode = False
152
+
153
+ if dev_mode:
154
+ self.using_adafruit = False
155
+ else:
156
+ try:
157
+ import adafruit_requests
158
+ self.adafruit_requests = adafruit_requests
159
+ self.using_adafruit = True
160
+ except ImportError:
161
+ self.using_adafruit = False
162
+
163
+ try:
164
+ import urllib.request
165
+ from urllib.error import URLError
166
+ self.urllib = urllib.request
167
+ self.URLError = URLError
168
+ except ImportError:
169
+ self.urllib = None
170
+ self.URLError = None
171
+
172
+ async def get(self, url, headers=None, max_retries=3):
173
+ """
174
+ Make a GET request with retries.
175
+
176
+ Blocking note (CircuitPython): the underlying ``adafruit_requests`` is
177
+ synchronous, so despite the ``await`` this call blocks the asyncio event
178
+ loop until the response arrives — the display scroll pauses for the
179
+ duration. This is not transparently async (spec FR-029). When fetching a
180
+ lot of data, split it into several small requests and ``await
181
+ asyncio.sleep(0)`` between them so the display renders between chunks
182
+ (see the hard demo, ``demos/hard/crypto_dashboard.py``). On desktop the
183
+ urllib path is used instead.
184
+
185
+ Returns:
186
+ A socket-free Response object: a detached ``BaseResponse`` (device
187
+ path; the native ``adafruit_requests`` response is read and closed so
188
+ its socket returns to the pool), a ``UrllibResponse`` (desktop), or a
189
+ ``MockResponse`` (from a mock provider). Callers never need to
190
+ ``.close()`` it.
191
+
192
+ Raises:
193
+ NetworkError: after all ``max_retries`` attempts fail (each attempt
194
+ still counts toward the consecutive-failure session rebuild).
195
+ ``self.last_error`` retains the raw underlying exception for
196
+ diagnostics (e.g. ``note_refresh_result``).
197
+ """
198
+ if headers is None:
199
+ headers = {"User-Agent": "Mozilla/5.0 (CircuitPython)"}
200
+
201
+ retry_count = 0
202
+ last_error = None
203
+
204
+ # Check mock data provider
205
+ if not self.session and not self.use_live_data and self.mock_provider:
206
+ mock_resp = self.mock_provider(url)
207
+ if mock_resp is not None:
208
+ return mock_resp
209
+
210
+ # No HTTP client at all (no adafruit session, no urllib) is a permanent
211
+ # configuration state, not a transient blip — fail fast instead of
212
+ # burning the retry loop's backoff on it.
213
+ if not (self.using_adafruit and self.session) and not self.urllib:
214
+ raise NetworkError("No HTTP client available")
215
+
216
+ while retry_count < max_retries:
217
+ try:
218
+ if self.using_adafruit and self.session:
219
+ resp = await self._get_adafruit(url, headers, retry_count)
220
+ else:
221
+ resp = self._get_urllib(url, headers)
222
+ self._note_success()
223
+ return resp
224
+ except Exception as outer_error:
225
+ _logger().error(outer_error, f"HTTP GET error (attempt {retry_count+1})")
226
+ last_error = outer_error
227
+ # Count the failure and, on a repeated failure, rebuild the
228
+ # (likely wedged) session so the NEXT retry uses a fresh socket
229
+ # pool instead of hammering the stuck one forever.
230
+ self._note_failure(outer_error)
231
+ retry_count += 1
232
+ import asyncio as _asyncio
233
+ await _asyncio.sleep(0.5 + retry_count * 0.5)
234
+
235
+ error_msg = str(last_error) if last_error else "Unknown error"
236
+ _logger().error(None, f"All {max_retries} GET attempts to {url} failed: {error_msg}")
237
+ # Raise a typed error so callers can catch scrollkit.exceptions.NetworkError
238
+ # at the boundary. self.last_error still holds the RAW underlying exception
239
+ # (set by _note_failure) so the app can record why the outage happened.
240
+ # No `raise ... from` chaining: retaining the cause traceback fragments the
241
+ # heap on CircuitPython.
242
+ raise NetworkError("GET %s failed after %d attempts: %s"
243
+ % (url, max_retries, error_msg))
244
+
245
+ @staticmethod
246
+ def _detach_response(resp):
247
+ """Copy a native ``adafruit_requests`` response into a socket-free
248
+ ``BaseResponse`` so the native one (and its socket) can be closed.
249
+
250
+ Reading ``.text`` pulls the whole body off the socket (the adafruit
251
+ response caches it); the caller then closes the native response and the
252
+ socket goes back to the ESP32-S3's ~4-socket pool. The returned
253
+ ``BaseResponse`` owns no socket, so callers may keep or discard it freely.
254
+
255
+ This mirrors the urllib path, which already detaches via
256
+ ``UrllibResponse``. Without it, every SUCCESSFUL device fetch returned the
257
+ live native response and nothing ever closed it (only failures were
258
+ closed), so a socket leaked per fetch until the pool was exhausted and
259
+ every subsequent ``session.get`` raised ``OSError 16 (EBUSY)`` — a
260
+ permanent wedge a ``_rebuild_session`` could not clear, because the leaked
261
+ sockets were owned by discarded, never-closed response objects.
262
+ """
263
+ status = getattr(resp, "status_code", 200)
264
+ try:
265
+ hdrs = dict(resp.headers)
266
+ except Exception:
267
+ hdrs = {}
268
+ text = resp.text
269
+ if not isinstance(text, str):
270
+ # Real adafruit_requests returns str; guard odd providers/mocks.
271
+ try:
272
+ text = text.decode("utf-8")
273
+ except Exception:
274
+ text = ""
275
+ return BaseResponse(status_code=status, text=text, headers=hdrs)
276
+
277
+ async def _get_adafruit(self, url, headers, retry_count):
278
+ """Issue one ``adafruit_requests`` GET and return a DETACHED response,
279
+ always closing the native socket.
280
+
281
+ The native response is read into a socket-free ``BaseResponse``
282
+ (``_detach_response``) and the native one is closed in ``finally`` on BOTH
283
+ success and failure — a socket leaked out of the ESP32-S3's ~4-socket pool
284
+ is the dominant field wedge (it exhausts the pool, then every fetch raises
285
+ ``OSError 16 EBUSY``). On any exception it propagates so ``get()``'s
286
+ retry/rebuild logic runs. Session recreation is handled centrally by
287
+ ``_note_failure()`` / ``_rebuild_session()``, NOT here, so EVERY repeated
288
+ failure — read/connect timeout, mbedTLS/SSL error, ConnectionError,
289
+ OSError, OutOfRetries — clears a wedged session, not just the rare
290
+ ``OutOfRetries`` case.
291
+ """
292
+ resp = None
293
+ try:
294
+ resp = self.session.get(url, headers=headers, timeout=self.timeout)
295
+ return self._detach_response(resp)
296
+ finally:
297
+ if resp is not None:
298
+ try:
299
+ resp.close()
300
+ except Exception:
301
+ pass
302
+
303
+ def _note_success(self):
304
+ """Record a successful request: clear the error + failure streak and
305
+ stamp the last-success time (drives the staleness signal)."""
306
+ self.last_error = None
307
+ self._failures_since_rebuild = 0
308
+ try:
309
+ import time
310
+ self._last_success_time = (
311
+ time.monotonic() if hasattr(time, "monotonic") else None)
312
+ except Exception:
313
+ self._last_success_time = None
314
+
315
+ def _note_failure(self, error):
316
+ """Record a failed request and rebuild the session once the consecutive
317
+ failure count crosses the threshold (the in-place wedge repair)."""
318
+ self.last_error = error
319
+ self._failures_since_rebuild += 1
320
+ if (self.using_adafruit and self.session is not None
321
+ and self._failures_since_rebuild >= self.session_rebuild_threshold):
322
+ if self._rebuild_session():
323
+ # Give the fresh session a clean slate so a transient blip after
324
+ # a rebuild doesn't immediately rebuild again (thrash).
325
+ self._failures_since_rebuild = 0
326
+
327
+ def _rebuild_session(self):
328
+ """Tear down and recreate the adafruit_requests session.
329
+
330
+ A fresh ``SocketPool(wifi.radio)`` + ssl context discards the wedged
331
+ pool's stuck sockets/TLS state — the only way to clear the dominant field
332
+ failure short of a reboot. Device-only (the imports exist on CircuitPython
333
+ only); a no-op that returns False on desktop, where the urllib path never
334
+ uses a session. Never raises into the caller.
335
+ """
336
+ if not (self.using_adafruit and self.session is not None):
337
+ return False
338
+ try:
339
+ import socketpool
340
+ import wifi
341
+ import ssl
342
+ import adafruit_requests
343
+ gc.collect() # reclaim RAM before allocating a new pool/context
344
+ pool = socketpool.SocketPool(wifi.radio)
345
+ ssl_context = ssl.create_default_context()
346
+ self.session = adafruit_requests.Session(pool, ssl_context)
347
+ gc.collect()
348
+ _logger().info("Recreated HTTP session after repeated failures")
349
+ return True
350
+ except Exception as e:
351
+ _logger().error(e, "HTTP session rebuild failed")
352
+ return False
353
+
354
+ def seconds_since_last_success(self):
355
+ """Seconds since the last successful request, or None if none yet.
356
+
357
+ A staleness signal for the app: a large/growing value while requests keep
358
+ failing means the displayed data is stale even though the box looks alive.
359
+ """
360
+ if self._last_success_time is None:
361
+ return None
362
+ try:
363
+ import time
364
+ if not hasattr(time, "monotonic"):
365
+ return None
366
+ return time.monotonic() - self._last_success_time
367
+ except Exception:
368
+ return None
369
+
370
+ def _get_urllib(self, url, headers):
371
+ if not self.urllib:
372
+ # Raise (not return a synthesized 500): a returned failure here was
373
+ # counted as a success by get() (which called _note_success on it).
374
+ raise NetworkError("No HTTP client available")
375
+ request = self.urllib.Request(url)
376
+ for key, value in headers.items():
377
+ request.add_header(key, value)
378
+ with self.urllib.urlopen(request, timeout=self.timeout) as response:
379
+ return UrllibResponse(response)
380
+
381
+ async def post(self, url, data, headers=None):
382
+ """Make a POST request.
383
+
384
+ Mirrors ``get()``: on the device path the native ``adafruit_requests``
385
+ response is read into a socket-free ``BaseResponse`` and CLOSED in a
386
+ ``finally`` so its socket returns to the ~4-socket pool — a leaked POST
387
+ socket wedges the device with ``OSError 16 (EBUSY)`` exactly like a leaked
388
+ GET. A per-request ``timeout`` is passed (both paths) so a hung POST can't
389
+ block the synchronous asyncio loop and trip the watchdog. POST is
390
+ single-shot (no retry loop), but a failure is recorded via
391
+ ``_note_failure`` so a wedged session still gets rebuilt on the next
392
+ request, raises ``NetworkError``, and the cause is retained on
393
+ ``last_error``.
394
+ """
395
+ if headers is None:
396
+ headers = {
397
+ "User-Agent": "Mozilla/5.0 (CircuitPython)",
398
+ "Content-Type": "application/json"
399
+ }
400
+ if isinstance(data, dict):
401
+ data = json.dumps(data)
402
+ # Raise cleanly BEFORE the try so the "no client" NetworkError isn't
403
+ # re-wrapped by the except below (and so the urllib branch never calls
404
+ # None.Request(...) -> AttributeError when urllib is absent on device).
405
+ if not (self.using_adafruit and self.session) and not self.urllib:
406
+ raise NetworkError("No HTTP client available")
407
+ try:
408
+ if self.using_adafruit and self.session:
409
+ resp = None
410
+ try:
411
+ resp = self.session.post(url, data=data, headers=headers,
412
+ timeout=self.timeout)
413
+ detached = self._detach_response(resp)
414
+ self._note_success()
415
+ return detached
416
+ finally:
417
+ if resp is not None:
418
+ try:
419
+ resp.close()
420
+ except Exception:
421
+ pass
422
+ else:
423
+ request = self.urllib.Request(
424
+ url,
425
+ data=data.encode('utf-8') if isinstance(data, str) else data,
426
+ method="POST"
427
+ )
428
+ for key, value in headers.items():
429
+ request.add_header(key, value)
430
+ with self.urllib.urlopen(request, timeout=self.timeout) as response:
431
+ # Keep last_error/last-success bookkeeping consistent with get().
432
+ self._note_success()
433
+ return UrllibResponse(response)
434
+ except Exception as e:
435
+ _logger().error(e, f"Error making POST request to {url}")
436
+ self._note_failure(e)
437
+ # self.last_error holds the raw cause (set by _note_failure). Plain
438
+ # raise, no `from e` chaining (heap fragmentation on CircuitPython).
439
+ raise NetworkError("POST %s failed: %s: %s" % (url, type(e).__name__, e))
440
+
441
+ def set_use_live_data(self, use_live_data):
442
+ """Set whether to use live data or mock data."""
443
+ self.use_live_data = use_live_data
444
+ _logger().info(f"HTTP client: {'live' if use_live_data else 'mock'} data")
445
+
446
+ def get_sync(self, url, headers=None, max_retries=3):
447
+ """Synchronous wrapper for GET requests (CircuitPython compatible)."""
448
+ gc.collect()
449
+ if headers is None:
450
+ headers = {"User-Agent": "Mozilla/5.0 (CircuitPython)"}
451
+
452
+ retry_count = 0
453
+ last_error = None
454
+
455
+ # Fail fast on a permanent no-client state instead of sleeping through
456
+ # the retry backoff (blocking real time.sleep on the sync path).
457
+ if not (self.using_adafruit and self.session) and not self.urllib:
458
+ raise NetworkError("No HTTP client available")
459
+
460
+ while retry_count < max_retries:
461
+ try:
462
+ if self.using_adafruit and self.session:
463
+ gc.collect()
464
+ resp = None
465
+ try:
466
+ _logger().debug(f"Sync GET: {url}")
467
+ resp = self.session.get(url, headers=headers, timeout=self.timeout)
468
+ # Detach BEFORE _note_success/return so the native socket
469
+ # is freed by the finally on the success path too — leaking
470
+ # it exhausts the ~4-socket pool (OSError 16 EBUSY).
471
+ detached = self._detach_response(resp)
472
+ self._note_success()
473
+ return detached
474
+ finally:
475
+ if resp is not None:
476
+ try:
477
+ resp.close()
478
+ except Exception:
479
+ pass
480
+ elif self.urllib:
481
+ request = self.urllib.Request(url)
482
+ for key, value in headers.items():
483
+ request.add_header(key, value)
484
+ with self.urllib.urlopen(request, timeout=self.timeout) as response:
485
+ self._note_success()
486
+ return UrllibResponse(response)
487
+ else:
488
+ raise NetworkError("No HTTP client available")
489
+ except Exception as e:
490
+ _logger().error(e, f"Sync GET error (attempt {retry_count+1})")
491
+ last_error = e
492
+ # Same repeated-failure session rebuild as the async path, so a
493
+ # wedged session self-recovers whichever entry point the app uses.
494
+ self._note_failure(e)
495
+ retry_count += 1
496
+ if retry_count < max_retries:
497
+ import time
498
+ gc.collect()
499
+ time.sleep(2 * retry_count)
500
+
501
+ error_msg = str(last_error) if last_error else "Unknown error"
502
+ _logger().error(None, f"All {max_retries} sync GET attempts to {url} failed: {error_msg}")
503
+ # Raise a typed error at the boundary (self.last_error keeps the raw cause).
504
+ raise NetworkError("sync GET %s failed after %d attempts: %s"
505
+ % (url, max_retries, error_msg))
@@ -0,0 +1,44 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """mDNS hostname advertising for the on-device config/web server.
3
+
4
+ Advertises ``<hostname>.local`` plus a service record so the device is reachable
5
+ by name on the LAN without knowing its IP. CircuitPython only — a no-op on desktop
6
+ (and harmless if the ``mdns`` module is unavailable), so callers never need a
7
+ platform check.
8
+
9
+ IMPORTANT: the caller MUST retain the returned ``mdns.Server`` for as long as it
10
+ wants ``.local`` to resolve. If it is garbage-collected the responder stops and
11
+ name resolution fails (intermittently — after the first cached query expires)::
12
+
13
+ from scrollkit.network import mdns
14
+
15
+ # keep a reference alive for the lifetime of the app
16
+ self._mdns = mdns.advertise(self.settings.get("hostname", "scrollkit"))
17
+ """
18
+ from __future__ import annotations
19
+
20
+
21
+ __all__ = ['advertise']
22
+
23
+ def advertise(hostname, *, port=80, service_type="_http", protocol="_tcp"):
24
+ """Advertise ``<hostname>.local`` over mDNS and register a service.
25
+
26
+ Returns the live ``mdns.Server`` on success (the caller MUST keep a reference
27
+ to it — see the module docstring), or ``None`` on desktop / when the ``mdns``
28
+ module or radio is unavailable. Never raises — mDNS must never block boot.
29
+ """
30
+ try:
31
+ import wifi
32
+ import mdns
33
+ except ImportError:
34
+ return None # desktop / no CircuitPython mdns
35
+ try:
36
+ server = mdns.Server(wifi.radio)
37
+ server.hostname = hostname
38
+ server.advertise_service(service_type=service_type, protocol=protocol,
39
+ port=port)
40
+ print("mDNS advertising %s.local" % hostname)
41
+ return server
42
+ except Exception as e: # never block boot on mDNS
43
+ print("mDNS setup failed:", e)
44
+ return None