# 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": } }`. - 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