diff --git a/lib/aprs_web/components/core_components.ex b/lib/aprs_web/components/core_components.ex
index 644cea3..3f19e97 100644
--- a/lib/aprs_web/components/core_components.ex
+++ b/lib/aprs_web/components/core_components.ex
@@ -411,6 +411,9 @@ defmodule AprsWeb.CoreComponents do
Home
+
+ API
+
About
diff --git a/lib/aprs_web/controllers/api/v1/callsign_controller.ex b/lib/aprs_web/controllers/api/v1/callsign_controller.ex
new file mode 100644
index 0000000..1d16b3e
--- /dev/null
+++ b/lib/aprs_web/controllers/api/v1/callsign_controller.ex
@@ -0,0 +1,100 @@
+defmodule AprsWeb.Api.V1.CallsignController do
+ @moduledoc """
+ API v1 controller for callsign-related endpoints.
+ """
+ use AprsWeb, :controller
+
+ alias Aprs.Packets
+ alias AprsWeb.Api.V1.CallsignJSON
+
+ action_fallback AprsWeb.Api.V1.FallbackController
+
+ @doc """
+ Get the most recent packet for a given callsign.
+
+ ## Parameters
+ * `callsign` - The callsign to search for, with optional SSID (e.g., "N0CALL" or "N0CALL-9")
+
+ ## Returns
+ * 200 - Success with packet data
+ * 404 - No packets found for the callsign
+ * 400 - Invalid callsign format
+ """
+ def show(conn, %{"callsign" => callsign}) do
+ with {:ok, normalized_callsign} <- validate_callsign(callsign),
+ {:ok, packet} <- get_latest_packet(normalized_callsign) do
+ conn
+ |> put_status(:ok)
+ |> put_view(json: CallsignJSON)
+ |> render(:show, packet: packet)
+ else
+ {:error, :invalid_callsign} ->
+ {:error, "Invalid callsign format"}
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:ok)
+ |> put_view(json: CallsignJSON)
+ |> render(:not_found, callsign: callsign)
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp validate_callsign(callsign) when is_binary(callsign) do
+ # Remove any whitespace and convert to uppercase
+ normalized = callsign |> String.trim() |> String.upcase()
+
+ if validate_callsign_format(normalized) do
+ {:ok, normalized}
+ else
+ {:error, :invalid_callsign}
+ end
+ end
+
+ defp validate_callsign(nil), do: {:error, :invalid_callsign}
+ defp validate_callsign(_), do: {:error, :invalid_callsign}
+
+ defp validate_callsign_format(callsign) do
+ # Basic callsign validation
+ # Matches patterns like: N0CALL, N0CALL-9, W1ABC-15, etc.
+ # This is a simplified regex - a full implementation might be more comprehensive
+ callsign_regex = ~r/^[A-Z0-9]{1,3}[0-9][A-Z]{1,4}(-[0-9]{1,2})?$/
+
+ String.match?(callsign, callsign_regex) and String.length(callsign) <= 12
+ end
+
+ defp get_latest_packet(callsign) do
+ # Try to get the most recent packet for this callsign
+ # We'll limit to packets from the last 30 days to keep queries efficient
+ thirty_days_ago = DateTime.add(DateTime.utc_now(), -30, :day)
+
+ # Use get_recent_packets which orders by desc received_at
+ opts = %{
+ callsign: callsign,
+ start_time: thirty_days_ago,
+ limit: 1
+ }
+
+ case Packets.get_recent_packets(opts) do
+ [] ->
+ {:error, :not_found}
+
+ [packet | _] ->
+ {:ok, packet}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ rescue
+ Ecto.QueryError ->
+ {:error, :database_error}
+
+ error ->
+ require Logger
+
+ Logger.error("Error fetching packet for callsign #{callsign}: #{inspect(error)}")
+ {:error, :internal_error}
+ end
+end
diff --git a/lib/aprs_web/controllers/api/v1/fallback_controller.ex b/lib/aprs_web/controllers/api/v1/fallback_controller.ex
new file mode 100644
index 0000000..08cbc6b
--- /dev/null
+++ b/lib/aprs_web/controllers/api/v1/fallback_controller.ex
@@ -0,0 +1,72 @@
+defmodule AprsWeb.Api.V1.FallbackController do
+ @moduledoc """
+ Translates controller action results into valid `Plug.Conn` responses.
+
+ See `Phoenix.Controller.action_fallback/1` for more details.
+ """
+ use AprsWeb, :controller
+
+ alias AprsWeb.Api.V1.ErrorJSON
+
+ # This clause handles errors returned by Ecto's insert/update/delete.
+ def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
+ conn
+ |> put_status(:unprocessable_entity)
+ |> put_view(json: AprsWeb.Api.V1.ChangesetJSON)
+ |> render(:error, changeset: changeset)
+ end
+
+ # This clause is an example of how to handle resources that cannot be found.
+ def call(conn, {:error, :not_found}) do
+ conn
+ |> put_status(:not_found)
+ |> put_view(json: ErrorJSON)
+ |> render(:"404")
+ end
+
+ # Handle generic errors
+ def call(conn, {:error, reason}) when is_atom(reason) do
+ status =
+ case reason do
+ :unauthorized -> :unauthorized
+ :forbidden -> :forbidden
+ :bad_request -> :bad_request
+ _ -> :internal_server_error
+ end
+
+ conn
+ |> put_status(status)
+ |> put_view(json: ErrorJSON)
+ |> render(:error, message: format_error_message(reason))
+ end
+
+ # Handle string error messages
+ def call(conn, {:error, message}) when is_binary(message) do
+ conn
+ |> put_status(:bad_request)
+ |> put_view(json: ErrorJSON)
+ |> render(:error, message: message)
+ end
+
+ # Handle timeout errors
+ def call(conn, :timeout) do
+ conn
+ |> put_status(:request_timeout)
+ |> put_view(json: ErrorJSON)
+ |> render(:error, message: "Request timeout")
+ end
+
+ # Default fallback for unexpected errors
+ def call(conn, _error) do
+ conn
+ |> put_status(:internal_server_error)
+ |> put_view(json: ErrorJSON)
+ |> render(:"500")
+ end
+
+ defp format_error_message(:not_found), do: "Resource not found"
+ defp format_error_message(:unauthorized), do: "Unauthorized access"
+ defp format_error_message(:forbidden), do: "Access forbidden"
+ defp format_error_message(:bad_request), do: "Bad request"
+ defp format_error_message(reason), do: "An error occurred: #{reason}"
+end
diff --git a/lib/aprs_web/controllers/api/v1/json/callsign_json.ex b/lib/aprs_web/controllers/api/v1/json/callsign_json.ex
new file mode 100644
index 0000000..e6afba7
--- /dev/null
+++ b/lib/aprs_web/controllers/api/v1/json/callsign_json.ex
@@ -0,0 +1,127 @@
+defmodule AprsWeb.Api.V1.CallsignJSON do
+ @moduledoc """
+ Renders callsign and packet data for API v1.
+ """
+
+ alias Aprs.Packet
+
+ def render("show.json", %{packet: packet}) do
+ %{data: packet_json(packet)}
+ end
+
+ def render("index.json", %{packets: packets}) do
+ %{data: Enum.map(packets, &packet_json/1)}
+ end
+
+ def render("not_found.json", %{callsign: callsign}) do
+ %{
+ data: nil,
+ message: "No packets found for callsign #{callsign}"
+ }
+ end
+
+ defp packet_json(%Packet{} = packet) do
+ %{
+ 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: packet.information_field,
+ raw_packet: packet.raw_packet,
+ received_at: packet.received_at,
+ region: packet.region,
+ position: position_json(packet),
+ symbol: symbol_json(packet),
+ comment: packet.comment,
+ timestamp: packet.timestamp,
+ aprs_messaging: packet.aprs_messaging,
+ weather: weather_json(packet),
+ equipment: equipment_json(packet),
+ message: message_json(packet),
+ has_position: packet.has_position,
+ inserted_at: packet.inserted_at,
+ updated_at: packet.updated_at
+ }
+ 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 position_json(%Packet{has_position: false}), do: nil
+ defp position_json(%Packet{lat: nil, lon: nil}), do: nil
+
+ defp position_json(%Packet{} = packet) do
+ base_position = %{
+ latitude: to_float(packet.lat),
+ longitude: to_float(packet.lon)
+ }
+
+ # Add course, speed, and altitude if available
+ base_position
+ |> maybe_add(:course, packet.course)
+ |> maybe_add(:speed, packet.speed)
+ |> maybe_add(:altitude, packet.altitude)
+ end
+
+ defp symbol_json(%Packet{symbol_code: nil, symbol_table_id: nil}), do: nil
+
+ defp symbol_json(%Packet{} = packet) do
+ %{
+ code: packet.symbol_code,
+ table_id: packet.symbol_table_id
+ }
+ end
+
+ defp weather_json(%Packet{} = packet) do
+ weather_data =
+ %{}
+ |> 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)
+
+ case weather_data do
+ empty when empty == %{} -> nil
+ data -> data
+ end
+ end
+
+ defp equipment_json(%Packet{} = packet) do
+ equipment_data =
+ %{}
+ |> maybe_add(:manufacturer, packet.manufacturer)
+ |> maybe_add(:equipment_type, packet.equipment_type)
+
+ case equipment_data do
+ empty when empty == %{} -> nil
+ data -> data
+ end
+ end
+
+ defp message_json(%Packet{addressee: nil, message_text: nil, message_number: nil}), do: nil
+
+ defp message_json(%Packet{} = packet) do
+ %{}
+ |> maybe_add(:addressee, packet.addressee)
+ |> maybe_add(:text, packet.message_text)
+ |> maybe_add(:number, packet.message_number)
+ 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
+end
diff --git a/lib/aprs_web/controllers/api/v1/json/changeset_json.ex b/lib/aprs_web/controllers/api/v1/json/changeset_json.ex
new file mode 100644
index 0000000..09ad47d
--- /dev/null
+++ b/lib/aprs_web/controllers/api/v1/json/changeset_json.ex
@@ -0,0 +1,30 @@
+defmodule AprsWeb.Api.V1.ChangesetJSON do
+ @moduledoc """
+ Renders changeset errors for API v1.
+ """
+
+ @doc """
+ Renders changeset errors.
+ """
+ def render("error.json", %{changeset: changeset}) do
+ %{
+ error: %{
+ message: "Validation failed",
+ code: "validation_error",
+ details: translate_errors(changeset)
+ }
+ }
+ end
+
+ defp translate_errors(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
+ end
+
+ defp translate_error({msg, opts}) do
+ # Because the error messages we show in our forms and APIs
+ # are defined inside Ecto, we need to translate them dynamically.
+ Enum.reduce(opts, msg, fn {key, value}, acc ->
+ String.replace(acc, "%{#{key}}", to_string(value))
+ end)
+ end
+end
diff --git a/lib/aprs_web/controllers/api/v1/json/error_json.ex b/lib/aprs_web/controllers/api/v1/json/error_json.ex
new file mode 100644
index 0000000..194406d
--- /dev/null
+++ b/lib/aprs_web/controllers/api/v1/json/error_json.ex
@@ -0,0 +1,86 @@
+defmodule AprsWeb.Api.V1.ErrorJSON do
+ @moduledoc """
+ Renders error responses for API v1.
+ """
+
+ def render("error.json", %{message: message}) do
+ %{
+ error: %{
+ message: message,
+ code: "generic_error"
+ }
+ }
+ end
+
+ def render("404.json", _assigns) do
+ %{
+ error: %{
+ message: "Resource not found",
+ code: "not_found"
+ }
+ }
+ end
+
+ def render("500.json", _assigns) do
+ %{
+ error: %{
+ message: "Internal server error",
+ code: "internal_server_error"
+ }
+ }
+ end
+
+ def render("422.json", _assigns) do
+ %{
+ error: %{
+ message: "Unprocessable entity",
+ code: "unprocessable_entity"
+ }
+ }
+ end
+
+ def render("400.json", _assigns) do
+ %{
+ error: %{
+ message: "Bad request",
+ code: "bad_request"
+ }
+ }
+ end
+
+ def render("401.json", _assigns) do
+ %{
+ error: %{
+ message: "Unauthorized",
+ code: "unauthorized"
+ }
+ }
+ end
+
+ def render("403.json", _assigns) do
+ %{
+ error: %{
+ message: "Forbidden",
+ code: "forbidden"
+ }
+ }
+ end
+
+ def render("408.json", _assigns) do
+ %{
+ error: %{
+ message: "Request timeout",
+ code: "request_timeout"
+ }
+ }
+ end
+
+ def render(template, _assigns) do
+ %{
+ error: %{
+ message: Phoenix.Controller.status_message_from_template(template),
+ code: "unknown_error"
+ }
+ }
+ end
+end
diff --git a/lib/aprs_web/live/api_docs_live.ex b/lib/aprs_web/live/api_docs_live.ex
new file mode 100644
index 0000000..81c3cf1
--- /dev/null
+++ b/lib/aprs_web/live/api_docs_live.ex
@@ -0,0 +1,466 @@
+defmodule AprsWeb.ApiDocsLive do
+ @moduledoc """
+ LiveView for API documentation page.
+ """
+ use AprsWeb, :live_view
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, page_title: "API Documentation")}
+ end
+
+ @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
+
+ Currently no rate limiting is enforced, but please be respectful and avoid excessive requests.
+ Rate limiting may be implemented in the future.
+
+
+
+
+
+
+
+
+
+
+
+
+
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 ~s|{
+ "data": {
+ "id": 12345,
+ "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:45.123456Z",
+ "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": {
+ "manufacturer": "Kenwood",
+ "equipment_type": "TM-D710"
+ },
+ "message": null,
+ "has_position": true,
+ "inserted_at": "2024-01-15T10:30:45.123456Z",
+ "updated_at": "2024-01-15T10:30:45.123456Z"
+ }
+ }| %>
+
+
+
Not Found Response (404)
+
+
<%= raw ~s|{
+ "data": null,
+ "message": "No packets found for callsign N0CALL-9"
+ }| %>
+
+
+
Error Response (400)
+
+
<%= raw ~s|{
+ "error": {
+ "message": "Invalid callsign format",
+ "code": "bad_request"
+ }
+ }| %>
+
+
+
+
+
+
+
+
+
Response Fields
+
+
+
+
+
+
+
+ |
+ Field
+ |
+
+ Type
+ |
+
+ Description
+ |
+
+
+
+
+ | id |
+ integer |
+ 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 (manufacturer, type)
+ |
+
+
+ |
+ 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 |
+
+
+ |
+ 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 data from weather stations
+
+
Planned
+
+
+
+
+
+
+
+
+
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
diff --git a/lib/aprs_web/router.ex b/lib/aprs_web/router.ex
index 46a4b14..fb27511 100644
--- a/lib/aprs_web/router.ex
+++ b/lib/aprs_web/router.ex
@@ -30,11 +30,6 @@ defmodule AprsWeb.Router do
live_dashboard "/dashboard", metrics: AprsWeb.Telemetry
- live_session :map_pages, layout: {AprsWeb.Layouts, :map} do
- live "/", MapLive.Index, :index
- live "/:callsign", MapLive.CallsignView, :index
- end
-
live_session :regular_pages do
live "/status", StatusLive.Index, :index
live "/packets", PacketsLive.Index, :index
@@ -42,13 +37,21 @@ defmodule AprsWeb.Router do
live "/badpackets", BadPacketsLive.Index, :index
live "/weather/:callsign", WeatherLive.CallsignView, :index
live "/about", AboutLive, :index
+ live "/api", ApiDocsLive, :index
+ end
+
+ live_session :map_pages, layout: {AprsWeb.Layouts, :map} do
+ live "/", MapLive.Index, :index
+ live "/:callsign", MapLive.CallsignView, :index
end
end
- # Other scopes may use custom stacks.
- # scope "/api", AprsWeb do
- # pipe_through :api
- # end
+ # API v1 routes
+ scope "/api/v1", AprsWeb.Api.V1, as: :api_v1 do
+ pipe_through :api
+
+ get "/callsign/:callsign", CallsignController, :show
+ end
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:aprs, :dev_routes) do