This repo powers RETINA, a passive-radar system: receiver nodes detect aircraft from reflections of broadcast transmitters, and the backend turns those detections into live tracks shown on a web map. "Tower Finder" — the original illuminator-search feature documented below — is one of several surfaces (along with the live map and the admin dashboard).
New here? Start with
ONBOARDING.mdfor the full picture and local setup, anddocs/architecture.mdfor how the pieces fit together.
Web application and API that helps passive radar operators find suitable broadcast tower illuminators near their location.
Given geographic coordinates, the system queries the Maprad.io transmitter database for nearby FM/VHF/UHF broadcast towers, then filters and ranks them by suitability for passive radar use.
backend/ Python API (FastAPI)
frontend/ React SPA (Vite)
dashboard/ Admin dashboard (React/Vite)
docs/ Architecture, pipeline, runbook, simulation, arc-display
libs/ Git submodules
retina-geolocator/ Bistatic passive radar geolocation solver
retina-tracker/ Multi-target Kalman tracker with anomaly detection
retina-custody/ Chain-of-custody signing for node detections
retina-analytics/ Coverage and detection analytics
retina-simulation/ Synthetic node fleet and world model
git clone --recursive https://github.com/offworldlabs/retina-server.git
cd retina-server
# If already cloned without --recursive:
git submodule update --init --recursivejust setupThat is the supported path: it initialises the submodules, builds the backend venv
with uv, installs all five libs/ packages editable, seeds backend/.env from
the example, applies the database migrations (backend/data/users.db does not
exist yet on a fresh clone, and create_all no longer builds it outside the test
suite), and installs the frontend dependencies. Install all five even if you only
care about tower search: retina-simulation imports the other four, so a partial
install fails at import time rather than at use.
Then either run the whole local stack:
just up # uvicorn + synthetic fleet + Vite, with hot reload
just status # what is alive
just down # stop itor just the API on its own:
cd backend && .venv/bin/uvicorn main:app --reloadThe API runs at http://localhost:8000. Interactive docs at /docs. Add your
Maprad.io API key to backend/.env for tower search; the live map does not need
it.
The schema is owned by Alembic (backend/migrations/). create_all runs only
in the test suite, which sets RETINA_SCHEMA_SOURCE=create_all; everywhere else
migrations are applied on every start, by just up locally (just migrate runs
it on its own) and by deploy/start.sh on every container boot. Pulling a branch
that adds a revision therefore needs nothing extra.
To change the schema, edit the models, then from backend/:
uv run alembic revision --autogenerate -m "what changed"
uv run alembic upgrade headReview the generated file before committing. Anything other than create_table
must go through op.batch_alter_table, because SQLite cannot ALTER.
RETINA_DB_PATH points Alembic at a scratch file if you want to try a
migration without touching backend/data/users.db.
just setup already installed the dependencies, and just up runs this alongside
the backend. To run it on its own:
cd frontend && npm run devOpens at http://localhost:5173. API calls are proxied to the backend during
development.
The four endpoints under /v1/nodes that receiver nodes talk to are a versioned
wire contract, published at contracts/nodes-v1.openapi.yaml.
The node client and the conformance harness are independent implementations of
it, so it is the one part of this API with consumers holding a pinned version.
That file is generated, not written. It comes from the routes and the Pydantic models, and CI regenerates it and fails when it differs from what is committed, which is what stops the file being edited to match a change instead of the change being noticed. Change a node route, then:
cd backend && RETINA_ENV=dev .venv/bin/python -m scripts.generate_openapiand commit the result alongside. Behaviour a schema cannot carry (whether a
refusal may be retried, and whether retrying can ever help) is annotated onto
the routes as x-retry and x-terminal, and the vocabulary is defined in the
contract's own description. A breaking change raises NODE_API_VERSION in
backend/routes/nodes.py.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
lat |
float | yes | Latitude (-90 to 90) | |
lon |
float | yes | Longitude (-180 to 180) | |
altitude |
float | no | 0 | Receiver altitude in metres |
limit |
int | no | 20 | Max towers to return (1–100) |
source |
string | no | au | Data source: au, us, ca |
Response:
{
"towers": [
{
"rank": 1,
"callsign": "ATN6",
"name": "ABC Tower 221 Pacific Highway GORE HILL",
"state": "NSW",
"frequency_mhz": 177.5,
"band": "VHF",
"latitude": -33.820079,
"longitude": 151.185,
"distance_km": 5.9,
"bearing_deg": 337.5,
"bearing_cardinal": "NNW",
"received_power_dbm": -7.7,
"distance_class": "Too Close",
"eirp_dbm": 79.1,
"licence_type": "Broadcasting",
"licence_subtype": "Commercial Television"
}
],
"query": { "latitude": -33.8688, "longitude": 151.2093, "altitude_m": 0, "radius_km": 80, "source": "au" },
"count": 20
}- Fetch all FM, VHF and UHF transmitters within 80 km from Maprad.io
- Discard towers whose estimated received power is below −95 dBm
- Classify each tower by band (VHF / UHF / FM) and distance suitability:
- Too Close (< 8 km) — direct signal may overwhelm the receiver
- Ideal (8–30 km) — best bistatic geometry
- Good (30–60 km) — workable
- Far (> 60 km) — fallback only
- Rank by: band preference (VHF → UHF → FM) → distance class → signal strength
- Return top N results
- Backend: Python 3.11+, FastAPI, httpx
- Frontend: React 18, Vite, Leaflet
- Data source: Maprad.io GraphQL API (ACMA RRL, FCC ULS, ISED SMS)