Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: package

on:
push:
branches:
- master
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref != 'refs/heads/master' && github.ref || github.run_id }}-${{ github.event_name }}
cancel-in-progress: true

jobs:
build-wheel:
name: build wheel
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

- name: Setup environment
run: ./setup.sh

- name: Build wheel
run: |
source .venv/bin/activate
pip install build
python -m build --wheel

- name: Verify wheel contents
run: |
source .venv/bin/activate
python scripts/check_wheel.py dist/*.whl

- name: Test wheel install
run: |
source .venv/bin/activate
pip install dist/*.whl
python -c "
from panda import FW_PATH
import os
REQUIRED = [
'panda_h7.bin.signed',
'bootstub.panda_h7.bin',
'panda_jungle_h7.bin.signed',
'bootstub.panda_jungle_h7.bin',
'body_h7.bin.signed',
'bootstub.body_h7.bin',
]
missing = [f for f in REQUIRED if not os.path.exists(os.path.join(FW_PATH, f))]
assert not missing, f'Missing firmware: {missing}'
print(f'All {len(REQUIRED)} firmware files present in {FW_PATH}')
"

- name: Upload wheel
uses: actions/upload-artifact@v4
with:
name: wheel
path: dist/*.whl

editable-install:
name: editable install
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

- name: Setup environment
run: ./setup.sh

- name: Editable install
run: |
source .venv/bin/activate
pip install -e . -v 2>&1 | tee install.log
grep -q "build_firmware" install.log || { echo "ERROR: build_firmware not in log"; exit 1; }

- name: Verify firmware path
run: |
source .venv/bin/activate
python -c "
from panda import FW_PATH
import os
REQUIRED = [
'panda_h7.bin.signed',
'bootstub.panda_h7.bin',
'panda_jungle_h7.bin.signed',
'bootstub.panda_jungle_h7.bin',
'body_h7.bin.signed',
'bootstub.body_h7.bin',
]
missing = [f for f in REQUIRED if not os.path.exists(os.path.join(FW_PATH, f))]
assert not missing, f'Missing firmware: {missing}'
print(f'All {len(REQUIRED)} firmware files present in {FW_PATH}')
"
4 changes: 4 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
recursive-include board *.c *.h *.s *.ld *.py
recursive-include board/certs *
include SConstruct SConscript
include board/crypto/*.py
4 changes: 4 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import os as _os

INCLUDE_PATH = _os.path.dirname(_os.path.abspath(__file__))

from .python.constants import McuType, BASEDIR, FW_PATH, USBPACKET_MAX_SIZE # noqa: F401
from .python.spi import PandaSpiException, PandaProtocolMismatch, STBootloaderSPIHandle # noqa: F401
from .python.serial import PandaSerial # noqa: F401
Expand Down
15 changes: 14 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ dev = [
]

[build-system]
requires = ["setuptools>=61", "wheel"]
requires = [
"setuptools>=61",
"wheel",
"scons",
"pycryptodome>=3.9.8",
"opendbc @ git+https://github.com/commaai/opendbc.git@master#egg=opendbc",
]
build-backend = "setuptools.build_meta"

[tool.setuptools]
Expand All @@ -52,6 +58,13 @@ packages = [
[tool.setuptools.package-dir]
panda = "."

[tool.setuptools.package-data]
panda = [
"board/obj/*.bin.signed",
"board/obj/bootstub.*.bin",
"board/certs/*",
]

# https://beta.ruff.rs/docs/configuration/#using-pyprojecttoml
[tool.ruff]
line-length = 160
Expand Down
50 changes: 50 additions & 0 deletions scripts/check_wheel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Verify wheel contents: required firmware present, no intermediate artifacts."""
import sys
import zipfile

REQUIRED = {
"panda/board/obj/panda_h7.bin.signed",
"panda/board/obj/bootstub.panda_h7.bin",
"panda/board/obj/panda_jungle_h7.bin.signed",
"panda/board/obj/bootstub.panda_jungle_h7.bin",
"panda/board/obj/body_h7.bin.signed",
"panda/board/obj/bootstub.body_h7.bin",
"panda/board/certs/debug",
"panda/board/certs/debug.pub",
"panda/board/certs/release.pub",
}

FORBIDDEN_SUFFIXES = (".o", ".d", ".elf", ".map")


def check_wheel(wheel_path):
errors = []
with zipfile.ZipFile(wheel_path) as zf:
names = set(zf.namelist())

missing = REQUIRED - names
if missing:
errors.append(f"Missing required files:\n " + "\n ".join(sorted(missing)))

forbidden = [n for n in names if n.endswith(FORBIDDEN_SUFFIXES)]
if forbidden:
errors.append(f"Forbidden intermediate files:\n " + "\n ".join(sorted(forbidden)))

if errors:
print("FAIL:")
for e in errors:
print(e)
return 1

print(f"PASS: {wheel_path}")
print(f" Required firmware: {len(REQUIRED)} files present")
print(f" No forbidden intermediates")
return 0


if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} <wheel_path>")
sys.exit(1)
sys.exit(check_wheel(sys.argv[1]))
84 changes: 84 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Build hook for panda firmware compilation."""
import os
import subprocess
from distutils.errors import DistutilsExecError
from setuptools import setup
from setuptools.command.build import build
from setuptools import Command

REPO_ROOT = os.path.dirname(os.path.abspath(__file__))

REQUIRED_OUTPUTS = [
"board/obj/panda_h7.bin.signed",
"board/obj/bootstub.panda_h7.bin",
"board/obj/panda_jungle_h7.bin.signed",
"board/obj/bootstub.panda_jungle_h7.bin",
"board/obj/body_h7.bin.signed",
"board/obj/bootstub.body_h7.bin",
]


class BuildFirmware(Command):
"""Compile panda firmware using SCons."""

description = "compile panda firmware"
user_options = [
("jobs=", "j", "number of parallel jobs"),
("release", "r", "build release firmware (default: debug)"),
]
editable_mode = False
build_lib = None

def initialize_options(self):
self.jobs = None
self.release = False

def finalize_options(self):
if self.jobs is None:
self.jobs = os.cpu_count() or 1
self.set_undefined_options("build_py", ("build_lib", "build_lib"))

def run(self):
cmd = ["scons", "-j", str(self.jobs)]
env = os.environ.copy()
if self.release:
env["RELEASE"] = "1"
print(f"Building RELEASE firmware")
else:
print(f"Building DEBUG firmware")
print(f"Running: {' '.join(cmd)} in {REPO_ROOT}")
result = subprocess.run(cmd, cwd=REPO_ROOT, env=env)
if result.returncode != 0:
raise DistutilsExecError(f"SCons firmware build failed (exit {result.returncode})")

missing = [f for f in REQUIRED_OUTPUTS if not os.path.exists(os.path.join(REPO_ROOT, f))]
if missing:
raise DistutilsExecError(f"Missing firmware outputs: {missing}")

def get_source_files(self):
return []

def get_outputs(self):
if self.build_lib is None:
return []
return [os.path.join(self.build_lib, "panda", f) for f in REQUIRED_OUTPUTS]

def get_output_mapping(self):
if self.build_lib is None:
return {}
return {
os.path.join(self.build_lib, "panda", f): os.path.join(REPO_ROOT, f)
for f in REQUIRED_OUTPUTS
}


class CustomBuild(build):
sub_commands = [("build_firmware", lambda self: True)] + build.sub_commands


setup(
cmdclass={
"build": CustomBuild,
"build_firmware": BuildFirmware,
}
)
Loading