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
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""CircuitPython terminalio compatibility module for SLDK simulator.
|
|
3
|
+
|
|
4
|
+
Provides the default FONT for text rendering.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from ..adafruit_bitmap_font import bitmap_font
|
|
9
|
+
|
|
10
|
+
# Load the default font (viii.bdf - 8 pixels tall)
|
|
11
|
+
_font_path = os.path.join(os.path.dirname(__file__), '..', 'fonts', 'viii.bdf')
|
|
12
|
+
try:
|
|
13
|
+
FONT = bitmap_font.load_font(_font_path)
|
|
14
|
+
except Exception as e:
|
|
15
|
+
print(f"Warning: Could not load default font from {_font_path}: {e}")
|
|
16
|
+
# Create a minimal fallback
|
|
17
|
+
FONT = None
|
|
18
|
+
|
|
19
|
+
# For compatibility with existing code that imports the module
|
|
20
|
+
__all__ = ['FONT']
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Color utilities for handling color conversions and manipulations.
|
|
3
|
+
Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Color name to (r, g, b) tuple mapping, for quick prototyping/tooling. Distinct
|
|
7
|
+
# from ColorUtils.colors below (Title-case names -> hex *strings*, used as
|
|
8
|
+
# settings defaults) -- different names, different value format, both kept.
|
|
9
|
+
NAMED_COLORS = {
|
|
10
|
+
'red': (255, 0, 0),
|
|
11
|
+
'green': (0, 255, 0),
|
|
12
|
+
'blue': (0, 0, 255),
|
|
13
|
+
'yellow': (255, 255, 0),
|
|
14
|
+
'purple': (128, 0, 128),
|
|
15
|
+
'cyan': (0, 255, 255),
|
|
16
|
+
'white': (255, 255, 255),
|
|
17
|
+
'orange': (255, 165, 0),
|
|
18
|
+
'pink': (255, 192, 203),
|
|
19
|
+
'magenta': (255, 0, 255),
|
|
20
|
+
'lime': (0, 255, 0),
|
|
21
|
+
'teal': (0, 128, 128),
|
|
22
|
+
'navy': (0, 0, 128),
|
|
23
|
+
'brown': (165, 42, 42),
|
|
24
|
+
'gray': (128, 128, 128),
|
|
25
|
+
'grey': (128, 128, 128),
|
|
26
|
+
'black': (0, 0, 0),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = ['ColorUtils', 'NAMED_COLORS']
|
|
31
|
+
|
|
32
|
+
class ColorUtils:
|
|
33
|
+
"""Utilities for handling colors and conversions"""
|
|
34
|
+
|
|
35
|
+
# Color definitions as a class variable
|
|
36
|
+
colors = {
|
|
37
|
+
"Red": "0xff0000",
|
|
38
|
+
"Green": "0x00ff00",
|
|
39
|
+
"Blue": "0x0000ff",
|
|
40
|
+
"White": "0xffffff",
|
|
41
|
+
"Black": "0x000000",
|
|
42
|
+
"Purple": "0x800080",
|
|
43
|
+
"Yellow": "0xffff00",
|
|
44
|
+
"Orange": "0xffa500",
|
|
45
|
+
"Pink": "0xffc0cb",
|
|
46
|
+
"Old Lace": "0xfdf5e6"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# NOTE: the old static conversion helpers (to_rgb/from_rgb/scale_color/
|
|
50
|
+
# hex_str_to_rgb/pad_hex/hex_str_to_number/number_to_hex_string) were
|
|
51
|
+
# removed as dead code — zero callers anywhere, including tests and the
|
|
52
|
+
# app. The modern int-based color API is scrollkit.display.colors. This
|
|
53
|
+
# class survives only to carry the hex-string `colors` table the settings
|
|
54
|
+
# schema consumes.
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""On-device crash/boot diagnostics, persisted in ``microcontroller.nvm``.
|
|
3
|
+
|
|
4
|
+
Why this exists: a field device can go black with no usable logs — a flash log
|
|
5
|
+
fills and is wiped on every reboot, so a crash erases its own evidence. NVM
|
|
6
|
+
survives BOTH soft resets and power loss and is independent of the filesystem, so
|
|
7
|
+
a compact fixed-size record here lets an app:
|
|
8
|
+
|
|
9
|
+
* **break a reboot loop** from a deterministic fault (bad settings, an API
|
|
10
|
+
change that crashes every boot): after a few fault-resets with no clean run,
|
|
11
|
+
drop into *safe mode* (no fetch; keep the config web UI up) instead of
|
|
12
|
+
resetting forever;
|
|
13
|
+
* **explain "why it went black"** on a config web UI without a serial cable
|
|
14
|
+
(reset reason, last exception, consecutive failures, last success).
|
|
15
|
+
|
|
16
|
+
NVM is wear-limited, so we write SPARINGLY: once per boot and only on state
|
|
17
|
+
changes (first clean run, a crash) — never per refresh.
|
|
18
|
+
|
|
19
|
+
The store takes an injectable ``backend`` (anything that behaves like a mutable
|
|
20
|
+
``bytearray``) so the boot-loop logic is unit-tested on desktop; on hardware the
|
|
21
|
+
backend is ``microcontroller.nvm``. On desktop with no NVM, ``open()`` returns a
|
|
22
|
+
no-op store so callers never need platform checks.
|
|
23
|
+
|
|
24
|
+
Usage::
|
|
25
|
+
|
|
26
|
+
from scrollkit.utils import diagnostics
|
|
27
|
+
|
|
28
|
+
diag = diagnostics.open() # NVM on device, no-op on desktop
|
|
29
|
+
diag.record_boot(diagnostics.read_reset_reason())
|
|
30
|
+
if diag.safe_mode:
|
|
31
|
+
... # skip the fetch; keep the UI up
|
|
32
|
+
diag.note_fetch_result(ok=True) # on a healthy refresh
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# Fixed binary layout at the head of NVM. Tiny and stable; bump _VERSION if it
|
|
36
|
+
# changes (a version/magic mismatch resets the record).
|
|
37
|
+
_MAGIC = 0x7A
|
|
38
|
+
_VERSION = 1
|
|
39
|
+
|
|
40
|
+
_OFF_MAGIC = 0
|
|
41
|
+
_OFF_VERSION = 1
|
|
42
|
+
_OFF_BOOT_COUNT = 2 # 2 bytes, wraps
|
|
43
|
+
_OFF_RAPID_BOOTS = 4 # 1 byte: boots since the last clean run
|
|
44
|
+
_OFF_RESET_REASON = 5 # 1 byte: code from _REASON_CODES
|
|
45
|
+
_OFF_CONSEC_FAILS = 6 # 1 byte: consecutive fetch failures (last known)
|
|
46
|
+
_OFF_FLAGS = 7 # 1 byte: bit0 = entered safe mode last boot
|
|
47
|
+
_OFF_MSG_LEN = 8 # 1 byte
|
|
48
|
+
_OFF_MSG = 9 # ascii crash/exception text
|
|
49
|
+
MSG_MAX = 180
|
|
50
|
+
_SIZE = _OFF_MSG + MSG_MAX
|
|
51
|
+
|
|
52
|
+
# After this many resets with no intervening clean run, enter safe mode.
|
|
53
|
+
RAPID_BOOT_LIMIT = 4
|
|
54
|
+
|
|
55
|
+
_FLAG_SAFE_MODE = 0x01
|
|
56
|
+
|
|
57
|
+
# microcontroller.ResetReason names we care about -> small codes (and back, for
|
|
58
|
+
# display). Stored as a code so we don't depend on the enum at read time.
|
|
59
|
+
_REASON_NAMES = ("UNKNOWN", "POWER_ON", "BROWNOUT", "SOFTWARE",
|
|
60
|
+
"DEEP_SLEEP_ALARM", "RESET_PIN", "WATCHDOG")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ['Diagnostics', 'open', 'read_reset_reason', 'MSG_MAX', 'RAPID_BOOT_LIMIT']
|
|
64
|
+
|
|
65
|
+
class Diagnostics:
|
|
66
|
+
"""Compact NVM-backed boot/crash record. All methods are failure-tolerant."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, backend):
|
|
69
|
+
self._nvm = backend
|
|
70
|
+
# Per-boot snapshot, filled by record_boot().
|
|
71
|
+
self.boot_count = 0
|
|
72
|
+
self.rapid_boots = 0
|
|
73
|
+
self.reset_reason = "UNKNOWN"
|
|
74
|
+
self.safe_mode = False
|
|
75
|
+
self.last_message = ""
|
|
76
|
+
self.consecutive_failures = 0
|
|
77
|
+
|
|
78
|
+
# --- low-level helpers ---------------------------------------------------
|
|
79
|
+
def _read(self):
|
|
80
|
+
try:
|
|
81
|
+
n = self._nvm
|
|
82
|
+
if n is None or len(n) < _SIZE:
|
|
83
|
+
return None
|
|
84
|
+
if n[_OFF_MAGIC] != _MAGIC or n[_OFF_VERSION] != _VERSION:
|
|
85
|
+
return None
|
|
86
|
+
return bytes(n[0:_SIZE])
|
|
87
|
+
except Exception:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def _write_byte(self, offset, value):
|
|
91
|
+
try:
|
|
92
|
+
self._nvm[offset] = value & 0xFF
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
def _write_u16(self, offset, value):
|
|
97
|
+
try:
|
|
98
|
+
self._nvm[offset] = value & 0xFF
|
|
99
|
+
self._nvm[offset + 1] = (value >> 8) & 0xFF
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
def _init_blank(self):
|
|
104
|
+
try:
|
|
105
|
+
self._nvm[_OFF_MAGIC] = _MAGIC
|
|
106
|
+
self._nvm[_OFF_VERSION] = _VERSION
|
|
107
|
+
for off in (_OFF_BOOT_COUNT, _OFF_BOOT_COUNT + 1, _OFF_RAPID_BOOTS,
|
|
108
|
+
_OFF_RESET_REASON, _OFF_CONSEC_FAILS, _OFF_FLAGS,
|
|
109
|
+
_OFF_MSG_LEN):
|
|
110
|
+
self._nvm[off] = 0
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
# --- public API ----------------------------------------------------------
|
|
115
|
+
def record_boot(self, reset_reason_name="UNKNOWN"):
|
|
116
|
+
"""Call once at the very start of boot. Increments counters, classifies
|
|
117
|
+
whether we're in a reboot loop, and returns ``self`` for chaining.
|
|
118
|
+
|
|
119
|
+
``reset_reason_name`` is the ``microcontroller.cpu.reset_reason`` name
|
|
120
|
+
(the caller reads it; we just store the code so the web UI can show it)."""
|
|
121
|
+
raw = self._read()
|
|
122
|
+
if raw is None:
|
|
123
|
+
self._init_blank()
|
|
124
|
+
raw = self._read() or bytes(_SIZE)
|
|
125
|
+
|
|
126
|
+
self.boot_count = (raw[_OFF_BOOT_COUNT] | (raw[_OFF_BOOT_COUNT + 1] << 8)) + 1
|
|
127
|
+
# "Boots since the last clean run": every boot increments this; a healthy
|
|
128
|
+
# device zeroes it via note_clean_run() on its first good fetch, so it only
|
|
129
|
+
# climbs when boots keep happening with no clean run between them (a reboot
|
|
130
|
+
# loop). reset_reason is recorded for display but does NOT gate this count.
|
|
131
|
+
self.rapid_boots = raw[_OFF_RAPID_BOOTS] + 1
|
|
132
|
+
self.reset_reason = reset_reason_name
|
|
133
|
+
self.consecutive_failures = raw[_OFF_CONSEC_FAILS]
|
|
134
|
+
msg_len = min(raw[_OFF_MSG_LEN], MSG_MAX)
|
|
135
|
+
try:
|
|
136
|
+
self.last_message = bytes(raw[_OFF_MSG:_OFF_MSG + msg_len]).decode("ascii")
|
|
137
|
+
except Exception:
|
|
138
|
+
self.last_message = ""
|
|
139
|
+
|
|
140
|
+
self.safe_mode = self.rapid_boots > RAPID_BOOT_LIMIT
|
|
141
|
+
|
|
142
|
+
self._write_u16(_OFF_BOOT_COUNT, self.boot_count)
|
|
143
|
+
self._write_byte(_OFF_RAPID_BOOTS, min(self.rapid_boots, 255))
|
|
144
|
+
try:
|
|
145
|
+
self._write_byte(_OFF_RESET_REASON, _REASON_NAMES.index(reset_reason_name))
|
|
146
|
+
except Exception:
|
|
147
|
+
self._write_byte(_OFF_RESET_REASON, 0)
|
|
148
|
+
flags = _FLAG_SAFE_MODE if self.safe_mode else 0
|
|
149
|
+
self._write_byte(_OFF_FLAGS, flags)
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def note_clean_run(self):
|
|
153
|
+
"""Call once the device is healthy (first successful fetch / stable run).
|
|
154
|
+
Zeroes the reboot-loop counter so transient single crashes never
|
|
155
|
+
accumulate into safe mode."""
|
|
156
|
+
self.rapid_boots = 0
|
|
157
|
+
self._write_byte(_OFF_RAPID_BOOTS, 0)
|
|
158
|
+
|
|
159
|
+
def note_fetch_result(self, ok, consecutive_failures=0):
|
|
160
|
+
"""Record refresh outcome (state-change only — cheap). A success also
|
|
161
|
+
counts as a clean run."""
|
|
162
|
+
self.consecutive_failures = 0 if ok else consecutive_failures
|
|
163
|
+
self._write_byte(_OFF_CONSEC_FAILS, min(self.consecutive_failures, 255))
|
|
164
|
+
if ok:
|
|
165
|
+
self.note_clean_run()
|
|
166
|
+
|
|
167
|
+
def record_crash(self, message):
|
|
168
|
+
"""Persist the last fatal exception text (truncated) before a reset."""
|
|
169
|
+
try:
|
|
170
|
+
text = "".join(c for c in str(message) if 32 <= ord(c) < 128)[:MSG_MAX]
|
|
171
|
+
data = text.encode("ascii")
|
|
172
|
+
self._write_byte(_OFF_MSG_LEN, len(data))
|
|
173
|
+
for i, b in enumerate(data):
|
|
174
|
+
self._nvm[_OFF_MSG + i] = b
|
|
175
|
+
self.last_message = text
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
def summary(self):
|
|
180
|
+
"""Dict for the config web UI / logs."""
|
|
181
|
+
return {
|
|
182
|
+
"boot_count": self.boot_count,
|
|
183
|
+
"reboot_streak": self.rapid_boots,
|
|
184
|
+
"reset_reason": self.reset_reason,
|
|
185
|
+
"safe_mode": self.safe_mode,
|
|
186
|
+
"consecutive_failures": self.consecutive_failures,
|
|
187
|
+
"last_error": self.last_message,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _NullDiagnostics(Diagnostics):
|
|
192
|
+
"""No-op store for desktop / when NVM is unavailable."""
|
|
193
|
+
def __init__(self):
|
|
194
|
+
super().__init__(None)
|
|
195
|
+
|
|
196
|
+
def record_boot(self, reset_reason_name="UNKNOWN"):
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
def note_clean_run(self):
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
def note_fetch_result(self, ok, consecutive_failures=0):
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
def record_crash(self, message):
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def read_reset_reason():
|
|
210
|
+
"""Return the microcontroller reset-reason NAME, or 'UNKNOWN' off-device."""
|
|
211
|
+
try:
|
|
212
|
+
import microcontroller
|
|
213
|
+
return str(microcontroller.cpu.reset_reason).rsplit(".", 1)[-1]
|
|
214
|
+
except Exception:
|
|
215
|
+
return "UNKNOWN"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def open():
|
|
219
|
+
"""Return a Diagnostics bound to NVM on hardware, else a no-op store."""
|
|
220
|
+
try:
|
|
221
|
+
import microcontroller
|
|
222
|
+
nvm = microcontroller.nvm
|
|
223
|
+
if nvm is not None and len(nvm) >= _SIZE:
|
|
224
|
+
return Diagnostics(nvm)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
return _NullDiagnostics()
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Error handling utility for logging errors and debug information.
|
|
3
|
+
Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import storage
|
|
10
|
+
except (ImportError, AttributeError):
|
|
11
|
+
# `storage` is a CircuitPython-only module. Bind it to None on desktop so the
|
|
12
|
+
# module attribute always exists (the detection branch below gates on
|
|
13
|
+
# `storage is not None`, and tests patch this name).
|
|
14
|
+
storage = None
|
|
15
|
+
|
|
16
|
+
__all__ = ['ErrorHandler']
|
|
17
|
+
|
|
18
|
+
class ErrorHandler:
|
|
19
|
+
"""
|
|
20
|
+
Centralized error handling and logging facility.
|
|
21
|
+
Handles writing to log files with fallback to console output.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# Class-level registry to track instances by filename
|
|
25
|
+
_instances = {}
|
|
26
|
+
|
|
27
|
+
# Class-level mode setting
|
|
28
|
+
DEVELOPMENT = "development"
|
|
29
|
+
PRODUCTION = "production"
|
|
30
|
+
_mode = PRODUCTION # Default to production mode
|
|
31
|
+
|
|
32
|
+
# Cap each log generation so logging can never fill the device's tiny flash
|
|
33
|
+
# (the field failure: unbounded append eventually fills storage and the app
|
|
34
|
+
# can't write settings/cache and goes dark). When the file reaches this size
|
|
35
|
+
# it is rotated to "<file>.old" (one generation kept), bounding total log
|
|
36
|
+
# storage to ~2x this value while preserving recent history.
|
|
37
|
+
MAX_LOG_BYTES = 16384
|
|
38
|
+
|
|
39
|
+
def __new__(cls, file_name, mode=None):
|
|
40
|
+
"""Return one shared instance per log file.
|
|
41
|
+
|
|
42
|
+
The previous code faked a singleton inside __init__ by copying fields out of
|
|
43
|
+
the cached instance into a *newly allocated* object and returning that — so
|
|
44
|
+
every ``ErrorHandler("error_log")`` was a distinct object. When one detected
|
|
45
|
+
a read-only filesystem and set is_readonly=True, the others never saw it and
|
|
46
|
+
kept thrashing the filesystem on every write; mode/state diverged silently.
|
|
47
|
+
Holding identity in __new__ makes all callers share a single object.
|
|
48
|
+
"""
|
|
49
|
+
inst = cls._instances.get(file_name)
|
|
50
|
+
if inst is None:
|
|
51
|
+
inst = super().__new__(cls)
|
|
52
|
+
cls._instances[file_name] = inst
|
|
53
|
+
return inst
|
|
54
|
+
|
|
55
|
+
def __init__(self, file_name, mode=None):
|
|
56
|
+
"""
|
|
57
|
+
Initialize the error handler with read-only filesystem detection
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
file_name: The name of the log file
|
|
61
|
+
mode: Either 'development' or 'production' (optional, uses class default if not specified)
|
|
62
|
+
"""
|
|
63
|
+
# Shared singleton: run setup exactly once per file. A later construction may
|
|
64
|
+
# still update the mode when the caller passes one explicitly (an explicit
|
|
65
|
+
# override wins); otherwise first-construction state stands.
|
|
66
|
+
if getattr(self, "_initialized", False):
|
|
67
|
+
if mode is not None:
|
|
68
|
+
self.mode = mode
|
|
69
|
+
return
|
|
70
|
+
self._initialized = True
|
|
71
|
+
|
|
72
|
+
# Continue with normal initialization for new instance
|
|
73
|
+
self.fileName = file_name
|
|
74
|
+
# Set instance mode - use parameter if provided, otherwise use class default
|
|
75
|
+
self.mode = mode if mode else ErrorHandler._mode
|
|
76
|
+
# Start with the assumption that the filesystem is read-only
|
|
77
|
+
# We'll only set it to writable if we can successfully write to it
|
|
78
|
+
self.is_readonly = True
|
|
79
|
+
|
|
80
|
+
# First check if we can directly detect read-only status via storage module
|
|
81
|
+
if storage is not None:
|
|
82
|
+
try:
|
|
83
|
+
# Get mount location
|
|
84
|
+
mount_path = '/'
|
|
85
|
+
try:
|
|
86
|
+
# Try to get the mount path for the file's directory
|
|
87
|
+
dir_path = os.path.dirname(file_name)
|
|
88
|
+
if dir_path and os.path.exists(dir_path):
|
|
89
|
+
mount_path = dir_path
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
# Check if storage shows readonly
|
|
94
|
+
self.is_readonly = storage.getmount(mount_path).readonly
|
|
95
|
+
print(f"Filesystem read-only status from storage: {self.is_readonly}")
|
|
96
|
+
|
|
97
|
+
# If storage says it's read-only, trust that and skip the write test
|
|
98
|
+
if self.is_readonly:
|
|
99
|
+
print("Filesystem is read-only according to storage module")
|
|
100
|
+
print("ErrorHandler initialized - read-only filesystem") # Exact match for test
|
|
101
|
+
return
|
|
102
|
+
except (AttributeError, OSError):
|
|
103
|
+
# Continue with write test if storage check fails
|
|
104
|
+
print("Storage module check failed, will try write test")
|
|
105
|
+
|
|
106
|
+
# Preserve crash evidence across reboots. The old code deleted the log here
|
|
107
|
+
# AND truncated it with a 'w' write-test below, so a crash -> reboot erased
|
|
108
|
+
# exactly the log that would explain the crash (why field failures were
|
|
109
|
+
# undiagnosable). Only DEVELOPMENT starts each run with a fresh log.
|
|
110
|
+
if self.mode == ErrorHandler.DEVELOPMENT:
|
|
111
|
+
try:
|
|
112
|
+
if self.file_exists(file_name):
|
|
113
|
+
print(f"Deleting existing log file: {file_name}")
|
|
114
|
+
os.remove(file_name)
|
|
115
|
+
except OSError:
|
|
116
|
+
# Can't delete, assume readonly
|
|
117
|
+
self.is_readonly = True
|
|
118
|
+
print(f"Failed to delete existing log file: {file_name}")
|
|
119
|
+
|
|
120
|
+
# Verify writability WITHOUT truncating: append-open creates the file if it
|
|
121
|
+
# is missing and leaves any existing contents intact (so PRODUCTION keeps
|
|
122
|
+
# the prior session's log for post-mortem).
|
|
123
|
+
try:
|
|
124
|
+
with open(self.fileName, 'a'):
|
|
125
|
+
pass
|
|
126
|
+
self.is_readonly = False
|
|
127
|
+
except OSError as e:
|
|
128
|
+
# If any error occurs during write/create, filesystem is read-only
|
|
129
|
+
self.is_readonly = True
|
|
130
|
+
print(f"Write test failed: {str(e)}")
|
|
131
|
+
|
|
132
|
+
# Log system state at initialization based on final determination
|
|
133
|
+
if self.is_readonly:
|
|
134
|
+
print("ErrorHandler initialized - read-only filesystem")
|
|
135
|
+
else:
|
|
136
|
+
print("ErrorHandler initialized - writable filesystem")
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def set_mode(cls, mode):
|
|
140
|
+
"""
|
|
141
|
+
Set the global mode for all new ErrorHandler instances
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
mode: Either 'development' or 'production'
|
|
145
|
+
"""
|
|
146
|
+
if mode in [cls.DEVELOPMENT, cls.PRODUCTION]:
|
|
147
|
+
cls._mode = mode
|
|
148
|
+
else:
|
|
149
|
+
raise ValueError(f"Invalid mode: {mode}. Must be either '{cls.DEVELOPMENT}' or '{cls.PRODUCTION}'")
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def get_mode(cls):
|
|
153
|
+
"""
|
|
154
|
+
Get the current global mode
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The current mode string
|
|
158
|
+
"""
|
|
159
|
+
return cls._mode
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def filter_non_ascii(text):
|
|
163
|
+
"""
|
|
164
|
+
Filter out non-ASCII characters from a string
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
text: The text to filter
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
A string with only ASCII characters
|
|
171
|
+
"""
|
|
172
|
+
if text is None:
|
|
173
|
+
return ""
|
|
174
|
+
return "".join(c for c in str(text) if ord(c) < 128)
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def file_exists(file_name):
|
|
178
|
+
"""
|
|
179
|
+
Check if a file exists
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
file_name: The name of the file to check
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
True if the file exists, False otherwise
|
|
186
|
+
"""
|
|
187
|
+
file_exists = True
|
|
188
|
+
try:
|
|
189
|
+
status = os.stat(file_name)
|
|
190
|
+
except OSError:
|
|
191
|
+
file_exists = False
|
|
192
|
+
return file_exists
|
|
193
|
+
|
|
194
|
+
def error(self, e, str_description):
|
|
195
|
+
"""
|
|
196
|
+
Log an error with a description and stack trace
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
e: The exception that occurred
|
|
200
|
+
str_description: A description of the error
|
|
201
|
+
"""
|
|
202
|
+
# Handle the case where e is None (no exception but error message)
|
|
203
|
+
if e is None:
|
|
204
|
+
except_str = str_description
|
|
205
|
+
st_str = ""
|
|
206
|
+
else:
|
|
207
|
+
except_str = str_description + ":" + str(e)
|
|
208
|
+
try:
|
|
209
|
+
st = traceback.format_exception(e)
|
|
210
|
+
st_str = "stack trace:"
|
|
211
|
+
for line in st:
|
|
212
|
+
st_str = st_str + line
|
|
213
|
+
except Exception:
|
|
214
|
+
# Fallback for cases where traceback.format_exception fails
|
|
215
|
+
st_str = "stack trace unavailable"
|
|
216
|
+
|
|
217
|
+
# Filter out non-ASCII characters to prevent UnicodeEncodeError
|
|
218
|
+
filtered_except_str = self.filter_non_ascii(except_str)
|
|
219
|
+
filtered_st_str = self.filter_non_ascii(st_str)
|
|
220
|
+
|
|
221
|
+
# Always print errors to console for visibility
|
|
222
|
+
print(filtered_except_str)
|
|
223
|
+
if filtered_st_str:
|
|
224
|
+
print(filtered_st_str)
|
|
225
|
+
|
|
226
|
+
# Only attempt to write to file if filesystem is writable. Errors ARE
|
|
227
|
+
# persisted in both modes (they're the post-mortem); rotation keeps the
|
|
228
|
+
# file bounded so even an error storm can't fill flash.
|
|
229
|
+
if not self.is_readonly:
|
|
230
|
+
self._rotate_if_needed()
|
|
231
|
+
try:
|
|
232
|
+
with open(self.fileName, 'a') as file:
|
|
233
|
+
file.write(filtered_except_str + "\n")
|
|
234
|
+
if filtered_st_str:
|
|
235
|
+
file.write(filtered_st_str + "\n")
|
|
236
|
+
except OSError:
|
|
237
|
+
# If write fails unexpectedly, update readonly state
|
|
238
|
+
self.is_readonly = True
|
|
239
|
+
# Only print this message once when we first detect a failure
|
|
240
|
+
print("Filesystem detected as read-only, logs will be displayed on console only")
|
|
241
|
+
|
|
242
|
+
def debug(self, message):
|
|
243
|
+
"""
|
|
244
|
+
Log a debug message
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
message: The debug message to log
|
|
248
|
+
"""
|
|
249
|
+
print(message)
|
|
250
|
+
# Only write debug messages to file in development mode
|
|
251
|
+
if self.mode == ErrorHandler.DEVELOPMENT:
|
|
252
|
+
self.write_to_file(message)
|
|
253
|
+
|
|
254
|
+
def _rotate_if_needed(self):
|
|
255
|
+
"""Rotate the log to '<file>.old' once it reaches MAX_LOG_BYTES, so logging
|
|
256
|
+
can never fill flash. Keeps one prior generation."""
|
|
257
|
+
if self.is_readonly:
|
|
258
|
+
return
|
|
259
|
+
try:
|
|
260
|
+
size = os.stat(self.fileName)[6] # st_size
|
|
261
|
+
except OSError:
|
|
262
|
+
return
|
|
263
|
+
if size < self.MAX_LOG_BYTES:
|
|
264
|
+
return
|
|
265
|
+
backup = self.fileName + ".old"
|
|
266
|
+
try:
|
|
267
|
+
try:
|
|
268
|
+
os.remove(backup) # drop the older generation
|
|
269
|
+
except OSError:
|
|
270
|
+
pass
|
|
271
|
+
os.rename(self.fileName, backup)
|
|
272
|
+
except OSError:
|
|
273
|
+
# Rename failed (transient FS hiccup). Do NOT blind-truncate here: the
|
|
274
|
+
# old code did `open(file, 'w')`, destroying exactly the recent crash
|
|
275
|
+
# evidence this log exists to preserve. Prefer a tail-preserving trim
|
|
276
|
+
# that keeps the most recent bytes; if even that fails, skip rotation
|
|
277
|
+
# this cycle (keep appending, accept temporary oversize) and leave a
|
|
278
|
+
# one-line marker. rename is retried next cycle and usually succeeds.
|
|
279
|
+
if not self._tail_trim():
|
|
280
|
+
try:
|
|
281
|
+
with open(self.fileName, 'a') as f:
|
|
282
|
+
f.write("[log rotation failed; not truncated]\n")
|
|
283
|
+
except OSError:
|
|
284
|
+
self.is_readonly = True
|
|
285
|
+
|
|
286
|
+
def _tail_trim(self):
|
|
287
|
+
"""Rewrite the log keeping only its most recent bytes — the fallback for when
|
|
288
|
+
rename-based rotation is unavailable, preserving recent crash evidence while
|
|
289
|
+
still bounding size. Returns True on success, False if seek/read/rewrite is
|
|
290
|
+
not reliable on this filesystem (the caller then leaves the file intact
|
|
291
|
+
rather than destroying it). Binary mode so end-relative seeks work on both
|
|
292
|
+
CPython and CircuitPython."""
|
|
293
|
+
keep = self.MAX_LOG_BYTES // 2
|
|
294
|
+
try:
|
|
295
|
+
with open(self.fileName, 'rb') as f:
|
|
296
|
+
try:
|
|
297
|
+
f.seek(-keep, 2) # last `keep` bytes (whence 2 = end)
|
|
298
|
+
except (OSError, ValueError):
|
|
299
|
+
f.seek(0) # file smaller than keep / no end-seek
|
|
300
|
+
tail = f.read()
|
|
301
|
+
with open(self.fileName, 'wb') as f:
|
|
302
|
+
f.write(b"[log trimmed to recent history]\n")
|
|
303
|
+
f.write(tail)
|
|
304
|
+
return True
|
|
305
|
+
except (OSError, ValueError):
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
def write_to_file(self, message):
|
|
309
|
+
"""
|
|
310
|
+
Write a message to the log file
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
message: The message to write
|
|
314
|
+
"""
|
|
315
|
+
# Only attempt to write if filesystem is writable
|
|
316
|
+
if self.is_readonly:
|
|
317
|
+
# In read-only mode, we'll just print to console without error messages
|
|
318
|
+
# We don't print "Error writing to log file" as that confuses users
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
self._rotate_if_needed()
|
|
322
|
+
try:
|
|
323
|
+
# Filter out non-ASCII characters to prevent UnicodeEncodeError
|
|
324
|
+
filtered_message = self.filter_non_ascii(message)
|
|
325
|
+
|
|
326
|
+
with open(self.fileName, 'a') as file:
|
|
327
|
+
file.write(filtered_message + "\n")
|
|
328
|
+
except OSError:
|
|
329
|
+
# If write fails unexpectedly, update readonly state
|
|
330
|
+
self.is_readonly = True
|
|
331
|
+
# Only print this message once when we first detect a failure
|
|
332
|
+
print("Filesystem detected as read-only, logs will be displayed on console only")
|
|
333
|
+
|
|
334
|
+
def info(self, message):
|
|
335
|
+
"""
|
|
336
|
+
Log an informational message
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
message: The informational message to log
|
|
340
|
+
"""
|
|
341
|
+
print(message)
|
|
342
|
+
# Info is high-frequency (every fetch attempt/success). Persisting it to
|
|
343
|
+
# flash on every refresh fills the device's tiny storage over days, so write
|
|
344
|
+
# it only in DEVELOPMENT; PRODUCTION keeps info on the console. Errors are
|
|
345
|
+
# still persisted in both modes (see error()).
|
|
346
|
+
if self.mode == ErrorHandler.DEVELOPMENT:
|
|
347
|
+
self.write_to_file(message)
|