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/exceptions.py
ADDED
|
@@ -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
|