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