English · 简体中文
An offline-first maintenance log for camera-trap wildlife surveys — record in the field with no signal, then sync the whole team's data automatically once you are back online.
Built for and used in a real camera-trap survey in the mountains around Beijing: 100+ camera traps, several field operators, and no cell coverage at the survey sites.
Camera-trap surveys share a set of everyday maintenance problems:
- No cell signal in the field — online spreadsheets and forms simply will not open
- Paper logs get lost, get skipped, or come back illegible
- Progress is invisible — nobody can tell which cameras are due for a check, or who last visited them
- Photos and coordinates do not line up with the right camera
This system puts the maintenance log on the phone: offline-capable, photo and GPS capture, automatic aggregation, and a camera roster that stays current.
- PWA + Service Worker caching — open it once while you have a connection, then use it with none
- Records are written to
localStoragefirst and uploaded automatically once a network appears
- Camera ID + operator + operation type (multi-select) + free-text note
- Automatic GPS capture (needs a secure context — see HTTPS is required)
- Field photos, auto-compressed via
canvasbefore storage - KML track upload, as exported from apps such as TwoStepRoute
- FastAPI + SQLite backend aggregates everyone's records
- Automatic two-way sync — uploads this device's unsynced records and pulls in everybody else's
- The camera roster keeps itself current: which camera was last maintained, by whom, and when
- Access password (HTTP Basic Auth) — for field team members: view and record
- Admin password — required separately for editing and deleting; team members never see those controls
- Recycle bin (soft delete) — deletions are recoverable, and permanent deletion needs confirmation
- Named survey routes (e.g.
MTG North Line) with an explicit camera order along each one - One KML per route, with version history — the last 3 versions are kept and any of them can be restored
- Lazy photo loading — the list endpoint returns text fields only; a photo is fetched when you open that record
GET /api/record/{rid}returns one full record, including the base64 photo
| Layer | Technology |
|---|---|
| Frontend | Single-file HTML + vanilla JS — no framework, no build step |
| Backend | FastAPI + SQLite (WAL) + uvicorn |
| Deployment | systemd + self-signed TLS |
| Offline | Service Worker + Cache API + localStorage |
No build tooling, no bundler, no npm. Edit index.html and reload.
python -m venv venv
venv/bin/pip install fastapi uvicorn
# Passwords are read from the environment only — never hard-coded
export IRCAM_USER=team
export IRCAM_PASS=your_password
export IRCAM_ADMIN_PASS=your_admin_password
venv/bin/python -m uvicorn server:app --host 0.0.0.0 --port 8000The database (ircam.db) and its tables are created automatically on first start.
index.html is a single-file app — put it in any static directory, or let the backend serve it.
When the frontend and backend share an origin, no configuration is needed: the frontend's
DEFAULT_SERVER = '' means "same origin".
| Variable | Meaning | Default |
|---|---|---|
IRCAM_USER |
Access username | team |
IRCAM_PASS |
Access password — for field team members | unset |
IRCAM_ADMIN_PASS |
Admin password — for edit / delete | unset |
QUOTE_USER |
Username for the /quote/* path only |
falls back to IRCAM_USER |
QUOTE_PASS |
Password for the /quote/* path only |
falls back to IRCAM_PASS |
Security behaviour: if IRCAM_PASS is not set, the server generates a random password at
startup and prints it, rather than falling back to a publicly known default. The system boots
closed, never open — there is no default password to forget to change.
QUOTE_USER / QUOTE_PASS exist so a public-facing page can live under the same server with a
different, separately shareable password.
[Unit]
Description=Camera Trap Maintenance Log
After=network.target
[Service]
Type=simple
User=your_user
WorkingDirectory=/path/to/ircam
Environment=IRCAM_USER=team
Environment=IRCAM_PASS=your_password
Environment=IRCAM_ADMIN_PASS=your_admin_password
# Needed only to bind a privileged port such as 80:
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
ExecStart=/path/to/venv/bin/python -m uvicorn server:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetFor HTTPS, append to ExecStart:
--ssl-keyfile /path/to/key.pem --ssl-certfile /path/to/cert.pem
Two features need a secure context and will silently refuse to work over plain HTTP:
- Service Worker (offline caching) → the page will not open offline in the field
- Geolocation (GPS) → position capture fails
A self-signed certificate is the only option when you have no domain name:
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
-days 3650 -nodes -subj "/CN=YOUR_IP" \
-addext "subjectAltName=IP:YOUR_IP"Every field operator then trusts that certificate once on their phone. The steps differ between Android and iOS — on iOS you must additionally enable it under Settings → General → About → Certificate Trust Settings.
All /api/* routes require the access password. Routes marked admin additionally require the
admin password in the x-admin-pass header.
| Method | Path | Description | Auth |
|---|---|---|---|
GET |
/api/records |
Record list (text fields only, has_photo flag) |
access |
POST |
/api/records |
Upload records (batch supported) | access |
GET |
/api/record/{rid} |
One full record, including photo / KML | access |
PATCH |
/api/record/{rid} |
Edit a record | admin |
DELETE |
/api/record/{rid} |
Soft delete → recycle bin | admin |
GET |
/api/deleted-record-ids |
IDs of soft-deleted records (for client-side sync) | access |
| Method | Path | Description | Auth |
|---|---|---|---|
GET |
/api/trash |
List deleted records | admin |
POST |
/api/restore/{rid} |
Restore from the recycle bin | admin |
DELETE |
/api/purge/{rid} |
Permanently delete (irreversible) | admin |
| Method | Path | Description | Auth |
|---|---|---|---|
GET |
/api/cameras |
Camera roster | access |
POST |
/api/cameras |
Bulk upsert from a cameras array — updates coordinates and status |
admin |
POST |
/api/camera |
Create / update one camera | admin |
POST |
/api/cameras/bulk |
Bulk-import new camera IDs (existing IDs are skipped) | admin |
DELETE |
/api/camera/{code} |
Remove a camera (its records are kept) | admin |
GET |
/api/camera/{code}/route |
Which route a camera belongs to | access |
| Method | Path | Description | Auth |
|---|---|---|---|
GET |
/api/routes |
Route list | access |
POST |
/api/routes |
Create a route | access |
GET |
/api/routes/{rid} |
Route detail (without the KML body) | access |
PATCH |
/api/routes/{rid} |
Rename / re-note a route | admin |
DELETE |
/api/routes/{rid} |
Soft-delete a route | admin |
GET |
/api/routes-map |
Camera → route mapping, for the roster page | access |
GET |
/api/routes/{rid}/kml |
Download the current KML | access |
POST |
/api/routes/{rid}/kml |
Replace the KML (the previous version is archived) | access |
GET |
/api/routes/{rid}/history |
KML version history | access |
GET |
/api/routes/{rid}/history/{hid} |
Download one historical version | access |
POST |
/api/routes/{rid}/restore/{hid} |
Restore a historical version as current | access |
| Method | Path | Description | Auth |
|---|---|---|---|
GET |
/api/operators |
Operator list (populates the entry form) | access |
POST |
/api/operators |
Add an operator | admin |
DELETE |
/api/operators/{name} |
Remove an operator (past records are unaffected) | admin |
| Method | Path | Description | Auth |
|---|---|---|---|
GET |
/api/stats |
Summary counters | access |
GET |
/api/export.csv |
Export records as CSV | access |
GET |
/api/export.json |
Full JSON export (backup) | access |
| Method | Path | Description |
|---|---|---|
GET |
/ |
The single-file frontend |
GET |
/sw.js |
Service Worker |
GET |
/manifest.json |
PWA manifest |
records (
id TEXT PRIMARY KEY,
ts TEXT, operator TEXT, code TEXT,
ops TEXT, -- JSON array of operation types
lat REAL, lng REAL, note TEXT,
photo TEXT, -- base64
kml_name TEXT, kml_content TEXT,
created_at TEXT
)
cameras (
code TEXT PRIMARY KEY,
status TEXT DEFAULT 'active', -- active | removed
lat REAL, lng REAL,
last_maint TEXT, last_by TEXT,
updated_at TEXT
)
operators (
name TEXT PRIMARY KEY,
created_at TEXT, created_by TEXT
)
routes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kml_content TEXT, kml_name TEXT,
kml_updated_at TEXT, kml_updated_by TEXT,
note TEXT,
created_at TEXT, created_by TEXT,
deleted INTEGER DEFAULT 0, deleted_at TEXT
)
route_cameras (
route_id TEXT, code TEXT, seq INTEGER,
PRIMARY KEY (route_id, code)
)
route_kml_history (
hid INTEGER PRIMARY KEY AUTOINCREMENT,
route_id TEXT, kml_content TEXT, kml_name TEXT,
saved_at TEXT, saved_by TEXT
)
purged_records (
id TEXT PRIMARY KEY, purged_at TEXT
)Tables are created on first start. Existing databases are migrated in place — init_db() adds the
soft-delete columns (deleted, deleted_at, deleted_by) to records if they are missing.
ircam/
├── index.html # Single-file frontend (entry / records / roster / settings)
├── server.py # FastAPI backend + SQLite
├── sw.js # Service Worker (offline cache)
├── manifest.json # PWA manifest
├── LICENSE # MIT
├── .gitignore
└── README.md
Runtime artifacts —
venv/,ircam.db,cert/, and any site-specific deploy script — are excluded by.gitignore. Survey data, certificates and deployment credentials must never be committed.
- Field data and credentials never enter the repository.
.gitignoreenforces this; please keep it that way rather than relying on remembering. - Passwords are read from the environment only. Do not add a hard-coded fallback default — booting with a known password is worse than failing to boot.
index.htmlis intentionally dependency-free. Please do not introduce a build step.