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,206 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Update manifest for OTA updates.
3
+
4
+ Manages versioning, file lists, and integrity checking.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import hashlib
11
+ try:
12
+ from typing import Any, Dict, List, Optional, Tuple, Union
13
+ except ImportError: # CircuitPython has no 'typing' module
14
+ pass
15
+
16
+
17
+ __all__ = ['UpdateManifest']
18
+
19
+ class UpdateManifest:
20
+ """Manages update manifest for OTA system.
21
+
22
+ The manifest describes an update package:
23
+ - Version information
24
+ - File list with checksums
25
+ - Dependencies and requirements
26
+ - Update instructions
27
+ """
28
+
29
+ version: str
30
+ description: str
31
+ files: Dict[str, Dict[str, Any]]
32
+ dependencies: List[Dict[str, str]]
33
+ requirements: Dict[str, Any]
34
+
35
+ def __init__(self, version: Optional[str] = None, description: Optional[str] = None) -> None:
36
+ """Initialize update manifest.
37
+
38
+ Args:
39
+ version: Version string (e.g., "1.2.3")
40
+ description: Human-readable description
41
+ """
42
+ self.version = version or "0.5.0"
43
+ self.description = description or "SLDK Update"
44
+ self.files = {}
45
+ self.dependencies = []
46
+ self.requirements = {
47
+ 'circuitpython_version': '8.0.0',
48
+ 'memory_required': 50000,
49
+ 'storage_required': 100000,
50
+ }
51
+ # NOTE: pre/post-update "scripts" were removed on purpose. They were
52
+ # exec()'d from the downloaded manifest with no signing — remote code
53
+ # execution surface — and no producer ever emitted one. Manifests that
54
+ # still carry the (always-empty) keys are silently ignored.
55
+
56
+ def add_file(
57
+ self,
58
+ path: str,
59
+ content: Optional[Union[str, bytes]] = None,
60
+ file_path: Optional[str] = None,
61
+ required: bool = True,
62
+ ) -> None:
63
+ """Add file to manifest.
64
+
65
+ Args:
66
+ path: Target path on device
67
+ content: File content (bytes or str)
68
+ file_path: Path to source file (alternative to content)
69
+ required: Whether file is required for update
70
+ """
71
+ if content is None and file_path is None:
72
+ raise ValueError("Must provide either content or file_path")
73
+
74
+ if file_path and content is None:
75
+ try:
76
+ with open(file_path, 'rb') as f:
77
+ content = f.read()
78
+ except (OSError, IOError) as e:
79
+ raise ValueError(f"Cannot read file {file_path}: {e}")
80
+
81
+ if isinstance(content, str):
82
+ content = content.encode('utf-8')
83
+
84
+ checksum = hashlib.sha256(content).hexdigest()
85
+
86
+ self.files[path] = {
87
+ 'size': len(content),
88
+ 'checksum': checksum,
89
+ 'required': required,
90
+ }
91
+
92
+ def to_dict(self) -> Dict[str, Any]:
93
+ """Convert manifest to dictionary.
94
+
95
+ Returns:
96
+ dict: Manifest data
97
+ """
98
+ return {
99
+ 'version': self.version,
100
+ 'description': self.description,
101
+ 'files': self.files,
102
+ 'dependencies': self.dependencies,
103
+ 'requirements': self.requirements,
104
+ }
105
+
106
+ def to_json(self, indent: int = 2) -> str:
107
+ """Convert manifest to JSON string.
108
+
109
+ Args:
110
+ indent: JSON indentation
111
+
112
+ Returns:
113
+ str: JSON manifest
114
+ """
115
+ return json.dumps(self.to_dict(), indent=indent)
116
+
117
+ @classmethod
118
+ def from_dict(cls, data: Dict[str, Any]) -> UpdateManifest:
119
+ """Create manifest from dictionary.
120
+
121
+ Args:
122
+ data: Manifest dictionary
123
+
124
+ Returns:
125
+ UpdateManifest: Parsed manifest
126
+ """
127
+ manifest = cls(
128
+ version=data.get('version', '1.0.0'),
129
+ description=data.get('description', 'SLDK Update'),
130
+ )
131
+
132
+ manifest.files = data.get('files', {})
133
+ manifest.dependencies = data.get('dependencies', [])
134
+ manifest.requirements = data.get('requirements', {})
135
+ # 'pre_update_scripts'/'post_update_scripts' in older manifests are
136
+ # deliberately ignored (removed exec() surface — see __init__).
137
+
138
+ return manifest
139
+
140
+ def validate(self) -> Tuple[bool, str]:
141
+ """Validate manifest data.
142
+
143
+ Returns:
144
+ tuple: (is_valid, error_message)
145
+ """
146
+ if not self.version or not isinstance(self.version, str):
147
+ return False, "Invalid version format"
148
+
149
+ for path, info in self.files.items():
150
+ if not isinstance(path, str) or not path:
151
+ return False, f"Invalid file path: {path}"
152
+
153
+ required_keys = ['size', 'checksum', 'required']
154
+ for key in required_keys:
155
+ if key not in info:
156
+ return False, f"Missing {key} for file {path}"
157
+
158
+ if not isinstance(info['size'], int) or info['size'] < 0:
159
+ return False, f"Invalid size for file {path}"
160
+
161
+ if not isinstance(info['checksum'], str) or len(info['checksum']) != 64:
162
+ return False, f"Invalid checksum for file {path}"
163
+
164
+ for dep in self.dependencies:
165
+ if not isinstance(dep, dict) or 'name' not in dep:
166
+ return False, "Invalid dependency format"
167
+
168
+ if not isinstance(self.requirements, dict):
169
+ return False, "Invalid requirements format"
170
+
171
+ return True, ""
172
+
173
+ def compare_version(self, other_version: str) -> int:
174
+ """Compare version with another version.
175
+
176
+ Args:
177
+ other_version: Version string to compare
178
+
179
+ Returns:
180
+ int: -1 if older, 0 if same, 1 if newer
181
+ """
182
+ def parse_version(version_str: str) -> Tuple[int, ...]:
183
+ """Parse version string into comparable tuple."""
184
+ try:
185
+ parts = version_str.split('.')
186
+ return tuple(int(part) for part in parts)
187
+ except (ValueError, AttributeError):
188
+ return (0, 0, 0)
189
+
190
+ self_ver = parse_version(self.version)
191
+ other_ver = parse_version(other_version)
192
+
193
+ if self_ver < other_ver:
194
+ return -1
195
+ elif self_ver > other_ver:
196
+ return 1
197
+ else:
198
+ return 0
199
+
200
+ def calculate_total_size(self) -> int:
201
+ """Calculate total size of all files.
202
+
203
+ Returns:
204
+ int: Total size in bytes
205
+ """
206
+ return sum(info['size'] for info in self.files.values())
@@ -0,0 +1,379 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """OTA release publishing — desktop / CI only.
3
+
4
+ This is the *producer* side of OTA, the mirror image of ``scrollkit.ota.client``.
5
+ The device only ever **reads** ``manifest.json`` + ``files/`` from one fixed
6
+ channel branch over ``raw.githubusercontent.com`` (see ``OTAClient.for_github``);
7
+ this module is how that payload is *built and published* from a workstation or CI.
8
+
9
+ Two pieces:
10
+
11
+ - :func:`build_manifest` walks a source tree, computes per-file size + SHA-256,
12
+ writes ``manifest.json`` and mirrors each file under ``files/<device-path>`` so
13
+ the layout matches exactly what ``OTAClient`` downloads.
14
+ - :func:`publish_to_branch` replaces a channel branch's contents with that payload
15
+ as a single fresh (parentless) commit and force-pushes it — or, with
16
+ ``dry_run=True``, prints the git commands for a CI job to run instead.
17
+
18
+ Recommended release model (see ``docs/guide/ota.md``): a maintainer cuts a
19
+ release on an immutable ``release-MAJOR.MINOR`` branch (or a tag); automation
20
+ runs this tool to publish the generated payload to a fixed channel branch
21
+ (default ``live``) that devices read. Branch selection stays **off-device** — the
22
+ device never enumerates branches (that path is deliberately omitted; see the doc).
23
+
24
+ It is **desktop/CI only**: like ``scrollkit.dev`` it raises ``ImportError`` on
25
+ CircuitPython so device code can never depend on it. It uses ``os.walk``,
26
+ ``subprocess`` and the ``git`` CLI, none of which exist on the device.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import sys
32
+
33
+ # Hard stop on CircuitPython — this is a desktop/CI publishing tool by design.
34
+ if getattr(sys, "implementation", None) is not None \
35
+ and getattr(sys.implementation, "name", None) == "circuitpython":
36
+ raise ImportError(
37
+ "scrollkit.ota.publish is a desktop/CI-only OTA publishing tool and "
38
+ "cannot run on CircuitPython. Run it from a workstation or CI job to "
39
+ "build and publish an update; the device only reads the published "
40
+ "manifest.json + files/ from its channel branch."
41
+ )
42
+
43
+ import hashlib
44
+ import json
45
+ import os
46
+ import shlex
47
+ import shutil
48
+ import subprocess
49
+ import tempfile
50
+
51
+ try:
52
+ from typing import Any, Dict, Iterable, List, Optional # noqa: F401
53
+ except ImportError: # pragma: no cover - desktop always has typing
54
+ pass
55
+
56
+
57
+ # --- Exclusion allowlist: things that must never be published --------------
58
+ #
59
+ # These keep secrets and machine-local state out of a public release payload.
60
+ # Matched against each file's path relative to ``src`` (posix separators).
61
+ EXCLUDED_FILENAMES = frozenset({"secrets.py", "settings.json"})
62
+ # A path component named any of these (a directory or a bare file) is excluded
63
+ # wherever it appears in the tree.
64
+ EXCLUDED_COMPONENTS = frozenset({"__pycache__", ".git", "credentials"})
65
+ # Exact relative paths to drop.
66
+ EXCLUDED_RELPATHS = frozenset({"logs/error_log"})
67
+ # Filename suffixes to drop.
68
+ EXCLUDED_SUFFIXES = (".pyc",)
69
+
70
+
71
+ __all__ = ['build_manifest', 'publish_to_branch', 'PublishPlan', 'main']
72
+
73
+ def _is_excluded(rel_posix, extra=()):
74
+ """Return True if a file (path relative to ``src``, posix) must not ship."""
75
+ extra = set(extra)
76
+ parts = rel_posix.split("/")
77
+ name = parts[-1]
78
+ if rel_posix in EXCLUDED_RELPATHS:
79
+ return True
80
+ if name in EXCLUDED_FILENAMES or name in extra:
81
+ return True
82
+ if name.endswith(EXCLUDED_SUFFIXES):
83
+ return True
84
+ for part in parts:
85
+ if part in EXCLUDED_COMPONENTS or part in extra:
86
+ return True
87
+ return False
88
+
89
+
90
+ def _join_device_path(device_root, rel_posix):
91
+ """Map a path relative to ``src`` onto an absolute on-device path.
92
+
93
+ ``device_root="/src"`` + ``"main.py"`` -> ``"/src/main.py"``;
94
+ ``device_root="/"`` (or "") + ``"code.py"`` -> ``"/code.py"``.
95
+ """
96
+ root = "/" + (device_root or "").strip("/")
97
+ if root == "/":
98
+ return "/" + rel_posix
99
+ return root + "/" + rel_posix
100
+
101
+
102
+ def build_manifest(src, out_dir, *, device_root, version, extra_excludes=()):
103
+ """Build an OTA payload (``manifest.json`` + ``files/``) from a source tree.
104
+
105
+ Walks ``src``, computing each file's size and SHA-256, and writes:
106
+
107
+ - ``out_dir/manifest.json`` — the manifest ``OTAClient`` consumes, with
108
+ absolute on-device paths as keys::
109
+
110
+ {"version": "<semver>",
111
+ "files": {"<device-path>": {"size": <int>,
112
+ "checksum": "<sha256hex>",
113
+ "required": true}, ...}}
114
+
115
+ (``required`` is included because the device's ``UpdateManifest.validate()``
116
+ requires it; ``OTAClient`` fetches each file at ``{base}/files/{path}``.)
117
+ - ``out_dir/files/<device-path>`` — a byte-for-byte mirror of every listed
118
+ file, at the path the device downloads from.
119
+
120
+ Files matching the exclusion allowlist (``secrets.py``, ``settings.json``,
121
+ ``logs/error_log``, ``__pycache__``, ``*.pyc``, ``.git``, ``credentials``,
122
+ plus any names in ``extra_excludes``) are never published.
123
+
124
+ Args:
125
+ src: Source tree to publish (a directory).
126
+ out_dir: Output directory for the manifest and mirrored files.
127
+ device_root: Absolute on-device path the files install under (e.g. ``/src``).
128
+ version: Release version (semver string).
129
+ extra_excludes: Additional filenames/path components to exclude.
130
+
131
+ Returns:
132
+ The manifest as a dict (also written to ``out_dir/manifest.json``).
133
+ """
134
+ src = os.path.abspath(src)
135
+ if not os.path.isdir(src):
136
+ raise NotADirectoryError("src is not a directory: %s" % src)
137
+
138
+ out_dir = os.path.abspath(out_dir)
139
+ files_root = os.path.join(out_dir, "files")
140
+ os.makedirs(files_root, exist_ok=True)
141
+
142
+ extra = set(extra_excludes)
143
+ files = {} # type: Dict[str, Dict[str, Any]]
144
+
145
+ for dirpath, dirnames, filenames in os.walk(src):
146
+ # Prune excluded directories so we never descend into them.
147
+ dirnames[:] = [d for d in dirnames
148
+ if d not in EXCLUDED_COMPONENTS and d not in extra]
149
+ for filename in filenames:
150
+ abs_path = os.path.join(dirpath, filename)
151
+ if os.path.islink(abs_path):
152
+ # Don't follow/ship symlinks — the device has a flat FS.
153
+ continue
154
+ rel_posix = os.path.relpath(abs_path, src).replace(os.sep, "/")
155
+ if _is_excluded(rel_posix, extra):
156
+ continue
157
+
158
+ with open(abs_path, "rb") as handle:
159
+ data = handle.read()
160
+
161
+ device_path = _join_device_path(device_root, rel_posix)
162
+ files[device_path] = {
163
+ "size": len(data),
164
+ "checksum": hashlib.sha256(data).hexdigest(),
165
+ "required": True,
166
+ }
167
+
168
+ mirror = os.path.join(files_root, *device_path.lstrip("/").split("/"))
169
+ os.makedirs(os.path.dirname(mirror), exist_ok=True)
170
+ with open(mirror, "wb") as handle:
171
+ handle.write(data)
172
+
173
+ manifest = {
174
+ "version": str(version),
175
+ "files": {path: files[path] for path in sorted(files)},
176
+ }
177
+
178
+ with open(os.path.join(out_dir, "manifest.json"), "w") as handle:
179
+ json.dump(manifest, handle, indent=2, sort_keys=True)
180
+ handle.write("\n")
181
+
182
+ return manifest
183
+
184
+
185
+ class PublishPlan:
186
+ """The result of (or the plan for) a :func:`publish_to_branch` call.
187
+
188
+ Always carries the ``script`` that publishes the payload; ``executed`` says
189
+ whether it was run (``False`` for a dry run).
190
+ """
191
+
192
+ def __init__(self, *, repo_path, channel_branch, remote, commit_message,
193
+ staging_dir, script, force, push, executed):
194
+ self.repo_path = repo_path
195
+ self.channel_branch = channel_branch
196
+ self.remote = remote
197
+ self.commit_message = commit_message
198
+ self.staging_dir = staging_dir
199
+ self.script = script
200
+ self.force = force
201
+ self.push = push
202
+ self.executed = executed
203
+
204
+ def __repr__(self):
205
+ return ("PublishPlan(channel=%r, remote=%r, push=%r, force=%r, "
206
+ "executed=%r)" % (self.channel_branch, self.remote, self.push,
207
+ self.force, self.executed))
208
+
209
+
210
+ def _build_script(*, repo_path, worktree, out_dir, remote, channel_branch,
211
+ commit_message, push, force):
212
+ """Render the git command sequence that publishes ``out_dir`` to a branch.
213
+
214
+ The payload is published as a single **parentless** commit (built in a
215
+ throwaway detached worktree, so the caller's checkout and current branch are
216
+ never touched) and the channel ref is reset to it — this is what "replace the
217
+ channel branch's contents" means. The same text is both printed for a dry run
218
+ and executed for real, so the two can never drift.
219
+ """
220
+ q = shlex.quote
221
+ repo, work_tree, out = q(repo_path), q(worktree), q(out_dir)
222
+ force_flag = " --force" if force else ""
223
+
224
+ lines = [
225
+ "set -euo pipefail",
226
+ "# Publish OTA payload to the '%s' channel branch." % channel_branch,
227
+ "git -C %s worktree add --force --detach %s" % (repo, work_tree),
228
+ # Clear the worktree's tracked files, then drop in only the payload.
229
+ "git -C %s rm -rf --quiet --ignore-unmatch . || true" % work_tree,
230
+ "cp -R %s/. %s/" % (out, work_tree),
231
+ "git -C %s add -A" % work_tree,
232
+ 'TREE=$(git -C %s write-tree)' % work_tree,
233
+ 'COMMIT=$(git -C %s commit-tree "$TREE" -m %s)'
234
+ % (work_tree, q(commit_message)),
235
+ ]
236
+ if push:
237
+ lines.append('git -C %s push%s %s "$COMMIT:refs/heads/%s"'
238
+ % (work_tree, force_flag, q(remote), channel_branch))
239
+ else:
240
+ lines.append('git -C %s update-ref refs/heads/%s "$COMMIT"'
241
+ % (repo, channel_branch))
242
+ lines.append("git -C %s worktree remove --force %s" % (repo, work_tree))
243
+ return "\n".join(lines)
244
+
245
+
246
+ def publish_to_branch(out_dir, *, repo_path, channel_branch="live",
247
+ commit_message, remote="origin", push=True, force=True,
248
+ dry_run=False):
249
+ """Publish a built payload (``out_dir``) to a channel branch via git.
250
+
251
+ Replaces the channel branch's contents with ``out_dir``'s ``manifest.json``
252
+ and ``files/`` as one fresh parentless commit and force-pushes it. The work
253
+ happens in a temporary detached worktree, so the caller's working tree and
254
+ current branch are left untouched.
255
+
256
+ With ``dry_run=True`` nothing is executed: the git commands are printed (and
257
+ returned on the plan) for a CI job to run, with the payload left staged in
258
+ ``out_dir``. No GitHub API is used — this is pure git.
259
+
260
+ Args:
261
+ out_dir: Directory holding ``manifest.json`` + ``files/`` (from
262
+ :func:`build_manifest`).
263
+ repo_path: Path to the git repository to publish from.
264
+ channel_branch: The branch devices read (default ``"live"``).
265
+ commit_message: Commit message for the published snapshot.
266
+ remote: Git remote to push to (default ``"origin"``).
267
+ push: If False, update the local channel ref but don't push.
268
+ force: If True (default), force-push (the channel is an overwritten
269
+ snapshot, so this is expected).
270
+ dry_run: If True, print/return the commands without running them.
271
+
272
+ Returns:
273
+ A :class:`PublishPlan` describing what was (or would be) run.
274
+ """
275
+ out_dir = os.path.abspath(out_dir)
276
+ manifest_path = os.path.join(out_dir, "manifest.json")
277
+ if not os.path.isfile(manifest_path):
278
+ raise FileNotFoundError(
279
+ "No manifest.json in %s — run build_manifest() first." % out_dir)
280
+
281
+ repo_path = os.path.abspath(repo_path)
282
+ if not os.path.exists(os.path.join(repo_path, ".git")):
283
+ raise ValueError("%s is not a git repository (no .git)." % repo_path)
284
+
285
+ # A path that does not yet exist (git worktree add creates it), inside a
286
+ # temp parent we can clean up wholesale.
287
+ tmp_parent = tempfile.mkdtemp(prefix="scrollkit-ota-")
288
+ worktree = os.path.join(tmp_parent, "wt")
289
+
290
+ script = _build_script(
291
+ repo_path=repo_path, worktree=worktree, out_dir=out_dir, remote=remote,
292
+ channel_branch=channel_branch, commit_message=commit_message,
293
+ push=push, force=force)
294
+
295
+ plan = PublishPlan(
296
+ repo_path=repo_path, channel_branch=channel_branch, remote=remote,
297
+ commit_message=commit_message, staging_dir=out_dir, script=script,
298
+ force=force, push=push, executed=False)
299
+
300
+ if dry_run:
301
+ # CI path: leave the payload staged in out_dir and print the commands.
302
+ os.rmdir(tmp_parent) # nothing was created in it; don't leave litter
303
+ print(script)
304
+ return plan
305
+
306
+ try:
307
+ result = subprocess.run(
308
+ ["bash", "-c", script], capture_output=True, text=True)
309
+ if result.returncode != 0:
310
+ raise RuntimeError(
311
+ "git publish failed (exit %d):\n%s\n%s"
312
+ % (result.returncode, result.stdout, result.stderr))
313
+ plan.executed = True
314
+ return plan
315
+ finally:
316
+ shutil.rmtree(tmp_parent, ignore_errors=True)
317
+ # The worktree should already be removed by the script; prune any
318
+ # leftover registration so a later run starts clean.
319
+ subprocess.run(["git", "-C", repo_path, "worktree", "prune"],
320
+ capture_output=True)
321
+
322
+
323
+ def main(argv=None):
324
+ """CLI: ``python -m scrollkit.ota.publish <src> --version X.Y --root /src``."""
325
+ import argparse
326
+
327
+ parser = argparse.ArgumentParser(
328
+ prog="python -m scrollkit.ota.publish",
329
+ description="Build an OTA manifest from a source tree and publish it to "
330
+ "a channel branch the device reads.")
331
+ parser.add_argument("src", help="source tree to publish")
332
+ parser.add_argument("--version", required=True,
333
+ help="release version (semver)")
334
+ parser.add_argument("--root", default="/",
335
+ help="absolute on-device root the files install under "
336
+ "(default /)")
337
+ parser.add_argument("--channel", default="live",
338
+ help="channel branch the device reads (default live)")
339
+ parser.add_argument("--repo", default=".",
340
+ help="git repository to publish into (default .)")
341
+ parser.add_argument("--out", default=None,
342
+ help="staging/output dir (default <repo>/build/ota)")
343
+ parser.add_argument("--remote", default="origin",
344
+ help="git remote to push to (default origin)")
345
+ parser.add_argument("-m", "--message", default=None,
346
+ help="commit message (default auto-generated)")
347
+ parser.add_argument("--no-push", action="store_true",
348
+ help="commit/update the local ref but do not push")
349
+ parser.add_argument("--no-force", action="store_true",
350
+ help="do not force-push")
351
+ parser.add_argument("--dry-run", action="store_true",
352
+ help="print the git commands without running them")
353
+ args = parser.parse_args(argv)
354
+
355
+ out_dir = args.out or os.path.join(os.path.abspath(args.repo), "build", "ota")
356
+ manifest = build_manifest(
357
+ args.src, out_dir, device_root=args.root, version=args.version)
358
+ print("Built manifest: %d files, version %s -> %s"
359
+ % (len(manifest["files"]), manifest["version"],
360
+ os.path.join(out_dir, "manifest.json")))
361
+
362
+ message = args.message or ("Publish OTA %s to %s"
363
+ % (args.version, args.channel))
364
+ plan = publish_to_branch(
365
+ out_dir, repo_path=args.repo, channel_branch=args.channel,
366
+ commit_message=message, remote=args.remote,
367
+ push=not args.no_push, force=not args.no_force, dry_run=args.dry_run)
368
+
369
+ if args.dry_run:
370
+ print("# (dry run — nothing was pushed; payload staged in %s)" % out_dir)
371
+ else:
372
+ suffix = "" if plan.push else " (local ref only)"
373
+ print("Published %s to branch '%s'%s."
374
+ % (args.version, args.channel, suffix))
375
+ return 0
376
+
377
+
378
+ if __name__ == "__main__":
379
+ raise SystemExit(main())
@@ -0,0 +1,20 @@
1
+ # Attribution
2
+
3
+ This project includes code adapted from the following open source projects:
4
+
5
+ ## CircuitPython
6
+
7
+ Portions of the displayio API implementation are inspired by CircuitPython's displayio module.
8
+
9
+ - Copyright (c) 2016-2024 Adafruit Industries
10
+ - Licensed under the MIT License
11
+ - Source: https://github.com/adafruit/circuitpython
12
+
13
+ The following components are influenced by CircuitPython's implementation:
14
+ - displayio module API structure
15
+ - Display, Group, Bitmap, Palette, and TileGrid class interfaces
16
+ - Font rendering approach for BDF files
17
+ - Text display components API
18
+
19
+ Note: This is a clean-room implementation in Python/Pygame that provides API compatibility
20
+ with CircuitPython's displayio module, but does not contain any direct code from CircuitPython.