# RadarScope — Cross-platform ADS-B Aircraft Radar Widget

## Build brief for Claude Code

Build a small, always-on-top desktop widget that displays a classic circular radar
scope centred on a fixed location (entered by UK postcode) and plots live nearby
aircraft as blips, with an animated sweep. Target **Linux (Ubuntu/GNOME), macOS, and
Windows** from one Python/Qt codebase. This document is the specification — implement
it; do not just echo it back.

> **Reliability note for the implementer:** wherever this brief names a run command, a
> file, or an acceptance test, treat them as a contract that must all agree. If you add
> a way to launch the app, add the file that makes that launch work and verify it the
> exact way a user will (see §11.1). Do not assume a documented command works because
> the code imports — import success and launch success are different things.

---

## 1. Goal & behaviour

- A frameless, draggable, resizable window (default ~360×360 px) that floats over other
  windows in a corner of the screen.
- A round radar scope: dark background, concentric **range rings**, compass ticks
  (N/E/S/W), an animated **sweep line** rotating clockwise (~6 s per revolution) with a
  fading trailing gradient behind it.
- **Aircraft blips** positioned by their real range and bearing from the home location.
  Each blip is a small triangle pointed in the aircraft's direction of travel (its
  ground track), with a short label (callsign + flight level).
- Home location is entered once as a **UK postcode**, geocoded to lat/lon, and persisted.
- Scope range (e.g. 50 miles) is configurable; range rings are drawn at 25 / 50 / 75 /
  100 % of that range, labelled in **statute miles** (mi).
- Clicking (or hovering) a blip shows a small detail readout: callsign, aircraft type,
  registration, altitude, ground speed, track, squawk, plus computed distance (in miles)
  and bearing from home.

### Units — read this before implementing
Everything the **user sees** is in **statute miles** ("miles" / `mi`) — most people have
no feel for nautical miles. But the **machine layer stays in nautical miles** because the
inputs demand it: the aircraft feed's `radius` parameter is in nautical miles (§3.2) and
ground speed `gs` is in **knots = nm/hour** (so dead-reckoning is naturally nm). Mixing
units mid-calculation is the easy way to ship a subtly-wrong scope.

Rule: **compute in nautical miles, convert once at the display boundary.**
- Conversion constant: `1 nm = 1.150779 statute miles` (`MILES_PER_NM`); `miles_to_nm()`
  and `nm_to_miles()` live in `geo.py` and are the only place the factor appears.
- Config, ring labels, the settings dialog, and the detail panel are in miles.
- The haversine result, `gs` (knots), the API `radius`, and the polar→screen ratio stay
  in nm. Convert to miles only when drawing text.
- The widget polls the aircraft feed on a timer; the sweep animates independently and
  smoothly between polls (see §6 dead-reckoning).

---

## 2. Tech stack (recommended)

- **Python 3.11+** (works on Linux, macOS, and Windows).
- **PySide6** (official Qt for Python, LGPL) for the GUI and `QPainter`-based custom
  drawing. Use PySide6 rather than PyQt6 to keep licensing clean. Qt itself is
  cross-platform, so the GUI, painting, and threading code is identical on all three OSes.
- **httpx** (or `requests`) for HTTP. Prefer `httpx` for timeouts/async-friendliness.
- Standard library `math` for the geo maths; no heavy geo deps needed.
- Config persisted as **TOML** via `tomllib` (read) + `tomli-w` (write), or JSON if you
  prefer zero extra deps.
- **Do not hard-code OS-specific paths.** Resolve the config directory at runtime with a
  cross-platform helper — `QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation)`
  (ships with Qt) or the `platformdirs` package. See §8 for the per-OS targets.

Package with a `venv` + `requirements.txt`, **and** a `pyproject.toml` that declares a
console-script entry point (see §10). Provide platform launchers/autostart hooks for each
OS (see §9). The app must run unchanged on X11/XWayland, macOS, and Windows.

---

## 3. Data sources (all free, no API key)

### 3.1 Postcode → coordinates — postcodes.io
```
GET https://api.postcodes.io/postcodes/{postcode}
```
Response shape:
```json
{ "status": 200, "result": { "postcode": "GL14 2AB", "latitude": 51.82, "longitude": -2.49 } }
```
Use `result.latitude` / `result.longitude`. Validate first with
`GET /postcodes/{postcode}/validate` (returns `{"result": true|false}`) and surface a
friendly error on a bad postcode. No key, no auth.

### 3.2 Aircraft — adsb.lol (primary)
Base URL: `https://api.adsb.lol`
```
GET /v2/point/{lat}/{lon}/{radius}
```
- `radius` is in **nautical miles**, capped at **250 nm**. This is an API constraint — do
  **not** pass miles here. Convert the user's mile-based scope range with `miles_to_nm()`
  and clamp the result to 250 nm before building the URL. (250 nm ≈ 287.7 miles, so the
  miles scope range effectively tops out around 287 mi.)
- No API key currently required; rate limits are dynamic under load. Be polite (see §7).
- ADSB-Exchange v2 compatible. Response:
```json
{ "ac": [ { ...aircraft fields... } ], "msg": "No error", "now": 1700000000000, "total": 12 }
```

**Fallback source: airplanes.live** — identical response format and endpoint
(`https://api.airplanes.live/v2/point/{lat}/{lon}/{radius}`), documented soft limit of
about 1 request/second. Make the data source selectable in config and fail over to the
fallback if the primary returns errors repeatedly.

### 3.3 Relevant aircraft fields (per object in `ac[]`)
| Field       | Meaning                                              |
|-------------|------------------------------------------------------|
| `hex`       | ICAO 24-bit address (unique id, use as the key)      |
| `flight`    | Callsign (may have trailing spaces — `.strip()`)     |
| `r`         | Registration (tail number)                           |
| `t`         | Aircraft type code (e.g. `B738`)                     |
| `desc`      | Human-readable type description (if present)         |
| `lat`,`lon` | Position, decimal degrees                            |
| `alt_baro`  | Barometric altitude in feet, or the string `"ground"`|
| `gs`        | Ground speed, knots                                  |
| `track`     | Ground track / heading, degrees true (0–360)         |
| `baro_rate` | Vertical rate, ft/min (climb +, descend −)           |
| `squawk`    | Transponder code (watch 7500/7600/7700 — emergency)  |
| `seen_pos`  | Seconds since this position was last updated         |
| `dbFlags`   | Bitfield; bit for military aircraft                  |

Ignore objects with no `lat`/`lon`. Treat `alt_baro == "ground"` as altitude 0 and mark
the blip differently (e.g. dimmed) if you wish.

---

## 4. Geo maths (home → aircraft)

For each aircraft compute **range** and **bearing** from the home location
(φ1,λ1 = home in radians; φ2,λ2 = aircraft).

**Great-circle distance (haversine)** — compute in nautical miles (Earth radius
R = 3440.065 nm), then convert to miles for display via `nm_to_miles()`:
```
a = sin²(Δφ/2) + cos φ1 · cos φ2 · sin²(Δλ/2)
d = 2R · atan2(√a, √(1−a))
```

**Initial bearing** (home → aircraft), then normalise to 0–360°:
```
θ = atan2( sin Δλ · cos φ2 ,
           cos φ1 · sin φ2 − sin φ1 · cos φ2 · cos Δλ )
bearing = (degrees(θ) + 360) mod 360
```

**Polar → screen** (centre `cx,cy`; 0° = North = straight up; clockwise). The ratio is
unit-agnostic, so keep both terms in nm and don't convert here:
```
r_px = (range_nm / scope_range_nm) · R_max_px      # clamp/ hide if range > scope
x = cx + r_px · sin(radians(bearing))
y = cy − r_px · cos(radians(bearing))
```
Hide (or clip to the rim) any aircraft beyond the scope range.

Keep all of §4 in a dependency-free `geo.py` module so it can be unit-tested without Qt or
the network (see §11.1).

---

## 5. Rendering details (QPainter)

- **Background:** near-black, optional slight translucency on the window.
- **Range rings:** thin phosphor-green circles at 25/50/75/100 % of `R_max_px`, each
  labelled with its value in **miles** (e.g. `25mi`). Faint cross-hairs / compass ticks
  with N E S W letters, North at top.
- **Sweep:** a line from centre to rim rotating clockwise; behind it a radial gradient
  "afterglow" wedge (≈30–45°) fading to transparent. Animate via a `QTimer` at ~60 fps
  driving an angle; this is purely cosmetic and independent of data polling.
- **Blips:** small filled triangle rotated to the aircraft's `track` so it points where
  the aircraft is heading. Colour-band by altitude (e.g. low = amber, mid = green,
  high = cyan); dim by age using `seen_pos` (older = more transparent); flash/red-ring any
  aircraft squawking 7500/7600/7700.
- **Blip labels:** callsign on line 1, `FLxxx` (alt_baro/100, zero-padded) on line 2.
  Keep small and legible; offset so labels don't overlap the triangle.
- **Fonts:** request a monospace family in a cross-platform-safe way
  (`QFontDatabase.systemFont(QFontDatabase.FixedFont)` or `QFont` with `StyleHint`
  `Monospace`) so it renders on Linux, macOS, and Windows without hard-coding a font name.
- **Optional sweep "paint":** brighten a blip when the sweep passes over it, then let it
  decay — mimics a real PPI scope.
- **Detail panel:** on hover/click, a compact overlay box with callsign, type (`t`/`desc`),
  registration, altitude + vertical trend arrow, ground speed, track, squawk, and the
  computed distance (in **miles**) and bearing (°) from home.

---

## 6. Polling, threading & smoothing

- Poll the aircraft endpoint every **5–10 s** (configurable; default 8 s). Do network I/O
  on a **worker thread / QThread** (or `QThreadPool` + signal) so the UI never blocks.
- Maintain an aircraft dict keyed by `hex`. On each poll, update existing entries, add new
  ones, and expire any not seen for > N seconds (e.g. 60 s) with a short fade-out.
- **Dead-reckoning between polls:** each animation frame, advance every aircraft's
  position from its last fix using `gs` and `track` and the elapsed time, so blips glide
  smoothly instead of jumping every poll. Snap back to the true position on the next poll.
  (Distance per frame = gs_knots × Δt_hours; project along `track` with the bearing maths.)
- Use a **monotonic clock** (`time.monotonic()`) for all animation/dead-reckoning timing
  so the widget is immune to wall-clock/NTP/DST adjustments on any OS.

---

## 7. Networking etiquette & errors

- Set a descriptive `User-Agent` (e.g. `RadarScope/1.0 (personal hobby widget)`).
- Sensible timeouts (connect 5 s, read 10 s). On timeout/5xx, keep showing the last good
  data, show a small "stale" indicator, and back off (exponential, capped) before retry.
- On repeated primary-source failure, fail over to the configured fallback source.
- Never poll faster than the configured interval; respect the ~1 req/s soft limit if using
  airplanes.live.

---

## 8. Configuration & persistence

Store config as `config.toml` inside the platform's standard per-user config directory.
**Resolve this at runtime — do not hard-code `~/.config`.** Targets:

| OS      | Config directory                                              |
|---------|--------------------------------------------------------------|
| Linux   | `$XDG_CONFIG_HOME/radarscope/` (default `~/.config/radarscope/`) |
| macOS   | `~/Library/Application Support/radarscope/`                   |
| Windows | `%APPDATA%\radarscope\` (e.g. `C:\Users\me\AppData\Roaming\radarscope\`) |

`QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation)` returns the right
directory on each OS after `app.setApplicationName("RadarScope")`; `platformdirs` is an
equivalent dependency-light alternative. Create the directory if missing.

Config contents:
```toml
postcode       = "GL14 2AB"    # entered once; lat/lon cached below
home_lat       = 51.82
home_lon       = -2.49
scope_range_mi = 50            # statute miles, 1–287 (converted to nm, clamped 250, for the API)
poll_seconds   = 8
data_source    = "adsb.lol"    # or "airplanes.live"
[window]
x = 1500
y = 60
size = 360
always_on_top = true
```
- First run (no config / no postcode): show a tiny settings dialog to enter a postcode;
  geocode via postcodes.io and cache lat/lon. Offer a settings dialog later to change
  postcode, scope range, poll interval, and data source.
- Remember window position and size between runs. (Clamp the restored position to the
  current virtual desktop so a saved off-screen position on one OS/monitor layout can't
  open the window where it can't be reached.)

---

## 9. Platform notes (important)

The GUI, drawing, networking, geo, and threading code is fully cross-platform via Qt. Only
window-manager integration and autostart differ per OS — handle them explicitly.

### 9.1 Linux / GNOME / Wayland
GNOME defaults to **Wayland**, which restricts programmatic window positioning and
"always-on-top". `Qt.WindowStaysOnTopHint` and manual placement are honoured reliably only
under **X11 / XWayland**.
- Detect the session type (`XDG_SESSION_TYPE`).
- Under Wayland, either force XWayland (`QT_QPA_PLATFORM=xcb`) via the launcher, or degrade
  gracefully and log a clear note that always-on-top/positioning may be limited.
- The provided `radarscope.desktop` launcher sets `QT_QPA_PLATFORM=xcb`. Document this in
  the README.

### 9.2 macOS
- `Qt.WindowStaysOnTopHint` and manual placement work without special flags.
- A frameless, draggable window is fine; consider `Qt.Tool` so the widget doesn't grab a
  Dock icon / app-switcher slot if you want a true "widget" feel.
- No Wayland concerns. The `QT_QPA_PLATFORM=xcb` workaround is Linux-only — do not set it
  on macOS.

### 9.3 Windows
- `Qt.WindowStaysOnTopHint` and manual placement work without special flags.
- A frameless window needs your own drag handling (already required by §1) and, if you want
  resize handles, hit-testing near the edges.
- High-DPI: enable Qt's automatic scaling so the scope stays crisp on scaled displays.

In all cases, guard OS-specific code behind a runtime platform check (`sys.platform` /
`QSysInfo`) rather than assuming Linux.

---

## 10. Project layout

```
radarscope/
├── pyproject.toml         # build metadata + console-script entry point (see below)
├── radarscope/
│   ├── __init__.py
│   ├── __main__.py        # REQUIRED: enables `python -m radarscope` -> calls main()
│   ├── main.py            # entry point: QApplication, window wiring, def main()
│   ├── scope_widget.py    # QWidget + QPainter: rings, sweep, blips, labels, hit-testing
│   ├── geo.py             # haversine, bearing, polar→screen, dead-reckoning (no Qt/net)
│   ├── data_source.py     # postcodes.io + adsb.lol/airplanes.live clients, failover
│   ├── poller.py          # QThread worker + signals, backoff
│   ├── model.py           # Aircraft dataclass, expiry/aging logic
│   ├── config.py          # cross-platform config dir, load/save TOML, defaults
│   └── settings_dialog.py # postcode / range / interval / source UI
├── requirements.txt
├── radarscope.desktop     # Linux/GNOME launcher (sets QT_QPA_PLATFORM=xcb)
├── packaging/
│   ├── macos-launchd/com.user.radarscope.plist   # optional macOS Login/launchd agent
│   └── windows/radarscope.vbs                     # optional Windows startup-folder launcher
├── systemd/radarscope.service  # optional Linux --user service
└── README.md              # install, run, config, attribution, per-OS notes
```

**Both launch paths must exist and must work:**

1. `python -m radarscope` — requires `radarscope/__main__.py`. It must do nothing but call
   the real entry point, e.g.:
   ```python
   # radarscope/__main__.py
   import sys
   from .main import main
   if __name__ == "__main__":
       sys.exit(main())
   ```
   `main.py` exposes `def main() -> int`. Without `__main__.py`, `python -m radarscope`
   fails with *"'radarscope' is a package and cannot be directly executed"* — this is the
   single most common way this spec gets mis-built, so add the file and test it (§11.1).

2. `radarscope` console script — declared in `pyproject.toml` so `pip install .` puts a
   launcher on PATH that works identically on Linux/macOS/Windows:
   ```toml
   [project.scripts]
   radarscope = "radarscope.main:main"
   ```

---

## 11. Run instructions to include in README

Per-OS, because venv activation differs:

**Linux / macOS**
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python -m radarscope            # first run prompts for postcode
# Linux/Wayland: prefix with QT_QPA_PLATFORM=xcb (or use the .desktop launcher)
```

**Windows (PowerShell)**
```powershell
py -3 -m venv .venv; .\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python -m radarscope            # first run prompts for postcode
```

### 11.1 Required verification before declaring done

Importing the modules is **not** proof the app runs. Verify the way a user will:

1. **Unit-test `geo.py`** against known values (e.g. London→Paris ≈ 185 nm ≈ 213 miles,
   bearing ≈ 148°; `nm_to_miles(1) ≈ 1.1508`; due-North placement lands straight up;
   600 kt for 60 s dead-reckons 10 nm). No Qt/network.
2. **Headless render** with `QT_QPA_PLATFORM=offscreen`: build the widget, inject a mix of
   aircraft (normal, emergency squawk, on-ground, one beyond scope range, one with no
   position), call `widget.grab()` to force a real `paintEvent`, and assert the on-scope /
   dropped / clipped counts. Catches paint-path crashes.
3. **Actually invoke the documented launch command** — run `python -m radarscope` itself (it
   may be killed after a moment under `offscreen`). This is the step that catches a missing
   `__main__.py`; importing `radarscope.main` would not.

---

## 12. Attribution & licensing (must include)

- adsb.lol data is licensed **ODbL 1.0** — show an attribution line in an About box / the
  README crediting adsb.lol (and airplanes.live if used).
- Postcode geocoding by **postcodes.io** (Open Government Licence data).
- This is a personal/hobby tool; do not redistribute the feed or use it commercially
  without checking each source's terms.

---

## 13. Stretch goals (optional, after the core works)

- Audio "ping" when a new contact first appears; distinct alert tone for emergency squawks.
- Hotkeys to cycle scope range (10 / 25 / 50 / 100 / 250 nm).
- Military-only filter using the `dbFlags` bit.
- Offline type/operator enrichment from adsb.lol's `vrs-standing-data` for nicer labels.
- Click a blip to open it on a web tracker (e.g. the aircraft's hex on a map site).
- A faint coastline/airport overlay for context.
- System-tray icon (cross-platform via `QSystemTrayIcon`) to show/hide and quit.

---

## 14. Acceptance criteria

1. Enter a UK postcode → scope centres on that location; lat/lon cached in the platform's
   standard per-user config dir (§8).
2. Live aircraft within the scope range appear as correctly-placed, correctly-oriented
   blips with callsign + flight level, updating every poll and gliding between polls.
3. Sweep animates smoothly at ~60 fps regardless of poll cadence; UI never freezes during
   network calls.
4. Hover/click a blip shows full detail including computed distance & bearing.
5. Window is frameless, draggable, resizable, stays on top (under X11/XWayland on Linux,
   natively on macOS/Windows), and remembers its position/size.
6. Network failures degrade gracefully (stale indicator, backoff, failover) without
   crashing.
7. **Both** `python -m radarscope` and the installed `radarscope` console script launch the
   app on a clean checkout (verified per §11.1, step 3), on at least the developer's OS.
