QUELLCODE / PY
build_downloads.py
project-site/build_downloads.py
1#!/usr/bin/env python32# OpenAss: nur privat und experimentell. Bau, Download und Nutzung auf eigene Verantwortung.3# Niemals Unternehmensrechner, Produktivsysteme, Industrieanlagen oder sicherheitskritische Systeme anschließen oder bedienen.4# Keine freiwillige Funktions-/Sicherheitsgarantie; zwingende gesetzliche Ansprüche bleiben unberührt. Siehe DISCLAIMER.txt.5"""Build public, deterministic hardware-box download artifacts.67Run with Python 3.11+ from any directory. Only this site's downloads/ and8data/downloads.json are written. Inputs use explicit allowlists; local runtime9data, credentials, environments, prior ZIPs and Blender backups never enter10the current project packages. No network is required. Compressed Blender11privacy checks use Python 3.14's standard library or the zstandard package.12"""13from __future__ import annotations1415import hashlib16import json17import struct18import zipfile19from pathlib import Path, PurePosixPath20from privacy_check import assert_public_bytes2122SITE = Path(__file__).resolve().parent23PROJECT = SITE.parent24OUTPUT = SITE / "downloads"25DATA = SITE / "data" / "downloads.json"26RELEASE = "2026-10-09"27PREFIX = "hardware-box"28ZIP_DATE = (1980, 1, 1, 0, 0, 0)29FORBIDDEN_PARTS = {30 ".git", ".venv", ".pytest_cache", "__pycache__", "data", "dist",31 "build", "build-native", "node_modules",32}33FORBIDDEN_NAMES = {".env", "local.toml", ".DS_Store"}34FORBIDDEN_SUFFIXES = {".pyc", ".pyo", ".blend1", ".blend2", ".zip"}35RENDER_NAMES = (36 "01-ivory-hero.png", "02-rear.png", "03-open.png",37 "04-internals.png", "05-anthracite-hero.png",38)39V1_RENDER_NAMES = ("01-hero.png", "02-internals.png", "03-exploded.png", "04-rear.png")40PRINT_NAMES = ("01-upper-shell.stl", "02-bottom-tray.stl", "03-rear-cartridge.stl")4142PUBLIC_README = """OPENASS / LINK — Öffentliche Projektdateien43Projektseite: https://openass.wolperdinger.at44Veröffentlichungsstand: 9. Oktober 20264546NUR FÜR PRIVATEN, EXPERIMENTELLEN GEBRAUCH.47Bau, Download und Nutzung erfolgen auf eigene Verantwortung.48Niemals an Unternehmensrechner, Produktivsysteme, Industrieanlagen oder49sicherheitskritische Systeme anschließen oder diese bedienen.50Die vollständigen Nutzungshinweise und der Haftungsausschluss stehen51in DISCLAIMER.txt und sind am Ende dieser Datei vollständig enthalten.5253Das Gesamtpaket enthält den Python-Host samt lokalem Webleitstand,54Pico-2-C-Firmware und Tests, Beispielkonfiguration, systemd-Beispiel,55die gebaute UF2, Dokumentation sowie die Blender-Modelle und Druckteile.56Aktuelles Gehäuse: design/v2/, mit dem kleinen roten Not-Aus-Pilz.57Frühere Designstände sind als Archiv gekennzeichnet.5859Die Dateien sind ein Referenzprototyp. Software-Simulationen und der60Firmware-Crossbuild sind dokumentiert; reale Hardware, elektrische61Not-Aus-Funktion, Passung, Kühlung und Zielbetrieb sind noch abzunehmen.62Der modellierte Not-Aus-Knopf ist eine Formreferenz für ein reales Kaufteil.63Die drei STLs enthalten ausschließlich die druckbaren Gehäuseteile.64Keine Firmware wurde am Gerät geflasht und kein Probedruck durchgeführt.6566Die Software startet standardmäßig im Demo-Modus. API-Schlüssel und67Sitzungstoken werden lokal bereitgestellt und sind nicht in diesem Paket.68Die safe demo benötigt keinen API-Key. Installation: README.md und69docs/INSTALLATION.md; praktische Abnahme: docs/TESTPLAN.md.7071Eine separate Projektlizenz wurde bisher nicht festgelegt. Dieses Paket72behauptet keine zusätzlichen Nutzungs- oder Weiterverbreitungsrechte.73Externe SDKs, Toolchains, Python-Abhängigkeiten und deren Lizenzen sind74nicht mitverpackt; sie werden anhand der Installationshinweise bezogen.7576SHA256SUMS.txt in jedem ZIP ermöglicht die Integritätsprüfung der77enthaltenen Dateien. Ein Hash ist kein digitaler Herkunftsnachweis.78Die Webseite stellt zudem SHA256SUMS.txt für alle Download-Artefakte bereit.79""".encode("utf-8")808182def sha256(data: bytes) -> str:83 return hashlib.sha256(data).hexdigest()848586def safe_relative(path: PurePosixPath) -> bool:87 return (not path.is_absolute() and ".." not in path.parts88 and not any(p in FORBIDDEN_PARTS or p.endswith(".egg-info") for p in path.parts)89 and path.name not in FORBIDDEN_NAMES90 and path.suffix not in FORBIDDEN_SUFFIXES)919293def input_file(relative: str) -> bytes:94 relative_path = PurePosixPath(relative)95 if not safe_relative(relative_path):96 raise ValueError(f"Unsafe input: {relative}")97 path = PROJECT / relative98 if path.is_symlink() or not path.is_file():99 raise ValueError(f"Missing or symlinked required input: {relative}")100 if not path.resolve().is_relative_to(PROJECT.resolve()):101 raise ValueError(f"Input escapes project: {relative}")102 data = path.read_bytes()103 assert_public_bytes(relative, data)104 return data105106107def tree(relative: str, suffixes: set[str] | None = None) -> list[str]:108 result = []109 for path in sorted((PROJECT / relative).rglob("*")):110 rel = path.relative_to(PROJECT).as_posix()111 if path.is_file() and safe_relative(PurePosixPath(rel)):112 if suffixes is None or path.suffix in suffixes:113 result.append(rel)114 return result115116117def bundle(paths: list[str]) -> dict[str, bytes]:118 return {f"{PREFIX}/{path}": input_file(path) for path in sorted(set(paths))}119120121def old_design_history() -> dict[str, bytes]:122 """Unpack only verified design artifacts from the saved pre-mushroom ZIP."""123 source = PROJECT / "design/v2/history/LINK-before-mini-stop.zip"124 target = "hardware-box/design/history/before-mini-stop"125 members: dict[str, bytes] = {}126 with zipfile.ZipFile(source) as archive:127 error = archive.testzip()128 if error:129 raise ValueError(f"Corrupt historical design member: {error}")130 for member in archive.infolist():131 if member.is_dir():132 continue133 path = PurePosixPath(member.filename)134 if path.parts[0] != "LINK-print-edition":135 raise ValueError(f"Unexpected historical design path: {path}")136 relative = PurePosixPath(*path.parts[1:])137 if not safe_relative(relative) or relative.suffix not in {".py", ".blend", ".glb", ".stl", ".png", ".txt"}:138 raise ValueError(f"Unsafe historical design member: {path}")139 data = archive.read(member)140 assert_public_bytes(str(relative), data)141 members[f"{target}/{relative}"] = data142 return members143144145def archive_bytes(files: dict[str, bytes]) -> tuple[dict[str, bytes], list[dict[str, object]]]:146 files = dict(files)147 disclaimer = input_file("DISCLAIMER.txt")148 if not disclaimer.strip():149 raise ValueError("The canonical project disclaimer is empty")150 # Applies to every ZIP, including firmware, print parts and old designs.151 files[f"{PREFIX}/DISCLAIMER.txt"] = disclaimer152 files[f"{PREFIX}/README-PUBLIC.txt"] = PUBLIC_README + b"\n" + disclaimer153 contents = [{"path": name, "bytes": len(data), "sha256": sha256(data)}154 for name, data in sorted(files.items())]155 checksum = "".join(f"{item['sha256']} {str(item['path']).removeprefix(PREFIX + '/')}\n" for item in contents)156 files[f"{PREFIX}/SHA256SUMS.txt"] = checksum.encode("utf-8")157 return files, contents158159160def write_zip(name: str, files: dict[str, bytes]) -> tuple[int, list[dict[str, object]]]:161 members, contents = archive_bytes(files)162 for path, data in members.items():163 assert_public_bytes(path, data)164 output = OUTPUT / name165 with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:166 for path, data in sorted(members.items()):167 info = zipfile.ZipInfo(path, ZIP_DATE)168 info.create_system = 3169 mode = 0o755 if path.endswith(".sh") else 0o644170 info.external_attr = (0o100000 | mode) << 16171 info.compress_type = zipfile.ZIP_DEFLATED172 archive.writestr(info, data, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)173 with zipfile.ZipFile(output) as archive:174 error = archive.testzip()175 if error:176 raise ValueError(f"Bad ZIP member in {name}: {error}")177 if set(archive.namelist()) != set(members):178 raise ValueError(f"Incomplete ZIP: {name}")179 for path, data in members.items():180 if archive.read(path) != data:181 raise ValueError(f"Byte mismatch in {name}: {path}")182 return len(members), contents183184185def validate_binary_inputs() -> None:186 uf2 = input_file("firmware/hardware_box_pico.uf2")187 if len(uf2) % 512 or not uf2:188 raise ValueError("UF2 must consist of 512-byte blocks")189 for offset in range(0, len(uf2), 512):190 if struct.unpack_from("<II", uf2, offset) != (0x0A324655, 0x9E5D5157):191 raise ValueError("Invalid UF2 block header")192 if struct.unpack_from("<I", uf2, offset + 508)[0] != 0x0AB16F30:193 raise ValueError("Invalid UF2 block terminator")194 expected = input_file("firmware/SHA256SUMS").decode("utf-8").split()[0]195 if sha256(uf2) != expected:196 raise ValueError("UF2 checksum does not match existing build verification")197 glb = input_file("design/v2/LINK-print-edition.glb")198 magic, version, length = struct.unpack_from("<4sII", glb)199 if magic != b"glTF" or version != 2 or length != len(glb):200 raise ValueError("Invalid current GLB header")201 for name in PRINT_NAMES:202 stl = input_file(f"design/v2/prints/{name}")203 triangles = struct.unpack_from("<I", stl, 80)[0]204 if len(stl) != 84 + triangles * 50:205 raise ValueError(f"Invalid binary STL length: {name}")206 for name in RENDER_NAMES:207 if not input_file(f"design/v2/renders/{name}").startswith(b"\x89PNG\r\n\x1a\n"):208 raise ValueError(f"Invalid render PNG: {name}")209210211def main() -> None:212 OUTPUT.mkdir(parents=True, exist_ok=True)213 DATA.parent.mkdir(parents=True, exist_ok=True)214 validate_binary_inputs()215216 basics = ["README.md", "DISCLAIMER.txt", "pyproject.toml", "requirements-tested.txt", ".env.example", ".gitignore"]217 host = (basics + tree("src/hardware_box", {".py", ".html", ".css", ".js"})218 + tree("tests", {".py"}) + ["config/example.toml", "deploy/hardware-box.service"])219 pico = tree("pico", {".c", ".h", ".sh", ".txt", ".md"}) + ["pico/.gitignore"]220 docs = tree("docs", {".md", ".jpg"})221 firmware = ["firmware/hardware_box_pico.uf2", "firmware/SHA256SUMS"]222 design = (["design/model_components.py", "design/public_export.py", "design/v2/build_print_model.py",223 "design/v2/LINK-print-edition.blend", "design/v2/LINK-print-edition.glb",224 "design/v2/DRUCKHINWEISE.txt", "design/v2/print-check.txt"]225 + [f"design/v2/prints/{name}" for name in PRINT_NAMES]226 + [f"design/v2/renders/{name}" for name in RENDER_NAMES])227 v1 = (["design/DESIGN_NOTES.md", "design/build_model.py", "design/public_export.py", "design/model_components.py",228 "design/LINK-hardware-box.blend", "design/LINK-hardware-box.glb"]229 + [f"design/renders/{name}" for name in V1_RENDER_NAMES])230 history = bundle(v1) | old_design_history()231 # The public site's source is part of the project, but generated downloads,232 # previews and output are deliberately never recursively collected.233 site_source_names = ["index.html", "styles.css", "app.js", "build_site.py",234 "build_downloads.py", "requirements-build.txt", "README.txt",235 "THIRD_PARTY_NOTICES.txt"]236 site_sources = [f"project-site/{name}" for name in site_source_names if (SITE / name).is_file()]237 if (SITE / "vendor").is_dir():238 site_sources += tree("project-site/vendor", {".js", ".txt", ".md", ""})239 if (SITE / "assets/vendor").is_dir():240 site_sources += tree("project-site/assets/vendor", {".js", ".LICENSE", ".txt", ".md", ""})241 if (SITE / "deploy").is_dir():242 site_sources += tree("project-site/deploy", {".conf", ".txt", ".sh"})243 site_sources += ["project-site/privacy_check.py", "project-site/test_privacy_check.py"]244 source_paths = host + pico + docs + ["scripts/package.py", "design/public_export.py", "design/model_components.py",245 "design/build_model.py", "design/v2/build_print_model.py"] + site_sources246 complete = bundle(source_paths + firmware + design) | history247248 artifacts: list[dict[str, object]] = []249 manifests: dict[str, list[dict[str, object]]] = {}250251 def add(id_: str, filename: str, title: str, description: str, group: str, count: int | None = None) -> None:252 output = OUTPUT / filename253 data = output.read_bytes()254 item: dict[str, object] = {255 "id": id_, "title": title, "description": description,256 "href": f"downloads/{filename}", "bytes": len(data),257 "sha256": sha256(data), "group": group,258 }259 if count is not None:260 item["count"] = count261 artifacts.append(item)262263 def zipped(id_: str, filename: str, title: str, description: str, group: str, files: dict[str, bytes]) -> None:264 count, contents = write_zip(filename, files)265 manifests[filename] = contents266 add(id_, filename, title, description, group, count)267268 zipped("complete", "openass-complete-project.zip", "Gesamtes Projekt", "Alle Projektquellen, Host und Webleitstand, Pico-Firmware und UF2, Tests, Konfiguration, Dokumentation, aktuelle 3D-Druckdateien und Designhistorie.", "project", complete)269 zipped("source", "openass-all-source.zip", "Gesamter Quellcode", "Python, lokaler Webleitstand, Pico-C-Firmware, Tests und Blender-Aufbauskripte mit Konfiguration und Dokumentation; ohne große 3D-Dateien.", "source", bundle(source_paths))270 zipped("host", "openass-python-host.zip", "Python-Host und Webleitstand", "Raspberry-Pi-Host, lokale Oberfläche, Hosttests, Abhängigkeiten, Demo-Konfiguration und systemd-Beispiel.", "source", bundle(host + docs))271 zipped("pico-source", "openass-pico-source.zip", "Pico-2-Firmware: Quellcode", "C-Firmware, USB-HID-Deskriptoren, HB1-Protokoll, CMake und native C-Tests mit Buildnachweis.", "source", bundle(pico + ["docs/INSTALLATION.md", "docs/HARDWARE.md", "docs/PROTOCOL.md", "docs/SAFETY.md"]))272 zipped("firmware-package", "openass-pico-firmware.zip", "Pico-2-Firmware: Build-Paket", "Gebaute RP2350-UF2, SHA-256 und dokumentierter Crossbuild mit Flash- und Verkabelungshinweisen. Noch nicht am Gerät geflasht.", "firmware", bundle(firmware + ["pico/BUILD_VALIDATION.md", "docs/INSTALLATION.md", "docs/HARDWARE.md", "docs/PROTOCOL.md", "docs/SAFETY.md"]))273 zipped("design", "LINK-print-edition.zip", "LINK Print Edition: komplettes 3D-Paket", "Aktuelles Gehäuse mit Mini-Not-Aus-Pilz: Blender, GLB, drei STL-Gehäuseteile, fünf Renderansichten, Aufbauskripte und Druckhinweise.", "design", bundle(design))274 zipped("prints", "LINK-stl-print-parts.zip", "Drei STL-Gehäuseteile", "Oberschale, Bodenwanne und Rückseiteneinsatz in Millimetern; Druck- und Montagehinweise enthalten.", "print", bundle([f"design/v2/prints/{name}" for name in PRINT_NAMES] + ["design/v2/DRUCKHINWEISE.txt", "design/v2/print-check.txt"]))275 zipped("documentation", "openass-documentation.zip", "Vollständige Dokumentation", "Projektübersicht, Hardware und Verkabelung, Installation, OpenAI-Anbindung, Protokoll, Sicherheit, Testplan, Prüfnachweise und Druckhinweise.", "docs", bundle(docs + ["README.md", "pico/BUILD_VALIDATION.md", "design/DESIGN_NOTES.md", "design/v2/DRUCKHINWEISE.txt", "design/v2/print-check.txt"]))276 zipped("design-history", "LINK-design-history.zip", "Designarchiv", "Ursprüngliches V1-Modell sowie Print Edition vor der Änderung zum Mini-Pilz. Zur Referenz; für den aktuellen Bau das V2-Paket verwenden.", "history", history)277278 individuals = [279 ("uf2", "firmware/hardware_box_pico.uf2", "hardware_box_pico.uf2", "Pico-2-UF2", "Gebaute Firmware für Pico 2 / RP2350 ARM Secure; Crossbuild geprüft, physischer Flash noch offen.", "firmware"),280 ("blend", "design/v2/LINK-print-edition.blend", "LINK-print-edition.blend", "Blender-Modell", "Bearbeitbare aktuelle Print Edition mit Materialien, Beleuchtung, Kameras und Innenaufbau.", "design"),281 ("glb", "design/v2/LINK-print-edition.glb", "LINK-print-edition.glb", "3D-Modell als GLB", "Aktuelle montierte Box einschließlich USB-Kabel, Anschlüssen, Mini-Pilz und Elektronik.", "design"),282 ("upper-shell", "design/v2/prints/01-upper-shell.stl", "01-upper-shell.stl", "STL: Oberschale", "215 × 145 × 38,5 mm; große Deckelfläche zum Drucken nach unten ausrichten.", "print"),283 ("bottom-tray", "design/v2/prints/02-bottom-tray.stl", "02-bottom-tray.stl", "STL: Bodenwanne", "213 × 143 × 10,7 mm einschließlich innerer Halter; Unterseite auf das Druckbett.", "print"),284 ("rear-cartridge", "design/v2/prints/03-rear-cartridge.stl", "03-rear-cartridge.stl", "STL: Anschluss-Einsatz", "171 × 3 × 29 mm; Rückseite auf das Druckbett, passend für die modellierten Anschlüsse.", "print"),285 ("print-guide", "design/v2/DRUCKHINWEISE.txt", "LINK-DRUCKHINWEISE.txt", "Druck- und Montagehinweise", "Abmessungen, Ausrichtung, Material-Startpunkte, Montage und Grenzen des Gehäuseprototyps.", "docs"),286 ]287 render_titles = ("Warmes Weiß", "Rückseite und Anschlüsse", "Geöffnetes Gehäuse", "Elektronik und Innenaufbau", "Anthrazit")288 for name, title in zip(RENDER_NAMES, render_titles):289 individuals.append(("render-" + name.removesuffix(".png"), f"design/v2/renders/{name}", f"LINK-{name}", "Render: " + title, "Aktuelle Produktansicht der Print Edition mit Mini-Not-Aus-Pilz, 1900 × 1400 Pixel.", "render"))290 for id_, source, filename, title, description, group in individuals:291 (OUTPUT / filename).write_bytes(input_file(source))292 add(id_, filename, title, description, group)293294 (OUTPUT / "DISCLAIMER.txt").write_bytes(input_file("DISCLAIMER.txt"))295 add("disclaimer", "DISCLAIMER.txt", "Nutzungshinweis und Haftungsausschluss", "Nur privat und experimentell; Bau, Downloads und Nutzung auf eigene Verantwortung. Verwendungsgrenzen und gesetzlich zulässiger Haftungsausschluss.", "docs")296 (OUTPUT / "README-PUBLIC.txt").write_bytes(PUBLIC_README + b"\n" + input_file("DISCLAIMER.txt"))297 add("public-readme", "README-PUBLIC.txt", "Hinweise zum Projektpaket", "Inhalt, Entwicklungsstand, Integritätsprüfung und Lizenzstatus der öffentlichen Dateien.", "docs")298 manifest_data = {"version": 1, "release": RELEASE, "archives": manifests}299 (OUTPUT / "contents.json").write_text(json.dumps(manifest_data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")300 add("contents", "contents.json", "Inhaltsmanifest", "Dateiliste, Größen und SHA-256-Werte aller enthaltenen Quellen und Assets je ZIP-Paket.", "integrity")301 sums = "".join(f"{item['sha256']} {PurePosixPath(str(item['href'])).name}\n" for item in artifacts)302 (OUTPUT / "SHA256SUMS.txt").write_text(sums, encoding="utf-8")303 add("checksums", "SHA256SUMS.txt", "SHA-256-Prüfsummen", "Prüfsummen aller ZIP-Pakete und Einzeldateien; Hashwerte zusätzlich direkt in der Downloadliste.", "integrity")304 metadata = {"version": 1, "release": RELEASE, "baseUrl": "https://openass.wolperdinger.at", "downloads": artifacts}305 DATA.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")306307 expected = {PurePosixPath(str(item["href"])).name for item in artifacts}308 for path in OUTPUT.iterdir():309 if path.is_file() and path.name not in expected:310 path.unlink()311 print(f"Built and verified {len(artifacts)} public artifacts ({len(manifests)} ZIP packages).")312 print(f"Total download bytes: {sum(int(item['bytes']) for item in artifacts):,}")313 print(f"Manifest: {DATA}")314315316if __name__ == "__main__":317 main()