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,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())
|
scrollkit/ota/publish.py
ADDED
|
@@ -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.
|