From ddbb72b1b2c6ca17e205b35647ffa0d1022c1adc Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Oct 2025 11:25:18 -0500 Subject: [PATCH] Add mobile API for iOS/Android real-time packet streaming - Create MobileChannel for geographic bounds-based packet filtering - Add MobileUserSocket for mobile client connections - Implement subscribe_bounds, update_bounds, and unsubscribe events - Leverage existing StreamingPacketsPubSub infrastructure - Add comprehensive mobile API documentation with Swift examples - WebSocket endpoint: wss://aprs.me/mobile/websocket - Channel: mobile:packets This enables iOS/Android apps to receive real-time APRS packets filtered by geographic viewport, with efficient bandwidth usage. --- CLAUDE.md | 15 + docs/mobile-api.md | 367 ++++++++++++++++++ lib/aprsme_web/channels/mobile_channel.ex | 241 ++++++++++++ lib/aprsme_web/channels/mobile_user_socket.ex | 36 ++ lib/aprsme_web/endpoint.ex | 5 + 5 files changed, 664 insertions(+) create mode 100644 docs/mobile-api.md create mode 100644 lib/aprsme_web/channels/mobile_channel.ex create mode 100644 lib/aprsme_web/channels/mobile_user_socket.ex diff --git a/CLAUDE.md b/CLAUDE.md index 703132f..c45f827 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,20 @@ This is a web application written using the Phoenix web framework. +## Mobile API + +The application provides a real-time WebSocket API for mobile clients (iOS/Android): + +- **Endpoint**: `wss://aprs.me/mobile/websocket` +- **Channel**: `mobile:packets` +- **Documentation**: See `docs/mobile-api.md` for complete API reference +- **Implementation**: `lib/aprsme_web/channels/mobile_channel.ex` + +**Key Features:** +- Real-time streaming of APRS packets +- Geographic bounds filtering (only receive packets in viewport) +- Dynamic bounds updates (follow user as they pan/zoom) +- Efficient - leverages existing StreamingPacketsPubSub infrastructure + ## Elixir guidelines - Elixir lists **do not support index based access via the access syntax** diff --git a/docs/mobile-api.md b/docs/mobile-api.md new file mode 100644 index 0000000..db0c232 --- /dev/null +++ b/docs/mobile-api.md @@ -0,0 +1,367 @@ +# 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. + +**Event:** `subscribe_bounds` + +**Payload:** +```json +{ + "north": 33.2, + "south": 33.0, + "east": -96.0, + "west": -96.2 +} +``` + +**Example:** +```json +{ + "topic": "mobile:packets", + "event": "subscribe_bounds", + "payload": { + "north": 33.2, + "south": 33.0, + "east": -96.0, + "west": -96.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). + +**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 real-time packets within your bounds. + +**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 | Yes | +| `callsign` | string | Station callsign | 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 meters | Optional | +| `speed` | float | Speed in knots | Optional | +| `course` | integer | Course in degrees (0-359) | Optional | +| `path` | string | APRS digipeater path | Optional | + +### 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" +} +``` + +## 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 + ) + } + } +} +``` + +## Rate Limiting + +The API is rate-limited to prevent abuse: +- 200 requests per minute per IP address +- Connections are automatically closed if idle for >60 seconds + +## 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 diff --git a/lib/aprsme_web/channels/mobile_channel.ex b/lib/aprsme_web/channels/mobile_channel.ex new file mode 100644 index 0000000..efe584d --- /dev/null +++ b/lib/aprsme_web/channels/mobile_channel.ex @@ -0,0 +1,241 @@ +defmodule AprsmeWeb.MobileChannel do + @moduledoc """ + Channel for mobile clients to receive real-time APRS packets filtered by geographic bounds. + + ## Usage + + 1. Connect to socket: wss://aprs.me/mobile/websocket + 2. Join channel: mobile:packets + 3. Subscribe to bounds: push "subscribe_bounds" with bounds payload + 4. Receive packets: listen for "packet" events + 5. Update bounds: push "update_bounds" with new bounds + + ## Messages + + ### Client -> Server + + - "subscribe_bounds" - Subscribe to packets within geographic bounds + Payload: %{north: float, south: float, east: float, west: float} + + - "update_bounds" - Update subscription bounds (e.g., when user pans/zooms map) + Payload: %{north: float, south: float, east: float, west: float} + + - "unsubscribe" - Stop receiving packets + + ### Server -> Client + + - "packet" - New APRS packet within subscribed bounds + Payload: %{ + id: string, + callsign: string, + lat: float, + lng: float, + timestamp: string (ISO 8601), + symbol_table_id: string, + symbol_code: string, + comment: string (optional), + altitude: float (optional), + speed: float (optional), + course: integer (optional) + } + + - "subscription_confirmed" - Bounds subscription successful + Payload: %{bounds: map, message: string} + """ + use AprsmeWeb, :channel + + require Logger + + @impl true + def join("mobile:packets", _payload, socket) do + Logger.info("Mobile client joined packets channel") + {:ok, %{message: "Connected to APRS mobile channel"}, socket} + end + + @impl true + def handle_in("subscribe_bounds", %{"north" => north, "south" => south, "east" => east, "west" => west}, socket) do + bounds = %{ + north: ensure_float(north), + south: ensure_float(south), + east: ensure_float(east), + west: ensure_float(west) + } + + # Validate bounds + with :ok <- validate_bounds(bounds) do + # Generate unique client ID + client_id = "mobile_#{:erlang.phash2(self())}" + + # Subscribe to StreamingPacketsPubSub with bounds + Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), bounds) + + # Store client info in socket + socket = + socket + |> assign(:client_id, client_id) + |> assign(:bounds, bounds) + |> assign(:subscribed, true) + + Logger.info("Mobile client #{client_id} subscribed to bounds: #{inspect(bounds)}") + + {:reply, {:ok, %{bounds: bounds, message: "Subscribed to packet stream"}}, socket} + else + {:error, reason} -> + {:reply, {:error, %{message: reason}}, socket} + end + end + + @impl true + def handle_in("update_bounds", %{"north" => north, "south" => south, "east" => east, "west" => west}, socket) do + bounds = %{ + north: ensure_float(north), + south: ensure_float(south), + east: ensure_float(east), + west: ensure_float(west) + } + + # Validate bounds + with :ok <- validate_bounds(bounds), + true <- socket.assigns[:subscribed] do + # Unsubscribe from old bounds + if socket.assigns[:bounds] do + Aprsme.StreamingPacketsPubSub.unsubscribe_from_bounds(self(), socket.assigns.bounds) + end + + # Subscribe to new bounds + Aprsme.StreamingPacketsPubSub.subscribe_to_bounds(self(), bounds) + + socket = assign(socket, :bounds, bounds) + + Logger.debug("Mobile client #{socket.assigns.client_id} updated bounds: #{inspect(bounds)}") + + {:reply, {:ok, %{bounds: bounds, message: "Bounds updated"}}, socket} + else + {:error, reason} -> + {:reply, {:error, %{message: reason}}, socket} + + false -> + {:reply, {:error, %{message: "Not subscribed. Call subscribe_bounds first."}}, socket} + end + end + + @impl true + def handle_in("unsubscribe", _payload, socket) do + if socket.assigns[:subscribed] && socket.assigns[:bounds] do + Aprsme.StreamingPacketsPubSub.unsubscribe_from_bounds(self(), socket.assigns.bounds) + + socket = + socket + |> assign(:subscribed, false) + |> assign(:bounds, nil) + + Logger.info("Mobile client #{socket.assigns[:client_id]} unsubscribed from packet stream") + + {:reply, {:ok, %{message: "Unsubscribed from packet stream"}}, socket} + else + {:reply, {:ok, %{message: "Not subscribed"}}, socket} + end + end + + @impl true + def handle_info({:streaming_packet, packet}, socket) do + # Convert packet to mobile-friendly format + packet_data = build_mobile_packet(packet) + + # Push packet to mobile client + push(socket, "packet", packet_data) + + {:noreply, socket} + end + + # Clean up on terminate + @impl true + def terminate(_reason, socket) do + if socket.assigns[:subscribed] && socket.assigns[:bounds] do + Aprsme.StreamingPacketsPubSub.unsubscribe_from_bounds(self(), socket.assigns.bounds) + end + + :ok + end + + # Private functions + + defp validate_bounds(%{north: north, south: south, east: east, west: west}) do + cond do + not is_number(north) or not is_number(south) or not is_number(east) or not is_number(west) -> + {:error, "All bounds must be numeric"} + + north < -90 or north > 90 or south < -90 or south > 90 -> + {:error, "Latitude must be between -90 and 90"} + + east < -180 or east > 180 or west < -180 or west > 180 -> + {:error, "Longitude must be between -180 and 180"} + + north <= south -> + {:error, "North must be greater than south"} + + true -> + :ok + end + end + + defp ensure_float(value) when is_integer(value), do: value * 1.0 + defp ensure_float(value) when is_float(value), do: value + defp ensure_float(value) when is_binary(value), do: String.to_float(value) + defp ensure_float(value), do: value + + defp build_mobile_packet(packet) do + # Extract coordinates + {lat, lon, _data_extended} = AprsmeWeb.MapLive.MapHelpers.get_coordinates(packet) + + # Build minimal packet data for mobile + %{ + id: get_field(packet, :id), + callsign: get_field(packet, :sender) || get_field(packet, :base_callsign), + lat: to_float(lat), + lng: to_float(lon), + timestamp: get_field(packet, :received_at) |> format_timestamp(), + symbol_table_id: get_field(packet, :symbol_table_id, "/"), + symbol_code: get_field(packet, :symbol_code, ">"), + comment: get_field(packet, :comment), + altitude: get_field(packet, :altitude), + speed: get_field(packet, :speed), + course: get_field(packet, :course), + path: get_field(packet, :path) + } + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + end + + defp get_field(packet, field, default \\ nil) when is_map(packet) do + Map.get(packet, field) || Map.get(packet, to_string(field), default) + end + + defp to_float(nil), do: nil + defp to_float(value) when is_float(value), do: value + defp to_float(value) when is_integer(value), do: value * 1.0 + + defp to_float(value) when is_binary(value) do + case Float.parse(value) do + {float, _} -> float + :error -> nil + end + end + + defp to_float(%Decimal{} = value), do: Decimal.to_float(value) + defp to_float(_), do: nil + + defp format_timestamp(nil), do: nil + + defp format_timestamp(%DateTime{} = dt) do + DateTime.to_iso8601(dt) + end + + defp format_timestamp(%NaiveDateTime{} = ndt) do + ndt + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_iso8601() + end + + defp format_timestamp(_), do: nil +end diff --git a/lib/aprsme_web/channels/mobile_user_socket.ex b/lib/aprsme_web/channels/mobile_user_socket.ex new file mode 100644 index 0000000..fbbf192 --- /dev/null +++ b/lib/aprsme_web/channels/mobile_user_socket.ex @@ -0,0 +1,36 @@ +defmodule AprsmeWeb.MobileUserSocket do + @moduledoc """ + Socket for mobile applications (iOS/Android) to receive real-time APRS packets. + """ + use Phoenix.Socket + + # Channels + channel "mobile:packets", AprsmeWeb.MobileChannel + + # Socket params are passed from the client and can be used to verify and authenticate a user. + # After verification, you can put default assigns into the socket that will be set for all channels. + @impl true + def connect(_params, socket, _connect_info) do + # For now, allow anonymous connections + # In the future, you can add authentication here: + # case verify_token(params["token"]) do + # {:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)} + # {:error, _} -> :error + # end + + {:ok, socket} + end + + # Socket id's are topics that allow you to identify all sockets for a given user: + # + # def id(socket), do: "mobile_user_socket:#{socket.assigns.user_id}" + # + # Would allow you to broadcast a "disconnect" event and terminate + # all active sockets and channels for a given user: + # + # Elixir.AprsmeWeb.Endpoint.broadcast("mobile_user_socket:#{user.id}", "disconnect", %{}) + # + # Returning `nil` makes this socket anonymous. + @impl true + def id(_socket), do: nil +end diff --git a/lib/aprsme_web/endpoint.ex b/lib/aprsme_web/endpoint.ex index 409c599..726a88d 100644 --- a/lib/aprsme_web/endpoint.ex +++ b/lib/aprsme_web/endpoint.ex @@ -16,6 +16,11 @@ defmodule AprsmeWeb.Endpoint do websocket: [connect_info: [session: @session_options], timeout: 60_000], longpoll: [connect_info: [session: @session_options]] + # Mobile API socket for iOS/Android apps + socket "/mobile", AprsmeWeb.MobileUserSocket, + websocket: [timeout: 60_000], + longpoll: false + # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phx.digest