defmodule AprsmeWeb.ApiDocsLive do @moduledoc """ LiveView for API documentation page. """ use AprsmeWeb, :live_view @impl true def mount(_params, _session, socket) do {:ok, assign(socket, page_title: "API Documentation", test_callsign: "", loading: false, api_result: nil, error: nil )} end @impl true def handle_event("update_callsign", %{"callsign" => callsign}, socket) do {:noreply, assign(socket, test_callsign: callsign, error: nil)} end @impl true def handle_event("test_api", %{"callsign" => callsign}, socket) do normalized_callsign = callsign |> String.trim() |> String.upcase() if normalized_callsign == "" do {:noreply, assign(socket, error: "Please enter a callsign")} else send(self(), {:call_api, normalized_callsign}) {:noreply, assign(socket, loading: true, error: nil, api_result: nil)} end end @impl true def handle_info({:call_api, callsign}, socket) do case make_http_request(callsign) do {:ok, json_result} -> {:noreply, assign(socket, loading: false, api_result: json_result)} {:error, error_message} -> {:noreply, assign(socket, loading: false, error: error_message)} end end defp make_http_request(callsign) do alias Aprsme.Packets alias AprsmeWeb.TimeUtils # Validate callsign format (same as controller) callsign_regex = ~r/^[A-Z0-9]{1,3}[0-9][A-Z]{1,4}(-[0-9]{1,2})?$/ if not String.match?(callsign, callsign_regex) or String.length(callsign) > 12 do response = %{ "error" => %{ "message" => "Invalid callsign format", "code" => "bad_request" } } {:ok, Jason.encode!(response, pretty: true)} else # Get packet data (same logic as controller) thirty_days_ago = TimeUtils.days_ago(30) opts = %{ callsign: callsign, start_time: thirty_days_ago, limit: 1 } case Packets.get_recent_packets(opts) do [] -> response = %{ "data" => nil, "message" => "No packets found for callsign #{callsign}" } {:ok, Jason.encode!(response, pretty: true)} [packet | _] -> # Format packet data using the same logic as CallsignJSON packet_data = %{ "id" => packet.id, "callsign" => format_callsign(packet.base_callsign, packet.ssid), "base_callsign" => packet.base_callsign, "ssid" => packet.ssid, "sender" => packet.sender, "destination" => packet.destination, "path" => packet.path, "data_type" => packet.data_type, "information_field" => get_in(packet.data || %{}, ["information_field"]), "raw_packet" => sanitize_raw_packet(packet.raw_packet), "received_at" => packet.received_at, "region" => packet.region, "position" => format_position(packet), "symbol" => format_symbol(packet), "comment" => packet.comment, "timestamp" => packet.timestamp, "aprs_messaging" => packet.aprs_messaging, "weather" => format_weather(packet), "equipment" => format_equipment(packet), "message" => format_message(packet), "has_position" => packet.has_position, "inserted_at" => packet.inserted_at, "updated_at" => packet.updated_at } response = %{"data" => packet_data} {:ok, Jason.encode!(response, pretty: true)} end end rescue error -> {:error, "Request error: #{inspect(error)}"} end defp format_callsign(base_callsign, nil), do: base_callsign defp format_callsign(base_callsign, "0"), do: base_callsign defp format_callsign(base_callsign, ssid), do: "#{base_callsign}-#{ssid}" defp format_position(%{has_position: false}), do: nil defp format_position(%{lat: nil, lon: nil}), do: nil defp format_position(packet) do base = %{ "latitude" => to_float(packet.lat), "longitude" => to_float(packet.lon) } |> maybe_add("course", packet.course) |> maybe_add("speed", packet.speed) |> maybe_add("altitude", packet.altitude) if map_size(base) == 2, do: base, else: base end defp format_symbol(%{symbol_code: nil, symbol_table_id: nil}), do: nil defp format_symbol(packet) do %{ "code" => packet.symbol_code, "table_id" => packet.symbol_table_id } end defp format_weather(packet) do weather = %{} |> maybe_add("temperature", packet.temperature) |> maybe_add("humidity", packet.humidity) |> maybe_add("wind_speed", packet.wind_speed) |> maybe_add("wind_direction", packet.wind_direction) |> maybe_add("wind_gust", packet.wind_gust) |> maybe_add("pressure", packet.pressure) |> maybe_add("rain_1h", packet.rain_1h) |> maybe_add("rain_24h", packet.rain_24h) |> maybe_add("rain_since_midnight", packet.rain_since_midnight) if map_size(weather) == 0, do: nil, else: weather end defp format_equipment(packet) do device = case packet.device_identifier do nil -> nil "" -> nil identifier -> Aprsme.DeviceCache.lookup_device(identifier) end equipment = %{} |> maybe_add("device_identifier", packet.device_identifier) |> maybe_add("manufacturer", packet.manufacturer) |> maybe_add("equipment_type", packet.equipment_type) |> maybe_add("device_model", device && device.model) |> maybe_add("device_vendor", device && device.vendor) |> maybe_add("device_contact", device && device.contact) |> maybe_add("device_class", device && device.class) if map_size(equipment) == 0, do: nil, else: equipment end defp format_message(%{addressee: nil, message_text: nil, message_number: nil}), do: nil defp format_message(packet) do message = %{} |> maybe_add("addressee", packet.addressee) |> maybe_add("text", packet.message_text) |> maybe_add("number", packet.message_number) if map_size(message) == 0, do: nil, else: message end defp maybe_add(map, _key, nil), do: map defp maybe_add(map, _key, ""), do: map defp maybe_add(map, key, value), do: Map.put(map, key, value) defp to_float(%Decimal{} = decimal), do: Decimal.to_float(decimal) defp to_float(value) when is_number(value), do: value defp to_float(_), do: nil defp sanitize_raw_packet(raw_packet) when is_binary(raw_packet) do Aprsme.EncodingUtils.sanitize_string(raw_packet) end defp sanitize_raw_packet(raw_packet), do: raw_packet defp highlight_json(text) do Regex.replace( ~r/(? ~s(#{key}) _, "", str, "", "", "", "" -> ~s(#{str}) _, "", "", bool, "", "", "" -> ~s(#{bool}) _, "", "", "", null, "", "" -> ~s(#{null}) _, "", "", "", "", num, "" -> ~s(#{num}) _, "", "", "", "", "", punc -> ~s(#{punc}) end ) end defp callsign_success_json, do: ~s|{ "data": { "id": "d7249877-d4a6-45c2-b314-2a8a355d2566", "callsign": "N0CALL-9", "base_callsign": "N0CALL", "ssid": "9", "sender": "N0CALL-9", "destination": "APRS", "path": "WIDE1-1,WIDE2-1", "data_type": "position", "information_field": "!4740.00N/12200.00W>Mobile Station", "raw_packet": "N0CALL-9>APRS,WIDE1-1,WIDE2-1:!4740.00N/12200.00W>Mobile Station", "received_at": "2024-01-15T10:30:45Z", "region": "US-West", "position": { "latitude": 47.666667, "longitude": -122.0, "course": 90, "speed": 35.5, "altitude": 152.4 }, "symbol": { "code": ">", "table_id": "/" }, "comment": "Mobile Station", "timestamp": null, "aprs_messaging": false, "weather": null, "equipment": { "device_identifier": "APK004", "manufacturer": "Kenwood", "equipment_type": "TM-D710", "device_model": "TM-D710G", "device_vendor": "Kenwood", "device_class": "rig" }, "message": null, "has_position": true, "inserted_at": "2024-01-15T10:30:45Z", "updated_at": "2024-01-15T10:30:45Z" } }| defp callsign_not_found_json, do: ~s|{ "data": null, "message": "No packets found for callsign N0CALL-9" }| defp callsign_error_json, do: ~s|{ "error": { "message": "Invalid callsign format", "code": "bad_request" } }| defp weather_success_json, do: ~s|{ "data": [ { "callsign": "N0CALL-13", "base_callsign": "N0CALL", "position": { "lat": 37.7849, "lon": -122.4094 }, "distance_miles": 0.87, "weather": { "temperature": 72.5, "humidity": 65.0, "pressure": 1013.2, "wind_speed": 8.5, "wind_direction": 270, "wind_gust": 12.0, "rain_1h": 0.0, "rain_24h": 0.5, "rain_since_midnight": 0.3 }, "symbol": { "table_id": "/", "code": "_" }, "comment": "Davis Vantage Pro2", "last_report": "2026-03-22T15:30:00Z" } ], "meta": { "count": 1, "params": { "latitude": 37.7749, "longitude": -122.4194, "radius_miles": 25.0, "hours": 6, "limit": 50 } } }| defp weather_empty_json, do: ~s|{ "data": [], "meta": { "count": 0, "params": { "latitude": 0.0, "longitude": -180.0, "radius_miles": 10.0, "hours": 6, "limit": 50 } } }| defp weather_error_param_json, do: ~s|{ "error": "Missing required parameter: lat" }| defp weather_error_invalid_json, do: ~s|{ "error": "Invalid latitude: must be a number between -90 and 90" }| @impl true def render(assigns) do ~H"""

APRS.me API Documentation

RESTful JSON API for accessing APRS packet data and station information.

Overview

The APRS.me API provides programmatic access to Amateur Radio APRS (Automatic Packet Reporting System) data. All API endpoints return JSON data and follow RESTful conventions.

Base URL

https://aprs.me/api/v1

Content Type

application/json

Rate Limiting

API requests are rate limited to 100 requests per minute per IP address. Exceeding this limit will return a 429 Too Many Requests response.

Get Latest Packet by Callsign

GET

Endpoint

GET /api/v1/callsign/{"{callsign}"}

Description

Retrieves the most recent APRS packet for the specified callsign. The callsign can include an SSID (e.g., N0CALL-9) or just the base callsign (e.g., N0CALL).

Parameters

Parameter Type Required Description
callsign string Yes Amateur radio callsign with optional SSID (e.g., N0CALL or N0CALL-9)

Example Request

curl -X GET "https://aprs.me/api/v1/callsign/N0CALL-9" \
     -H "Accept: application/json"

Response Format

Success Response (200 OK)

<%= raw highlight_json(callsign_success_json()) %>

Not Found Response (404)

<%= raw highlight_json(callsign_not_found_json()) %>

Error Response (400)

<%= raw highlight_json(callsign_error_json()) %>

Find Nearby Weather Stations

GET

Endpoint

GET /api/v1/weather/nearby

Description

Find weather stations within a specified radius of a geographic point. Returns stations with recent weather data, ordered by distance (closest first). Uses PostGIS spatial queries for efficient radius filtering and deduplicates by base callsign to avoid duplicate SSIDs.

Parameters

Parameter Type Required Description
lat float Yes Latitude of center point (-90 to 90)
lon float Yes Longitude of center point (-180 to 180)
radius float Yes Search radius in miles (0.0001 to 1000)
hours integer No Time window for recent weather data in hours (default: 6, max: 168)
limit integer No Maximum number of results to return (default: 50, max: 100)

Example Request

curl -X GET "https://aprs.me/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25" \
     -H "Accept: application/json"

Response Format

Success Response (200 OK)

<%= raw highlight_json(weather_success_json()) %>

Empty Response (200 OK)

<%= raw highlight_json(weather_empty_json()) %>

Error Response (400 Bad Request)

<%= raw highlight_json(weather_error_param_json()) %>

Error Response (422 Unprocessable Entity)

<%= raw highlight_json(weather_error_invalid_json()) %>

Response Fields

Field Type Description
id string (UUID) Unique packet identifier
callsign string Full callsign with SSID (e.g., "N0CALL-9")
base_callsign string Base callsign without SSID
ssid string SSID (Secondary Station Identifier), null if not present
received_at datetime When the packet was received (ISO 8601 format)
position object Position data (latitude, longitude, course, speed, altitude)
symbol object APRS symbol information (code and table_id)
weather object Weather data if present (temperature, humidity, wind, etc.)
equipment object Equipment information (device_identifier, manufacturer, equipment_type, device_model, device_vendor, device_class, device_contact)
message object Message data if the packet is a message
raw_packet string Original raw APRS packet as received

HTTP Status Codes

Status Code Description
200 OK Request successful, packet data returned
400 Bad Request Invalid callsign format or malformed request
404 Not Found No packets found for the specified callsign
408 Request Timeout Request took too long to process
429 Too Many Requests Rate limit exceeded (100 requests per minute per IP)
500 Internal Server Error Server error occurred while processing the request

Planned Endpoints

The following endpoints are planned for future releases:

GET /api/v1/callsign/{"{callsign}"}/history

Get historical packets for a callsign

Planned
GET /api/v1/packets/recent

Get recent packets with filtering options

Planned
GET /api/v1/packets/area

Get packets within a geographic area

Planned
GET /api/v1/weather/{"{callsign}"}

Get weather history for a specific weather station

Planned

Test the API

Try the API directly from this page. Enter a callsign to see the most recent packet data.

<%= if @error do %>
{@error}
<% end %> <%= if @api_result do %>

API Response

JSON Response
<%= @api_result %>
<% end %>

Support

This API is provided free of charge for amateur radio and educational purposes. If you encounter issues or have suggestions for improvements, please reach out.

Guidelines

  • • Use reasonable request rates to avoid overwhelming the service
  • • Cache responses when appropriate to reduce server load
  • • Include a User-Agent header identifying your application
  • • This service is for amateur radio and educational use
""" end end