feat(firmware): ELF symbol store for server-side crash decoding

Crash reports carry elf_sha256 (first 16 hex chars of the crashing build's
ELF SHA-256). ELFs are now stored under that key in
{firmware_storage_path}/elf/, uploaded either with a firmware release
(optional elf_file on POST /upload and PUT /{id}) or standalone via
POST /api/firmware/elf. The ELF is validated before the release is created.

GET /api/firmware/elf/{sha} reports whether that build's ELF exists and
whether the decoder is available; POST /api/firmware/elf/{sha}/decode runs
xtensa-esp32-elf-addr2line -pfiaC over the addresses. The decoder is looked
up via settings.addr2line_path or PATH. It is NOT in the Docker image yet,
so decoding reports "not installed" until the toolchain is added; the
console falls back to a copy-paste addr2line command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:28:41 +03:00
co-authored by Claude Opus 5.5
parent 963516ece0
commit 82cf95a9af
3 changed files with 183 additions and 1 deletions
+51 -1
View File
@@ -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