diff --git a/backend/config.py b/backend/config.py index 5c801ec..d9d5dc8 100644 --- a/backend/config.py +++ b/backend/config.py @@ -35,6 +35,9 @@ class Settings(BaseSettings): # Local file storage built_melodies_storage_path: str = "./storage/built_melodies" firmware_storage_path: str = "./storage/firmware" + # xtensa-esp32-elf-addr2line for server-side crash decoding (firmware/elf_store.py). + # Empty = look it up on PATH; if absent, the console shows a copy-paste command instead. + addr2line_path: str = "" flash_assets_storage_path: str = "./storage/flash_assets" melody_binaries_storage_path: str = "./storage/melody_binaries" melody_download_base_url: str = "http://melodies.bellsystems.net/download" diff --git a/backend/firmware/elf_store.py b/backend/firmware/elf_store.py new file mode 100644 index 0000000..d92d1e1 --- /dev/null +++ b/backend/firmware/elf_store.py @@ -0,0 +1,129 @@ +"""Firmware ELF symbol store + server-side crash decoding (firmware F-070). + +Crash reports carry `elf_sha256`: the first 16 hex chars of the SHA-256 of the +crashing build's firmware.elf. ELFs uploaded here are stored under that same +key, so a crash can be matched to its exact build and its PC/backtrace +addresses decoded to function + file:line with xtensa addr2line. + +Decoding is optional: it needs a matching ELF AND the xtensa-esp32-elf-addr2line +binary in the backend container (settings.addr2line_path, or on PATH). When +either is missing the console falls back to a copy-paste addr2line command. +""" + +import hashlib +import json +import re +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +from config import settings +from shared.exceptions import NotFoundError, ValidationError + +ADDR2LINE_NAME = "xtensa-esp32-elf-addr2line" +_SHA_PREFIX = re.compile(r"^[0-9a-f]{16}$") +_MAX_ADDRESSES = 40 + + +def _elf_dir() -> Path: + return Path(settings.firmware_storage_path) / "elf" + + +def elf_sha_prefix(data: bytes) -> str: + return hashlib.sha256(data).hexdigest()[:16] + + +def _check_sha(sha: str) -> str: + sha = (sha or "").lower() + if not _SHA_PREFIX.match(sha): + raise ValidationError("elf_sha256 must be 16 hex characters.") + return sha + + +def addr2line_path() -> str | None: + configured = getattr(settings, "addr2line_path", "") or "" + if configured and Path(configured).is_file(): + return configured + return shutil.which(ADDR2LINE_NAME) + + +def validate_elf(data: bytes) -> None: + if not data.startswith(b"\x7fELF"): + raise ValidationError("Not an ELF file — upload the firmware.elf from the build, not the .bin.") + + +def save_elf(data: bytes, filename: str | None, uploaded_by: str | None, + firmware: dict | None = None) -> dict: + """Store an ELF keyed by its sha256 prefix. Re-uploading the same file is a + no-op overwrite. `firmware` optionally records which release it belongs to.""" + validate_elf(data) + sha = elf_sha_prefix(data) + d = _elf_dir() + d.mkdir(parents=True, exist_ok=True) + (d / f"{sha}.elf").write_bytes(data) + meta = { + "elf_sha256": sha, + "filename": filename or "firmware.elf", + "size": len(data), + "uploaded_at": datetime.now(timezone.utc).isoformat(), + "uploaded_by": uploaded_by, + "firmware": firmware, + } + (d / f"{sha}.json").write_text(json.dumps(meta), encoding="utf-8") + return meta + + +def get_elf_info(sha: str) -> dict: + """Whether a matching ELF exists and whether the decoder is installed — + the crash view uses this to pick decode-in-console vs. copy-command.""" + sha = _check_sha(sha) + meta_path = _elf_dir() / f"{sha}.json" + meta = None + if (_elf_dir() / f"{sha}.elf").is_file(): + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + meta = {"elf_sha256": sha} + return { + "elf_sha256": sha, + "exists": meta is not None, + "meta": meta, + "decoder_available": addr2line_path() is not None, + } + + +def decode(sha: str, addresses: list[int]) -> list[dict]: + """Run addr2line -pfiaC over the addresses. Returns one entry per address: + {"address": "0x400D8A3F", "frames": ["func at file:line", "(inlined by) ..."]}.""" + sha = _check_sha(sha) + elf = _elf_dir() / f"{sha}.elf" + if not elf.is_file(): + raise NotFoundError("ELF") + tool = addr2line_path() + if tool is None: + raise ValidationError(f"{ADDR2LINE_NAME} is not installed on the server.") + + addrs = [a for a in addresses if isinstance(a, int) and 0 <= a <= 0xFFFFFFFF][:_MAX_ADDRESSES] + if not addrs: + return [] + hex_addrs = [f"0x{a:08X}" for a in addrs] + proc = subprocess.run( + [tool, "-pfiaC", "-e", str(elf), *hex_addrs], + capture_output=True, text=True, timeout=20, + ) + if proc.returncode != 0: + raise ValidationError(f"addr2line failed: {proc.stderr.strip()[:300]}") + + # With -a each address starts a new block "0x400d8a3f: func at file:line"; + # -i adds " (inlined by) ..." continuation lines under it. + results: list[dict] = [] + for line in proc.stdout.splitlines(): + if not line.strip(): + continue + m = re.match(r"^(0x[0-9a-fA-F]+):\s*(.*)$", line) + if m: + results.append({"address": f"0x{int(m.group(1), 16):08X}", "frames": [m.group(2).strip()]}) + elif results: + results[-1]["frames"].append(line.strip()) + return results diff --git a/backend/firmware/router.py b/backend/firmware/router.py index 953092a..166cb57 100644 --- a/backend/firmware/router.py +++ b/backend/firmware/router.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from auth.models import TokenPayload from auth.dependencies import require_permission from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType -from firmware import service +from firmware import service, elf_store from database.postgres import get_pg_session from shared.audit import log_action @@ -29,10 +29,15 @@ async def upload_firmware( release_note: Optional[str] = Form(None), bespoke_uid: Optional[str] = Form(None), file: UploadFile = File(...), + elf_file: Optional[UploadFile] = File(None), _user: TokenPayload = Depends(require_permission("manufacturing", "add")), db: AsyncSession = Depends(get_pg_session), ): file_bytes = await file.read() + elf_bytes = await elf_file.read() if elf_file and elf_file.filename else None + if elf_bytes is not None: + # Validate before the release is created, so a wrong file doesn't leave a half-done upload. + elf_store.validate_elf(elf_bytes) fw = service.upload_firmware( hw_type=hw_type, channel=channel, @@ -44,11 +49,48 @@ async def upload_firmware( release_note=release_note, bespoke_uid=bespoke_uid, ) + if elf_bytes is not None: + elf_store.save_elf(elf_bytes, elf_file.filename, _user.name or _user.email, + firmware={"id": fw.id, "hw_type": hw_type, "channel": channel, "version": version}) await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "firmware", fw.id, f"{hw_type} v{version} ({channel})") return fw +# ── Firmware ELF symbols (crash decoding, F-070) ───────────────────────────── +# Registered before the /{hw_type}/... routes. Viewing/decoding is gated on +# mqtt:view like the crash data itself; uploading is a firmware-release action. + +class ElfDecodeRequest(BaseModel): + addresses: list[int] + + +@router.post("/elf", status_code=201) +async def upload_elf( + file: UploadFile = File(...), + _user: TokenPayload = Depends(require_permission("manufacturing", "add")), +): + data = await file.read() + return elf_store.save_elf(data, file.filename, _user.name or _user.email) + + +@router.get("/elf/{elf_sha256}") +def get_elf_info( + elf_sha256: str, + _user: TokenPayload = Depends(require_permission("mqtt", "view")), +): + return elf_store.get_elf_info(elf_sha256) + + +@router.post("/elf/{elf_sha256}/decode") +def decode_addresses( + elf_sha256: str, + body: ElfDecodeRequest, + _user: TokenPayload = Depends(require_permission("mqtt", "view")), +): + return {"elf_sha256": elf_sha256.lower(), "results": elf_store.decode(elf_sha256, body.addresses)} + + @router.get("", response_model=FirmwareListResponse) def list_firmware( hw_type: Optional[str] = Query(None), @@ -114,10 +156,14 @@ async def edit_firmware( release_note: Optional[str] = Form(None), bespoke_uid: Optional[str] = Form(None), file: Optional[UploadFile] = File(None), + elf_file: Optional[UploadFile] = File(None), _user: TokenPayload = Depends(require_permission("manufacturing", "add")), db: AsyncSession = Depends(get_pg_session), ): file_bytes = await file.read() if file and file.filename else None + elf_bytes = await elf_file.read() if elf_file and elf_file.filename else None + if elf_bytes is not None: + elf_store.validate_elf(elf_bytes) fw = service.edit_firmware( doc_id=firmware_id, channel=channel, @@ -129,6 +175,10 @@ async def edit_firmware( bespoke_uid=bespoke_uid, file_bytes=file_bytes, ) + if elf_bytes is not None: + elf_store.save_elf(elf_bytes, elf_file.filename, _user.name or _user.email, + firmware={"id": firmware_id, "hw_type": fw.hw_type if fw else None, + "channel": fw.channel if fw else None, "version": fw.version if fw else None}) await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "firmware", firmware_id, f"{fw.hw_type} v{fw.version} ({fw.channel})" if fw else firmware_id) return fw