Skip to content

API Reference

The same web server that serves the mobile UI exposes a small read-mostly REST API and one WebSocket. It is LAN-only, has no authentication, and runs on port 8080.

http://<desktop-ip>:8080/api/...
ws://<desktop-ip>:8080/ws/tracking

Errors use FastAPI's shape: {"detail": "<message>"} with a 4xx/5xx status (404 unknown satellite, 422 bad datetime, 503 when the engine/rig isn't ready, 409 when a rig action needs a connection).

Server source: src/web/app.py.


REST endpoints

Satellites

GET /api/satellites — optional ?group= (amateur, weather, …, operational, or fav:<id>).

[
  {
    "norad_cat_id": 25544,
    "name": "ISS (ZARYA)",
    "alt_names": ["ZARYA"],
    "status": "alive",
    "updated_at": "2026-05-10T09:00:00Z",
    "amsat_status": "operational",
    "favorite_group": 0,
    "tle_no_result_since": null
  }
]

GET /api/favorites — same shape, every satellite in any custom group.

GET /api/custom-groups — [{ "id": 1, "name": "Favorite 1", "sort_order": 0 }]

PUT /api/satellites/{norad}/favorite-group — body { "group_id": 1 } (0 clears). 204.

Transmitters

GET /api/satellites/{norad}/transmitters

[
  {
    "uuid": "…", "norad_cat_id": 25544, "description": "Voice Repeater",
    "type": "Transponder",
    "downlink_low": 145800000, "downlink_high": 145800000,
    "uplink_low": 437800000, "uplink_high": 437800000,
    "mode": "FM", "invert": false, "baud": null,
    "ctcss_tone": 67.0, "ctcss_tone_type": "…",
    "alive": true, "source": "satnogs", "manual_override": false, "notes": ""
  }
]

Frequencies are in Hz. downlink_low/downlink_high differ for a transponder passband and are equal for a fixed channel.

Passes

GET /api/satellites/{norad}/passes

Query Default Notes
hours 24 prediction window, 0 < hours ≤ 168
min_el 5 minimum max-elevation, degrees
track false include 15-second (az, el) samples per pass
from_dt, to_dt — ISO 8601; overrides hours
[
  {
    "norad_cat_id": 25544,
    "aos": "2026-05-10T12:45:00+00:00",
    "tca": "2026-05-10T12:53:00+00:00",
    "los": "2026-05-10T13:01:00+00:00",
    "max_elevation_deg": 72.4,
    "max_elevation_time": "2026-05-10T12:53:00+00:00",
    "aos_azimuth_deg": 201.0,
    "los_azimuth_deg": 22.0,
    "duration_s": 960.0,
    "duration_seconds": 960.0,
    "quality": "excellent",
    "track_points": null
  }
]

quality is excellent (≥60°) / good (≥30°) / fair (≥10°) / low.

GET /api/passes/group — ?group= (default amateur), from_dt, to_dt, min_el. Each item adds sat_name and amsat_status and omits the azimuth detail.

TLE, status, location

GET /api/tle/status — per-satellite quality_score, epoch, fetched_at, source.

GET /api/status — { "version", "status", "satellite_count", "tle_count", "uptime_s" }

GET /api/amsat — { "<designator>": "<status>" } map.

GET /api/location — observer QTH (latitude_deg, longitude_deg, elevation_m, source, city, country, status_text). 404 if unset.

POST /api/location/browser — body { "latitude", "longitude", "accuracy_m"?, "elevation_m"? }; stores a browser-geolocation fix.

Rig / rotator (mobile Antenna tab)

All return 204; 503 if unavailable, 409 if not connected.

  • POST /api/rig/toggle — toggle Doppler correction
  • POST /api/rot/toggle — toggle rotator tracking
  • POST /api/rig/connect — body { "norad": 25544, "xpdr_uuid": "…" }
  • POST /api/rig/disconnect

WebSocket

ws://<host>:8080/ws/tracking?norad=<id> (defaults to 25544). One JSON message per second for that one satellite:

{
  "norad": 25544,
  "timestamp": "2026-05-10T12:00:00+00:00",
  "elevation_deg": 45.12,
  "azimuth_deg": 180.34,
  "range_km": 412.5,
  "range_rate_km_s": -2.134567,
  "is_above_horizon": true
}

If the engine isn't configured or the TLE is missing, the payload is { "norad": <id>, "error": "<message>" }.


Example

# ISS passes in the next 3 days above 20°
curl "http://localhost:8080/api/satellites/25544/passes?hours=72&min_el=20" | jq '.[].aos'
import websockets, asyncio, json

async def main():
    url = "ws://localhost:8080/ws/tracking?norad=25544"
    async with websockets.connect(url) as ws:
        while True:
            print(json.loads(await ws.recv()))

asyncio.run(main())