diff --git a/README.md b/README.md
index 0a6a2c8..4e92849 100644
--- a/README.md
+++ b/README.md
@@ -303,6 +303,8 @@ From left to right:
This does **not** stop the backend service.
- **Import**: opens the import menu and lets the user load a previous experiment
configuration.
+- **Downloads**: opens the downloads menu and lets the user grab a ZIP of their
+ own past experiment folders (images + metadata + saved config).
- **User**: opens the on-screen keyboard to change the saved researcher name.
- **Gallery**: opens the image gallery.
- **Live**: opens the live camera preview.
@@ -323,6 +325,25 @@ The import menu lists previous experiments from history.
- a Tropism config opens the **Tropism** screen
- The **X** button closes the import menu without loading anything.
+### Downloads menu
+
+The downloads menu lists previous experiments from history, filtered to the
+ones whose saved `username` matches the current researcher name (the same
+name shown on the **User** button, and the same one baked into each
+experiment's folder name). This box has no login/auth, so the filter is a
+convenience for finding your own runs quickly, not an access-control
+boundary — any experiment folder is reachable by anyone on the LAN who knows
+its ID.
+
+- Each row shows the experiment name, start date, and image count, plus a
+ **Download ZIP** button.
+- Tapping **Download ZIP** downloads a `.zip` of that experiment's entire
+ folder — every captured image, `metadata.json`, and the saved `.xml`
+ protocol config — via `GET /api/experiments/{id}/download`. The browser
+ saves it like any other file download; no separate tool (SSH, Samba, etc.)
+ is required.
+- The **X** button closes the downloads menu.
+
### Settings menu
The settings menu has two tabs:
@@ -501,3 +522,5 @@ Compared with the old single-purpose UI flow, the current system now includes:
off the capture path, so a dead share can never stall or fail a running
experiment. The share password is session-only and never written to disk, and
the UI says so plainly both while it is typed and after a restart clears it.
+- a **downloads menu** for grabbing a ZIP of your own experiment files
+ (images + metadata + config) straight from the browser, no SSH/Samba needed
diff --git a/back/rapidboxes/api/experiments.py b/back/rapidboxes/api/experiments.py
index 5cd472a..4164e57 100644
--- a/back/rapidboxes/api/experiments.py
+++ b/back/rapidboxes/api/experiments.py
@@ -1,9 +1,13 @@
"""Experiment lifecycle + history."""
from __future__ import annotations
+import os
+import tempfile
+import zipfile
from typing import List
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
+from fastapi.responses import FileResponse
from .. import config_xml
from ..models import ExperimentConfig, ExperimentStatus, SavedExperimentConfig, StartResponse
@@ -85,3 +89,45 @@ async def get_config(experiment_id: str, state: AppState = Depends(get_state)):
return config_xml.parse(data)
except Exception:
raise HTTPException(500, "could not parse saved config")
+
+
+@router.get("/{experiment_id}/download")
+async def download_experiment(
+ experiment_id: str, background_tasks: BackgroundTasks, state: AppState = Depends(get_state)
+):
+ """Zip an experiment's whole folder (images + metadata.json + saved config
+ XML) and hand it back as a downloadable attachment.
+
+ An experiment can accumulate hundreds of JPEGs over a multi-day run, and
+ this runs on a Pi with as little as 2GB of RAM. Building the archive in an
+ `io.BytesIO()` would hold the *entire* zip in memory at once (easily
+ hundreds of MB), which risks real memory pressure -- possibly enough to
+ OOM a box that's unattended and mid-protocol on another experiment. There
+ is plenty of disk under storage_root by comparison, so instead we stream
+ the archive to a temp file on disk (bounded, constant memory regardless of
+ experiment size) and hand that off to FileResponse, which streams it to
+ the client in chunks. The temp file is removed by a background task once
+ the response has been sent.
+ """
+ exp = state.storage.get_experiment(experiment_id)
+ if exp is None:
+ raise HTTPException(404, "experiment not found")
+
+ fd, tmp_path = tempfile.mkstemp(suffix=".zip", prefix=f"{exp.experiment_id}-")
+ os.close(fd)
+ try:
+ with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
+ for f in sorted(exp.path.rglob("*")):
+ if f.is_file():
+ zf.write(f, arcname=f.relative_to(exp.path))
+ except Exception:
+ os.remove(tmp_path)
+ raise
+
+ background_tasks.add_task(os.remove, tmp_path)
+ return FileResponse(
+ tmp_path,
+ media_type="application/zip",
+ filename=f"{exp.experiment_id}.zip",
+ background=background_tasks,
+ )
diff --git a/back/tests/test_api.py b/back/tests/test_api.py
index f0b8d55..1107d71 100644
--- a/back/tests/test_api.py
+++ b/back/tests/test_api.py
@@ -1,6 +1,8 @@
"""FastAPI integration tests with simulated hardware."""
from __future__ import annotations
+import io
+import zipfile
from datetime import datetime, timezone
from pathlib import Path
@@ -318,6 +320,46 @@ async def test_abort_stops_and_deletes_experiment(client: AsyncClient, app_confi
assert (await client.get("/api/experiments/history")).json() == []
+@pytest.mark.asyncio
+async def test_download_experiment_zip_contains_all_files(app_config: AppConfig):
+ """The download endpoint should zip everything under the experiment's
+ folder (images, metadata.json, saved config xml) and offer it as a
+ downloadable attachment, without loading the whole archive into memory
+ at once (see the comment on download_experiment for why)."""
+ app = create_app(app_config)
+ async with app.router.lifespan_context(app):
+ storage = app.state.app.storage
+ exp = storage.create_experiment("alice", "zip-test")
+ (exp.path / "dark_00000.jpg").write_bytes(b"fake-jpeg-bytes-1")
+ (exp.path / "bending_00000.jpg").write_bytes(b"fake-jpeg-bytes-2")
+ exp.write_metadata({"experimentName": "zip-test", "username": "alice", "imagesCaptured": 2})
+ exp.write_config_xml(b"
+ No experiments found for "{username}" yet. +
+ ) : ( +