Skip to content

board_test

birdnet_stm32.deploy.board_test

Board test: standalone firmware on STM32N6570-DK.

Full on-device pipeline — nothing is precomputed on the host: 1. Deploy model: stedgeai generate → patch NPU_Validation project → n6_loader build + flash. 2. Firmware on board: read WAV from SD card → STFT on Cortex-M55 → NPU inference → results over UART. 3. This script captures UART output and parses per-file predictions.

Requires: - USB-connected STM32N6570-DK with SD card containing audio/ and labels.txt. - pyserial (pip install pyserial).

BoardTestConfig dataclass

Configuration for standalone on-board inference tests.

Attributes:

Name Type Description
deploy_cfg DeployConfig

Base deployment configuration (paths to tools, model, etc.).

model_config_path str

Path to the _model_config.json file.

labels_path str

Path to the _labels.txt file.

serial_port str

Serial port for UART capture (e.g. /dev/ttyACM0).

top_k int

Number of top predictions to show per file.

score_threshold float

Minimum score to display.

timeout int

Maximum seconds to wait for firmware to finish.

Source code in birdnet_stm32/deploy/board_test.py
@dataclass
class BoardTestConfig:
    """Configuration for standalone on-board inference tests.

    Attributes:
        deploy_cfg: Base deployment configuration (paths to tools, model, etc.).
        model_config_path: Path to the _model_config.json file.
        labels_path: Path to the _labels.txt file.
        serial_port: Serial port for UART capture (e.g. /dev/ttyACM0).
        top_k: Number of top predictions to show per file.
        score_threshold: Minimum score to display.
        timeout: Maximum seconds to wait for firmware to finish.
    """

    deploy_cfg: DeployConfig = field(default_factory=DeployConfig)
    model_config_path: str = ""
    labels_path: str = ""
    serial_port: str = "/dev/ttyACM0"
    top_k: int = 5
    score_threshold: float = 0.01
    timeout: int = 300

load_model_config(config_path)

Load model configuration from JSON.

Parameters:

Name Type Description Default
config_path str

Path to _model_config.json.

required

Returns:

Type Description
dict

Dict with model configuration.

Source code in birdnet_stm32/deploy/board_test.py
def load_model_config(config_path: str) -> dict:
    """Load model configuration from JSON.

    Args:
        config_path: Path to _model_config.json.

    Returns:
        Dict with model configuration.
    """
    with open(config_path) as f:
        return json.load(f)

load_labels(labels_path)

Load class labels from a labels.txt file.

Parameters:

Name Type Description Default
labels_path str

Path to _labels.txt (one label per line).

required

Returns:

Type Description
list[str]

List of label strings.

Source code in birdnet_stm32/deploy/board_test.py
def load_labels(labels_path: str) -> list[str]:
    """Load class labels from a labels.txt file.

    Args:
        labels_path: Path to _labels.txt (one label per line).

    Returns:
        List of label strings.
    """
    with open(labels_path) as f:
        return [line.strip() for line in f if line.strip()]

patch_project(project, model_cfg, num_classes, labels)

Copy firmware sources into the NPU_Validation project and patch build files.

Parameters:

Name Type Description Default
project Path

Path to the NPU_Validation project root.

required
model_cfg dict

Model configuration dict from _model_config.json.

required
num_classes int

Number of output classes.

required
labels list[str]

Ordered list of class label strings. If empty, generates placeholder class_0class_N labels.

required

Returns a list of .bak paths that must be passed to restore_project.

Source code in birdnet_stm32/deploy/board_test.py
def patch_project(
    project: Path,
    model_cfg: dict,
    num_classes: int,
    labels: list[str],
) -> list[Path]:
    """Copy firmware sources into the NPU_Validation project and patch build files.

    Args:
        project: Path to the NPU_Validation project root.
        model_cfg: Model configuration dict from _model_config.json.
        num_classes: Number of output classes.
        labels: Ordered list of class label strings.  If empty, generates
                placeholder ``class_0`` … ``class_N`` labels.

    Returns a list of .bak paths that must be passed to ``restore_project``.
    """
    fw = _firmware_dir()
    core_src = project / "Core" / "Src"
    core_inc = project / "Core" / "Inc"
    hal_src = project / "Drivers" / "STM32N6xx_HAL_Driver" / "Src"
    bsp_dk = project / "Drivers" / "BSP" / "STM32N6570-DK"
    fatfs_dst = project / "FatFs"
    makefile = project / "armgcc" / "Makefile"
    hal_conf = core_inc / "stm32n6xx_hal_conf.h"
    app_conf = core_inc / "app_config.h"

    backups: list[Path] = []

    # --- 1. Copy firmware C sources into Core/Src --------------------------
    for name in ("main.c", "wav_reader.c", "audio_stft.c", "audio_mel.c", "sd_handler.c", "fft.c"):
        dst = core_src / name
        backups.append(_backup(dst))
        shutil.copy2(fw / "Src" / name, dst)

    # --- 2. Copy firmware headers into Core/Inc ----------------------------
    #  Skip app_config.h — we patch the NPU_Validation original in step 6.
    for name in ("wav_reader.h", "audio_stft.h", "audio_mel.h", "sd_handler.h", "fft.h"):
        dst = core_inc / name
        backups.append(_backup(dst))
        shutil.copy2(fw / "Inc" / name, dst)

    # FatFs config headers also go to Core/Inc (already in include path).
    for name in ("ffconf.h", "sd_diskio_config.h"):
        dst = core_inc / name
        backups.append(_backup(dst))
        shutil.copy2(fw / "Config" / name, dst)

    # --- 2b. Generate app_labels.h from the model's label list -------------
    if not labels:
        labels = [f"class_{i}" for i in range(num_classes)]
    app_labels_dst = core_inc / "app_labels.h"
    backups.append(_backup(app_labels_dst))
    app_labels_dst.write_text(_generate_app_labels_h(labels))
    log.info("Generated app_labels.h with %d labels", len(labels))

    # --- 3. Copy HAL SD driver sources and headers --------------------------
    hal_inc = project / "Drivers" / "STM32N6xx_HAL_Driver" / "Inc"
    for name in ("stm32n6xx_hal_sd.c", "stm32n6xx_ll_sdmmc.c"):
        dst = hal_src / name
        backups.append(_backup(dst))
        shutil.copy2(fw / "Drivers" / "HAL_SD" / name, dst)
    for name in ("stm32n6xx_hal_sd.h", "stm32n6xx_hal_sd_ex.h", "stm32n6xx_ll_sdmmc.h", "stm32n6xx_ll_dlyb.h"):
        dst = hal_inc / name
        backups.append(_backup(dst))
        shutil.copy2(fw / "Drivers" / "HAL_SD" / name, dst)

    # --- 4. Copy BSP SD driver ---------------------------------------------
    for name in ("stm32n6570_discovery_sd.c", "stm32n6570_discovery_sd.h"):
        dst = bsp_dk / name
        backups.append(_backup(dst))
        shutil.copy2(fw / "Drivers" / name, dst)

    # --- 5. Copy FatFs middleware -------------------------------------------
    fatfs_dst.mkdir(exist_ok=True)
    backups.append(fatfs_dst)  # entire dir — removed on cleanup
    for p in (fw / "Drivers" / "FatFs").iterdir():
        if p.is_file():
            shutil.copy2(p, fatfs_dst / p.name)

    # --- 6. Generate app_config.h from model_config.json ------------------
    #  Replaces the NPU_Validation app_config.h with a generated version
    #  that includes both the board support defines and our audio/inference
    #  parameters from model_config.json.
    backups.append(_backup(app_conf))
    _patch_app_config(app_conf, model_cfg, num_classes)

    # --- 7. Patch hal_conf.h — enable HAL_SD module ------------------------
    backups.append(_backup(hal_conf))
    text = hal_conf.read_text()
    if HAL_SD_COMMENTED in text:
        hal_conf.write_text(text.replace(HAL_SD_COMMENTED, HAL_SD_UNCOMMENTED))
        log.info("Enabled HAL_SD_MODULE in hal_conf.h")

    # --- 8. Patch Makefile — add our sources and include paths -------------
    backups.append(_backup(makefile))
    _patch_makefile(makefile)

    log.info("Project patched (%d backups created)", len(backups))
    return backups

restore_project(backups)

Undo all project patches by restoring .bak files and removing new files.

For each entry in backups: - If it is a directory (FatFs), remove it entirely. - If it is a .bak path and the .bak exists, move it back over the original. - If it is a .bak path but no .bak was created (file was new), delete the original that we copied in.

Source code in birdnet_stm32/deploy/board_test.py
def restore_project(backups: list[Path]) -> None:
    """Undo all project patches by restoring .bak files and removing new files.

    For each entry in *backups*:
    - If it is a directory (FatFs), remove it entirely.
    - If it is a ``.bak`` path and the .bak exists, move it back over the original.
    - If it is a ``.bak`` path but no .bak was created (file was new), delete
      the original that we copied in.
    """
    for entry in reversed(backups):
        if entry.is_dir() and entry.suffix != ".bak":
            shutil.rmtree(entry, ignore_errors=True)
        else:
            _restore(entry)
    log.info("Project restored")

parse_serial_output(lines, labels, top_k=5, threshold=0.01)

Parse firmware UART output into structured results.

Parameters:

Name Type Description Default
lines list[str]

Raw serial lines captured from the board.

required
labels list[str]

Class label list (for reference; firmware already prints names).

required
top_k int

Max detections per file to keep.

5
threshold float

Minimum score fraction (0–1) to include.

0.01

Returns:

Type Description
dict

Dict with 'results' (list of per-file dicts), 'processed', 'errors',

dict

'raw_lines'.

Source code in birdnet_stm32/deploy/board_test.py
def parse_serial_output(
    lines: list[str],
    labels: list[str],
    top_k: int = 5,
    threshold: float = 0.01,
) -> dict:
    """Parse firmware UART output into structured results.

    Args:
        lines: Raw serial lines captured from the board.
        labels: Class label list (for reference; firmware already prints names).
        top_k: Max detections per file to keep.
        threshold: Minimum score fraction (0–1) to include.

    Returns:
        Dict with 'results' (list of per-file dicts), 'processed', 'errors',
        'raw_lines'.
    """
    results: list[dict] = []
    current_file: dict | None = None
    processed = 0
    total = 0
    errors = 0
    benchmark_avg: dict | None = None

    for line in lines:
        m = _RE_FILE.match(line)
        if m:
            if current_file is not None:
                results.append(current_file)
            current_file = {
                "file": m.group(3),
                "detections": [],
                "bench": None,
            }
            continue

        m = _RE_DET.match(line)
        if m and current_file is not None:
            score = float(m.group(3)) / 100.0
            if score >= threshold and len(current_file["detections"]) < top_k:
                current_file["detections"].append(
                    {
                        "label": m.group(2),
                        "score": score,
                    }
                )
            continue

        m = _RE_BENCH.match(line)
        if m and current_file is not None:
            current_file["bench"] = {
                "read_ms": int(m.group(1)),
                "stft_ms": int(m.group(2)),
                "npu_ms": int(m.group(3)),
                "total_ms": int(m.group(4)),
            }
            continue

        m = _RE_BENCH_SUMMARY.match(line)
        if m:
            benchmark_avg = {
                "avg_read_ms": int(m.group(1)),
                "avg_stft_ms": int(m.group(2)),
                "avg_npu_ms": int(m.group(3)),
                "avg_total_ms": int(m.group(4)),
            }
            continue

        m = _RE_SUMMARY.match(line)
        if m:
            processed = int(m.group(1))
            total = int(m.group(2))
            errors = int(m.group(3))

    if current_file is not None:
        results.append(current_file)

    return {
        "results": results,
        "processed": processed,
        "total": total,
        "errors": errors,
        "benchmark": benchmark_avg,
        "raw_lines": lines,
    }

run_board_test(cfg)

Execute the full on-board inference test.

Steps: 1. stedgeai generate → produce NPU binary from the TFLite model. 2. Patch NPU_Validation project with our firmware sources. 3. n6_loader: copy network.c, build firmware, flash, run. 4. Capture UART output until '=== DONE ===' marker. 5. Restore the NPU_Validation project to its original state. 6. Parse and report results.

Parameters:

Name Type Description Default
cfg BoardTestConfig

Board test configuration.

required

Returns:

Type Description
dict

Dict with 'results', 'processed', 'errors', 'raw_lines', 'labels'.

Source code in birdnet_stm32/deploy/board_test.py
def run_board_test(cfg: BoardTestConfig) -> dict:
    """Execute the full on-board inference test.

    Steps:
    1. stedgeai generate → produce NPU binary from the TFLite model.
    2. Patch NPU_Validation project with our firmware sources.
    3. n6_loader: copy network.c, build firmware, flash, run.
    4. Capture UART output until '=== DONE ===' marker.
    5. Restore the NPU_Validation project to its original state.
    6. Parse and report results.

    Args:
        cfg: Board test configuration.

    Returns:
        Dict with 'results', 'processed', 'errors', 'raw_lines', 'labels'.
    """
    deploy = cfg.deploy_cfg

    # --- Validate prerequisites ---
    if not os.path.isfile(deploy.model_path):
        print(f"[ERROR] Model not found: {deploy.model_path}")
        sys.exit(1)
    if not os.path.isfile(deploy.stedgeai_path):
        print(f"[ERROR] stedgeai not found: {deploy.stedgeai_path}")
        sys.exit(1)
    if not os.path.isfile(cfg.model_config_path):
        print(f"[ERROR] Model config not found: {cfg.model_config_path}")
        sys.exit(1)
    if not os.path.isfile(deploy.n6_loader_script):
        print(f"[ERROR] n6_loader.py not found: {deploy.n6_loader_script}")
        sys.exit(1)
    if not os.path.isfile(deploy.n6_loader_config):
        print(f"[ERROR] n6_loader config not found: {deploy.n6_loader_config}")
        sys.exit(1)

    model_cfg = load_model_config(cfg.model_config_path)
    labels = load_labels(cfg.labels_path) if cfg.labels_path else []
    num_classes = len(labels) if labels else model_cfg.get("num_classes", 1000)

    print("\n=== BirdNET-STM32 Board Test (standalone firmware) ===\n")
    print(f"  Model:       {deploy.model_path}")
    print(f"  Config:      {cfg.model_config_path}")
    print(f"  Labels:      {cfg.labels_path or '(none)'} ({num_classes} classes)")
    print(f"  Serial port: {cfg.serial_port}")
    print(f"  Timeout:     {cfg.timeout}s")
    print()

    project = _resolve_project_path(deploy)

    # Step 1: stedgeai generate
    print("--- Step 1: Generate NPU binary (stedgeai generate) ---")
    generate(deploy)

    # Step 2: Patch the NPU_Validation project
    print("\n--- Step 2: Patch NPU_Validation project ---")
    backups = patch_project(project, model_cfg, num_classes, labels)

    try:
        # Step 3: Start serial capture (before n6_loader starts the firmware)
        print("\n--- Step 3: Build, flash, and run ---")
        serial_lines: list[str] = []
        done_event = threading.Event()
        serial_thread = threading.Thread(
            target=_serial_capture,
            args=(cfg.serial_port, UART_BAUDRATE, serial_lines, done_event, cfg.timeout),
            daemon=True,
        )
        serial_thread.start()

        # Step 4: n6_loader — copies network.c, builds, flashes, runs firmware
        n6_cmd = [
            sys.executable,
            deploy.n6_loader_script,
            "--n6-loader-config",
            deploy.n6_loader_config,
            "--clean",
        ]
        print(f"  $ {' '.join(n6_cmd)}")
        result = subprocess.run(n6_cmd, check=False)
        if result.returncode != 0:
            print(f"[ERROR] n6_loader failed (exit code {result.returncode})")
            sys.exit(result.returncode)

        # Wait for firmware to finish (or timeout)
        print("\n--- Step 4: Waiting for firmware output ---")
        if not done_event.is_set():
            done_event.wait(timeout=cfg.timeout)
        serial_thread.join(timeout=5)

        if not done_event.is_set():
            print(f"[WARN] Timeout after {cfg.timeout}s — firmware may not have finished")

    finally:
        # Step 5: Restore original project files
        print("\n--- Step 5: Restore NPU_Validation project ---")
        restore_project(backups)

    # Step 6: Parse and display results
    print("\n--- Raw UART output ---")
    for sl in serial_lines:
        print(f"  | {sl}")

    print("\n--- Results ---")
    parsed = parse_serial_output(serial_lines, labels, cfg.top_k, cfg.score_threshold)

    for r in parsed["results"]:
        det_str = ", ".join(f"{d['label']} ({d['score']:.1%})" for d in r["detections"])
        print(f"\n  {r['file']}")
        if det_str:
            print(f"    {det_str}")
        else:
            print("    (no detections above threshold)")
        if r.get("bench"):
            b = r["bench"]
            print(f"    [{b['read_ms']}ms read, {b['stft_ms']}ms STFT, {b['npu_ms']}ms NPU, {b['total_ms']}ms total]")

    print(f"\n=== DONE: {parsed['processed']}/{parsed['total']} files ({parsed['errors']} errors) ===")

    bench = parsed.get("benchmark")
    if bench:
        chunk_sec = model_cfg.get("chunk_duration", 3)
        avg_total = bench["avg_total_ms"]
        rtf = avg_total / (chunk_sec * 1000) if chunk_sec else 0
        speedup = (chunk_sec * 1000) / avg_total if avg_total else 0
        print(f"\n--- Benchmark (per file, averaged over {parsed['processed']} files) ---")
        print(f"  SD read:        {bench['avg_read_ms']} ms")
        print(f"  STFT (M55):     {bench['avg_stft_ms']} ms")
        print(f"  NPU inference:  {bench['avg_npu_ms']} ms")
        print(f"  Total:          {avg_total} ms")
        print(f"  Real-time factor: {rtf:.4f}x  ({speedup:.0f}x faster than real-time)")

    parsed["labels"] = labels
    return parsed