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
+3
View File
@@ -35,6 +35,9 @@ class Settings(BaseSettings):
# Local file storage # Local file storage
built_melodies_storage_path: str = "./storage/built_melodies" built_melodies_storage_path: str = "./storage/built_melodies"
firmware_storage_path: str = "./storage/firmware" 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" flash_assets_storage_path: str = "./storage/flash_assets"
melody_binaries_storage_path: str = "./storage/melody_binaries" melody_binaries_storage_path: str = "./storage/melody_binaries"
melody_download_base_url: str = "http://melodies.bellsystems.net/download" melody_download_base_url: str = "http://melodies.bellsystems.net/download"
+129
View File
@@ -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
+51 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from auth.models import TokenPayload from auth.models import TokenPayload
from auth.dependencies import require_permission from auth.dependencies import require_permission
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType 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 database.postgres import get_pg_session
from shared.audit import log_action from shared.audit import log_action
@@ -29,10 +29,15 @@ async def upload_firmware(
release_note: Optional[str] = Form(None), release_note: Optional[str] = Form(None),
bespoke_uid: Optional[str] = Form(None), bespoke_uid: Optional[str] = Form(None),
file: UploadFile = File(...), file: UploadFile = File(...),
elf_file: Optional[UploadFile] = File(None),
_user: TokenPayload = Depends(require_permission("manufacturing", "add")), _user: TokenPayload = Depends(require_permission("manufacturing", "add")),
db: AsyncSession = Depends(get_pg_session), db: AsyncSession = Depends(get_pg_session),
): ):
file_bytes = await file.read() 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( fw = service.upload_firmware(
hw_type=hw_type, hw_type=hw_type,
channel=channel, channel=channel,
@@ -44,11 +49,48 @@ async def upload_firmware(
release_note=release_note, release_note=release_note,
bespoke_uid=bespoke_uid, 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", await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "firmware",
fw.id, f"{hw_type} v{version} ({channel})") fw.id, f"{hw_type} v{version} ({channel})")
return fw 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) @router.get("", response_model=FirmwareListResponse)
def list_firmware( def list_firmware(
hw_type: Optional[str] = Query(None), hw_type: Optional[str] = Query(None),
@@ -114,10 +156,14 @@ async def edit_firmware(
release_note: Optional[str] = Form(None), release_note: Optional[str] = Form(None),
bespoke_uid: Optional[str] = Form(None), bespoke_uid: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None), file: Optional[UploadFile] = File(None),
elf_file: Optional[UploadFile] = File(None),
_user: TokenPayload = Depends(require_permission("manufacturing", "add")), _user: TokenPayload = Depends(require_permission("manufacturing", "add")),
db: AsyncSession = Depends(get_pg_session), db: AsyncSession = Depends(get_pg_session),
): ):
file_bytes = await file.read() if file and file.filename else None 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( fw = service.edit_firmware(
doc_id=firmware_id, doc_id=firmware_id,
channel=channel, channel=channel,
@@ -129,6 +175,10 @@ async def edit_firmware(
bespoke_uid=bespoke_uid, bespoke_uid=bespoke_uid,
file_bytes=file_bytes, 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", 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) firmware_id, f"{fw.hw_type} v{fw.version} ({fw.channel})" if fw else firmware_id)
return fw return fw