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,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)