# Mobile API Documentation

## Overview

The APRS.me Mobile API provides real-time streaming of APRS packets filtered by geographic bounds for iOS and Android applications.

## Connection

**WebSocket URL:** `wss://aprs.me/mobile/websocket`

**Protocol:** Phoenix Channels over WebSocket

## Authentication

Currently, connections are anonymous. Future versions will support token-based authentication.

## Channel: `mobile:packets`

### Joining

```json
{
  "topic": "mobile:packets",
  "event": "phx_join",
  "payload": {},
  "ref": "1"
}
```

**Response:**
```json
{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "message": "Connected to APRS mobile channel"
    }
  },
  "ref": "1"
}
```

### Subscribing to Geographic Bounds

Subscribe to receive packets within specific geographic bounds. Upon subscription, the server will immediately send historical packets within the bounds, followed by real-time streaming packets.

**Event:** `subscribe_bounds`

**Payload:**
```json
{
  "north": 33.2,
  "south": 33.0,
  "east": -96.0,
  "west": -96.2,
  "limit": 1000,
  "hours_back": 1
}
```

**Payload Fields:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `north` | float | Yes | - | Northern boundary latitude |
| `south` | float | Yes | - | Southern boundary latitude |
| `east` | float | Yes | - | Eastern boundary longitude |
| `west` | float | Yes | - | Western boundary longitude |
| `limit` | integer | No | 1000 | Maximum historical packets to load (max: 5000) |
| `hours_back` | integer | No | 1 | How many hours of historical data to load (max: 24) |

**Example:**
```json
{
  "topic": "mobile:packets",
  "event": "subscribe_bounds",
  "payload": {
    "north": 33.2,
    "south": 33.0,
    "east": -96.0,
    "west": -96.2,
    "limit": 2000,
    "hours_back": 2
  },
  "ref": "2"
}
```

**Response:**
```json
{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "bounds": {
        "north": 33.2,
        "south": 33.0,
        "east": -96.0,
        "west": -96.2
      },
      "message": "Subscribed to packet stream"
    }
  },
  "ref": "2"
}
```

### Updating Bounds

Update the geographic bounds (e.g., when user pans/zooms the map). This updates the real-time streaming filter only; it does not re-send historical packets for the new bounds.

**Event:** `update_bounds`

**Payload:**
```json
{
  "north": 33.3,
  "south": 32.9,
  "east": -95.9,
  "west": -96.3
}
```

**Response:**
```json
{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "bounds": {
        "north": 33.3,
        "south": 32.9,
        "east": -95.9,
        "west": -96.3
      },
      "message": "Bounds updated"
    }
  },
  "ref": "3"
}
```

### Receiving Packets

Once subscribed, you'll receive packets within your bounds. Historical packets are sent immediately after subscription, followed by real-time streaming packets as they arrive.

**Event:** `packet`

**Payload:**
```json
{
  "id": "d7249877-d4a6-45c2-b314-2a8a355d2566",
  "callsign": "K5GVL-10",
  "lat": 33.1225,
  "lng": -96.124,
  "timestamp": "2025-10-25T16:17:20Z",
  "symbol_table_id": "/",
  "symbol_code": "#",
  "comment": "6/SVARA U=13.9V,T=75.3F",
  "altitude": 150.5,
  "speed": 45.2,
  "course": 180,
  "path": "TCPIP*,qAS,K5GVL"
}
```

**Packet Fields:**

| Field | Type | Description | Required |
|-------|------|-------------|----------|
| `id` | string | Unique packet identifier (UUID) | Yes |
| `callsign` | string | Station callsign (sender). For APRS objects/items, this is still the sender callsign, not the object name. | Yes |
| `lat` | float | Latitude (-90 to 90) | Yes |
| `lng` | float | Longitude (-180 to 180) | Yes |
| `timestamp` | string | ISO 8601 timestamp | Yes |
| `symbol_table_id` | string | APRS symbol table (/, \\) | Yes |
| `symbol_code` | string | APRS symbol code | Yes |
| `comment` | string | Station comment/status | Optional |
| `altitude` | float | Altitude in feet | Optional |
| `speed` | float | Speed in knots | Optional |
| `course` | integer | Course in degrees (0-359) | Optional |
| `path` | string | APRS digipeater path | Optional |

**Note:** Optional fields are omitted from the payload entirely when their value is `nil`.

### Receiving Batched Packets (Historical Loads)

When the server delivers historical packets in bulk (for example, the replay
that follows `subscribe_bounds` or `subscribe_callsign`), they arrive as a
batched `packets` event instead of many individual `packet` events. Live
streaming packets continue to use the single `packet` event described above.

**Event:** `packets`

**Payload:**
```json
{
  "packets": [
    { "id": "...", "callsign": "...", "lat": 33.1, "lng": -96.1, "timestamp": "2025-10-25T16:17:20Z", "symbol_table_id": "/", "symbol_code": ">" },
    { "id": "...", "callsign": "...", "lat": 33.2, "lng": -96.2, "timestamp": "2025-10-25T16:17:25Z", "symbol_table_id": "/", "symbol_code": "#" }
  ],
  "count": 2
}
```

Each entry inside `packets` has the same schema as the single-packet `packet`
event above. Batch sizes are capped at 100 entries per frame, so a 1,000-packet
history load arrives as ~10 `packets` events.

**Migration guidance:** existing clients that only listen for the `packet`
event will still receive live updates correctly — they simply won't populate
the historical backlog. To show history, add a handler for `packets` that
iterates over `payload.packets` and applies the same rendering logic.

### Rate Limiting

- **Connect:** up to 30 new socket connections per IP per minute. Exceeding the
  limit returns an immediate `:error` from the socket handshake; retry after
  the minute window elapses.
- **Channel requests:** the expensive operations (`subscribe_bounds`,
  `subscribe_callsign`, `search_callsign`) are rate-limited to 30 calls per
  socket per minute. Exceeding the limit returns
  `{ "status": "error", "response": { "message": "Rate limit exceeded; retry later", "retry_after_ms": <ms> } }`.
- Streaming packet delivery is *not* rate-limited.

### Unsubscribing

Stop receiving packets.

**Event:** `unsubscribe`

**Payload:** `{}`

**Response:**
```json
{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "message": "Unsubscribed from packet stream"
    }
  },
  "ref": "4"
}
```

### Searching for Callsigns

Search for callsigns matching a pattern.

**Event:** `search_callsign`

**Payload:**
```json
{
  "query": "W5ISP",
  "limit": 50
}
```

**Payload Fields:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `query` | string | Yes | - | Callsign to search (supports * wildcard) |
| `limit` | integer | No | 50 | Maximum results to return (max: 500) |

**Response:**
```json
{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "results": [
        {
          "callsign": "W5ISP-9",
          "base_callsign": "W5ISP",
          "last_seen": "2025-10-25T17:30:00Z",
          "lat": 33.1225,
          "lon": -96.124
        }
      ],
      "count": 1
    }
  },
  "ref": "5"
}
```

**Examples:**
- `"W5ISP"` - Finds W5ISP and all SSIDs (W5ISP-1, W5ISP-9, etc.)
- `"W5ISP-9"` - Finds only W5ISP-9
- `"W5ISP*"` - Finds all callsigns starting with W5ISP

### Subscribing to Callsign Updates

Subscribe to live updates for a specific callsign or pattern. When subscribed, you'll receive historical packets for that callsign, followed by real-time updates.

**Event:** `subscribe_callsign`

**Payload:**
```json
{
  "callsign": "W5ISP-9",
  "hours_back": 24
}
```

**Payload Fields:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `callsign` | string | Yes | - | Callsign to track (supports * wildcard) |
| `hours_back` | integer | No | 24 | Hours of historical data to load (max: 168) |

**Response:**
```json
{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "callsign": "W5ISP-9",
      "message": "Subscribed to callsign updates"
    }
  },
  "ref": "6"
}
```

**Note:** When tracking a callsign, you'll receive packets matching that callsign even if they're outside your geographic bounds. To receive only callsign packets within bounds, use both `subscribe_bounds` and `subscribe_callsign` together.

### Unsubscribing from Callsign Updates

Stop receiving updates for the tracked callsign.

**Event:** `unsubscribe_callsign`

**Payload:** `{}`

**Response:**
```json
{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "ok",
    "response": {
      "message": "Unsubscribed from callsign updates"
    }
  },
  "ref": "7"
}
```

## Error Responses

**Invalid Bounds:**
```json
{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "error",
    "response": {
      "message": "North must be greater than south"
    }
  },
  "ref": "2"
}
```

**Not Subscribed:**
```json
{
  "topic": "mobile:packets",
  "event": "phx_reply",
  "payload": {
    "status": "error",
    "response": {
      "message": "Not subscribed. Call subscribe_bounds first."
    }
  },
  "ref": "3"
}
```

## Swift Example

```swift
import SwiftPhoenixClient

class APRSService {
    let socket: Socket
    var channel: Channel?

    init() {
        socket = Socket("wss://aprs.me/mobile/websocket")
        socket.connect()
    }

    func joinChannel() {
        channel = socket.channel("mobile:packets")

        channel?.on("packet") { message in
            if let packet = message.payload as? [String: Any] {
                self.handlePacket(packet)
            }
        }

        channel?.join()
            .receive("ok") { _ in
                print("Joined mobile:packets channel")
                self.subscribeToBounds()
            }
            .receive("error") { error in
                print("Failed to join: \\(error)")
            }
    }

    func subscribeToBounds(north: Double, south: Double, east: Double, west: Double) {
        let bounds = [
            "north": north,
            "south": south,
            "east": east,
            "west": west
        ]

        channel?.push("subscribe_bounds", payload: bounds)
            .receive("ok") { response in
                print("Subscribed to bounds: \\(response)")
            }
            .receive("error") { error in
                print("Subscribe error: \\(error)")
            }
    }

    func updateBounds(north: Double, south: Double, east: Double, west: Double) {
        let bounds = [
            "north": north,
            "south": south,
            "east": east,
            "west": west
        ]

        channel?.push("update_bounds", payload: bounds)
    }

    func handlePacket(_ packet: [String: Any]) {
        guard let callsign = packet["callsign"] as? String,
              let lat = packet["lat"] as? Double,
              let lng = packet["lng"] as? Double else {
            return
        }

        // Update your map with the new packet
        print("Received packet from \\(callsign) at \\(lat), \\(lng)")
    }
}
```

## SwiftUI MapKit Integration

```swift
import SwiftUI
import MapKit

struct APRSMapView: View {
    @StateObject private var aprsService = APRSService()
    @State private var region = MKCoordinateRegion(
        center: CLLocationCoordinate2D(latitude: 33.1, longitude: -96.1),
        span: MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.2)
    )

    var body: some View {
        Map(coordinateRegion: $region)
            .onAppear {
                aprsService.joinChannel()
            }
            .onChange(of: region) { newRegion in
                let center = newRegion.center
                let span = newRegion.span

                aprsService.updateBounds(
                    north: center.latitude + span.latitudeDelta / 2,
                    south: center.latitude - span.latitudeDelta / 2,
                    east: center.longitude + span.longitudeDelta / 2,
                    west: center.longitude - span.longitudeDelta / 2
                )
            }
    }
}
```

## Connection Timeout

WebSocket connections have a 60-second heartbeat timeout. If the server does not receive a heartbeat within 60 seconds, the connection is closed. Phoenix Channels clients send heartbeats automatically (every 30 seconds by default).

## Best Practices

1. **Update bounds only when map movement stops** - Use a debounce to avoid excessive updates
2. **Unsubscribe when app backgrounds** - Save battery and bandwidth
3. **Handle reconnection** - Phoenix Channels will automatically reconnect on connection loss
4. **Validate coordinates** - Always check lat/lng before adding to map
5. **Limit visible area** - Don't subscribe to bounds larger than what the user can see

## Dependencies

**Swift:**
- [SwiftPhoenixClient](https://github.com/davidstump/SwiftPhoenixClient) - Phoenix Channels client

**Installation (Swift Package Manager):**
```swift
dependencies: [
    .package(url: "https://github.com/davidstump/SwiftPhoenixClient.git", from: "5.3.0")
]
```

## Support

For issues or questions:
- GitHub: https://github.com/aprsme/aprs.me/issues
- Documentation: https://docs.aprs.me
