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.
This commit is contained in:
parent
4bc076906f
commit
ddbb72b1b2
5 changed files with 664 additions and 0 deletions
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -1,5 +1,20 @@
|
||||||
This is a web application written using the Phoenix web framework.
|
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 guidelines
|
||||||
|
|
||||||
- Elixir lists **do not support index based access via the access syntax**
|
- Elixir lists **do not support index based access via the access syntax**
|
||||||
|
|
|
||||||
367
docs/mobile-api.md
Normal file
367
docs/mobile-api.md
Normal file
|
|
@ -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
|
||||||
241
lib/aprsme_web/channels/mobile_channel.ex
Normal file
241
lib/aprsme_web/channels/mobile_channel.ex
Normal file
|
|
@ -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
|
||||||
36
lib/aprsme_web/channels/mobile_user_socket.ex
Normal file
36
lib/aprsme_web/channels/mobile_user_socket.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -16,6 +16,11 @@ defmodule AprsmeWeb.Endpoint do
|
||||||
websocket: [connect_info: [session: @session_options], timeout: 60_000],
|
websocket: [connect_info: [session: @session_options], timeout: 60_000],
|
||||||
longpoll: [connect_info: [session: @session_options]]
|
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.
|
# Serve at "/" the static files from "priv/static" directory.
|
||||||
#
|
#
|
||||||
# You should set gzip to true if you are running phx.digest
|
# You should set gzip to true if you are running phx.digest
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue