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,186 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Board registry: how ScrollKit builds a panel on each supported board.
3
+
4
+ ScrollKit runs the *same* app on more than one HUB75 driver board. The boards
5
+ differ in exactly three places — how the RGB matrix is constructed on
6
+ CircuitPython, the default panel geometry, and the calibrated performance profile
7
+ (that last one lives in ``simulator/core/hardware_profile.py``). This module owns
8
+ the first two and the board-detection logic.
9
+
10
+ This module is imported **on the device** (by ``display/unified.py``), so it must
11
+ stay CircuitPython-safe: no ``dataclasses``, no runtime ``typing``, no
12
+ ``os.environ`` (use ``os.getenv``). All hardware imports (``rgbmatrix``,
13
+ ``adafruit_matrixportal``, ``board`` …) are **function-local** inside the
14
+ ``make_matrix`` builders, which only run on the real board — so the module
15
+ imports cleanly on the desktop and in unit tests with no hardware present.
16
+
17
+ To add a board, see ``docs/guide/hardware.md`` (the "Adding new hardware" guide).
18
+ """
19
+
20
+ import os
21
+ import sys
22
+
23
+ # Canonical board ids (the keys used everywhere: settings, baselines, profiles).
24
+ MATRIXPORTAL_S3 = "adafruit_matrixportal_s3"
25
+ INTERSTATE75_W = "pimoroni_interstate75_w"
26
+
27
+ DEFAULT_BOARD_ID = MATRIXPORTAL_S3
28
+
29
+
30
+ # --- per-board matrix constructors (run ONLY on CircuitPython hardware) --------
31
+ #
32
+ # Each returns the triple UnifiedDisplay stores: (hardware, display, matrix).
33
+ # Imports are function-local on purpose — see the module docstring.
34
+
35
+ __all__ = ['BoardSpec', 'BOARDS', 'resolve_board', 'detect_board_id', 'DEFAULT_BOARD_ID', 'MATRIXPORTAL_S3', 'INTERSTATE75_W']
36
+
37
+ def _make_matrix_s3(spec, width, height, bit_depth):
38
+ """Adafruit MatrixPortal S3: the adafruit_matrixportal wrapper (unchanged)."""
39
+ from adafruit_matrixportal.matrix import Matrix
40
+ m = Matrix(width=width, height=height, bit_depth=bit_depth)
41
+ return m, m.display, m
42
+
43
+
44
+ def _make_matrix_interstate75(spec, width, height, bit_depth):
45
+ """Pimoroni Interstate 75 / 75 W: direct rgbmatrix + framebufferio.
46
+
47
+ Prefers the board's own RGBMatrix convenience aliases
48
+ (``board.MTX_COMMON`` / ``board.MTX_ADDRESS``) so no GPIO numbers are
49
+ hard-coded; falls back to the explicit Interstate 75 pin names if a given
50
+ build doesn't expose them.
51
+ """
52
+ import board
53
+ import rgbmatrix
54
+ import framebufferio
55
+
56
+ common = getattr(board, "MTX_COMMON", None) # rgb_pins, clock/latch/oe
57
+ address = getattr(board, "MTX_ADDRESS", None) # tuple of address pins
58
+ if common is not None and address is not None:
59
+ matrix = rgbmatrix.RGBMatrix(
60
+ width=width, height=height, bit_depth=bit_depth,
61
+ addr_pins=address[:spec.addr_pin_count], **common)
62
+ else:
63
+ matrix = rgbmatrix.RGBMatrix(
64
+ width=width, height=height, bit_depth=bit_depth,
65
+ rgb_pins=[board.R0, board.G0, board.B0,
66
+ board.R1, board.G1, board.B1],
67
+ addr_pins=[board.ROW_A, board.ROW_B, board.ROW_C,
68
+ board.ROW_D, board.ROW_E][:spec.addr_pin_count],
69
+ clock_pin=board.CLK, latch_pin=board.LAT,
70
+ output_enable_pin=board.OE)
71
+ display = framebufferio.FramebufferDisplay(matrix, auto_refresh=False)
72
+ return matrix, display, matrix
73
+
74
+
75
+ class BoardSpec:
76
+ """Static description of a supported board.
77
+
78
+ Args:
79
+ board_id: canonical id (the registry key).
80
+ name: human-readable board name.
81
+ default_width / default_height: panel geometry used when the app does
82
+ not pass explicit dimensions.
83
+ pitch: physical LED pitch in mm (cosmetic; used by the simulator render).
84
+ addr_pin_count: HUB75 address lines (4 for 64-row panels, 5 for 64-tall).
85
+ matrix_builder: callable ``(spec, width, height, bit_depth)`` returning
86
+ ``(hardware, display, matrix)`` — runs only on CircuitPython.
87
+ """
88
+
89
+ def __init__(self, board_id, name, default_width, default_height, pitch,
90
+ addr_pin_count, matrix_builder):
91
+ self.board_id = board_id
92
+ self.name = name
93
+ self.default_width = default_width
94
+ self.default_height = default_height
95
+ self.pitch = pitch
96
+ self.addr_pin_count = addr_pin_count
97
+ self._matrix_builder = matrix_builder
98
+
99
+ def make_matrix(self, width, height, bit_depth):
100
+ """Construct the RGB matrix on hardware (CircuitPython only)."""
101
+ return self._matrix_builder(self, width, height, bit_depth)
102
+
103
+
104
+ BOARDS = {
105
+ MATRIXPORTAL_S3: BoardSpec(
106
+ MATRIXPORTAL_S3, "Adafruit MatrixPortal S3 (ESP32-S3)",
107
+ default_width=64, default_height=32, pitch=3.0, addr_pin_count=4,
108
+ matrix_builder=_make_matrix_s3),
109
+ INTERSTATE75_W: BoardSpec(
110
+ INTERSTATE75_W, "Pimoroni Interstate 75 W (RP2350)",
111
+ default_width=64, default_height=32, pitch=3.0, addr_pin_count=4,
112
+ matrix_builder=_make_matrix_interstate75),
113
+ }
114
+
115
+
116
+ # Map the on-device ``board.board_id`` (or ``os.uname().machine``) strings to a
117
+ # canonical id. The exact RP2350 "W" id should be confirmed on the real board;
118
+ # the substring fallback in _map_ondevice_id() catches naming variants.
119
+ _ONDEVICE_ID_MAP = {
120
+ "adafruit_matrixportal_s3": MATRIXPORTAL_S3,
121
+ "pimoroni_interstate75": INTERSTATE75_W,
122
+ "pimoroni_interstate75_w": INTERSTATE75_W,
123
+ "pimoroni_interstate75_rp2350": INTERSTATE75_W,
124
+ "pimoroni_interstate75_w_rp2350": INTERSTATE75_W,
125
+ }
126
+
127
+
128
+ def _read_ondevice_id():
129
+ """The raw board id string on CircuitPython; ``None`` on desktop."""
130
+ if not (hasattr(sys, "implementation")
131
+ and sys.implementation.name == "circuitpython"):
132
+ return None
133
+ try:
134
+ import board
135
+ bid = getattr(board, "board_id", None)
136
+ if bid:
137
+ return bid
138
+ except Exception:
139
+ pass
140
+ try:
141
+ return os.uname().machine
142
+ except Exception:
143
+ return None
144
+
145
+
146
+ def _map_ondevice_id(raw):
147
+ """Resolve a raw on-device id (or any id string) to a canonical board id."""
148
+ if not raw:
149
+ return DEFAULT_BOARD_ID
150
+ key = str(raw).strip().lower()
151
+ if key in BOARDS:
152
+ return key
153
+ if key in _ONDEVICE_ID_MAP:
154
+ return _ONDEVICE_ID_MAP[key]
155
+ if "interstate75" in key or "interstate 75" in key:
156
+ return INTERSTATE75_W
157
+ if "matrixportal" in key:
158
+ return MATRIXPORTAL_S3
159
+ return DEFAULT_BOARD_ID
160
+
161
+
162
+ def detect_board_id():
163
+ """Best-effort canonical board id for the board we're running on.
164
+
165
+ On CircuitPython this reads ``board.board_id``; on the desktop there is no
166
+ board, so it returns the default (MatrixPortal S3).
167
+ """
168
+ return _map_ondevice_id(_read_ondevice_id())
169
+
170
+
171
+ def resolve_board(board=None):
172
+ """Return the :class:`BoardSpec` to use.
173
+
174
+ Precedence: an explicit ``board`` argument, then the ``SCROLLKIT_HW_BOARD``
175
+ environment variable, then auto-detection, then the default board. Accepts a
176
+ canonical id or a raw on-device id; unknown ids fall back to the default.
177
+ """
178
+ chosen = board
179
+ if not chosen:
180
+ try:
181
+ chosen = os.getenv("SCROLLKIT_HW_BOARD")
182
+ except Exception:
183
+ chosen = None
184
+ if not chosen:
185
+ chosen = detect_board_id()
186
+ return BOARDS.get(_map_ondevice_id(chosen), BOARDS[DEFAULT_BOARD_ID])
@@ -0,0 +1,176 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Continuous 24-bit colour generators (device-safe, pure-integer math).
3
+
4
+ These expose the **full** colour space as functions an app or effect samples at any
5
+ resolution — deliberately NOT a catalogue of named palettes. The point is to escape
6
+ fixed mini-palettes (the old 6-colour rainbow ramp, the 16 named colours): generate
7
+ exactly the gradient/spectrum you want, at the resolution that suits the panel.
8
+
9
+ CircuitPython-safe: integer arithmetic, no ``colorsys``, no typing at runtime. Build a
10
+ ramp ONCE (at effect construction or import) and reuse it — none of these belong in a
11
+ per-frame loop. Every function takes/returns packed ``0xRRGGBB`` ints.
12
+
13
+ spectrum(24) # 24 smooth hues around the wheel
14
+ gradient(0x102840, 0x00CCFF, 16) # 16 steps, deep blue -> cyan
15
+ multi_gradient((0x330000, 0xFF4400, 0xFFF0A0), 32) # fire ramp, 32 steps
16
+ hsv(210, 0.8, 1.0) # any hue/sat/value -> one colour
17
+ """
18
+
19
+
20
+ __all__ = ['wheel', 'spectrum', 'lerp', 'gradient', 'multi_gradient', 'scale', 'depth_palette', 'hsv']
21
+
22
+ def _clamp8(v):
23
+ if v < 0:
24
+ return 0
25
+ if v > 255:
26
+ return 255
27
+ return v
28
+
29
+
30
+ def wheel(pos):
31
+ """Full-saturation, full-value hue for ``pos`` 0..255 -> ``0xRRGGBB``.
32
+
33
+ A continuous colour wheel (red -> green -> blue -> red), cheap integer math so it
34
+ is safe to call on CircuitPython. Wraps, so ``pos`` may be any int.
35
+ """
36
+ pos = pos & 0xFF
37
+ if pos < 85:
38
+ return ((255 - pos * 3) << 16) | ((pos * 3) << 8)
39
+ if pos < 170:
40
+ pos -= 85
41
+ return ((255 - pos * 3) << 8) | (pos * 3)
42
+ pos -= 170
43
+ return ((pos * 3) << 16) | (255 - pos * 3)
44
+
45
+
46
+ def spectrum(n):
47
+ """``n`` evenly spaced full-saturation hues across the wheel (cyclic-friendly).
48
+
49
+ Endpoints don't duplicate (positions are ``i*256//n``), so the ramp tiles/rotates
50
+ without a visible seam — ideal for a rotating rainbow.
51
+ """
52
+ if n <= 0:
53
+ return ()
54
+ return tuple(wheel(i * 256 // n) for i in range(n))
55
+
56
+
57
+ def lerp(a, b, t):
58
+ """Blend ``0xRRGGBB`` ``a`` -> ``b`` by ``t`` in 0.0..1.0."""
59
+ if t <= 0:
60
+ return a
61
+ if t >= 1:
62
+ return b
63
+ ar = (a >> 16) & 0xFF
64
+ ag = (a >> 8) & 0xFF
65
+ ab = a & 0xFF
66
+ br = (b >> 16) & 0xFF
67
+ bg = (b >> 8) & 0xFF
68
+ bb = b & 0xFF
69
+ r = ar + int((br - ar) * t)
70
+ g = ag + int((bg - ag) * t)
71
+ bl = ab + int((bb - ab) * t)
72
+ return (_clamp8(r) << 16) | (_clamp8(g) << 8) | _clamp8(bl)
73
+
74
+
75
+ def gradient(a, b, n):
76
+ """``n`` colours interpolated from ``a`` to ``b`` (both inclusive)."""
77
+ if n <= 0:
78
+ return ()
79
+ if n == 1:
80
+ return (a,)
81
+ return tuple(lerp(a, b, i / (n - 1)) for i in range(n))
82
+
83
+
84
+ def multi_gradient(stops, n):
85
+ """``n`` colours interpolated across a sequence of ``stops`` (>= 1 colour)."""
86
+ stops = tuple(stops)
87
+ if not stops or n <= 0:
88
+ return ()
89
+ if len(stops) == 1:
90
+ return tuple(stops[0] for _ in range(n))
91
+ if n == 1:
92
+ return (stops[0],)
93
+ segs = len(stops) - 1
94
+ out = []
95
+ for i in range(n):
96
+ pos = i * segs / (n - 1) # 0 .. segs
97
+ si = int(pos)
98
+ if si >= segs:
99
+ si = segs - 1
100
+ t = 1.0
101
+ else:
102
+ t = pos - si
103
+ out.append(lerp(stops[si], stops[si + 1], t))
104
+ return tuple(out)
105
+
106
+
107
+ def scale(color, factor):
108
+ """Scale a ``0xRRGGBB`` colour's brightness by ``factor`` (0.0-1.0). Integer-only.
109
+
110
+ Effects call this once at construction to derive shades from a base colour, so
111
+ there is no per-frame cost.
112
+ """
113
+ if factor <= 0.0:
114
+ return 0
115
+ f = 256 if factor >= 1.0 else int(factor * 256)
116
+ r = (((color >> 16) & 0xFF) * f) >> 8
117
+ g = (((color >> 8) & 0xFF) * f) >> 8
118
+ b = ((color & 0xFF) * f) >> 8
119
+ return (r << 16) | (g << 8) | b
120
+
121
+
122
+ def depth_palette(color, strength=0.4, steps=2):
123
+ """Two (or more) close shades of ``color`` for subtle "lit from above" depth.
124
+
125
+ Returns a ramp from ``color`` (the highlight) down to a darker shade
126
+ ``scale(color, 1 - strength)`` (the shadow). Feed it straight to a gradient
127
+ text fill::
128
+
129
+ ScrollingText("NEXT TRAIN", palette=depth_palette(0x66CCFF))
130
+
131
+ This is a colour *transform*, NOT a named palette — it keeps the
132
+ "expose the full space via generators" philosophy: you pass any base colour
133
+ and a subtlety knob, and get a tasteful close ramp instead of hand-picking
134
+ two near-identical hex values.
135
+
136
+ Args:
137
+ color: Base ``0xRRGGBB`` highlight colour.
138
+ strength: 0.0..1.0 depth amount; the shadow is ``color`` darkened by this
139
+ fraction. ~0.4 is a tasteful default; below ~0.15 the two shades
140
+ can collapse to one step on the 4-bit panel.
141
+ steps: Length of the returned ramp. 2 returns just ``(highlight,
142
+ shadow)`` (the renderer expands it to ``palette_steps``); >2
143
+ returns an already-interpolated ramp.
144
+ """
145
+ shadow = scale(color, max(0.0, 1.0 - strength))
146
+ if steps <= 2:
147
+ return (color, shadow)
148
+ return gradient(color, shadow, steps)
149
+
150
+
151
+ def hsv(h, s=1.0, v=1.0):
152
+ """HSV -> ``0xRRGGBB``. ``h`` in degrees (0..360, wraps), ``s``/``v`` in 0.0..1.0.
153
+
154
+ The general-purpose generator when you want a specific hue at a chosen saturation
155
+ and value rather than a point on the full-bright :func:`wheel`.
156
+ """
157
+ h = h % 360
158
+ c = v * s
159
+ x = c * (1 - abs((h / 60.0) % 2 - 1))
160
+ m = v - c
161
+ if h < 60:
162
+ rp, gp, bp = c, x, 0.0
163
+ elif h < 120:
164
+ rp, gp, bp = x, c, 0.0
165
+ elif h < 180:
166
+ rp, gp, bp = 0.0, c, x
167
+ elif h < 240:
168
+ rp, gp, bp = 0.0, x, c
169
+ elif h < 300:
170
+ rp, gp, bp = x, 0.0, c
171
+ else:
172
+ rp, gp, bp = c, 0.0, x
173
+ r = _clamp8(int((rp + m) * 255))
174
+ g = _clamp8(int((gp + m) * 255))
175
+ b = _clamp8(int((bp + m) * 255))
176
+ return (r << 16) | (g << 8) | b