FastAPI backend that sits at the centre of the Rice Vision system. It connects Raspberry Pi edge devices to the React web dashboard, using Supabase as the data layer (PostgreSQL + Storage).
Raspberry Pi Edge Client
│ POST /scans (multipart images + metadata)
▼
Rice Vision API ◄──── Bearer JWT ──── React Web Dashboard
│
▼
Supabase (PostgreSQL + Storage)
- Python 3.11+
- A Supabase project with the schema applied — see docs-and-architecture/api-server/database-schema.md
cd api-server
python -m venv .venv
source .venv/bin/activate # macOS / Linux
# .venv\Scripts\activate # Windowspip install -e ".[dev]"cp .env.example .envOpen .env and fill in your Supabase credentials:
| Variable | Where to find it |
|---|---|
SUPABASE_URL |
Supabase Dashboard → Project Settings → API → Project URL |
SUPABASE_SERVICE_ROLE_KEY |
Supabase Dashboard → Project Settings → API → service_role key |
SUPABASE_JWT_SECRET |
Supabase Dashboard → Project Settings → API → JWT Settings |
The service role key bypasses Row Level Security. Keep it secret — never expose it to the frontend.
uvicorn app.main:app --reload --host 0.0.0.0 --port 3001- API root: http://localhost:3001
- Interactive docs: http://localhost:3001/docs
- OpenAPI schema: http://localhost:3001/openapi.json
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
None | Liveness check |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /scans |
None (device_id in body) | Ingest scan from edge device |
POST /scans — multipart/form-data
| Field | Type | Description |
|---|---|---|
raw |
File | White-light (LED) JPEG |
ir |
File | Infrared (NoIR) JPEG |
device_id |
string | Device UUID (must exist in devices table) |
session_id |
string | UUID generated by the edge client |
captured_at |
string | ISO 8601 timestamp |
mode |
string | production (default) |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /devices |
JWT | List devices (region-scoped for admins) |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /results |
JWT | Paginated scan results with filters |
| PATCH | /results/{id} |
JWT | Update rice_variety or operator_name |
GET /results query params: device_id, start_date, end_date, page, page_size
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /analytics |
JWT | Aggregated quality metrics |
GET /analytics query params: start_date, end_date, device_id, region_id
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /regions |
JWT (superadmin only) | List all regions |
Two separate auth paths:
Dashboard users — Supabase JWT
The React frontend sends Authorization: Bearer <supabase_access_token> on every request after login. FastAPI verifies the JWT using SUPABASE_JWT_SECRET and loads the user's role and region_id from the users table.
admin— can only see data scoped to theirregion_idsuperadmin— can see all regions
Edge devices
Edge devices currently upload scan payloads with device_id in the request body for ingest endpoints.
Layered architecture: HTTP → routers → services → repositories → Supabase, with adapters beside services for external systems (vision model, Roboflow).
api-server/
├── pyproject.toml ← Python project metadata & dependencies
├── .env.example ← Environment variable template
├── .env ← Local config (git-ignored)
├── tests/
│ └── test_layering.py ← Static checks that pin the architecture in place
└── app/
├── main.py ← FastAPI factory, CORS, lifespan, router mounts
├── config.py ← pydantic-settings; all env vars
├── dependencies.py ← get_supabase, get_current_user
│
├── routers/ ← HTTP layer (no DB, no business logic)
│ ├── edge/ ← X-Device-ID auth (Raspberry Pi caller)
│ │ ├── scans.py ← POST /scans, /scans/batch (legacy edge upload)
│ │ ├── sessions.py ← /edge/v1/sessions/...
│ │ ├── devices.py ← /edge/v1/devices/{provision,claim,status,upload-training}
│ │ └── deps.py ← require_device(X-Device-ID) FastAPI dep
│ └── dashboard/ ← Supabase JWT auth (web caller)
│ ├── results.py, devices.py, analytics.py, events.py,
│ ├── regions.py, suggestions.py
│
├── services/ ← business logic, orchestration
│ ├── scan_service.py, grading_service.py, annotation_service.py,
│ ├── result_service.py, device_service.py,
│ ├── device_provisioning_service.py, device_event_service.py,
│ ├── device_auth_service.py, analytics_service.py,
│ ├── training_upload_service.py
│
├── repositories/ ← only modules allowed to call supabase
│ ├── results_repo.py, result_images_repo.py, corrections_repo.py,
│ ├── devices_repo.py, device_events_repo.py,
│ ├── device_secrets_repo.py, edge_sessions_repo.py,
│ ├── regions_repo.py, suggestions_repo.py,
│ ├── analytics_repo.py, storage_repo.py
│
├── adapters/ ← external systems
│ └── roboflow.py ← Roboflow upload API
│
├── grading/ ← YOLOv8 ONNX inference + PNS/BAFS 290:2025 grading
│ ├── inference.py ← RiceGrader (model load, detect, merge, post-process)
│ ├── grader.py ← grade_from_per_grain, GRADE_THRESHOLDS, PARAMETER_ORDER
│ ├── features.py ← per-grain feature extraction, PX_PER_MM
│ ├── constants.py ← MASS_PER_MM2 calibration
│ └── report.py ← build_payload, build_report, save_excel
│
├── schemas/ ← Pydantic models, no logic
│ └── results.py, scans.py, corrections.py, analytics.py, devices.py,
│ events.py, regions.py, suggestions.py, edge.py
│
└── utils/ ← pure helpers (no I/O, no supabase)
├── auth_roles.py, datetime_parsing.py, device_auth.py,
├── event_persistence.py, metrics.py, scoping.py
Layering rule: routers may not call supabase.table(...) — that's enforced by tests/test_layering.py and the TID251 ruff ban on app.routers imports from services/repos/adapters. For the layer-by-layer explainer, the audience-split rationale, the cross-cutting flows, and the "how to add a new endpoint" recipe, see docs-and-architecture/api-server/architecture.md.
Linting:
ruff check app/
ruff format app/Running tests:
pytestGenerate TypeScript types for the web dashboard (run from web-dashboard/):
npx openapi-typescript http://localhost:3001/openapi.json -o src/api/types/openapi.d.ts-
results.operator_nameis currentlyNOT NULLin the database schema but the edge client does not send it. It defaults to""on ingest and should be updated toNULL-able if needed. Run the following in the Supabase SQL Editor:ALTER TABLE results ALTER COLUMN operator_name DROP NOT NULL;
-
Images are stored in the
result-imagesSupabase Storage bucket as:results/{result_id}/raw.jpg— white-light (LED),camera_type = 'led'results/{result_id}/ir.jpg— infrared (NoIR),camera_type = 'noir'
-
Legacy preview relay and per-device secret columns were removed during earlier cleanup.
The API uses a tiered event strategy to prevent unbounded growth in device_events:
- Warm audit: only meaningful events are persisted in
device_events. - Cold archive: raw event snapshots can be exported as daily NDJSON/GZIP files.
- Persist always:
WARN,ERROR - Persist selected
INFO: command lifecycle/status transition signals - Drop noisy operational
INFOlogs from persistence
Implementation files:
app/utils/event_persistence.pyapp/routers/events.pyapp/utils/device_events.py
Use tools/device_events_retention.sql in Supabase SQL Editor (or schedule via pg_cron) to keep:
INFOfor 14 daysWARN/ERRORfor 120 days
Run tools/archive_device_events.py on a schedule (daily) to export events to Supabase Storage:
python tools/archive_device_events.pyEnvironment variables used by the archiver:
SUPABASE_URLSUPABASE_SERVICE_ROLE_KEYDEVICE_EVENT_ARCHIVE_BUCKET(default:device-event-archives)DEVICE_EVENT_ARCHIVE_DAYS(default:1)DEVICE_EVENT_ARCHIVE_BATCH_SIZE(default:1000)