Architecture

How the components connect, the boot sequence, and the data flow.

Network Topology

DeskRadar operates in one of two network modes:

AP Mode (initial setup / fallback)

The Pi creates a WiFi access point named deskradar (password: deskradaradsb) with IP 10.42.0.50. The MatrixPortal S3 joins this AP automatically (its wifi.txt is pre-configured with these credentials). The web configurator is reachable at http://10.42.0.50:5000.

  Laptop/Phone ──── WiFi (SSID: deskradar) ────► Pi (AP, 10.42.0.50)
                                                      │
                                            MatrixPortal S3
                                            also on this AP

LAN Mode (normal operation)

Once a home WiFi network is configured via the web UI, both the Pi and the MatrixPortal join it. The Pi's boot service resolves deskradar-portal.local via mDNS to find the MatrixPortal's IP and writes it to the config file.

  Home Router ──── Pi (deskradar.local)
              └─── MatrixPortal S3 (deskradar-portal.local)

Boot Sequence

systemd enforces this ordering on every Pi boot:

  1. deskradar-lcd.service starts
     └─ FastAPI server on :8010 ready to accept LCD messages

  2. deskradar-bootstrap.service starts (oneshot, requires lcd)
     ├─ Waits 10 seconds for network to stabilise
     ├─ Resolves deskradar-portal.local (retries every 2s, 30s timeout)
     ├─ Sends status updates to LCD service throughout
     └─ Writes resolved IP to /etc/deskradar/config.json as MATRIX_HTTP_URL

  3. deskradar.service starts (after bootstrap + network-online)
     └─ Main aircraft tracking app, reads config, polls dump1090

  4. deskradar-configurator.service starts (after bootstrap + network-online)
     └─ Flask web UI on :5000
If bootstrap fails (mDNS timeout), deskradar.service and deskradar-configurator.service may start without a valid MATRIX_HTTP_URL. The main app will log connection errors until the MatrixPortal becomes reachable.

Service Dependency Graph

  network-online.target
        │
        ▼
  deskradar-lcd ──────────────────────────────────┐
        │                                         │
        ▼ (Requires)                              │
  deskradar-bootstrap (oneshot)                   │
        │                                         │
        ├──► deskradar (After)          (After) ◄─┘
        │                                         │
        └──► deskradar-configurator (After) ◄─────┘
ServiceTypeRestartUser
deskradar-lcdsimplealways(default)
deskradar-bootstraponeshoton-failureroot
deskradarsimplealwaysroot
deskradar-configuratorsimplealways(default)

Data Flow

Per polling cycle (every POLL_CADENCE seconds)

  dump1090 API (:8080/data/aircraft.json)
        │
        │  HTTP GET
        ▼
  fetch raw JSON
        │
        ▼
  sanitise_new_data()
  ├─ drop aircraft missing required fields
  ├─ drop altitude below MIN_ALT
  └─ drop aircraft outside lat/lon bounds
        │
        ▼
  iterate_data()  — merge new points into per-flight store
        │
        ▼
  format_blocks()  — 10-stage pipeline per flight
  │  1. clear_hidden()             remove deferred smoothing flags
  │  2. clear_closest_marker()     reset previous closest colour
  │  3. enforce_max_history()      trim to MAX_HISTORY points
  │  4. enforce_max_time()         remove if unseen > MAX_UNSEEN_SECS
  │  5. plot()                     lat/lon → pixel coordinates
  │  6. remove_latest_dupe()       drop points at same pixel (within 500ft alt)
  │  7. fill_gaps()                interpolate across gaps > 1 pixel
  │  8. smooth_steps_deferred_head() remove 90° corners from track
  │  9. add_colour()               altitude → RGB (white→yellow→green→blue→purple→red)
  │ 10. fade()                     dim trail colours by FADE_FACTOR each cycle
        │
        ▼
  extract_draw()
  ├─ skip _hidden points
  └─ deduplicate by pixel: keep highest-altitude point per (x,y)
        │
        ├──► send_matrix()   HTTP POST /draw  to MatrixPortal
        │    or send_no_change() if pixel data unchanged
        │
        └──► manage_closest()  HTTP POST /display to LCD service
             (sends closest aircraft callsign + spinner)

State: the flight store

The main app maintains an in-memory dictionary keyed by ICAO flight callsign. Each entry is a list of data points accumulated over time:

  store = {
    "BAW123": [
      {
        "timestamp": 1745000000.0,
        "hex": "400abc",
        "flight": "BAW123",
        "seen": 0.5,
        "alt": 28000,
        "lat": 51.234,
        "lon": -0.456,
        "x": 34,
        "y": 21,
        "r": 30,
        "g": 80,
        "b": 255
      },
      ...
    ],
    ...
  }

Points accumulate up to MAX_HISTORY. Aircraft not seen for MAX_UNSEEN_SECS are removed from the store entirely.

Altitude Color Mapping

The radar_altitude_to_rgb() function maps altitude (0–42,000 ft) to a smooth gradient across 8 stops. Colors are linearly interpolated between stops:

AltitudeColorRGB
0 ftWhite255, 255, 255
6,000 ftYellow255, 255, 0
12,000 ftGreen0, 200, 0
18,000 ftLight Blue0, 200, 255
24,000 ftMid Blue0, 80, 255
30,000 ftDark Blue0, 0, 200
36,000 ftPurple150, 0, 200
42,000 ftRed255, 0, 0

Stale aircraft (seen but not updated recently) are rendered in red regardless of altitude. The closest aircraft is recolored to CLOSEST_COLOUR (white by default).

LCD Display Protocol

Any service that wants to show text on the LCD sends a POST request to the LCD service on the Pi:

POST http://127.0.0.1:8010/display
Content-Type: application/json

{
  "line1": "BAW123",
  "line2": ""
}

The LCD service deduplicates: if the message hasn't changed it returns {"status": "ok", "skipped": true} and does not re-send I2C commands.

Configuration Update Flow

  Browser → POST /config (Flask configurator)
                │
                ▼
           app.py merges form values into existing config dict
                │
                ▼
           calls update_config.py subprocess (JSON on stdin)
                │
                ├─ writes /etc/deskradar/config.json
                └─ sudo systemctl restart deskradar